create-evda 0.1.9 → 0.1.11

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 (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
package/lib/presets.mjs CHANGED
@@ -28,7 +28,10 @@
28
28
  // that are missing, and reports the rest: a manifest that already says the same thing is skipped,
29
29
  // one that says something else is a conflict and is left exactly as it is, and a preset path that
30
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
31
+ // writes nothing the second time. A folder typed person also brings its layout (HQ-2024): a sibling
32
+ // Processing/ for processing docs, and a README inside the folder stating the one-file-per-person
33
+ // rule doctor checks, each only when missing and only when the folder's manifest is the preset's.
34
+ // A shipped preset that does not validate fails
32
35
  // test/presets.test.mjs, so the person running it never meets one.
33
36
 
34
37
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
@@ -40,6 +43,8 @@ import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders
40
43
  import { ROUTINE_KEYS } from "./routines.mjs";
41
44
  import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
42
45
  import { EvdaError } from "./errors.mjs";
46
+ import { PERSON_FOLDER_README, PROCESSING_DIR, README_NAME } from "./person-folders.mjs";
47
+ import { outsideRecord } from "./record-walk.mjs";
43
48
 
44
49
  export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
45
50
  export const PRESET_FILE = "preset.json";
@@ -62,7 +67,7 @@ function pathProblem(p) {
62
67
  if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
63
68
  for (const seg of p.split("/")) {
64
69
  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`;
70
+ if (seg.startsWith(".") || outsideRecord(seg)) return `has the segment "${seg}", which doctor does not read`;
66
71
  }
67
72
  return null;
68
73
  }
@@ -223,6 +228,7 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
223
228
  // create directories that do not exist yet
224
229
  // write manifests that do not exist yet ({ path, folder, manifest })
225
230
  // views view files a manifest declares that do not exist yet
231
+ // files files a person folder brings that do not exist yet ({ path, text }): its README
226
232
  // matched manifests that exist and already say what the preset says (skipped)
227
233
  // conflicts manifests that exist and say something else, and preset paths that are files
228
234
  // ({ path, why }); reported, never overwritten
@@ -231,7 +237,7 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
231
237
  export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
232
238
  const evda = openEvda(resolve(root));
233
239
  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: [] };
240
+ const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], files: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
235
241
  // The four roles the record does not declare yet. A key it has with another path is a conflict,
236
242
  // left as it is; a path it already maps under a key of the same role is already there.
237
243
  const has = evda.manifest.mapping;
@@ -263,15 +269,29 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
263
269
  const want = manifestFor(f);
264
270
  const mpath = `${f.path}/${FOLDER_MANIFEST}`;
265
271
  const mabs = join(abs, FOLDER_MANIFEST);
272
+ let ours = true;
266
273
  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" });
274
+ else if (!lstatSync(mabs).isFile()) { plan.conflicts.push({ path: mpath, why: "exists and is not a file" }); ours = false; }
268
275
  else {
269
276
  let existing;
270
277
  try { existing = JSON.parse(readFileSync(mabs, "utf8")); } catch { plan.conflicts.push({ path: mpath, why: "exists and is not valid JSON" }); continue; }
271
278
  const diff = differences(existing, want);
272
- if (diff.length) plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` });
279
+ if (diff.length) { plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` }); ours = false; }
273
280
  else plan.matched.push({ path: mpath });
274
281
  }
282
+ if (f.type === "person" && ours) {
283
+ const readme = `${f.path}/${README_NAME}`;
284
+ const rabs = join(abs, README_NAME);
285
+ if (!existsSync(rabs)) plan.files.push({ path: readme, text: PERSON_FOLDER_README });
286
+ else if (!lstatSync(rabs).isFile()) plan.conflicts.push({ path: readme, why: "exists and is not a file" });
287
+ const parent = f.path.includes("/") ? f.path.slice(0, f.path.lastIndexOf("/")) : "";
288
+ const processing = parent ? `${parent}/${PROCESSING_DIR}` : PROCESSING_DIR;
289
+ const pfile = blockedBy(evda.dir, processing);
290
+ if (pfile) {
291
+ if (!blocked.has(pfile)) plan.conflicts.push({ path: pfile, why: `exists as a file, so ${processing}/ for processing docs beside ${f.path} cannot be made there` });
292
+ blocked.add(pfile);
293
+ } else if (!existsSync(join(evda.dir, processing)) && !plan.create.includes(processing)) plan.create.push(processing);
294
+ }
275
295
  for (const v of want.views) {
276
296
  if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
277
297
  }
@@ -295,6 +315,7 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
295
315
  }
296
316
  for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
297
317
  for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
318
+ for (const w of plan.files) writeFileSync(join(evda.dir, w.path), w.text, { flag: "wx" });
298
319
  for (const v of plan.views) {
299
320
  mkdirSync(dirname(resolve(evda.dir, v.path)), { recursive: true });
300
321
  writeFileSync(resolve(evda.dir, v.path), viewHeader(v.view), { flag: "wx" });
@@ -317,7 +338,7 @@ export function diffPreset(root, id, opts = {}) {
317
338
  return applyPreset(root, id, { ...opts, dry: true });
318
339
  }
319
340
 
320
- export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
341
+ export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !(plan.files ?? []).length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
321
342
 
322
343
  // The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
323
344
  export function summary(plan, preset = loadPreset(plan.id)) {
@@ -344,6 +365,7 @@ export function planLines(plan, { diff = false } = {}) {
344
365
  const verb = plan.dry || diff ? "would" : "did";
345
366
  for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
346
367
  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(", ")}` : ""}`);
368
+ for (const w of plan.files ?? []) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} the person folder's rule`);
347
369
  for (const r of plan.roles ?? []) out.push(` ${verb === "did" ? "declared" : "declare "} ${r.key} -> ${r.rel} in evda.json`);
348
370
  for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
349
371
  if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
@@ -23,10 +23,30 @@
23
23
  //
24
24
  // EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
25
25
  // that shows the hook reads the owner's answer.
26
+ //
27
+ // ON A MACHINE THAT DOES NOT WRITE THE DAY (HQ-2817). EVDA_DAY_WRITER set means another machine
28
+ // writes the daily file, and a local append is the iCloud race HQ-2165 exists to stop (lib/
29
+ // day-writer.mjs). There the hook never touches the day file. It hands the words to the command in
30
+ // EVDA_DAY_WRITER_SEND, which receives evda_write's arguments as JSON on stdin and exits 0 once the
31
+ // writer filed them (Mike's: ssh to Hestia and its evda-tool.mjs evda_write). Each message is first
32
+ // saved as one JSON file in this machine's own outbox, outside the record and outside any synced
33
+ // folder (EVDA_PROMPT_OUTBOX, default ~/.evda/outbox), then the outbox is sent oldest first and a
34
+ // file is deleted only after its send exits 0. So a message said while the writer is unreachable
35
+ // waits there and goes on the next message that reaches it, filed once:
36
+ // - one drainer: only this machine's hook reads its outbox, so no other machine can file it too;
37
+ // - one sender per file: a send first renames the file to `.sending`, an atomic claim, so two
38
+ // sessions submitting at once never send the same file; a claim older than SENDING_STALE_MS
39
+ // (a hook that was killed mid-send) is put back for the next run.
40
+ // The whole send is bounded by EVDA_DAY_WRITER_SEND_MS (default 6000) because Claude Code holds the
41
+ // owner's message until this hook returns. One case can still file twice: a send that reached the
42
+ // writer and timed out before it answered. A connect timeout on the sender's side keeps that rare.
43
+ // With EVDA_DAY_WRITER set and no EVDA_DAY_WRITER_SEND, nothing on this machine can reach the
44
+ // writer, so nothing is queued and the hook tells the model to file the message with evda_write.
26
45
 
27
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
46
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
28
47
  import { dirname, join, relative } from "node:path";
29
- import { tmpdir } from "node:os";
48
+ import { homedir, hostname, tmpdir } from "node:os";
49
+ import { spawnSync } from "node:child_process";
30
50
  import { fileURLToPath } from "node:url";
31
51
  import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
32
52
  import { secretShape } from "./secret-shape.mjs";
@@ -35,14 +55,100 @@ export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
35
55
 
36
56
  const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
37
57
 
58
+ export const DAY_WRITER_SEND_ENV = "EVDA_DAY_WRITER_SEND";
59
+ export const PROMPT_OUTBOX_ENV = "EVDA_PROMPT_OUTBOX";
60
+ export const SEND_BUDGET_MS = 6000;
61
+ export const SENDING_STALE_MS = 2 * 60 * 1000;
62
+
63
+ export function promptOutboxDir(env = process.env) {
64
+ return env[PROMPT_OUTBOX_ENV] || join(homedir(), ".evda", "outbox");
65
+ }
66
+
67
+ const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
68
+
69
+ // Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
70
+ export function queuePrompt(root, { words, context, now = new Date(), env = process.env }) {
71
+ const dir = promptOutboxDir(env);
72
+ mkdirSync(dir, { recursive: true });
73
+ const at = now.toISOString();
74
+ const stamp = at.replace(/[-:.]/g, "");
75
+ let file = join(dir, `${stamp}-${process.pid}.json`);
76
+ for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
77
+ writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
78
+ return file;
79
+ }
80
+
81
+ // Send this record's queued messages to the writer, oldest first, within `budgetMs`. Returns
82
+ // { sent: [file...], left, error }. Stops at the first failure, so the order of the day is kept.
83
+ // A claim's age is measured against the file system's own clock (the mtime of a file touched now),
84
+ // never Date.now(): a claim's mtime is stamped by the file system, and the two clocks can disagree.
85
+ export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(env.EVDA_DAY_WRITER_SEND_MS) || SEND_BUDGET_MS, nowMs = null } = {}) {
86
+ const cmd = (env[DAY_WRITER_SEND_ENV] ?? "").trim();
87
+ const dir = promptOutboxDir(env);
88
+ const out = { sent: [], left: 0, error: null };
89
+ if (!cmd || !existsSync(dir)) return out;
90
+ if (nowMs === null) { const probe = join(dir, ".now"); writeFileSync(probe, ""); nowMs = statSync(probe).mtimeMs; }
91
+ const rec = realOr(root);
92
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".json.sending"))) {
93
+ const abs = join(dir, f);
94
+ try { if (nowMs - statSync(abs).mtimeMs > SENDING_STALE_MS) renameSync(abs, abs.replace(/\.sending$/, "")); } catch { /* another run took it back */ }
95
+ }
96
+ const mine = readdirSync(dir).filter((n) => n.endsWith(".json")).sort().filter((n) => {
97
+ try { return JSON.parse(readFileSync(join(dir, n), "utf8")).record === rec; } catch { return false; }
98
+ });
99
+ const deadline = Date.now() + budgetMs;
100
+ for (let i = 0; i < mine.length; i++) {
101
+ const abs = join(dir, mine[i]);
102
+ const claim = `${abs}.sending`;
103
+ try { renameSync(abs, claim); } catch { continue; } // another session claimed it
104
+ let entry;
105
+ try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
106
+ const left = deadline - Date.now();
107
+ const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}) };
108
+ const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
109
+ if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
110
+ try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
111
+ out.error = r.error ? (r.error.code === "ETIMEDOUT" ? `no answer in ${budgetMs}ms` : r.error.code ?? r.error.message) : `exited ${r.status ?? r.signal}: ${String(r.stderr ?? "").trim().split("\n").pop() ?? ""}`.trim();
112
+ out.left = mine.length - i;
113
+ break;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
119
+
120
+ // The non-writer path: queue, send, and say what happened. Never touches the day file.
121
+ function viaWriter(root, prompt, now, env) {
122
+ const writer = env.EVDA_DAY_WRITER.trim();
123
+ if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
124
+ return `evda: this machine does not write the daily file (EVDA_DAY_WRITER is ${writer}) and has no ${DAY_WRITER_SEND_ENV} to reach it, so the owner's message above is NOT filed yet. File it verbatim with evda_write this turn.`;
125
+ }
126
+ const file = queuePrompt(root, { words: prompt, context: `Claude Code on ${hostname().split(".")[0]}, said ${timeOf(now)}`, now, env });
127
+ const r = drainPromptOutbox(root, { env });
128
+ if (r.sent.includes(file)) {
129
+ const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
130
+ return `evda: the owner's message above is already filed verbatim through the day writer (${writer})${more}. Do not file it again with evda_write.`;
131
+ }
132
+ return `evda: the day writer (${writer}) could not be reached (${r.error ?? "not sent"}), so the owner's message above is queued in ${dirname(file)} on this machine and goes to the writer with their next message. Do not file it again with evda_write; if you tell them, say it is queued, not filed.`;
133
+ }
134
+
38
135
  function stateFile(sid) {
39
136
  const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
40
137
  mkdirSync(dir, { recursive: true });
41
138
  return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
42
139
  }
43
140
 
141
+ // The keep hook reads this (HQ-2817): on a machine that does not write the day, the day file does
142
+ // not change here when a message is filed, so the time it was handed over is the evidence.
143
+ function markFiled(sid, now) {
144
+ const sf = stateFile(sid ?? "unknown");
145
+ let session = {};
146
+ try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* none yet */ }
147
+ writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
148
+ }
149
+
44
150
  // One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
45
- export function run(payload, { now = new Date() } = {}) {
151
+ export function run(payload, { now = new Date(), env = process.env } = {}) {
46
152
  try {
47
153
  const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
48
154
  if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
@@ -55,6 +161,11 @@ export function run(payload, { now = new Date() } = {}) {
55
161
  if (!role) return "";
56
162
  const shape = prompt.split("\n").map(secretShape).find(Boolean);
57
163
  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.`;
164
+ if ((env.EVDA_DAY_WRITER ?? "").trim()) {
165
+ const said = viaWriter(root, prompt, now, env);
166
+ if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
167
+ return said;
168
+ }
58
169
  const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
59
170
  mkdirSync(dayAbs, { recursive: true });
60
171
  const abs = join(dayAbs, `${localDate(now)}.md`);
@@ -62,7 +173,7 @@ export function run(payload, { now = new Date() } = {}) {
62
173
  let session = {};
63
174
  try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
64
175
  appendToDay(abs, { now, words: prompt, session });
65
- writeFileSync(sf, JSON.stringify(session) + "\n");
176
+ writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
66
177
  const rel = relative(root, abs).split("\\").join("/");
67
178
  return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
68
179
  } catch {
package/lib/publish.mjs CHANGED
@@ -29,7 +29,9 @@
29
29
  // written, and a hit refuses. A label is its opaque id, its namespace, its own directory, a
30
30
  // member path, or its name between slashes. The exported files' own paths are the released
31
31
  // set (doctor prints them every run) and are left out of that search, since a marked file
32
- // necessarily lives under a keyed circle's member path.
32
+ // necessarily lives under a keyed circle's member path. A markdown file's `sources:`
33
+ // frontmatter field is cut from its exported bytes before this search (HQ-2698): it is
34
+ // provenance, the record's own paths, and is never published.
33
35
  //
34
36
  // The fourth check in the spec (the site holds no credential to anything else) is a property of
35
37
  // where the export is served, not of this code.
@@ -70,6 +72,41 @@ function filesUnder(abs) {
70
72
  const posix = (rel) => rel.split(sep).join("/");
71
73
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
74
 
75
+ // Provenance (HQ-2698). A top-level `sources:` field in a markdown file's frontmatter lists the
76
+ // record paths the file was written from: a blog Companion names the private draft and the journal
77
+ // days behind it. That is the record's own bookkeeping, never part of what a stranger reads, so it
78
+ // is cut from every exported markdown file, whatever folder or rule released it, the way a sealed
79
+ // section is. Only the frontmatter field is cut: a private path anywhere in the body still reaches
80
+ // the stranger check and refuses. The value runs to the next top-level key, so a block list,
81
+ // indented or not, a flow list on one or more lines and a bare string all go with the key.
82
+ const PROVENANCE_KEY = /^(["']?)sources\1[ \t]*:/;
83
+ const isFence = (line) => line.replace(/\r$/, "") === "---";
84
+ const continues = (line) => /^[ \t]/.test(line) || /^-([ \t]|\r?$)/.test(line);
85
+ const blank = (line) => line.replace(/\r$/, "").trim() === "";
86
+ export function withoutProvenance(text) {
87
+ const lines = String(text).split("\n");
88
+ if (!isFence(lines[0])) return { text, cut: 0 };
89
+ let end = 1;
90
+ while (end < lines.length && !isFence(lines[end])) end++;
91
+ if (end >= lines.length) return { text, cut: 0 };
92
+ const kept = [lines[0]];
93
+ let cut = 0;
94
+ for (let i = 1; i < end; i++) {
95
+ if (!PROVENANCE_KEY.test(lines[i])) { kept.push(lines[i]); continue; }
96
+ cut++;
97
+ let j = i + 1;
98
+ for (;;) {
99
+ let k = j;
100
+ while (k < end && blank(lines[k])) k++;
101
+ if (k < end && continues(lines[k])) j = k + 1;
102
+ else break;
103
+ }
104
+ i = j - 1;
105
+ }
106
+ if (!cut) return { text, cut: 0 };
107
+ return { text: [...kept, ...lines.slice(end)].join("\n"), cut };
108
+ }
109
+
73
110
  // Build the export in memory and run the checks. Writes nothing.
74
111
  export function buildPublicExport(evda, { circleName = null, publishedAt = new Date() } = {}) {
75
112
  const circle = publicCircle(evda, circleName);
@@ -114,14 +151,21 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
114
151
  // A sealed section (HQ-1220) is cut out of every markdown file on the way out; the rest of the
115
152
  // file is exported, and nothing marks where the section was. A sealed FILE never gets here:
116
153
  // released() refuses it, and a refusal refuses the publish above.
154
+ // A file's `sources:` provenance is cut the same way (withoutProvenance above, HQ-2698).
117
155
  let sealedCut = 0;
156
+ let provenanceCut = 0;
118
157
  const exportBytes = (abs) => {
119
158
  const bytes = readFileSync(abs);
120
- if (!/\.md$/i.test(abs) || !carries("publish", "sealed")) return bytes;
121
- const cut = withoutSealed(bytes.toString("utf8"));
122
- if (!cut.sections) return bytes;
123
- sealedCut += cut.sections;
124
- return Buffer.from(cut.text, "utf8");
159
+ if (!/\.md$/i.test(abs)) return bytes;
160
+ let text = bytes.toString("utf8");
161
+ let changed = false;
162
+ if (carries("publish", "sealed")) {
163
+ const cut = withoutSealed(text);
164
+ if (cut.sections) { sealedCut += cut.sections; text = cut.text; changed = true; }
165
+ }
166
+ const prov = withoutProvenance(text);
167
+ if (prov.cut) { provenanceCut += 1; text = prov.text; changed = true; }
168
+ return changed ? Buffer.from(text, "utf8") : bytes;
125
169
  };
126
170
  const loaded = files.filter((f) => !consumed.has(f.path)).map((f) => ({ path: f.path, bytes: exportBytes(f.abs), by: f.by }));
127
171
  for (const v of rendered.views) loaded.push({ path: v.path, bytes: v.bytes, by: "view", view: v.view, sources: v.entries.map((e) => ({ path: e.path, sha256: e.sha256 })) });
@@ -157,9 +201,9 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
157
201
  // What identifies a private circle to a stranger is a LABEL: a path segment, the circle's own
158
202
  // directory, or the opaque id that travels on a Change and in an export manifest. A name
159
203
  // matched as a word anywhere in the bytes is not that test, and on a real record it refuses
160
- // every publish there is: his private circles are core, health, money, people, school,
161
- // therapy, wellness and workshop, and his public fact sheet says "10 core mechanics" and
162
- // "help other people understand me" (2026-09-21, HQ-1199). So the name is matched only
204
+ // every publish there is: private circles are named with ordinary words (a circle called
205
+ // "core" or "people"), and a public fact sheet uses those words in plain prose ("10 core
206
+ // mechanics", "help other people understand me") (2026-09-21, HQ-1199). So the name is matched only
163
207
  // between slashes, and the id and namespace, which the word match never looked at, are
164
208
  // matched literally. The gap this leaves, stated so the next reader does not rediscover it:
165
209
  // a distinctive circle name written as bare prose passes.
@@ -188,7 +232,7 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
188
232
  }
189
233
  }
190
234
 
191
- return { manifest, files: loaded, sealedCut };
235
+ return { manifest, files: loaded, sealedCut, provenanceCut };
192
236
  }
193
237
 
194
238
  export function canonicalManifest(manifest) {
@@ -259,6 +303,7 @@ export async function publishCommand(dir, circleName, { out, key }) {
259
303
  console.log(`published ${built.files.length} file(s) from the public circle to ${at} (${built.files.length - marked - views.length} by path, ${marked} by mark${views.length ? `, ${views.length} rendered from ${drawn} entries` : ""})`);
260
304
  for (const f of views) console.log(` view ${f.path} rendered from ${f.sources.length} entries (${f.view})`);
261
305
  if (built.sealedCut) console.log(` ${built.sealedCut} sealed section(s) left out of the files they sit in`);
306
+ if (built.provenanceCut) console.log(` ${built.provenanceCut} file(s) exported without their sources: provenance`);
262
307
  console.log(`CHECKED ${built.files.length} public members, signed by ${signed.publicKey}`);
263
308
  return 0;
264
309
  }
package/lib/readme.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // README.md, the owner-facing half of the operating layer (HQ-2812).
2
+ //
3
+ // AGENTS.md sends the agent to sections of README.md for the facts it must never give from memory:
4
+ // the steps for turning training off, what each AI company says, and running a model locally. A
5
+ // record create-evda makes has them, because it writes README.md from templates/README.md. A record
6
+ // `evda adopt` makes keeps the owner's own README.md, which has none of them, so the privacy step
7
+ // pointed at nothing. This module renders the README once, for both, and keeps those sections in an
8
+ // adopted README as one marked block the owner's text never shares:
9
+ //
10
+ // <the owner's README, byte for byte>
11
+ // <!-- evda:readme -->
12
+ // _lead line_
13
+ // ## Turning training off ... ## Running a model on your own computer ...
14
+ // <!-- /evda:readme -->
15
+ //
16
+ // `ensureReadme` writes the file when it is absent, appends the block when a cited section is
17
+ // missing, and refreshes the block's content when it is there (`evda upgrade`). A README that already
18
+ // holds every cited heading outside a block (create-evda's own) is left alone.
19
+
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { trainingSection, termsSection, INTRO } from "./setup.mjs";
24
+
25
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
26
+ export const README_REL = "README.md";
27
+ export const README_OPEN = "<!-- evda:readme -->";
28
+ export const README_CLOSE = "<!-- /evda:readme -->";
29
+ export const README_LEAD = "_EVÐA wrote this block and `evda upgrade` keeps it current. Everything above it is yours, and EVÐA never changes it._";
30
+ // The first and the last heading of templates/README.md the block carries, in that file's order.
31
+ const FIRST = "## Turning training off";
32
+ const AFTER = "## Getting started";
33
+
34
+ // The local-model doc as a README section: its title dropped and its headings one level down.
35
+ export function localModelSection() {
36
+ const text = readFileSync(join(PKG, "docs", "local-model.md"), "utf8");
37
+ return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
38
+ }
39
+
40
+ // The README create-evda writes into a new record.
41
+ export function renderReadme() {
42
+ let text = readFileSync(join(PKG, "templates", "README.md"), "utf8");
43
+ const vars = { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() };
44
+ for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
45
+ return text;
46
+ }
47
+
48
+ // The sections AGENTS.md cites, cut from the rendered README rather than written a second time.
49
+ export function readmeSections() {
50
+ const text = renderReadme();
51
+ const from = text.indexOf(FIRST);
52
+ const to = text.indexOf(AFTER);
53
+ if (from === -1 || to === -1 || to < from) throw new Error(`templates/README.md no longer has "${FIRST}" before "${AFTER}", so the adopted README block cannot be cut from it.`);
54
+ return text.slice(from, to).trimEnd();
55
+ }
56
+
57
+ export function readmeBlock() {
58
+ return `${README_OPEN}\n${README_LEAD}\n\n${readmeSections()}\n${README_CLOSE}\n`;
59
+ }
60
+
61
+ // Every README.md section an operating layer sends the agent to: a quoted heading followed by
62
+ // `section of` or `in`, then `README.md`, line breaks inside the quote allowed.
63
+ export function readmeCitations(agentsText) {
64
+ const flat = String(agentsText).replace(/\s+/g, " ");
65
+ const out = [];
66
+ for (const m of flat.matchAll(/"([^"]{2,120})"(?: section)? (?:of|in) `?README\.md`?/g)) if (!out.includes(m[1])) out.push(m[1]);
67
+ return out;
68
+ }
69
+
70
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ export const hasHeading = (text, heading) => new RegExp(`^#{1,6}[ \\t]+${escape(heading)}[ \\t]*#*[ \\t]*$`, "m").test(text);
72
+
73
+ // The cited headings README text lacks.
74
+ export function missingHeadings(readmeText, citations) {
75
+ return citations.filter((h) => !hasHeading(readmeText ?? "", h));
76
+ }
77
+
78
+ // The block's place in README text: { start, end } around the markers (end past the closing line
79
+ // break), or null when there is none. Two blocks, or an open with no close, is a problem, not a block.
80
+ export function findBlock(text) {
81
+ const open = text.indexOf(README_OPEN);
82
+ const close = text.indexOf(README_CLOSE);
83
+ if (open === -1 && close === -1) return null;
84
+ if (open === -1 || close === -1 || close < open || text.indexOf(README_OPEN, open + 1) !== -1 || text.indexOf(README_CLOSE, close + 1) !== -1) {
85
+ throw new Error(`${README_REL} has a broken ${README_OPEN} block (one marker without the other, or two of them). Fix the markers by hand; nothing was changed.`);
86
+ }
87
+ let end = close + README_CLOSE.length;
88
+ if (text[end] === "\n") end += 1;
89
+ return { start: open, end };
90
+ }
91
+
92
+ // What the README should become. Returns { action, text, added }:
93
+ // "written" absent: the whole README create-evda writes
94
+ // "appended" a cited section was missing and there was no block: the block is added at the end
95
+ // "refreshed" the block was there with other content: its content is replaced
96
+ // "current" the block is there and matches
97
+ // "present" no block, and every cited heading is already in the owner's text
98
+ // `added` is exactly what "appended" put after the owner's bytes, so a revert can take it back out.
99
+ export function planReadme(text, citations) {
100
+ if (text === null) return { action: "written", text: renderReadme(), added: null };
101
+ const block = readmeBlock();
102
+ const at = findBlock(text);
103
+ if (at) {
104
+ const next = text.slice(0, at.start) + block + text.slice(at.end);
105
+ return next === text ? { action: "current", text, added: null } : { action: "refreshed", text: next, added: null };
106
+ }
107
+ if (!missingHeadings(text, citations).length) return { action: "present", text, added: null };
108
+ const added = `${text === "" || text.endsWith("\n") ? "" : "\n"}\n${block}`;
109
+ return { action: "appended", text: text + added, added };
110
+ }
111
+
112
+ // Apply planReadme to <dir>/README.md. `citations` defaults to the record's AGENTS.md, or the shipped
113
+ // template's when there is none.
114
+ export function ensureReadme(dir, { citations = null, dry = false, agents = null } = {}) {
115
+ const abs = join(dir, README_REL);
116
+ const cites = citations ?? readmeCitations(agents ?? readFileSync(join(PKG, "templates", "AGENTS.md"), "utf8"));
117
+ const before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
118
+ const plan = planReadme(before, cites);
119
+ if (!dry && plan.text !== before) writeFileSync(abs, plan.text);
120
+ return { ...plan, abs, rel: README_REL, citations: cites };
121
+ }
122
+
123
+ // README text with the block taken back out: exactly what was there before an "appended", when the
124
+ // owner did not edit around the block. Null when there is no block to take.
125
+ export function withoutBlock(text, added) {
126
+ if (added && text.endsWith(added)) return text.slice(0, -added.length);
127
+ let at;
128
+ try { at = findBlock(text); } catch { return null; }
129
+ if (!at) return null;
130
+ // The line breaks the adopt put in front of the block go with it.
131
+ const lead = added ? added.slice(0, added.indexOf(README_OPEN)) : "";
132
+ const start = lead && text.slice(at.start - lead.length, at.start) === lead ? at.start - lead.length : at.start;
133
+ return text.slice(0, start) + text.slice(at.end);
134
+ }
135
+
136
+ export function readmeLine(r) {
137
+ const names = r.citations.map((c) => `"${c}"`).join(", ");
138
+ switch (r.action) {
139
+ case "written": return `README.md: written, with ${names}`;
140
+ case "appended": return `README.md: EVÐA's block added at the end, with ${names}; your text above it is unchanged`;
141
+ case "refreshed": return "README.md: EVÐA's block refreshed to what this tool ships; your text around it is unchanged";
142
+ case "current": return "README.md: EVÐA's block already matches what this tool ships";
143
+ default: return `README.md: already has ${names}; left alone`;
144
+ }
145
+ }
@@ -0,0 +1,9 @@
1
+ // The one rule every walker over the record uses for what is outside it (HQ-2811): a folder named
2
+ // node_modules (packages, not notes), and any name ending in .nosync, the iCloud convention for
3
+ // "do not sync this". A .nosync folder is one the owner chose to keep out of sync, which is the same
4
+ // as keeping it out of the record; Mike's vault keeps its packages in node_modules.nosync, and the
5
+ // bears-on notice once named a vendored eslint doc in it as an existing entry. Dot-names and app
6
+ // folders stay each walker's own choice, since they differ by what the walk is for.
7
+ export function outsideRecord(name) {
8
+ return name === "node_modules" || name.endsWith(".nosync");
9
+ }
package/lib/record.mjs CHANGED
@@ -98,3 +98,57 @@ export function moment(evda, { from, to = from } = {}) {
98
98
  out.entries.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : a.date.localeCompare(b.date)));
99
99
  return out;
100
100
  }
101
+
102
+ // What a reader needs from each section of a file to know what it was about without opening it
103
+ // (HQ-2324): its heading, its context line (the one line the filing agent wrote above the owner's
104
+ // words, `Context, not their words:` or `*Context (Iris, ...): ...*`), and the opening of the
105
+ // owner's words, cut at a word. A day file's headings are mostly times (`## 20:13`), so a heading
106
+ // alone told a chat app nothing about his last days. Text before the first heading is a section
107
+ // with no heading. Frontmatter, fences and the one-line marker a sealed part leaves are skipped.
108
+ export const OPENING_MAX = 280;
109
+ const CONTEXT_MAX = 240;
110
+ const CONTEXT_LINE = /^\*{0,2}_?\s*Context\b[^:]*:/i;
111
+
112
+ function cutAt(text, max) {
113
+ if (text.length <= max) return text;
114
+ const cut = text.slice(0, max);
115
+ const at = cut.lastIndexOf(" ");
116
+ return `${at > max / 2 ? cut.slice(0, at) : cut}...`;
117
+ }
118
+
119
+ export function sectionDigest(text, { openingMax = OPENING_MAX } = {}) {
120
+ let body = text;
121
+ if (body.startsWith("---")) {
122
+ const end = body.indexOf("\n---", 3);
123
+ if (end !== -1) body = body.slice(end + 4);
124
+ }
125
+ const out = [];
126
+ let cur = { heading: null, lines: [] };
127
+ let fence = false;
128
+ const flush = () => {
129
+ const lines = cur.lines.map((l) => l.trim()).filter((l) => l && !/^\[sealed[:\]]/i.test(l));
130
+ const ctxAt = lines.findIndex((l) => CONTEXT_LINE.test(l));
131
+ const context = ctxAt === -1 ? null : cutAt(lines[ctxAt].replace(/^[*_\s]+|[*_\s]+$/g, ""), CONTEXT_MAX);
132
+ const words = lines.filter((_, i) => i !== ctxAt)
133
+ .map((l) => l.replace(/^>\s?/, "").replace(/^>\s?/, "").trim())
134
+ .filter(Boolean)
135
+ .join(" ")
136
+ .replace(/\s+/g, " ")
137
+ .trim();
138
+ if (cur.heading !== null || context || words) out.push({ heading: cur.heading, context, opening: words ? cutAt(words, openingMax) : null });
139
+ };
140
+ for (const line of body.split("\n")) {
141
+ if (/^\s*(`{3,}|~{3,})/.test(line)) { fence = !fence; continue; }
142
+ if (fence) continue;
143
+ const h = /^#{1,6} (.*)$/.exec(line);
144
+ if (h) { flush(); cur = { heading: h[1].trim(), lines: [] }; continue; }
145
+ cur.lines.push(line);
146
+ }
147
+ flush();
148
+ return out;
149
+ }
150
+
151
+ // The date a heading names (`## 2026-10-02, morning: ...`, `## October 4 (added 2026-10-04)`), or null.
152
+ export function headingDate(heading) {
153
+ return /\b(\d{4}-\d{2}-\d{2})\b/.exec(heading ?? "")?.[1] ?? null;
154
+ }
package/lib/routines.mjs CHANGED
@@ -243,7 +243,17 @@ export function uninstallLaunchd(key, dir, { env = process.env, home = env.HOME
243
243
  // What setup does with the routines it was given (HQ-1246). On a Mac each is installed as a launchd
244
244
  // job; elsewhere nothing can be scheduled from here, so it prints the Claude desktop steps and says
245
245
  // so. Returns the lines to print.
246
- export function installAtSetup(keys, dir, { env = process.env, platform = process.platform } = {}) {
246
+ //
247
+ // `say` prints a line at once, before anything is installed (HQ-1368): a run with --yes asks nothing,
248
+ // so the line that a launchd job is about to be loaded into this Mac is said before launchctl runs,
249
+ // not only in the summary after it. `launchctl` is the tests' stand-in, handed through to
250
+ // installLaunchd with loading on, so a test can watch the order without the real launchctl.
251
+ export function setupNotice(key, dir) {
252
+ const r = ROUTINES[key];
253
+ return `Installing a launchd job on this Mac: ${r.title}, ${r.when.toLowerCase()}, runs ${r.command.join(" ")} on ${resolve(dir)} with your own Claude. Not wanted: create-evda --no-routines, or afterwards evda routine ${key} ${resolve(dir)} --uninstall`;
254
+ }
255
+
256
+ export function installAtSetup(keys, dir, { env = process.env, platform = process.platform, say = null, launchctl = null } = {}) {
247
257
  const lines = [];
248
258
  for (const key of keys) {
249
259
  const r = ROUTINES[key];
@@ -251,7 +261,8 @@ export function installAtSetup(keys, dir, { env = process.env, platform = proces
251
261
  if (platform === "darwin") {
252
262
  let done;
253
263
  try {
254
- done = installLaunchd(key, dir, { env });
264
+ if (say && !falsified(env, "silent-install")) say(setupNotice(key, dir));
265
+ done = installLaunchd(key, dir, launchctl ? { env, launchctl, load: true } : { env });
255
266
  } catch (e) {
256
267
  if (e.code !== "TEMP_RECORD") throw e;
257
268
  lines.push(e.message);