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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Declaring circles over a record that already exists (Spec 08, stage S0).
|
|
2
|
+
//
|
|
3
|
+
// A record adopts circles by saying where its files already are; nothing moves. A plan names each
|
|
4
|
+
// circle by the folders it roots at, most specific first in meaning but in any order on the page:
|
|
5
|
+
//
|
|
6
|
+
// { "exclude": ["Videos"],
|
|
7
|
+
// "circles": [
|
|
8
|
+
// { "name": "therapy", "roots": ["02_Areas/Health & Energy/Therapy"] },
|
|
9
|
+
// { "name": "health", "roots": ["02_Areas/Health & Energy"] },
|
|
10
|
+
// { "name": "core", "roots": [""] } ],
|
|
11
|
+
// "devices": {
|
|
12
|
+
// "laptop": { "atlasId": "laptop.atlas", "grants": { "*": "owner" } },
|
|
13
|
+
// "hestia": { "atlasId": "hestia.atlas", "grants": { "core": "editor", "people": "viewer" } } } }
|
|
14
|
+
//
|
|
15
|
+
// A root claims everything under it except another circle's root. So `health` above is every child
|
|
16
|
+
// of Health & Energy except Therapy, written as the direct children the carve-out leaves, found by
|
|
17
|
+
// listing, so its eco.yaml names only paths that exist today. The record root is different
|
|
18
|
+
// (HQ-1364): `core` is written as ONE member, the root itself (`path: ""`), and every other circle
|
|
19
|
+
// carves its subtree out of it by longest member path (`circleForPath`). So a file created at the
|
|
20
|
+
// root tomorrow is core without anyone re-declaring; before this, core enumerated the root's
|
|
21
|
+
// children at declare time and `.doc-actions-ack`, written the next morning, was in no circle.
|
|
22
|
+
//
|
|
23
|
+
// Re-declaring over a record that already has circles keeps each circle's opaque id, release list,
|
|
24
|
+
// rule and table members, mount, policy, description, title and maintainers, and replaces only its
|
|
25
|
+
// path members; the plan is what the paths are, the manifest is everything the owner wrote since.
|
|
26
|
+
//
|
|
27
|
+
// The plan is read from `<circles role>/plan.json` when a command is not handed one (`planBeside`),
|
|
28
|
+
// so doctor and `circles check` exclude what the scan excludes without being told twice.
|
|
29
|
+
//
|
|
30
|
+
// What this module promises, and `evda circles check` proves: every file that syncs is in exactly
|
|
31
|
+
// one circle. A file no circle covers would never reach any device, and one two circles cover would
|
|
32
|
+
// have two access answers.
|
|
33
|
+
|
|
34
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { excluder, isPlaceholder, walk } from "./sync-rules.mjs";
|
|
37
|
+
import { circleForPath } from "./filechanges.mjs";
|
|
38
|
+
import { circleManifest, readCircles, writeCircle, opaqueIdOf, circlesDir, isRelease } from "./circles.mjs";
|
|
39
|
+
import { grant } from "./grants.mjs";
|
|
40
|
+
import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
|
|
41
|
+
import { EvdaError } from "./errors.mjs";
|
|
42
|
+
|
|
43
|
+
export const PLAN_FILE = "plan.json";
|
|
44
|
+
|
|
45
|
+
// The plan kept beside the circles it declared, `<circles role>/plan.json`, or null when the record
|
|
46
|
+
// has no circles role or no plan there. Throws when the file is there and is not JSON.
|
|
47
|
+
export function planBeside(evda) {
|
|
48
|
+
const role = circlesDir(evda);
|
|
49
|
+
if (!role.mapped || !role.exists || !role.isDir) return null;
|
|
50
|
+
const p = join(role.abs, PLAN_FILE);
|
|
51
|
+
if (!existsSync(p)) return null;
|
|
52
|
+
try {
|
|
53
|
+
return { path: join(role.rel, PLAN_FILE), abs: p, plan: JSON.parse(readFileSync(p, "utf8")) };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new EvdaError(`REFUSED: ${join(role.rel, PLAN_FILE)} is not valid JSON (${e.message}).`, { code: "BAD_PLAN", exit: 2 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const norm = (rel) => rel.replace(/^\/+|\/+$/g, "");
|
|
60
|
+
const under = (rel, root) => root === "" || rel === root || rel.startsWith(`${root}/`);
|
|
61
|
+
const strictlyUnder = (rel, root) => rel !== root && under(rel, root);
|
|
62
|
+
|
|
63
|
+
// Check the plan's own shape. Throws on anything that would make the carve-out ambiguous.
|
|
64
|
+
export function checkPlan(plan) {
|
|
65
|
+
if (!plan || !Array.isArray(plan.circles) || plan.circles.length === 0) throw new EvdaError("a circle plan needs a non-empty \"circles\" list.", { exit: 2 });
|
|
66
|
+
const seen = new Map();
|
|
67
|
+
for (const c of plan.circles) {
|
|
68
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(c.name ?? "")) throw new EvdaError(`"${c.name}" is not a circle name.`, { exit: 2 });
|
|
69
|
+
if (!Array.isArray(c.roots) || c.roots.length === 0) throw new EvdaError(`circle "${c.name}" has no roots.`, { exit: 2 });
|
|
70
|
+
for (const r of c.roots.map(norm)) {
|
|
71
|
+
if (r.split("/").includes("..")) throw new EvdaError(`circle "${c.name}" root ${r} leaves the record.`, { exit: 2 });
|
|
72
|
+
if (seen.has(r)) throw new EvdaError(`${r || "(the record root)"} is a root of both "${seen.get(r)}" and "${c.name}".`, { exit: 2 });
|
|
73
|
+
seen.set(r, c.name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return seen;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The members each circle gets on this disk. Returns { circles: [{ name, public, roots, members:
|
|
80
|
+
// [{ path, isDir }] }], missing: [root] }. A root with nothing on disk is kept as a member, so the
|
|
81
|
+
// declaration still says which circle the path belongs to once it appears.
|
|
82
|
+
export function planCircles(dir, plan, { exclude = excluder(plan.exclude) } = {}) {
|
|
83
|
+
const rootOf = checkPlan(plan);
|
|
84
|
+
const roots = [...rootOf.keys()];
|
|
85
|
+
const missing = [];
|
|
86
|
+
|
|
87
|
+
const expand = (rel, circle, out) => {
|
|
88
|
+
// The record root is one member, never an enumeration (HQ-1364): the other circles carve their
|
|
89
|
+
// subtrees out of it by longest member path, and a file that appears at the root is its.
|
|
90
|
+
if (rel === "") {
|
|
91
|
+
out.push({ path: "", isDir: true });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const otherRootBelow = roots.some((r) => rootOf.get(r) !== circle && strictlyUnder(r, rel));
|
|
95
|
+
const excludedBelow = (plan.exclude ?? []).map(norm).some((x) => strictlyUnder(x, rel));
|
|
96
|
+
const abs = join(dir, rel);
|
|
97
|
+
const st = lstatSync(abs);
|
|
98
|
+
if (!st.isDirectory() || (!otherRootBelow && !excludedBelow)) {
|
|
99
|
+
out.push({ path: rel, isDir: st.isDirectory() });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const name of readdirSync(abs).sort()) {
|
|
103
|
+
const child = rel === "" ? name : `${rel}/${name}`;
|
|
104
|
+
if (exclude(child)) continue;
|
|
105
|
+
if (rootOf.has(child) && rootOf.get(child) !== circle) continue;
|
|
106
|
+
const cst = lstatSync(join(dir, child));
|
|
107
|
+
// Links and iCloud stubs are never members; the coverage check names them instead.
|
|
108
|
+
if (cst.isSymbolicLink() || isPlaceholder(name)) continue;
|
|
109
|
+
expand(child, circle, out);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const circles = plan.circles.map((c) => {
|
|
114
|
+
const members = [];
|
|
115
|
+
for (const root of c.roots.map(norm)) {
|
|
116
|
+
if (!existsSync(root === "" ? dir : join(dir, root))) {
|
|
117
|
+
missing.push(root);
|
|
118
|
+
members.push({ path: root, isDir: null });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
expand(root, c.name, members);
|
|
122
|
+
}
|
|
123
|
+
return { name: c.name, public: c.public === true, roots: c.roots.map(norm), members };
|
|
124
|
+
});
|
|
125
|
+
return { circles, missing };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A plan, as the index `circleForPath` reads, before anything is written.
|
|
129
|
+
export const planIndex = (planned) =>
|
|
130
|
+
planned.circles.map((c) => ({ name: c.name, id: c.name, public: c.public, members: c.members.map((m) => ({ rel: m.path, isDir: m.isDir })) }));
|
|
131
|
+
|
|
132
|
+
// Every file under the record, each in exactly one circle. Returns counts and problems; prints
|
|
133
|
+
// nothing. `index` is circleIndex(evda) after S0, or planIndex(plan) before it.
|
|
134
|
+
//
|
|
135
|
+
// UNCOVERED a syncable file no circle covers
|
|
136
|
+
// TWO_CIRCLES a file two circles cover
|
|
137
|
+
// SYMLINK a link inside a circle's tree: never followed, never carried
|
|
138
|
+
// PLACEHOLDER an iCloud stub: the bytes are not on this disk
|
|
139
|
+
// OVERSIZE larger than the relay's blob lane takes
|
|
140
|
+
// UNMOUNTED under the mount root (`mountRoot`, the `mounts` role) and in no circle held from
|
|
141
|
+
// another holder (HQ-835)
|
|
142
|
+
export function coverage(dir, index, { exclude, mountRoot = null } = {}) {
|
|
143
|
+
const perCircle = Object.fromEntries(index.map((c) => [c.name, 0]));
|
|
144
|
+
const problems = [];
|
|
145
|
+
let checked = 0;
|
|
146
|
+
let excluded = 0;
|
|
147
|
+
for (const e of walk(dir, { exclude })) {
|
|
148
|
+
if (e.kind === "excluded") { excluded += 1; continue; }
|
|
149
|
+
let circle = null;
|
|
150
|
+
try {
|
|
151
|
+
circle = circleForPath(index, e.rel);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
problems.push({ code: "TWO_CIRCLES", rel: e.rel, why: err.message });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (e.kind === "file") {
|
|
157
|
+
if (!circle) { problems.push({ code: "UNCOVERED", rel: e.rel, why: "no circle covers it, so it would reach no device" }); continue; }
|
|
158
|
+
if (mountRoot !== null && e.rel.startsWith(`${mountRoot}/`) && !circle.mount) { problems.push({ code: "UNMOUNTED", rel: e.rel, why: `under the mount root ${mountRoot} and in no circle held from another holder; "${circle.name}" would sync it as this record's own` }); continue; }
|
|
159
|
+
perCircle[circle.name] += 1;
|
|
160
|
+
checked += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const why = {
|
|
164
|
+
symlink: "a symlink: sync never follows or carries one; exclude it in the plan or replace it with the file",
|
|
165
|
+
placeholder: "an iCloud placeholder: the file's bytes are not on this disk; download it before syncing",
|
|
166
|
+
oversize: `${e.size} bytes, over the relay's blob lane; exclude it in the plan`,
|
|
167
|
+
}[e.kind];
|
|
168
|
+
problems.push({ code: e.kind.toUpperCase(), rel: e.rel, why, circle: circle?.name ?? null });
|
|
169
|
+
}
|
|
170
|
+
return { checked, perCircle, excluded, problems };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Path members named m001, m002, ... in plan order, skipping names `taken` already uses.
|
|
174
|
+
const numbered = (paths, taken = new Set()) => {
|
|
175
|
+
const out = {};
|
|
176
|
+
let i = 0;
|
|
177
|
+
for (const p of paths) {
|
|
178
|
+
let name;
|
|
179
|
+
do name = `m${String(++i).padStart(3, "0")}`; while (taken.has(name));
|
|
180
|
+
out[name] = p;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// An existing manifest, re-declared: the same object with its path members replaced by the plan's
|
|
186
|
+
// and everything else as the owner left it (id, namespace, title, description, release, mount,
|
|
187
|
+
// policy, maintainers, unknown keys, key order). Rule and table members keep their names and follow
|
|
188
|
+
// the path members. `public` is the plan's, since the plan is what says who may read a circle.
|
|
189
|
+
export function redeclared(was, planned) {
|
|
190
|
+
if (opaqueIdOf(was) === null) throw new EvdaError(`circle "${planned.name}" is declared with an id this tool cannot keep (${JSON.stringify(was.id)} / ${JSON.stringify(was.namespace)}); it will not mint a new one over it.`, { exit: 2 });
|
|
191
|
+
const out = { ...was };
|
|
192
|
+
const kept = Object.entries(was.members ?? {}).filter(([, m]) => m && typeof m === "object" && typeof m.path !== "string");
|
|
193
|
+
const paths = numbered(planned.members.map((m) => ({ path: m.path })), new Set(kept.map(([n]) => n)));
|
|
194
|
+
out.members = { ...paths, ...Object.fromEntries(kept) };
|
|
195
|
+
out.public = planned.public;
|
|
196
|
+
if (planned.public) {
|
|
197
|
+
delete out.release;
|
|
198
|
+
delete out.mount;
|
|
199
|
+
} else if (out.release === undefined || !isRelease(out.release)) out.release = "never";
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Write the plan into the record: the circles role and grants role in evda.json (added, never
|
|
204
|
+
// replacing a mapping it already has), one eco.yaml per circle, one grant per device per circle.
|
|
205
|
+
// A circle already declared keeps its opaque id and everything the owner wrote on it (`redeclared`).
|
|
206
|
+
// Returns what it wrote.
|
|
207
|
+
export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsRel = "06_Metadata/Grants", recordId, holder, at = new Date() } = {}) {
|
|
208
|
+
const planned = planCircles(dir, plan);
|
|
209
|
+
const manifest = readManifest(dir);
|
|
210
|
+
const layers = Array.isArray(manifest.layers) ? manifest.layers : [];
|
|
211
|
+
if (!layers.includes("circles")) manifest.layers = [...layers, "circles"];
|
|
212
|
+
manifest.mapping = { ...(manifest.mapping ?? {}) };
|
|
213
|
+
manifest.mapping.circles ??= circlesRel;
|
|
214
|
+
manifest.mapping.grants ??= grantsRel;
|
|
215
|
+
if (!manifest.id) {
|
|
216
|
+
if (!recordId) throw new EvdaError("the record has no id; pass recordId (its atlas id) so its grants can say who granted them.", { exit: 2 });
|
|
217
|
+
manifest.id = recordId;
|
|
218
|
+
}
|
|
219
|
+
writeManifest(dir, manifest);
|
|
220
|
+
mkdirSync(join(dir, manifest.mapping.circles), { recursive: true });
|
|
221
|
+
mkdirSync(join(dir, manifest.mapping.grants), { recursive: true });
|
|
222
|
+
const evda = openEvda(dir);
|
|
223
|
+
const existing = new Map((readCircles(evda) ?? []).filter((c) => c.manifest !== null).map((c) => [c.name, c.manifest]));
|
|
224
|
+
const owner = holder ?? manifest.owner?.name ?? manifest.id;
|
|
225
|
+
|
|
226
|
+
const written = [];
|
|
227
|
+
for (const c of planned.circles) {
|
|
228
|
+
const was = existing.get(c.name);
|
|
229
|
+
const m = was ? redeclared(was, c) : circleManifest({
|
|
230
|
+
name: c.name,
|
|
231
|
+
title: c.name,
|
|
232
|
+
isPublic: c.public,
|
|
233
|
+
holders: [owner],
|
|
234
|
+
members: numbered(c.members.map((m) => ({ path: m.path }))),
|
|
235
|
+
description: c.public ? "Public: anyone may read it." : `Private: read by the devices granted it, and no one else.`,
|
|
236
|
+
});
|
|
237
|
+
written.push(writeCircle(evda, c.name, m, { overwrite: true }));
|
|
238
|
+
}
|
|
239
|
+
const iso = at instanceof Date ? at.toISOString() : String(at);
|
|
240
|
+
for (const [device, d] of Object.entries(plan.devices ?? {})) {
|
|
241
|
+
for (const c of planned.circles) {
|
|
242
|
+
if (c.public) continue;
|
|
243
|
+
const role = d.grants?.[c.name] ?? d.grants?.["*"];
|
|
244
|
+
if (!role) continue;
|
|
245
|
+
written.push(grant(evda, { circle: c.name, to: { id: d.atlasId, kind: "person", name: device }, role, by: manifest.id, at: iso }, { overwrite: true }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { planned, written };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The circles a device holds and the ones it must never hold, by name.
|
|
252
|
+
//
|
|
253
|
+
// With the record's grant view (`grantView` in filechanges.mjs), the grant files decide: they are
|
|
254
|
+
// what routing, key delivery and receipt already read, so parity reads the same fact rather than a
|
|
255
|
+
// second copy of it. The plan's `devices.<name>.grants` only seeds `declare`. `drift` names each
|
|
256
|
+
// circle where the plan says otherwise, so a stale plan is printed and not believed (HQ-1219,
|
|
257
|
+
// 2026-09-22: Hestia was granted every circle of his record while the plan still said "never
|
|
258
|
+
// therapy"). Without a view the plan decides, as for a record whose grants are not declared yet.
|
|
259
|
+
export function deviceCircles(plan, device, view = null) {
|
|
260
|
+
const d = plan.devices?.[device];
|
|
261
|
+
if (!d) throw new EvdaError(`the plan names no device "${device}".`, { exit: 2 });
|
|
262
|
+
const byPlan = (c) => c.public === true || Boolean(d.grants?.[c.name] || d.grants?.["*"]);
|
|
263
|
+
let held = byPlan;
|
|
264
|
+
if (view) {
|
|
265
|
+
const grantHolds = view.holds(d.atlasId);
|
|
266
|
+
held = (c) => {
|
|
267
|
+
if (c.public === true) return true;
|
|
268
|
+
const entry = view.index.find((e) => e.name === c.name);
|
|
269
|
+
return entry !== undefined && grantHolds(entry);
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const holds = plan.circles.filter(held).map((c) => c.name);
|
|
273
|
+
const drift = view
|
|
274
|
+
? plan.circles.filter((c) => byPlan(c) !== held(c)).map((c) => `${c.name}: the plan says ${byPlan(c) ? "held" : "never"}, the grant files say ${held(c) ? "held" : "never"}`)
|
|
275
|
+
: [];
|
|
276
|
+
return { atlasId: d.atlasId, holds, never: plan.circles.map((c) => c.name).filter((n) => !holds.includes(n)), drift };
|
|
277
|
+
}
|