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
@@ -0,0 +1,85 @@
1
+ // New tools in a chat app (HQ-2205, the last step). A chat app keeps a connector's tool list and the
2
+ // permission set on each tool, and the permissions a person set before do not cover a tool that came
3
+ // later: a call to it can be refused, or held for approval, without the model being told why. Adding
4
+ // or renaming a tool on this server is exactly that. So the server remembers, per record and per
5
+ // chat app, every tool name it has offered that app, and the first time it offers a name the app has
6
+ // never been offered, the next tool result says so once, in plain words, with where the permissions
7
+ // are set. Then the name joins the remembered list and is never raised again in that app.
8
+ //
9
+ // Only names count. A permission belongs to a tool's name, so a changed description needs nothing,
10
+ // and a tool that left the list (evda_every_chat once the owner answered it) needs nothing either.
11
+ // One that comes back is not new: the app was offered it before.
12
+ //
13
+ // A first connection is told nothing: the person is setting permissions for the first time, and the
14
+ // setup already says where (ALWAYS_ALLOW_LINE in lib/mcp.mjs). It only remembers what was offered.
15
+ //
16
+ // Where it is kept: `<record>/.evda/connector-tools.json`, the record's own state, which sync never
17
+ // sends to another holder (sync-rules EXCLUDE_ROOT_NAMES) and nothing reads as the owner's words. It
18
+ // is bookkeeping about what was offered an app, kept with the record rather than the computer because
19
+ // an app's permissions belong to the owner's account in that app, whichever computer serves it. Lost,
20
+ // it costs one notice: the next connection is taken for a first one and remembers the list again.
21
+ //
22
+ // { "claude": { "tools": ["evda_read", ...], "date": "2026-10-18" } }
23
+
24
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { CHAT_APPS } from "./every-chat.mjs";
27
+
28
+ export const CONNECTOR_TOOLS_FILE = "connector-tools.json";
29
+
30
+ // Where each app sets a connector's tool permissions. Claude's is the one this package already gives
31
+ // in setup (ALWAYS_ALLOW_LINE); an app whose page has not been read gets the general words, because
32
+ // naming a menu that does not exist would send them hunting for it.
33
+ export const PERMISSIONS_WHERE = {
34
+ claude: "claude.ai Settings, Connectors, EVÐA, tool permissions",
35
+ };
36
+ export const GENERIC_PERMISSIONS_WHERE = "the EVÐA connector's settings in their app, where each tool's permission is set";
37
+
38
+ export const CONNECTOR_TOOLS = join(".evda", CONNECTOR_TOOLS_FILE);
39
+ export function connectorToolsPath(evda) {
40
+ return typeof evda?.dir === "string" && evda.dir ? join(evda.dir, CONNECTOR_TOOLS) : null;
41
+ }
42
+
43
+ function readSeen(path) {
44
+ try {
45
+ const all = JSON.parse(readFileSync(path, "utf8"));
46
+ return all && typeof all === "object" && !Array.isArray(all) ? all : {};
47
+ } catch { return {}; }
48
+ }
49
+
50
+ function writeSeen(path, all) {
51
+ mkdirSync(join(path, ".."), { recursive: true });
52
+ const part = `${path}.${process.pid}.tmp`;
53
+ writeFileSync(part, `${JSON.stringify(all, null, 2)}\n`);
54
+ renameSync(part, path);
55
+ }
56
+
57
+ // Compare what this app is offered now against every name it was offered before, and remember the
58
+ // union. Returns the names it has never been offered, or [] on a first connection, an unchanged
59
+ // list, or no record to keep the list in.
60
+ export function newToolsFor(evda, app, offered, { now = new Date() } = {}) {
61
+ if (!app) return [];
62
+ const path = connectorToolsPath(evda);
63
+ if (!path) return [];
64
+ const all = readSeen(path);
65
+ const before = Array.isArray(all[app]?.tools) ? all[app].tools.filter((n) => typeof n === "string") : null;
66
+ const names = [...new Set(offered)].sort();
67
+ const fresh = before ? names.filter((n) => !before.includes(n)) : [];
68
+ if (before && !fresh.length) return [];
69
+ all[app] = { tools: [...new Set([...(before ?? []), ...names])].sort(), date: now.toISOString().slice(0, 10) };
70
+ writeSeen(path, all);
71
+ return fresh;
72
+ }
73
+
74
+ // What the model reads, after the tool's own result, the once an app is offered new tools. `titles`
75
+ // maps a tool name to the title the app shows the person beside its permission.
76
+ export function newToolsNotice(app, names, titles = {}) {
77
+ const label = CHAT_APPS[app]?.label ?? "this app";
78
+ const where = PERMISSIONS_WHERE[app] ?? GENERIC_PERMISSIONS_WHERE;
79
+ const list = names.map((n) => (titles[n] ? `${titles[n]} (${n})` : n)).join(", ");
80
+ return [
81
+ `## New EVÐA tools in ${label}`,
82
+ "",
83
+ `EVÐA has tools ${label} was not offered before: ${list}. A chat app keeps the tool permissions the owner set when they connected, and those do not cover a tool added later, so a call to one of these can be refused or held without saying why. Once, after answering what they asked, tell them in a sentence or two: EVÐA added tools, and to use them they should open ${where} and set the new ones to allow. If a call to one of these tools fails in this chat, tell them this is the likely reason rather than trying it again. This is said once in ${label} and not again.`,
84
+ ].join("\n");
85
+ }
@@ -0,0 +1,34 @@
1
+ // Where a correction lands (HQ-2325). By default it is one dated line in the operating layer's
2
+ // "## What I have been taught". A record that keeps that list in its own file names it in evda.json:
3
+ //
4
+ // "corrections": "<path to a Markdown file, relative to the record>"
5
+ //
6
+ // and evda_record_correction appends a dated section there instead. The owner's vault keeps its list
7
+ // in a Schema Friction Log that CLAUDE.md names as its "What I have been taught", so without this
8
+ // every correction from a chat app was refused and lost. Doctor reads the same setting through this
9
+ // one function, so the tool and the check cannot disagree on what a valid setting is.
10
+
11
+ import { existsSync, statSync } from "node:fs";
12
+ import { isAbsolute, relative, resolve, sep } from "node:path";
13
+ import { isSealed } from "./circles.mjs";
14
+
15
+ export const CORRECTIONS_KEY = "corrections";
16
+
17
+ // null when evda.json does not set it. Otherwise { rel, abs, problem }: problem is null when the
18
+ // setting names an existing, unsealed Markdown file inside the record, and a plain sentence when not.
19
+ export function correctionsTarget(evda) {
20
+ const value = evda.manifest?.[CORRECTIONS_KEY];
21
+ if (value === undefined) return null;
22
+ const bad = (problem, rel = String(value)) => ({ rel, abs: null, problem });
23
+ if (typeof value !== "string" || value.trim() === "") return bad(`"${CORRECTIONS_KEY}" must be the path of a Markdown file, relative to the record.`);
24
+ const want = value.trim();
25
+ if (isAbsolute(want)) return bad(`"${CORRECTIONS_KEY}" is ${want}, an absolute path; it names a file relative to the record.`, want);
26
+ const root = resolve(evda.dir);
27
+ const abs = resolve(root, want);
28
+ if (!abs.startsWith(root + sep)) return bad(`"${CORRECTIONS_KEY}" is ${want}, which is outside the record.`, want);
29
+ const rel = relative(root, abs).split("\\").join("/");
30
+ if (!/\.md$/i.test(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is not a Markdown file.` };
31
+ if (!existsSync(abs) || !statSync(abs).isFile()) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, and there is no file there, so a correction has nowhere to land.` };
32
+ if (isSealed(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is sealed, so no agent writes a correction into it.` };
33
+ return { rel, abs, problem: null };
34
+ }
@@ -0,0 +1,120 @@
1
+ // One-time move of Credentials/ files written before HQ-1916 into the Keychain (HQ-2622).
2
+ //
3
+ // HQ-1916 made `evda gather apply` put each credential value into the login Keychain and leave a
4
+ // pointer in the file. Files already sitting in a record's Credentials/ folder, sealed before that
5
+ // shipped (or kept plain because the Keychain write failed then), still hold their values. This
6
+ // walks Credentials/ once and gives each of those files the same treatment: every value goes into
7
+ // the Keychain, and its line keeps its label with the value replaced by the pointer HQ-1916 writes:
8
+ // Password: in your Keychain (EVÐA, wifi.md, password)
9
+ //
10
+ // Safe to run again: a line that already holds a pointer is not a value (extractSecrets skips it),
11
+ // so a migrated file has nothing left to move and is not rewritten.
12
+ // All-or-nothing per file: a value is never removed from a file until every value in that file is
13
+ // in the Keychain, and if any write fails, the items this run added for that file are removed again
14
+ // and the file is left byte for byte as it was. Nothing is lost.
15
+ // A Keychain item already at an account with a different value (another file of the same name, or
16
+ // one the owner made by hand) is never overwritten: the value is filed under the next free label,
17
+ // "password 2", and the pointer names that label. One already holding the same value is reused.
18
+ //
19
+ // The Keychain is reached only through lib/keychain.mjs, so EVDA_KEYCHAIN_FALSIFY and a throwaway
20
+ // keychain path work here exactly as they do for gather apply.
21
+
22
+ import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { CREDENTIALS } from "./gather.mjs";
25
+ import { SERVICE, accountFor, addSecret, extractSecrets, keychainAvailable, pointerFor, readSecret, removeSecret } from "./keychain.mjs";
26
+
27
+ // The label a value is filed under: its own, unless the Keychain already holds a different value
28
+ // there or this file already used it, then "<label> 2", "<label> 3", ... until one is free or
29
+ // already holds this exact value. Returns { label, exists }.
30
+ function freeLabel(fileName, label, value, used, keychainPath) {
31
+ for (let n = 1; ; n++) {
32
+ const candidate = n === 1 ? label : `${label} ${n}`;
33
+ if (used.has(candidate)) continue;
34
+ let held;
35
+ try { held = readSecret({ account: accountFor(fileName, candidate), keychainPath }); } catch { return { label: candidate, exists: false }; }
36
+ if (held === value) return { label: candidate, exists: true };
37
+ }
38
+ }
39
+
40
+ // Move one file's values into the Keychain and return its new text, or say why it stays as it is.
41
+ // Returns { status: "moved", text, added, labels } | { status: "unchanged" } | { status: "kept", why }.
42
+ export function migrateText(text, fileName, { keychainPath = null, dry = false } = {}) {
43
+ const secrets = extractSecrets(text);
44
+ if (!secrets.length) return { status: "unchanged" };
45
+ if (dry) return { status: "moved", text: null, added: [], labels: secrets.map((s) => s.label), dry: true };
46
+ const used = new Set();
47
+ const added = [];
48
+ const labels = [];
49
+ try {
50
+ for (const s of secrets) {
51
+ const { label, exists } = freeLabel(fileName, s.label, s.value, used, keychainPath);
52
+ used.add(label);
53
+ labels.push(label);
54
+ if (exists) continue;
55
+ const account = accountFor(fileName, label);
56
+ addSecret({ account, value: s.value, keychainPath });
57
+ added.push(account);
58
+ }
59
+ } catch (e) {
60
+ for (const account of added) removeSecret({ account, keychainPath });
61
+ return { status: "kept", why: `the Keychain write failed (${String(e.message ?? e).split("\n")[0]})` };
62
+ }
63
+ const lines = text.split("\n");
64
+ secrets.forEach((s, i) => { lines[s.index] = s.whole ? pointerFor(fileName, labels[i]) : `${s.prefix}${pointerFor(fileName, labels[i])}`; });
65
+ return { status: "moved", text: lines.join("\n"), added, labels };
66
+ }
67
+
68
+ // Every file directly in <root>/Credentials/, migrated. Never throws for one file's failure.
69
+ // Returns { folder, files: [{ name, status, why?, count? }], moved, unchanged, kept, skipped }.
70
+ export function migrateCredentials(root, { keychainPath = null, dry = false } = {}) {
71
+ const folder = join(root, CREDENTIALS);
72
+ const r = { folder, exists: existsSync(folder), available: true, dry, files: [], moved: 0, unchanged: 0, kept: 0, skipped: 0 };
73
+ if (!r.exists) return r;
74
+ const names = readdirSync(folder).filter((n) => !n.startsWith(".")).sort();
75
+ r.available = keychainAvailable();
76
+ for (const name of names) {
77
+ const abs = join(folder, name);
78
+ const st = statSync(abs);
79
+ if (!st.isFile() || !/\.(md|txt)$/i.test(name)) { r.skipped += 1; r.files.push({ name, status: "skipped", why: st.isFile() ? "not a text note" : "a folder; only files directly in Credentials/ are read" }); continue; }
80
+ const text = readFileSync(abs, "utf8");
81
+ if (!extractSecrets(text).length) { r.unchanged += 1; r.files.push({ name, status: "unchanged" }); continue; }
82
+ if (!r.available) { r.kept += 1; r.files.push({ name, status: "kept", why: "not on macOS, or the Keychain is unavailable" }); continue; }
83
+ const m = migrateText(text, name, { keychainPath, dry });
84
+ if (m.status === "kept") { r.kept += 1; r.files.push({ name, status: "kept", why: m.why }); continue; }
85
+ if (!dry) {
86
+ // Written beside the original and renamed over it, so the file is never half written.
87
+ const tmp = join(folder, `.${name}.hq2622-${process.pid}.tmp`);
88
+ try {
89
+ writeFileSync(tmp, m.text, { mode: st.mode & 0o7777 });
90
+ chmodSync(tmp, st.mode & 0o7777);
91
+ renameSync(tmp, abs);
92
+ } catch (e) {
93
+ try { unlinkSync(tmp); } catch { /* never made */ }
94
+ for (const account of m.added) removeSecret({ account, keychainPath });
95
+ r.kept += 1;
96
+ r.files.push({ name, status: "kept", why: `the file could not be rewritten (${String(e.message ?? e).split("\n")[0]})` });
97
+ continue;
98
+ }
99
+ }
100
+ r.moved += 1;
101
+ r.files.push({ name, status: "moved", count: m.labels.length });
102
+ }
103
+ return r;
104
+ }
105
+
106
+ // What `evda secret migrate` prints, ending in one CHECKED or SKIPPED line.
107
+ export function migrateLines(r) {
108
+ if (!r.exists) return [`SKIPPED Credentials: ${r.folder} does not exist, so there is nothing to move`];
109
+ const out = [];
110
+ for (const f of r.files) {
111
+ if (f.status === "moved") out.push(` ${r.dry ? "would move" : "moved"} ${f.name}: ${f.count} value(s) ${r.dry ? "would go" : "went"} to the Keychain (${SERVICE}); the file holds a pointer, not the value`);
112
+ else if (f.status === "kept") out.push(` kept ${f.name}: the value stays in the sealed file (${f.why})`);
113
+ else if (f.status === "skipped") out.push(` skipped ${f.name}: ${f.why}`);
114
+ else out.push(` done ${f.name}: no value left in the file`);
115
+ }
116
+ const n = r.files.length - r.skipped;
117
+ if (!n) out.push(`SKIPPED Credentials: ${r.folder} holds no credential file`);
118
+ else out.push(`CHECKED ${n} credential file(s) ${r.moved} ${r.dry ? "would move" : "moved"} to the Keychain, ${r.unchanged} already holding no value, ${r.kept} kept in the sealed file`);
119
+ return out;
120
+ }
package/lib/day-file.mjs CHANGED
@@ -6,9 +6,10 @@
6
6
  // local time) with its one line of context; every later write in the same conversation appends
7
7
  // under it, as a new paragraph, with no heading. A conversation is whatever the caller passes as
8
8
  // `session`: one MCP session (a stdio process, or one Mcp-Session-Id over HTTP), or one Claude Code
9
- // session id in the prompt hook. When the file changed since this conversation last wrote to it
10
- // (another conversation wrote a section, or someone edited it by hand), the next write opens a new
11
- // heading, so words never land under a heading that is not theirs.
9
+ // session id in the prompt hook. When another conversation wrote a section below this one since,
10
+ // the next write still goes into this conversation's own section, above theirs (HQ-2330), so words
11
+ // never land under a heading that is not theirs and one conversation stays one section. Only a hand
12
+ // edit of the section itself makes the next write open a new heading.
12
13
  //
13
14
  // FILING. evda.json `filing` is the owner's answer to the one setup question: "everything" (file
14
15
  // every message they send, without being asked) or "asked" (only when they ask). Absent means they
@@ -22,7 +23,7 @@
22
23
  // EVDA_CHAT_FALSIFY=heading-per-write opens a new heading on every write, for the test that shows
23
24
  // one conversation is one section.
24
25
 
25
- import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
26
+ import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
26
27
  import { dirname, join, resolve } from "node:path";
27
28
 
28
29
  export const FILING_MODES = ["everything", "asked"];
@@ -62,6 +63,48 @@ export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
62
63
 
63
64
  export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
64
65
 
66
+ // NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
67
+ // (lib/touches.mjs, HQ-2631), and a conversation named some and added to none of them is asked once.
68
+ // The MCP server keeps what it named per chat; the Stop hook reads the same lines back out of the
69
+ // Claude Code transcript. Both build and parse them here, so the line one writes is the line the
70
+ // other reads, and nothing is stored in the owner's files: the transcript and the chat are the state.
71
+ export const RELATED_LEAD = "These existing entries bear on what you just filed: ";
72
+ export const TICKETS_LEAD = "These open tickets on the board bear on it: ";
73
+ export const ENTRIES_ASK_LEAD = "This conversation was pointed at existing entries and added to none of them:";
74
+
75
+ export const relatedLine = (related) => `${RELATED_LEAD}${related.map((r) => `${r.path} (named as "${r.name}")`).join("; ")}.`;
76
+
77
+ export const ticketsLine = (tickets) => `${TICKETS_LEAD}${tickets.map((k) => `${k.id} "${k.title}" (named as "${k.named}")`).join("; ")}.`;
78
+
79
+ // The open tickets a tool result's text names (HQ-2798's line), as ["HQ-<n>", ...].
80
+ export function ticketsNamedIn(text) {
81
+ const out = [];
82
+ for (const line of String(text ?? "").split("\n")) {
83
+ if (!line.startsWith(TICKETS_LEAD)) continue;
84
+ for (const m of line.slice(TICKETS_LEAD.length).matchAll(/(?:^|; )(HQ-\d+) "/g)) if (!out.includes(m[1])) out.push(m[1]);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // The entries a tool result's text names, as [{ path, name }], from its RELATED_LEAD line.
90
+ export function namedIn(text) {
91
+ const out = [];
92
+ for (const line of String(text ?? "").split("\n")) {
93
+ if (!line.startsWith(RELATED_LEAD)) continue;
94
+ const body = line.slice(RELATED_LEAD.length).replace(/\.$/, "");
95
+ for (const m of body.matchAll(/(?:^|; )(.+?) \(named as "([^"]*)"\)/g)) out.push({ path: m[1], name: m[2] });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Whether a text already carries the ask, so neither surface asks a second time.
101
+ export const askedIn = (text) => String(text ?? "").includes(ENTRIES_ASK_LEAD);
102
+
103
+ // The ask, naming the entries and tickets. `how` says how this surface adds to an entry.
104
+ export function entriesAsk(paths, how = "evda_append; a person's page with evda_record_person_note", tickets = []) {
105
+ return `${ENTRIES_ASK_LEAD} ${[...paths, ...tickets].join(", ")}. If what the owner said adds something to one of them, add a dated section to it now (${how})${tickets.length ? ", or update the ticket on the board" : ""}. If nothing new applies, adding nothing is fine. This is asked once.`;
106
+ }
107
+
65
108
  // The bytes one write appends to a day file whose current text is `current` (null when it does not
66
109
  // exist yet). `continuing` is true when this conversation's section is the last thing in the file.
67
110
  // Returns { text, opened }.
@@ -125,18 +168,84 @@ function withDayLock(fn) {
125
168
  }
126
169
 
127
170
  // Append `words` to the day file at `abs`. `session` is the conversation's mutable state ({} to
128
- // start one, null for a write that belongs to no conversation, which always opens a heading); it
129
- // remembers which file this conversation's section is in and how long that file was after its last
130
- // write. Returns { opened, bytes }.
171
+ // start one, null for a write that belongs to no conversation, which always opens a heading).
172
+ //
173
+ // HQ-2330: the conversation keeps its section, one per file, in `session.sections[abs]`: the
174
+ // section's exact text as this conversation last wrote it (`tail`, from its heading to its last
175
+ // words) and whether it carries a context line. A later write finds that text in the file and goes
176
+ // straight after it: at the end of the file when nothing came after (a plain append, as before),
177
+ // or above whatever another writer added below it since (a read-modify-write under the same lock).
178
+ // Keyed by file, so the agent log and the owner's day each keep their own section in one chat
179
+ // (measured 2026-10-07: a Claude Code session's evda_write_agent_log between two evda_writes left
180
+ // the next write a bare `## 20:41`, and the agent log a bare heading per entry). When the section's
181
+ // text is no longer in the file (edited by hand), the write opens a new heading, and that heading
182
+ // repeats the conversation's context line, so a continuing conversation never has a bare heading.
183
+ // Returns { opened, bytes, inserted }.
131
184
  export function appendToDay(abs, { now = new Date(), words, context = null, session = null }) {
132
185
  return withDayLock(() => {
133
186
  const exists = existsSync(abs);
134
187
  const current = exists ? readFileSync(abs, "utf8") : null;
135
- const s = session?.section;
136
- const continuing = Boolean(s && exists && s.abs === abs && statSync(abs).size === s.size);
137
- const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context, continuing, contextWritten: Boolean(s?.context) });
188
+ const mine = session ? sectionOf(session, abs) : null;
189
+ const at = mine && current !== null && !falsified("heading-per-write") ? current.lastIndexOf(mine.tail) : -1;
190
+ if (at >= 0) {
191
+ const body = String(words).replace(/\n+$/, "");
192
+ const ctx = context && !mine.context ? `${contextLine(context)}\n\n` : "";
193
+ const add = `\n${ctx}${body}\n`;
194
+ const end = at + mine.tail.length;
195
+ if (end === current.length) appendFileSync(abs, add);
196
+ else writeFileSync(abs, current.slice(0, end) + add + current.slice(end));
197
+ session.sections[abs] = { tail: mine.tail + add, context: mine.context || Boolean(ctx), line: mine.line ?? (context ? String(context).trim() : null) };
198
+ return { opened: false, bytes: Buffer.byteLength(add), inserted: end !== current.length };
199
+ }
200
+ // A new section. A conversation that already had one here and lost it keeps its context line.
201
+ const line = context ?? mine?.line ?? null;
202
+ const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context: line, continuing: false });
138
203
  appendFileSync(abs, w.text);
139
- if (session) session.section = { abs, size: statSync(abs).size, context: Boolean(context) || (continuing && Boolean(s?.context)) };
140
- return { opened: w.opened, bytes: Buffer.byteLength(w.text) };
204
+ if (session) {
205
+ const start = w.text.indexOf("\n## ");
206
+ session.sections ??= {};
207
+ session.sections[abs] = { tail: w.text.slice(start + 1), context: Boolean(line), line: line === null ? null : String(line).trim() };
208
+ }
209
+ return { opened: true, bytes: Buffer.byteLength(w.text), inserted: false };
210
+ });
211
+ }
212
+
213
+ // This conversation's section in the file at `abs`, or null. A session saved before HQ-2330 kept
214
+ // one `section` by size, not text; it is read as no section, so its next write opens a heading.
215
+ function sectionOf(session, abs) {
216
+ const s = session.sections?.[abs];
217
+ return s && typeof s.tail === "string" && s.tail ? s : null;
218
+ }
219
+
220
+ // The one pointer line under `heading` in the day file at `abs` (HQ-2306): the line in the owner's
221
+ // day that points at an agent's own log for that day. Exactly one line starting with `prefix` lives
222
+ // in that section; this replaces it in place, inserts it under the heading, appends the heading
223
+ // after everything else, or creates the day, and touches no other byte. Under the same lock as
224
+ // appendToDay, because this is a read-modify-write and a stale read would drop an append.
225
+ // Returns "created", "added", "inserted" or "refreshed".
226
+ export function setPointerLine(abs, { date, heading, prefix, line }) {
227
+ return withDayLock(() => {
228
+ const existing = existsSync(abs) ? readFileSync(abs, "utf8") : "";
229
+ if (existing === "") {
230
+ writeFileSync(abs, `# ${date}\n\n${heading}\n\n${line}\n`);
231
+ return "created";
232
+ }
233
+ const lines = existing.split("\n");
234
+ const at = lines.findIndex((l) => l.trim() === heading);
235
+ if (at === -1) {
236
+ appendFileSync(abs, `${existing.endsWith("\n") ? "" : "\n"}\n${heading}\n\n${line}\n`);
237
+ return "added";
238
+ }
239
+ let end = lines.length;
240
+ for (let i = at + 1; i < lines.length; i++) if (/^#{1,6} /.test(lines[i])) { end = i; break; }
241
+ const idx = lines.slice(at + 1, end).findIndex((l) => l.startsWith(prefix));
242
+ if (idx === -1) {
243
+ lines.splice(at + 1, 0, "", line);
244
+ writeFileSync(abs, lines.join("\n"));
245
+ return "inserted";
246
+ }
247
+ lines[at + 1 + idx] = line;
248
+ writeFileSync(abs, lines.join("\n"));
249
+ return "refreshed";
141
250
  });
142
251
  }
@@ -17,12 +17,14 @@
17
17
  //
18
18
  // It refuses Write, Edit, MultiEdit and NotebookEdit of a file directly in the daily folder, and a
19
19
  // shell command that names such a file next to a write-shaped operator (>, >>, tee, sed -i, cp, mv,
20
- // rm and the rest). Like the read guard, it stops a cooperating agent on the ordinary paths; a
20
+ // rm and the rest), after expanding the variables the command itself sets, $HOME and ~ (HQ-2817).
21
+ // Like the read guard, it stops a cooperating agent on the ordinary paths; a
21
22
  // command built to hide its target gets past it. Anything it cannot parse is allowed.
22
23
  //
23
24
  // EVDA_DAY_WRITER_FALSIFY=allow-all every decision is allow, so the tests can be watched failing
24
25
 
25
26
  import { realpathSync } from "node:fs";
27
+ import { homedir } from "node:os";
26
28
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
27
29
  import { openEvda } from "./manifest.mjs";
28
30
  import { dayRole, findRecord } from "./day-file.mjs";
@@ -35,9 +37,34 @@ export const DAY_WRITER_ENV = "EVDA_DAY_WRITER";
35
37
  const WRITERS = /((^|[\s;&|(`])(tee|cp|mv|rm|truncate|touch|install|ln|dd|ed|ex)(\s|$)|\b(sed|perl|ruby)\s+(-[A-Za-z]*\s+)*-[A-Za-z]*i)/;
36
38
  const REDIRECT = />>?\|?\s*("[^"]*"|'[^']*'|[^\s;&|<>()`]+)/g;
37
39
 
40
+ // HQ-2817: a command that names the day file through a variable it set itself got past the guard
41
+ // (2026-10-22: `V="<vault>"; ... cat >> "$V/06_Metadata/Daily Journal/<date>.md"`). This expands,
42
+ // in order, every `NAME=value` the command assigns (bare, `export`, quoted or not) and then every
43
+ // `$NAME` / `${NAME}` it names, plus `$HOME` and a leading `~`. A variable the command neither sets
44
+ // nor HOME supplies is left as written. It is not a shell: a value built by `$(...)` stays unexpanded.
45
+ const ASSIGN = /(^|[\s;&|(`]|\bexport\s+)([A-Za-z_][A-Za-z0-9_]*)=("([^"]*)"|'([^']*)'|[^\s;&|<>()`]*)/g;
46
+ export function expandShellVars(command, { env = process.env } = {}) {
47
+ const vars = { HOME: env.HOME || homedir() };
48
+ const sub = (s) => s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, a, b) => (vars[a ?? b] ?? m));
49
+ const tilde = (s) => s.replace(/(^|[\s;&|(`=>"'])~(?=\/|[\s"';&|)]|$)/g, (m, lead) => `${lead}${vars.HOME}`);
50
+ let out = "";
51
+ let last = 0;
52
+ for (const m of String(command).matchAll(ASSIGN)) {
53
+ out += sub(tilde(String(command).slice(last, m.index)));
54
+ const raw = m[4] ?? m[5] ?? m[3];
55
+ const next = String(command)[m.index + m[0].length] ?? "";
56
+ // A value the shell computes (`$(...)`, backticks) is not known here: the name stays unexpanded.
57
+ if (/[$`]/.test(raw.replace(/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/g, "")) || next === "(" || next === "`") delete vars[m[2]];
58
+ else vars[m[2]] = m[5] !== undefined ? raw : sub(tilde(raw));
59
+ out += m[0].slice(0, m[0].length - m[3].length) + (m[5] !== undefined ? m[3] : sub(tilde(m[3])));
60
+ last = m.index + m[0].length;
61
+ }
62
+ return out + sub(tilde(String(command).slice(last)));
63
+ }
64
+
38
65
  // The paths a shell command may write: every path when a writing command runs, else redirect targets.
39
- export function shellWriteTargets(command) {
40
- const c = String(command);
66
+ export function shellWriteTargets(command, { env = process.env } = {}) {
67
+ const c = expandShellVars(command, { env });
41
68
  if (WRITERS.test(c)) return shellPaths(c);
42
69
  return [...c.matchAll(REDIRECT)].map((m) => m[1].replace(/^["']|["']$/g, "")).filter((w) => w && !w.startsWith("&"));
43
70
  }
@@ -66,8 +93,15 @@ export function isDayFile(abs) {
66
93
  return real(dirname(abs)) === day;
67
94
  }
68
95
 
96
+ // HQ-2596: where words wait when the writer cannot be reached. One JSON file per write in the daily
97
+ // folder's `.outbox/`, so each file has one writer and no sync can drop it; the writer files them,
98
+ // oldest first, once its own copy of the day has synced. A file in that folder is not a day file, so
99
+ // this guard allows writing it.
100
+ export const OUTBOX_DIR = ".outbox";
101
+
69
102
  function sentence(writer, file) {
70
- return `EVÐA: ${basename(file)} is the daily file, and this machine does not write it. ${DAY_WRITER_ENV} names the machine that does (${writer}). Two machines writing one daily file through a synced folder lose one of the writes with no conflict copy. Append the words with the evda MCP tool evda_write, which that machine serves. If evda_write is not connected in this session, tell the owner in one plain sentence that the words were not filed and why, and do not write the file here.`;
103
+ const outbox = `${join(dirname(file), OUTBOX_DIR)}/`;
104
+ return `EVÐA: ${basename(file)} is the daily file, and this machine does not write it. ${DAY_WRITER_ENV} names the machine that does (${writer}). Two machines writing one daily file through a synced folder lose one of the writes with no conflict copy. Append the words with the evda MCP tool evda_write, which that machine serves. If evda_write is not connected or that machine cannot be reached, queue the words in the outbox instead: write one new file per message to ${outbox}<UTC timestamp>-<host>-evda_write.json holding {tool, words, context, at, host} (tool "evda_write", words verbatim, at in UTC ISO), and the writer files them into the day when it is back. Tell the owner the words are queued, not filed, and do not write the daily file here.`;
71
105
  }
72
106
 
73
107
  // The decision for one PreToolUse input. { decision: "allow" } or { decision: "deny", reason }.
@@ -82,7 +116,7 @@ export function decide(input, { env = process.env } = {}) {
82
116
  let paths = [];
83
117
  if (FILE_TOOLS.has(tool)) paths = [args.file_path ?? args.notebook_path].filter((p) => typeof p === "string" && p);
84
118
  else if (tool === "Bash") {
85
- paths = shellWriteTargets(args.command ?? "");
119
+ paths = shellWriteTargets(args.command ?? "", { env });
86
120
  } else return { decision: "allow" };
87
121
  const hit = paths.map(at).find(isDayFile);
88
122
  if (!hit) return { decision: "allow" };
package/lib/doctor.mjs CHANGED
@@ -46,6 +46,7 @@
46
46
  // the same records print "FAIL mounts 1 file(s) under the mount root mounts that no held circle
47
47
  // covers: mounts/ava.atlas/stray.md" and exit 1.
48
48
 
49
+ import { checkoutFreshness } from "./checkout-freshness.mjs";
49
50
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
50
51
  import { join, relative, resolve, sep } from "node:path";
51
52
  import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
@@ -69,12 +70,15 @@ import { folderManifests, folderFor, viewNamedIn, FOLDER_MANIFEST } from "./fold
69
70
  import { referencesIn } from "./references.mjs";
70
71
  import { KIND_FOR_TYPE, PERSON_KINDS, entryFromFile, readEntry } from "./kinds.mjs";
71
72
  import { datedHeadings } from "./migrate-people.mjs";
73
+ import { personFolderProblems, README_NAME } from "./person-folders.mjs";
72
74
  import { EvdaError } from "./errors.mjs";
73
75
  import { hookStatus, promptHookStatus, HOOK_REL, PROMPT_HOOK_REL, SETTINGS_REL } from "./hooks.mjs";
74
76
  import { FILING_MODES, filingOf } from "./day-file.mjs";
77
+ import { correctionsTarget } from "./corrections.mjs";
75
78
  import { EVERY_CHAT_KEY } from "./every-chat.mjs";
76
79
  import { listSkills, SKILLS_REL } from "./skills.mjs";
77
80
  import { guardStatus } from "./connect.mjs";
81
+ import { readmeCitations, missingHeadings, README_REL } from "./readme.mjs";
78
82
 
79
83
  // The dated sections of a person-profile that no observation covers (HQ-1397). An observation
80
84
  // covers a section when its text is the heading's exactly, or else when it carries the heading's
@@ -97,7 +101,10 @@ export function uncoveredSections(headings, observations) {
97
101
  // A suffix after the date is allowed (`2026-01-19 - The Scale Question.md`); no date is not.
98
102
  const DAILY_NAME = /^\d{4}-\d{2}-\d{2}(?:[ .-]|$)/;
99
103
 
100
- export function doctor(dir, { out = console.log, via = "argument", depth = 0 } = {}) {
104
+ // `checkout` (HQ-2736): the directory this doctor's code runs from. When given, the doctor says,
105
+ // just before its verdict, whether that checkout is behind origin/main or not on main (see
106
+ // lib/checkout-freshness.mjs). Only the CLI passes it; programmatic callers judge records, not tools.
107
+ export function doctor(dir, { out = console.log, via = "argument", depth = 0, checkout = null } = {}) {
101
108
  const failures = [];
102
109
  const warnings = [];
103
110
  const checked = [];
@@ -237,7 +244,12 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
237
244
  fail(rel, e.message);
238
245
  }
239
246
  if (fence === null) warn(rel, "has no evda:generated fence. The owner has taken the whole file; `evda upgrade` has nothing it owns to replace.");
240
- if (!/## What I have been taught/.test(agents)) warn(rel, 'has no "## What I have been taught" section, so a correction has nowhere to land.');
247
+ // HQ-2325: a record that keeps its corrections in their own file names it in evda.json, and
248
+ // then the heading is not where they land, so its absence is not a warning.
249
+ const corrections = correctionsTarget({ dir, manifest });
250
+ if (corrections?.problem) warn("evda.json", `${corrections.problem} evda_record_correction refuses until it does.`);
251
+ else if (corrections) ok(1, "corrections", `evda.json names ${corrections.rel}: a correction lands there as a dated section`);
252
+ else if (!/## What I have been taught/.test(agents)) warn(rel, 'has no "## What I have been taught" section, and evda.json names no "corrections" file, so a correction has nowhere to land.');
241
253
  }
242
254
  // Claude Code reads CLAUDE.md and nothing else. If the operating layer is elsewhere, CLAUDE.md
243
255
  // has to import it or a Claude Code session here starts with no instructions.
@@ -247,6 +259,20 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
247
259
  if (!imports) warn("CLAUDE.md", `does not import ${rel}. Claude Code loads CLAUDE.md and not ${rel}, so a Claude Code session here would start with no operating layer.`);
248
260
  }
249
261
  ok(1, "operating layer", rel);
262
+
263
+ // 5a. README.md sections the operating layer cites (HQ-2812). AGENTS.md gives the training-off
264
+ // steps, each AI company's terms and the local-model walk-through only from README.md, never from
265
+ // memory, so a cited section that is not there is the privacy step pointing at nothing: a failure.
266
+ const cites = readmeCitations(agents);
267
+ if (!cites.length) skip("README.md sections", `${rel} cites no section of README.md`);
268
+ else {
269
+ const readmeAbs = join(dir, README_REL);
270
+ const readmeText = existsSync(readmeAbs) ? readFileSync(readmeAbs, "utf8") : null;
271
+ const missing = missingHeadings(readmeText, cites);
272
+ if (readmeText === null) fail(README_REL, `is missing, and ${rel} sends the agent there for ${cites.map((c) => `"${c}"`).join(", ")}, including the steps for turning training off. \`evda upgrade\` writes it.`);
273
+ else if (missing.length) fail(README_REL, `has no ${missing.map((c) => `"${c}"`).join(", ")} section, and ${rel} sends the agent there${missing.includes("Turning training off") ? " for the steps that turn training off before anything personal is written" : ""}. \`evda upgrade\` adds EVÐA's block with ${missing.length === 1 ? "it" : "them"} after your text, which it does not change.`);
274
+ ok(cites.length, "README.md sections", `cited by ${rel}, ${cites.length - (readmeText === null ? cites.length : missing.length)} present`);
275
+ }
250
276
  }
251
277
 
252
278
  // 5b. the keep-record Stop hook (HQ-1376): whether a Claude Code session opened here is asked to
@@ -288,7 +314,11 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
288
314
  else if (prompt.registered && !prompt.current) warn(PROMPT_HOOK_REL, "or a file copied beside it differs from what this tooling ships. `evda hook install` updates them.");
289
315
  else if (prompt.registered && !everything) warn(SETTINGS_REL, `registers the prompt hook, but evda.json does not say "filing": "everything", so it files nothing.`);
290
316
  else if (prompt.registered) ok(1, "prompt hook", `${SETTINGS_REL} runs ${PROMPT_HOOK_REL}, current: each message the owner sends is filed verbatim`);
291
- else skip("prompt hook", everything ? `not installed. \`evda hook install\` adds it, and each message the owner sends in Claude Code is filed before the model sees it.` : `installed only for an owner whose evda.json says "filing": "everything".`);
317
+ // HQ-2817: "everything" with no prompt hook is a promise nothing keeps in Claude Code (his own vault
318
+ // filed nothing across four messages on 2026-10-22), so it warns. The keep-record Stop hook stays a
319
+ // skip: it asks once before a quiet stop, and evda.json promises nothing that rests on it alone.
320
+ else if (everything) warn("evda.json", `says "filing": "everything", and no prompt hook is installed, so a Claude Code session here files the owner's messages only if the model remembers to. \`evda hook install\` adds it to ${SETTINGS_REL}; on a machine that does not write the day it sends each message to the writer (EVDA_DAY_WRITER, EVDA_DAY_WRITER_SEND).`);
321
+ else skip("prompt hook", `installed only for an owner whose evda.json says "filing": "everything".`);
292
322
 
293
323
  // 5c. the read guard (HQ-1374), the other hook create-evda installs for a Claude owner (HQ-1377):
294
324
  // a PreToolUse hook in the record's own settings that stops a Claude Code session reading a noted
@@ -673,10 +703,21 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
673
703
  if (name === FOLDER_MANIFEST) continue;
674
704
  const rel = relative(dir, p);
675
705
  const read = entryFromFile(name, readFileSync(p, "utf8"));
676
- if (read === null) continue;
706
+ // In a person folder every file directly inside is a person's profile (HQ-2024), so one
707
+ // with no kind is a profile doctor would otherwise never read, and fails. Its README, its
708
+ // manifest and its declared views are not people; Processing/ sits beside the folder.
709
+ const unread = want === "person-profile" && p.slice(0, p.lastIndexOf(sep)) === f.dir && name !== README_NAME;
710
+ if (read === null) {
711
+ if (unread && /\.md$/i.test(name)) fail(rel, `has no person-profile frontmatter, so doctor never reads it. ${where} declares type person: every file in it opens with kind: person-profile (scripts/migrate-people.mjs adds it).`);
712
+ continue;
713
+ }
677
714
  if (read.error) { fail(rel, `${read.error} ${where} holds ${want} entries.`); continue; }
678
715
  const kind = read.raw?.kind;
679
- if (kind === undefined) { warn(rel, `has no "kind", so it is not read as a ${want}. ${where} declares type ${f.type}.`); continue; }
716
+ if (kind === undefined) {
717
+ if (unread) fail(rel, `has no "kind", so doctor never reads it as a person-profile. ${where} declares type person.`);
718
+ else warn(rel, `has no "kind", so it is not read as a ${want}. ${where} declares type ${f.type}.`);
719
+ continue;
720
+ }
680
721
  entries += 1;
681
722
  if (kind !== want) { fail(rel, `is a ${kind}, and ${where} declares type ${f.type}, whose entries are ${want}. Move it, or say what the folder holds.`); continue; }
682
723
  counts[want] = (counts[want] ?? 0) + 1;
@@ -695,6 +736,17 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
695
736
  }
696
737
  }
697
738
  }
739
+ // Person folders (HQ-2024, lib/person-folders.mjs): one file per person, named for the
740
+ // person, no subfolder, no lone parenthetical, no redirect stub. Read across every person
741
+ // folder at once, because two people who share a name can live in two folders.
742
+ const people = typed.filter((f) => f.type === "person");
743
+ if (people.length) {
744
+ const skipPaths = new Set(people.flatMap((f) => [join(f.dir, FOLDER_MANIFEST), ...f.views.map((v) => resolve(dir, v.path))]));
745
+ const layout = personFolderProblems(people.map((f) => f.dir), { skip: skipPaths });
746
+ for (const p of layout.problems) fail(relative(dir, p.path), p.message);
747
+ if (layout.files === 0) skip("person folder layout", `${people.length} person folder(s) hold no person files yet`);
748
+ else ok(layout.files, "person files", `across ${people.length} person folder(s): one file each, no subfolder, no lone parenthetical, no redirect stub`);
749
+ }
698
750
  if (entries === 0) skip("typed entries", `${typed.length} typed folder(s) hold no entry files yet`);
699
751
  else ok(entries, "typed entries", `across ${typed.length} folder(s): ${PERSON_KINDS.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(", ") || "none of the declared kind"}`);
700
752
  }
@@ -770,6 +822,9 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
770
822
  return finish(0);
771
823
 
772
824
  function finish(refusedExit) {
825
+ // The tool's own freshness, not the record's: printed, never counted as a failure or warning,
826
+ // so it cannot change the exit, which stays the record's verdict.
827
+ if (checkout && depth === 0) for (const line of checkoutFreshness(checkout).lines) out(` CHECKOUT ${line}`);
773
828
  if (refusedExit === 2) {
774
829
  out(`REFUSED nothing was written`);
775
830
  return { failures, warnings, checked, exit: 2 };