create-evda 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +93 -8
  3. package/bin/evda.mjs +437 -5
  4. package/create-evda.mjs +68 -12
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +351 -19
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +22 -8
  17. package/lib/gather-apply.mjs +635 -0
  18. package/lib/gather.mjs +808 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +467 -35
  29. package/lib/migrate-people.mjs +231 -0
  30. package/lib/organize.mjs +52 -12
  31. package/lib/parity.mjs +111 -0
  32. package/lib/presets.mjs +379 -0
  33. package/lib/prompt-hook.mjs +88 -0
  34. package/lib/publish.mjs +264 -0
  35. package/lib/readers.mjs +319 -0
  36. package/lib/records.mjs +52 -0
  37. package/lib/references.mjs +5 -51
  38. package/lib/render.mjs +158 -0
  39. package/lib/roles.mjs +102 -1
  40. package/lib/secret-shape.mjs +58 -0
  41. package/lib/setup.mjs +146 -2
  42. package/lib/skills.mjs +266 -0
  43. package/lib/subrecord.mjs +121 -0
  44. package/lib/sync-rules.mjs +87 -0
  45. package/lib/tools.mjs +120 -29
  46. package/lib/update-check.mjs +146 -0
  47. package/lib/work.mjs +180 -0
  48. package/lib/yaml.mjs +117 -42
  49. package/package.json +25 -15
  50. package/presets/journal/preset.json +19 -0
  51. package/presets/projects-and-areas/preset.json +34 -0
  52. package/skills/evda/SKILL.md +76 -0
  53. package/templates/AGENTS.md +129 -15
  54. package/templates/README.md +15 -1
  55. package/templates/circles-README.md +3 -3
  56. package/templates/evda-digest.md +32 -5
  57. package/LICENSE +0 -21
@@ -0,0 +1,379 @@
1
+ // Presets: ways to organize a record, shipped as a library so an agent picks one instead of making
2
+ // one up. Each is `presets/<id>/preset.json` in this package:
3
+ //
4
+ // {
5
+ // "id": "journal",
6
+ // "title": "Journal",
7
+ // "for": "One sentence: who it suits.",
8
+ // "folders": [{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." }],
9
+ // "circles": [{ "name": "family", "public": false, "description": "...", "members": { "people": { "path": "People" } } }],
10
+ // "routines": ["weekly"],
11
+ // "mapping": { "journal": "Journal", "collections": "People" }
12
+ // }
13
+ //
14
+ // `mapping` is what the preset declares for the four roles (Spec 07 section 2, HQ-1371): each key
15
+ // a role or a `<role>/<facet>`, each value a folder the preset makes. Every folder the preset makes
16
+ // fills a role, by lying at or under one of these paths; a folder that fills none is refused, so
17
+ // no preset ships a folder an agent cannot say the purpose of. Applying writes the role paths the
18
+ // record does not have into its evda.json and never replaces a key it has: when the key is taken,
19
+ // the folder fills the same role under a facet key named for it (`collections/people`).
20
+ //
21
+ // `folders` are paths relative to the record root. A folder with a `type` gets folder.evda.json
22
+ // (type, connectors, views), checked by the same validator doctor uses; a folder with no type is a
23
+ // plain directory and nothing more. `circles` are starter circles, in the substrate's eco shape with
24
+ // members by path, and they are only ever PROPOSED: a proposal file under proposals/, never a circle.
25
+ // `routines` name routines lib/routines.mjs already knows, and are offered, never scheduled.
26
+ //
27
+ // Applying a preset only adds. It creates the directories that are missing, writes the manifests
28
+ // that are missing, and reports the rest: a manifest that already says the same thing is skipped,
29
+ // one that says something else is a conflict and is left exactly as it is, and a preset path that
30
+ // already exists as a file is a conflict. Nothing is moved, renamed or deleted, so applying twice
31
+ // writes nothing the second time. A shipped preset that does not validate fails
32
+ // test/presets.test.mjs, so the person running it never meets one.
33
+
34
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
35
+ import { dirname, join, resolve, sep } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
38
+ import { resolveRole, fourRoleOf, FOUR_ROLES, LEGACY_FACETS } from "./roles.mjs";
39
+ import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders.mjs";
40
+ import { ROUTINE_KEYS } from "./routines.mjs";
41
+ import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
42
+ import { EvdaError } from "./errors.mjs";
43
+
44
+ export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
45
+ export const PRESET_FILE = "preset.json";
46
+ export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines", "mapping"];
47
+ export const PRESET_FOLDER_KEYS = ["path", "type", "connectors", "views", "note"];
48
+ export const PRESET_CIRCLE_KEYS = ["name", "public", "description", "members"];
49
+ export const NONE = "none";
50
+
51
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
52
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
53
+ const ID = /^[a-z0-9][a-z0-9-]*$/;
54
+ const CIRCLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
55
+
56
+ // A preset path: relative, forward slashes, no empty, dot or dot-dot segment, and no segment doctor's
57
+ // walk would skip (a dot-directory or node_modules), because a folder doctor cannot see is a promise
58
+ // nothing checks.
59
+ function pathProblem(p) {
60
+ if (!nonEmpty(p)) return "must be a non-empty path";
61
+ if (p.includes("\\")) return "uses a backslash; paths use /";
62
+ if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
63
+ for (const seg of p.split("/")) {
64
+ if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
65
+ if (seg.startsWith(".") || seg === "node_modules") return `has the segment "${seg}", which doctor does not read`;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // Validate one preset. Returns a list of problem strings; empty is valid. Stricter than a folder
71
+ // manifest on disk: an unknown key or type in something this package ships is a mistake, not a
72
+ // newer tool's key to preserve.
73
+ export function validatePreset(p, dirName = null) {
74
+ const problems = [];
75
+ const fail = (m) => problems.push(`${dirName ?? p?.id ?? "preset"}: ${m}`);
76
+ if (!isObject(p)) { fail("must hold a JSON object."); return problems; }
77
+ const unknown = Object.keys(p).filter((k) => !PRESET_KEYS.includes(k));
78
+ if (unknown.length) fail(`unknown key(s): ${unknown.join(", ")}.`);
79
+ if (!nonEmpty(p.id) || !ID.test(p.id)) fail(`"id" must be lowercase letters, digits and -, got ${JSON.stringify(p.id)}.`);
80
+ else if (dirName !== null && p.id !== dirName) fail(`"id" is ${JSON.stringify(p.id)} but the preset lives in ${dirName}/; the directory names it.`);
81
+ if (!nonEmpty(p.title)) fail(`"title" is missing.`);
82
+ if (!nonEmpty(p.for)) fail(`"for" is missing: one sentence saying who it suits.`);
83
+ else if (/\n/.test(p.for)) fail(`"for" is one sentence on one line.`);
84
+ const paths = new Set();
85
+ if (!Array.isArray(p.folders) || p.folders.length === 0) fail(`"folders" must be a non-empty list.`);
86
+ else {
87
+ p.folders.forEach((f, i) => {
88
+ const at = `folders[${i}]`;
89
+ if (!isObject(f)) { fail(`${at} must be { path, type?, connectors?, views?, note? }.`); return; }
90
+ const extra = Object.keys(f).filter((k) => !PRESET_FOLDER_KEYS.includes(k));
91
+ if (extra.length) fail(`${at} unknown key(s): ${extra.join(", ")}.`);
92
+ const bad = pathProblem(f.path);
93
+ if (bad) { fail(`${at} path ${bad}.`); return; }
94
+ if (paths.has(f.path)) fail(`${at} path ${f.path} is listed twice.`);
95
+ paths.add(f.path);
96
+ if (f.note !== undefined && !nonEmpty(f.note)) fail(`${at} "note" must be a sentence.`);
97
+ if (f.type === undefined) {
98
+ if (f.connectors !== undefined || f.views !== undefined) fail(`${at} (${f.path}) has connectors or views and no type. An untyped folder is a plain directory with no manifest to hold them.`);
99
+ return;
100
+ }
101
+ if (!FOLDER_TYPES.includes(f.type)) fail(`${at} (${f.path}) type ${JSON.stringify(f.type)} is not one of ${FOLDER_TYPES.join(", ")}.`);
102
+ const v = validateFolderManifest(manifestFor(f), `${f.path}/${FOLDER_MANIFEST}`);
103
+ for (const pr of v.problems) fail(pr.message);
104
+ for (const view of f.views ?? []) {
105
+ if (isObject(view) && nonEmpty(view.path) && pathProblem(view.path)) fail(`${at} view "${view.view}" path ${pathProblem(view.path)}.`);
106
+ }
107
+ });
108
+ }
109
+ // The four roles. Required: a preset is a way to fill them.
110
+ if (!isObject(p.mapping) || Object.keys(p.mapping).length === 0) fail(`"mapping" must name the folders that fill the four roles (${FOUR_ROLES.join(", ")}).`);
111
+ else {
112
+ const rolePaths = [];
113
+ for (const [key, rel] of Object.entries(p.mapping)) {
114
+ const role = fourRoleOf(key);
115
+ if (!role || LEGACY_FACETS[key]) { fail(`mapping key "${key}" is not one of the four roles or a <role>/<facet> of one.`); continue; }
116
+ if (!nonEmpty(rel) || !paths.has(rel)) { fail(`mapping "${key}" is ${JSON.stringify(rel)}, which is not a folder this preset makes.`); continue; }
117
+ rolePaths.push({ role, rel });
118
+ }
119
+ const missing = FOUR_ROLES.filter((r) => !rolePaths.some((x) => x.role === r));
120
+ if (missing.length) fail(`fills no folder for ${missing.join(", ")}. Every record has the four roles.`);
121
+ for (const fp of paths) {
122
+ if (!rolePaths.some((x) => fp === x.rel || fp.startsWith(`${x.rel}/`))) fail(`folder ${fp} fills no role: it is not at or under any path in "mapping".`);
123
+ }
124
+ const seen = new Map();
125
+ for (const x of rolePaths) { if (seen.has(x.rel) && seen.get(x.rel) !== x.role) fail(`folder ${x.rel} fills two roles.`); seen.set(x.rel, x.role); }
126
+ }
127
+ if (p.circles !== undefined) {
128
+ if (!Array.isArray(p.circles)) fail(`"circles" must be a list.`);
129
+ else {
130
+ const names = new Set();
131
+ p.circles.forEach((c, i) => {
132
+ const at = `circles[${i}]`;
133
+ if (!isObject(c)) { fail(`${at} must be { name, public, description, members }.`); return; }
134
+ const extra = Object.keys(c).filter((k) => !PRESET_CIRCLE_KEYS.includes(k));
135
+ if (extra.length) fail(`${at} unknown key(s): ${extra.join(", ")}.`);
136
+ if (!nonEmpty(c.name) || !CIRCLE_NAME.test(c.name)) fail(`${at} "name" must be letters, digits, - and _.`);
137
+ else if (names.has(c.name)) fail(`${at} circle ${c.name} is listed twice.`);
138
+ else names.add(c.name);
139
+ if (typeof c.public !== "boolean") fail(`${at} "public" must be true or false.`);
140
+ if (!nonEmpty(c.description)) fail(`${at} "description" says in plain words who may read it and why.`);
141
+ if (!isObject(c.members) || Object.keys(c.members).length === 0) { fail(`${at} "members" must name at least one path.`); return; }
142
+ for (const [name, m] of Object.entries(c.members)) {
143
+ if (!isObject(m) || Object.keys(m).length !== 1 || !nonEmpty(m.path)) { fail(`${at} member "${name}" must be { path }; a preset circle holds folders this preset makes.`); continue; }
144
+ if (![...paths].some((fp) => m.path === fp || m.path.startsWith(`${fp}/`))) fail(`${at} member "${name}" path ${m.path} is not a folder this preset makes.`);
145
+ }
146
+ });
147
+ }
148
+ }
149
+ if (p.routines !== undefined) {
150
+ if (!Array.isArray(p.routines)) fail(`"routines" must be a list.`);
151
+ else {
152
+ for (const r of p.routines) if (!ROUTINE_KEYS.includes(r)) fail(`routine ${JSON.stringify(r)} is not one this package has (${ROUTINE_KEYS.join(", ")}).`);
153
+ if (new Set(p.routines).size !== p.routines.length) fail(`"routines" lists one twice.`);
154
+ }
155
+ }
156
+ return problems;
157
+ }
158
+
159
+ // The folder.evda.json a typed preset folder writes. The same three keys the scaffold writes.
160
+ export function manifestFor(folder) {
161
+ return { type: folder.type, connectors: folder.connectors ?? [], views: folder.views ?? [] };
162
+ }
163
+
164
+ // Read one preset from `dir` (the presets directory). Throws when it is missing or does not validate.
165
+ export function loadPreset(id, { dir = PRESETS_DIR } = {}) {
166
+ if (!nonEmpty(id) || !ID.test(id)) throw new EvdaError(`REFUSED: ${JSON.stringify(id)} is not a preset id. Presets: ${presetIds({ dir }).join(", ")}.`, { code: "NO_PRESET", exit: 2 });
167
+ const p = join(dir, id, PRESET_FILE);
168
+ if (!existsSync(p)) throw new EvdaError(`REFUSED: no preset named "${id}". Presets: ${presetIds({ dir }).join(", ")}.`, { code: "NO_PRESET", exit: 2 });
169
+ let preset;
170
+ try {
171
+ preset = JSON.parse(readFileSync(p, "utf8"));
172
+ } catch (e) {
173
+ throw new EvdaError(`REFUSED: preset ${id} is not valid JSON (${e.message}).`, { code: "BAD_PRESET", exit: 2 });
174
+ }
175
+ const problems = validatePreset(preset, id);
176
+ if (problems.length) throw new EvdaError(`REFUSED: preset ${id} is malformed: ${problems[0]}`, { code: "BAD_PRESET", exit: 2 });
177
+ return preset;
178
+ }
179
+
180
+ export function presetIds({ dir = PRESETS_DIR } = {}) {
181
+ if (!existsSync(dir)) return [];
182
+ return readdirSync(dir).filter((n) => !n.startsWith(".") && existsSync(join(dir, n, PRESET_FILE))).sort();
183
+ }
184
+
185
+ // Every shipped preset, loaded and validated.
186
+ export function listPresets(opts = {}) {
187
+ return presetIds(opts).map((id) => loadPreset(id, opts));
188
+ }
189
+
190
+ // What `folder`'s existing manifest lacks against the preset's: a different type, a connector or a
191
+ // view the preset declares and it does not. [] means it already says what the preset says; a
192
+ // connector or view the owner added beyond the preset is theirs and is not a difference.
193
+ function differences(existing, want) {
194
+ const out = [];
195
+ if (!isObject(existing)) return ["it does not hold a JSON object"];
196
+ if (existing.type !== want.type) out.push(`its type is ${JSON.stringify(existing.type)}, the preset's is "${want.type}"`);
197
+ const has = Array.isArray(existing.connectors) ? existing.connectors : [];
198
+ const missing = want.connectors.filter((c) => !has.includes(c));
199
+ if (missing.length) out.push(`it does not declare connector(s) ${missing.join(", ")}`);
200
+ const views = Array.isArray(existing.views) ? existing.views.map((v) => v?.view) : [];
201
+ const noView = want.views.filter((v) => !views.includes(v.view)).map((v) => v.view);
202
+ if (noView.length) out.push(`it does not declare view(s) ${noView.join(", ")}`);
203
+ return out;
204
+ }
205
+
206
+ // The first segment of `rel` that exists and is not a directory, or null.
207
+ function blockedBy(root, rel) {
208
+ const segs = rel.split("/");
209
+ for (let i = 1; i <= segs.length; i++) {
210
+ const sub = segs.slice(0, i).join("/");
211
+ const abs = join(root, sub);
212
+ let st;
213
+ try { st = lstatSync(abs); } catch { return null; }
214
+ if (st.isSymbolicLink()) { try { st = statSync(abs); } catch { return sub; } }
215
+ if (!st.isDirectory()) return sub;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}`, "---", ""].join("\n");
221
+
222
+ // The plan for applying preset `id` to the record at `root`, and with `dry: false` the writes.
223
+ // create directories that do not exist yet
224
+ // write manifests that do not exist yet ({ path, folder, manifest })
225
+ // views view files a manifest declares that do not exist yet
226
+ // matched manifests that exist and already say what the preset says (skipped)
227
+ // conflicts manifests that exist and say something else, and preset paths that are files
228
+ // ({ path, why }); reported, never overwritten
229
+ // circles preset circles with no circle and no proposal yet, proposed on apply
230
+ // routines routines the preset suggests, offered and never scheduled
231
+ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
232
+ const evda = openEvda(resolve(root));
233
+ const preset = loadPreset(id, { dir });
234
+ const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
235
+ // The four roles the record does not declare yet. A key it has with another path is a conflict,
236
+ // left as it is; a path it already maps under a key of the same role is already there.
237
+ const has = evda.manifest.mapping;
238
+ for (const [key, rel] of Object.entries(preset.mapping ?? {})) {
239
+ if (has[key] === rel) continue;
240
+ if (Object.entries(has).some(([k, v]) => v === rel && fourRoleOf(k) === fourRoleOf(key))) continue;
241
+ // A key the record already uses for another path keeps it; the preset's folder fills the same
242
+ // role under a facet key named for the folder, so two presets compose and nothing is replaced.
243
+ let k = key;
244
+ if (has[k] !== undefined || plan.roles.some((r) => r.key === k)) {
245
+ const slug = rel.split("/").pop().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "folder";
246
+ k = `${fourRoleOf(key)}/${slug}`;
247
+ for (let n = 2; has[k] !== undefined || plan.roles.some((r) => r.key === k); n++) k = `${fourRoleOf(key)}/${slug}-${n}`;
248
+ }
249
+ plan.roles.push({ key: k, rel });
250
+ }
251
+ const blocked = new Set();
252
+ for (const f of preset.folders) {
253
+ const abs = resolve(evda.dir, f.path);
254
+ if (!abs.startsWith(evda.dir + sep)) { plan.conflicts.push({ path: f.path, why: "resolves outside the record" }); continue; }
255
+ const file = blockedBy(evda.dir, f.path);
256
+ if (file) {
257
+ if (!blocked.has(file)) plan.conflicts.push({ path: file, why: `exists as a file, so the folder ${f.path} cannot be made there` });
258
+ blocked.add(file);
259
+ continue;
260
+ }
261
+ if (!existsSync(abs) && !plan.create.includes(f.path)) plan.create.push(f.path);
262
+ if (f.type === undefined) continue;
263
+ const want = manifestFor(f);
264
+ const mpath = `${f.path}/${FOLDER_MANIFEST}`;
265
+ const mabs = join(abs, FOLDER_MANIFEST);
266
+ if (!existsSync(mabs)) plan.write.push({ path: mpath, folder: f, manifest: want });
267
+ else if (!lstatSync(mabs).isFile()) plan.conflicts.push({ path: mpath, why: "exists and is not a file" });
268
+ else {
269
+ let existing;
270
+ try { existing = JSON.parse(readFileSync(mabs, "utf8")); } catch { plan.conflicts.push({ path: mpath, why: "exists and is not valid JSON" }); continue; }
271
+ const diff = differences(existing, want);
272
+ if (diff.length) plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` });
273
+ else plan.matched.push({ path: mpath });
274
+ }
275
+ for (const v of want.views) {
276
+ if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
277
+ }
278
+ }
279
+ // A role is declared only over a folder this apply leaves in place; one a file blocks is not.
280
+ plan.roles = plan.roles.filter((r) => blockedBy(evda.dir, r.rel) === null);
281
+ const circlesRole = resolveRole(evda, "circles");
282
+ const proposals = join(evda.dir, PROPOSALS);
283
+ const proposalNames = existsSync(proposals) ? readdirSync(proposals) : [];
284
+ for (const c of preset.circles ?? []) {
285
+ const exists = circlesRole.mapped && existsSync(join(circlesRole.abs, c.name));
286
+ const proposed = proposalNames.some((n) => new RegExp(`^\\d{4}-\\d{2}-\\d{2}-circle-${c.name}(-\\d+)?\\.md$`).test(n));
287
+ if (!exists && !proposed) plan.circles.push(c);
288
+ }
289
+ if (dry) return plan;
290
+
291
+ if (plan.roles.length) {
292
+ const m = readManifest(evda.dir);
293
+ for (const r of plan.roles) m.mapping[r.key] ??= r.rel;
294
+ writeManifest(evda.dir, m);
295
+ }
296
+ for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
297
+ for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
298
+ for (const v of plan.views) {
299
+ mkdirSync(dirname(resolve(evda.dir, v.path)), { recursive: true });
300
+ writeFileSync(resolve(evda.dir, v.path), viewHeader(v.view), { flag: "wx" });
301
+ }
302
+ for (const c of plan.circles) {
303
+ const r = proposeCircle(evda, {
304
+ name: c.name,
305
+ who: c.public ? "Anyone." : "Only the people the owner grants it to, by name.",
306
+ what: Object.values(c.members).map((m) => m.path).join(", "),
307
+ why: `${c.description} Suggested by the ${preset.title} preset.`,
308
+ }, { now });
309
+ plan.proposed.push(r.path);
310
+ }
311
+ return plan;
312
+ }
313
+
314
+ // What preset `id` has that the record at `root` does not: the dry-run plan, which by construction
315
+ // holds only additions and conflicts. This is how an agent proposes one folder from the library later.
316
+ export function diffPreset(root, id, opts = {}) {
317
+ return applyPreset(root, id, { ...opts, dry: true });
318
+ }
319
+
320
+ export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
321
+
322
+ // The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
323
+ export function summary(plan, preset = loadPreset(plan.id)) {
324
+ const lines = [];
325
+ const adds = preset.folders.filter((f) => plan.create.includes(f.path) || plan.write.some((w) => w.folder.path === f.path));
326
+ if (!adds.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length) {
327
+ lines.push(`This record already has everything the ${plan.title} preset makes. Nothing would be written.`);
328
+ } else {
329
+ lines.push(`The ${plan.title} preset would add ${adds.length} folder(s) to this record:`);
330
+ for (const f of adds) lines.push(`- ${f.path}/${f.type ? ` (${f.type})` : ""}${f.note ? `: ${f.note}` : ""}`);
331
+ if (plan.roles?.length) lines.push(`It would declare ${plan.roles.length} role path(s) in evda.json: ${plan.roles.map((r) => `${r.key} -> ${r.rel}`).join(", ")}.`);
332
+ if (plan.circles.length) lines.push(`It would propose ${plan.circles.length} circle(s), as proposal files only: ${plan.circles.map((c) => c.name).join(", ")}.`);
333
+ if (plan.matched.length) lines.push(`${plan.matched.length} folder(s) are already set up the same way and would be left alone.`);
334
+ for (const c of plan.conflicts) lines.push(`Left as it is: ${c.path} ${c.why}.`);
335
+ lines.push("Nothing is moved, renamed or deleted. It is applied only when the owner says yes.");
336
+ }
337
+ if (plan.routines.length) lines.push(`It suits the ${plan.routines.join(" and ")} routine(s), offered separately and never scheduled without a yes.`);
338
+ return lines.join("\n");
339
+ }
340
+
341
+ // Plan lines for the CLI, in the package's CHECKED / SKIPPED convention.
342
+ export function planLines(plan, { diff = false } = {}) {
343
+ const out = [];
344
+ const verb = plan.dry || diff ? "would" : "did";
345
+ for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
346
+ for (const w of plan.write) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} type ${w.manifest.type}${w.manifest.connectors.length ? `, connectors ${w.manifest.connectors.join(", ")}` : ""}`);
347
+ for (const r of plan.roles ?? []) out.push(` ${verb === "did" ? "declared" : "declare "} ${r.key} -> ${r.rel} in evda.json`);
348
+ for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
349
+ if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
350
+ for (const c of plan.conflicts) out.push(` CONFLICT ${c.path} ${c.why}. Left as it is.`);
351
+ if (plan.proposed.length) for (const p of plan.proposed) out.push(` proposed ${p} (a proposal file; no circle was created)`);
352
+ else for (const c of plan.circles) out.push(` propose circle ${c.name} (a proposal file only)`);
353
+ for (const r of plan.routines) out.push(` offer routine ${r}: evda routine ${r} (nothing is scheduled without a yes)`);
354
+ return out;
355
+ }
356
+
357
+ // The MCP tools. Each takes the open record and its arguments, and refuses an argument it does not take.
358
+
359
+ export function presetsTool(evda, args = {}) {
360
+ onlyKeys("evda_presets", args, ["id"]);
361
+ if (args.id === undefined) return { presets: listPresets().map((p) => ({ id: p.id, title: p.title, for: p.for, folders: p.folders.length })) };
362
+ return { preset: loadPreset(args.id) };
363
+ }
364
+
365
+ export function proposeStructureTool(evda, args = {}) {
366
+ onlyKeys("evda_propose_structure", args, ["id"], "It only proposes. Applying a preset is evda_apply_preset, after the owner says yes.");
367
+ if (!nonEmpty(args.id)) throw refuse(`evda_propose_structure needs "id", a preset from evda_presets.`, "NO_PRESET");
368
+ const plan = applyPreset(evda.dir, args.id, { dry: true });
369
+ return { plan, summary: summary(plan) };
370
+ }
371
+
372
+ export function applyPresetTool(evda, args = {}, { now = new Date() } = {}) {
373
+ onlyKeys("evda_apply_preset", args, ["id", "confirmed"], "It adds the preset's folders and manifests and nothing else; it never moves, renames or deletes.");
374
+ if (!nonEmpty(args.id)) throw refuse(`evda_apply_preset needs "id", a preset from evda_presets.`, "NO_PRESET");
375
+ if (args.confirmed !== true) throw refuse(`evda_apply_preset needs "confirmed": true, and that is true only when the owner said yes to this preset in this conversation. Show them evda_propose_structure first and wait.`, "NOT_CONFIRMED");
376
+ loadPreset(args.id);
377
+ const plan = applyPreset(evda.dir, args.id, { now });
378
+ return { plan, lines: planLines(plan) };
379
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Every message the owner sends, filed, in Claude Code (HQ-1385): a UserPromptSubmit hook that
3
+ // appends each prompt verbatim to the day's record, deterministically, before the model sees it.
4
+ //
5
+ // Mike, 2026-09-22, asked what the Claude app files: "Everything... how we can make this even more
6
+ // sustainable so that I can do this for all of the users." A tool description and an instruction
7
+ // are prompts, and a model can ignore both; a hook cannot be ignored. It exists only for an owner who
8
+ // answered "everything" to the one setup question: it reads evda.json `filing` on every call and
9
+ // files nothing when it says anything else, and `evda hook install` installs it only then.
10
+ //
11
+ // The append is lib/day-file.mjs appendToDay, the path evda_write uses: the first prompt of a
12
+ // session opens `## HH:MM`, later ones go under it. The session is Claude Code's session_id, kept
13
+ // in a small state file outside the record. A prompt holding something shaped like a password, key
14
+ // or token is not filed (lib/secret-shape.mjs, the check evda_write makes); a slash command is not
15
+ // the owner's words and is not filed.
16
+ //
17
+ // What it prints is added to the model's context: one line naming the file, so the model does not
18
+ // file the same words again with evda_write.
19
+ //
20
+ // Copied byte for byte into <record>/.claude/hooks/evda-file-prompt.mjs beside day-file.mjs and
21
+ // secret-shape.mjs. Any error of its own exits 0 with no output: a broken hook must never block the
22
+ // owner's message.
23
+ //
24
+ // EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
25
+ // that shows the hook reads the owner's answer.
26
+
27
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
28
+ import { dirname, join, relative } from "node:path";
29
+ import { tmpdir } from "node:os";
30
+ import { fileURLToPath } from "node:url";
31
+ import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
32
+ import { secretShape } from "./secret-shape.mjs";
33
+
34
+ export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
35
+
36
+ const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
37
+
38
+ function stateFile(sid) {
39
+ const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
40
+ mkdirSync(dir, { recursive: true });
41
+ return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
42
+ }
43
+
44
+ // One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
45
+ export function run(payload, { now = new Date() } = {}) {
46
+ try {
47
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
48
+ if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
49
+ const here = dirname(fileURLToPath(import.meta.url));
50
+ const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
51
+ if (!root) return "";
52
+ const manifest = JSON.parse(readFileSync(join(root, "evda.json"), "utf8"));
53
+ if (filingOf(manifest) !== "everything" && !falsified("ignore-filing")) return "";
54
+ const role = dayRole(manifest.mapping ?? {});
55
+ if (!role) return "";
56
+ const shape = prompt.split("\n").map(secretShape).find(Boolean);
57
+ if (shape) return `evda: this message was not filed, because it holds ${shape}. Keep that in a password manager; file the rest of what they said with evda_write if it is worth keeping.`;
58
+ const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
59
+ mkdirSync(dayAbs, { recursive: true });
60
+ const abs = join(dayAbs, `${localDate(now)}.md`);
61
+ const sf = stateFile(payload.session_id ?? "unknown");
62
+ let session = {};
63
+ try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
64
+ appendToDay(abs, { now, words: prompt, session });
65
+ writeFileSync(sf, JSON.stringify(session) + "\n");
66
+ const rel = relative(root, abs).split("\\").join("/");
67
+ return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ const entry = (() => {
74
+ try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
75
+ })();
76
+
77
+ if (entry && existsSync(process.argv[1])) {
78
+ let input = "";
79
+ process.stdin.setEncoding("utf8");
80
+ process.stdin.on("data", (d) => (input += d));
81
+ process.stdin.on("end", () => {
82
+ let payload = null;
83
+ try { payload = JSON.parse(input); } catch { /* not a hook call */ }
84
+ const out = run(payload);
85
+ if (out) process.stdout.write(out);
86
+ process.exit(0);
87
+ });
88
+ }