create-evda 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/circles.mjs CHANGED
@@ -74,6 +74,7 @@ import { resolveRole } from "./roles.mjs";
74
74
  import { boundaries, insideAny } from "./records.mjs";
75
75
  import { parseYaml, toYaml } from "./yaml.mjs";
76
76
  import { EvdaError } from "./errors.mjs";
77
+ import { outsideRecord } from "./record-walk.mjs";
77
78
 
78
79
  export const ECO_SCHEMA_VERSION = "0.1";
79
80
  // atlas-arch:yaml-encoding: the eco manifest is a config file the substrate names by its suffix, not an entity key
@@ -544,13 +545,14 @@ export function frontmatterOf(text) {
544
545
  }
545
546
  }
546
547
 
547
- // Every markdown file under the record root, skipping dot-directories and node_modules. The mark
548
+ // Every markdown file under the record root, skipping dot-directories and what outsideRecord names
549
+ // (node_modules, any *.nosync). The mark
548
550
  // walk reads the whole record, not only the record roles: a released fact may live anywhere.
549
551
  export function markdownFiles(root) {
550
552
  const out = [];
551
553
  const walk = (abs) => {
552
554
  for (const name of readdirSync(abs).sort()) {
553
- if (name.startsWith(".") || name === "node_modules") continue;
555
+ if (name.startsWith(".") || outsideRecord(name)) continue;
554
556
  const p = join(abs, name);
555
557
  // A dangling symlink (target lives only on another machine, e.g. raw media kept
556
558
  // outside the synced vault) has nothing to stat. It is not markdown content either
package/lib/connect.mjs CHANGED
@@ -34,50 +34,114 @@ export function serverCommand(recordDir, { execPath = process.execPath, bin = jo
34
34
  return { command: stableNode(execPath, env), args: [real, "mcp", dir] };
35
35
  }
36
36
 
37
- // The command Claude Code runs for the read guard (lib/guard.mjs), by the same rule as the server:
38
- // absolute paths when the package is installed somewhere stable, the npx form from an npx cache.
39
- export function guardCommand({ execPath = process.execPath, hook = join(PACKAGE_ROOT, "hooks", "evda-read-guard.mjs"), env = process.env } = {}) {
40
- let real = hook;
41
- try { real = realpathSync(hook); } catch { /* not on disk yet, as in a test */ }
42
- const q = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
43
- if (/[\\/]_npx[\\/]/.test(real)) return "npx -y -p create-evda@latest evda guard";
44
- return `${q(stableNode(execPath, env))} ${q(real)}`;
37
+ // The read guard (lib/guard.mjs) runs from a copy inside the record, like the keep-record and prompt
38
+ // hooks (lib/hooks.mjs), never from the package that installed it (HQ-2819). An absolute path into
39
+ // the installer dies with it: the npx cache npm clears, a checkout or worktree that is removed. The
40
+ // npx form (`npx -y -p create-evda@latest evda guard`) outlives the installer but asks the registry
41
+ // before every Read, and an offline laptop then runs no guard, so it is not written either. The copy
42
+ // is the entry point plus every lib/ file it imports, found by walking the imports at install time,
43
+ // so a new import in lib/guard.mjs is copied without anyone listing it.
44
+ export const GUARD_DIR_REL = ".claude/hooks/evda-guard";
45
+ export const GUARD_REL = `${GUARD_DIR_REL}/evda-read-guard.mjs`;
46
+ export const GUARD_COMMAND = `node "$CLAUDE_PROJECT_DIR/${GUARD_REL}"`;
47
+ export const GUARD_MATCHER = "Read|Grep|Bash|NotebookRead";
48
+ const SHIPPED_GUARD = join(PACKAGE_ROOT, "hooks", "evda-read-guard.mjs");
49
+ const LIB_DIR = join(PACKAGE_ROOT, "lib");
50
+
51
+ // Every file the record's copy of the guard holds, as [path in the record, bytes]. The entry point
52
+ // is the shipped one with its one import pointed at the lib/ copied beside it; the lib files are
53
+ // byte for byte. Throws if the shipped entry no longer imports ../lib/guard.mjs, so a change there
54
+ // cannot install a copy that fails at load.
55
+ export function guardFiles() {
56
+ const entry = readFileSync(SHIPPED_GUARD, "utf8");
57
+ const IMPORT = 'from "../lib/guard.mjs"';
58
+ if (!entry.includes(IMPORT)) throw new Error(`${SHIPPED_GUARD} no longer imports ../lib/guard.mjs, so the guard copy cannot be built`);
59
+ const files = [[GUARD_REL, Buffer.from(entry.replace(IMPORT, 'from "./lib/guard.mjs"'))]];
60
+ const seen = new Set();
61
+ const walk = (name) => {
62
+ if (seen.has(name)) return;
63
+ seen.add(name);
64
+ const bytes = readFileSync(join(LIB_DIR, name));
65
+ files.push([`${GUARD_DIR_REL}/lib/${name}`, bytes]);
66
+ for (const m of bytes.toString("utf8").matchAll(/\bfrom\s*["']\.\/([^"']+)["']|\bimport\s*\(?\s*["']\.\/([^"']+)["']/g)) walk(m[1] ?? m[2]);
67
+ };
68
+ walk("guard.mjs");
69
+ return files;
45
70
  }
46
71
 
47
- // Install the read guard into one record's .claude/settings.json, for Claude Code sessions opened
48
- // in that folder. Merges into a settings file that is already there: an existing PreToolUse entry
49
- // with the same command is left alone, and nothing else in the file is touched.
50
- export function installGuard(recordDir, { command = guardCommand() } = {}) {
51
- const path = join(resolve(recordDir), ".claude", "settings.json");
72
+ // The settings commands that run the guard, in any form an installer ever wrote: this copy, an
73
+ // absolute path to some package's hooks/evda-read-guard.mjs, or the npx form.
74
+ export const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
75
+ const isGuard = (h) => typeof h?.command === "string" && GUARD_MARK.test(h.command);
76
+
77
+ // Install the read guard into one record: copy its files into .claude/hooks/evda-guard/ and make
78
+ // .claude/settings.json run that copy. Every guard entry already there (a stale absolute path, the
79
+ // npx form, a duplicate from a second install) is removed, so exactly one remains. Nothing else in
80
+ // the settings file is touched, and a file that already says exactly this is not rewritten.
81
+ export function installGuard(recordDir) {
82
+ const dir = resolve(recordDir);
83
+ const path = join(dir, ".claude", "settings.json");
52
84
  let settings = {};
53
85
  if (existsSync(path)) {
54
86
  try { settings = JSON.parse(readFileSync(path, "utf8")); } catch { return { path, action: "left alone (not valid JSON):" }; }
87
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return { path, action: "left alone (not a JSON object):" };
88
+ }
89
+ let copied = false;
90
+ for (const [rel, bytes] of guardFiles()) {
91
+ const abs = join(dir, rel);
92
+ if (existsSync(abs) && readFileSync(abs).equals(bytes)) continue;
93
+ mkdirSync(dirname(abs), { recursive: true });
94
+ writeFileSync(abs, bytes);
95
+ copied = true;
55
96
  }
56
- const pre = (settings.hooks ??= {}).PreToolUse ??= [];
57
- if (pre.some((m) => (m.hooks ?? []).some((h) => h.command === command))) return { path, action: "already installed:" };
58
- pre.push({ matcher: "Read|Grep|Bash|NotebookRead", hooks: [{ type: "command", command }] });
97
+ const pre = Array.isArray(settings.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
98
+ const found = pre.flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []).filter(isGuard).map((h) => h.command));
99
+ if (found.length === 1 && found[0] === GUARD_COMMAND) return { path, action: copied ? "updated the guard copy:" : "already installed:" };
100
+ const kept = pre
101
+ .map((m) => (Array.isArray(m?.hooks) && m.hooks.some(isGuard) ? { ...m, hooks: m.hooks.filter((h) => !isGuard(h)) } : m))
102
+ .filter((m) => !Array.isArray(m?.hooks) || m.hooks.length);
103
+ kept.push({ matcher: GUARD_MATCHER, hooks: [{ type: "command", command: GUARD_COMMAND }] });
104
+ if (settings.hooks === null || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
105
+ settings.hooks.PreToolUse = kept;
59
106
  mkdirSync(dirname(path), { recursive: true });
60
107
  writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
61
- return { path, action: "installed" };
108
+ return { path, action: found.length ? `replaced ${found.length} older guard ${found.length === 1 ? "entry" : "entries"}:` : "installed" };
62
109
  }
63
110
 
64
- // Whether one record's .claude/settings.json runs the read guard, for doctor (HQ-1377):
65
- // { registered, target, file, problem }. `target` is the hook file the command names, `file` whether
66
- // it is on disk; the npx form names no file and is present whenever it is registered. Never throws.
67
- export const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
111
+ // The hook file one guard command runs, resolved against the record ($CLAUDE_PROJECT_DIR is the
112
+ // record, since the guard is registered in the record's own settings). Null for the npx form.
113
+ export function guardTarget(command, recordDir) {
114
+ const dir = resolve(recordDir);
115
+ const m = /"([^"]*evda-read-guard\.mjs)"|'([^']*evda-read-guard\.mjs)'|(\S*evda-read-guard\.mjs)/.exec(command);
116
+ if (!m) return null;
117
+ return resolve(dir, (m[1] ?? m[2] ?? m[3]).replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, () => dir));
118
+ }
119
+
120
+ // Whether one record's .claude/settings.json runs the read guard, for doctor (HQ-1377, HQ-2819):
121
+ // { registered, target, file, missing, count, current, problem }. `target` is the hook file the
122
+ // first entry names, `missing` every registered file not on disk (`file` is true when there is
123
+ // none), `count` how many guard entries there are, `current` whether the record's copy matches what
124
+ // ships. The npx form names no file and is present whenever it is registered. Never throws.
68
125
  // EVDA_HOOKS_FALSIFY=blind reports no guard at all, for the test that shows doctor's line is read
69
126
  // from the settings file.
70
127
  export function guardStatus(recordDir) {
71
- if ((process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind")) return { registered: false, target: null, file: false, problem: null };
72
- const path = join(resolve(recordDir), ".claude", "settings.json");
73
- if (!existsSync(path)) return { registered: false, target: null, file: false, problem: null };
128
+ const none = { registered: false, target: null, file: false, missing: [], count: 0, current: false, problem: null };
129
+ if ((process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind")) return none;
130
+ const dir = resolve(recordDir);
131
+ const path = join(dir, ".claude", "settings.json");
132
+ if (!existsSync(path)) return none;
74
133
  let settings;
75
- try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return { registered: false, target: null, file: false, problem: `.claude/settings.json is not valid JSON (${e.message}), so no hook in it runs.` }; }
76
- const commands = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).filter((c) => typeof c === "string" && GUARD_MARK.test(c));
77
- if (!commands.length) return { registered: false, target: null, file: false, problem: null };
78
- const m = /'([^']*evda-read-guard\.mjs)'|(\S*evda-read-guard\.mjs)/.exec(commands[0]);
79
- const target = m ? (m[1] ?? m[2]) : null;
80
- return { registered: true, target, file: target === null || existsSync(target), problem: null };
134
+ try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return { ...none, problem: `.claude/settings.json is not valid JSON (${e.message}), so no hook in it runs.` }; }
135
+ const pre = settings?.hooks?.PreToolUse;
136
+ const commands = (Array.isArray(pre) ? pre : []).flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []).filter(isGuard).map((h) => h.command));
137
+ if (!commands.length) return none;
138
+ const targets = commands.map((c) => guardTarget(c, dir));
139
+ const missing = targets.filter((t) => t !== null && !existsSync(t));
140
+ let current = true;
141
+ if (targets.includes(join(dir, GUARD_REL))) {
142
+ try { current = guardFiles().every(([rel, bytes]) => existsSync(join(dir, rel)) && readFileSync(join(dir, rel)).equals(bytes)); } catch { current = false; }
143
+ }
144
+ return { registered: true, target: targets[0], file: missing.length === 0, missing, count: commands.length, current, problem: null };
81
145
  }
82
146
 
83
147
  // A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
@@ -0,0 +1,68 @@
1
+ // A conversation's section state kept between separate evda_write calls (HQ-2823).
2
+ //
3
+ // appendToDay (lib/day-file.mjs) keeps one conversation in one section through a `session` object,
4
+ // and an MCP process holds that object in memory. A relay that spawns a fresh `evda mcp` for every
5
+ // message (Hestia's evda-tool.mjs, which the prompt hook on a non-writer laptop sends through) has
6
+ // no memory between calls, so on 2026-10-22 every message the owner sent from his laptop opened
7
+ // its own `## HH:MM`. A caller that names its conversation (evda_write's `conversation`) gets the
8
+ // section state loaded from and saved to one small file here instead.
9
+ //
10
+ // Where: EVDA_CONVERSATION_DIR, default ~/.evda/conversations: outside the record and outside any
11
+ // synced folder, the way the prompt outbox is (lib/prompt-hook.mjs promptOutboxDir). What: the
12
+ // `sections` appendToDay keeps, which hold the section's own text so the next write can find it.
13
+ // That is the owner's words outside the record, so the folder is the owner's alone (0700) and a
14
+ // file not written for CONVERSATION_KEEP_MS is deleted on the next save. A section lives in one
15
+ // day's file, so a conversation older than that has nothing left to continue. Age is the
16
+ // `savedAt` written inside each file by the same clock that judges it, never the file's mtime:
17
+ // a clock the OS does not share (a shifted test clock, a restored backup) would otherwise read a
18
+ // file saved a moment ago as old and delete a live conversation.
19
+
20
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { isConversationId } from "./day-file.mjs";
24
+
25
+ export const CONVERSATION_DIR_ENV = "EVDA_CONVERSATION_DIR";
26
+ export const CONVERSATION_KEEP_MS = 2 * 24 * 60 * 60 * 1000;
27
+
28
+ export function conversationDir(env = process.env) {
29
+ return env[CONVERSATION_DIR_ENV] || join(homedir(), ".evda", "conversations");
30
+ }
31
+
32
+ const fileOf = (id, env) => join(conversationDir(env), `${id}.json`);
33
+
34
+ // The session object appendToDay takes for conversation `id`: { sections } as last saved, or {}.
35
+ export function loadConversation(id, { env = process.env } = {}) {
36
+ if (!isConversationId(id)) throw new Error(`not a conversation id: ${JSON.stringify(id)}`);
37
+ try {
38
+ const s = JSON.parse(readFileSync(fileOf(id, env), "utf8"));
39
+ return s && typeof s.sections === "object" && s.sections ? { sections: s.sections } : {};
40
+ } catch { return {}; }
41
+ }
42
+
43
+ // Save it (written whole, then renamed into place, so a reader never sees half a file), and delete
44
+ // every conversation not saved for CONVERSATION_KEEP_MS.
45
+ export function saveConversation(id, session, { env = process.env, nowMs = Date.now() } = {}) {
46
+ if (!isConversationId(id)) throw new Error(`not a conversation id: ${JSON.stringify(id)}`);
47
+ const dir = conversationDir(env);
48
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ const abs = fileOf(id, env);
50
+ const part = `${abs}.${process.pid}.part`;
51
+ writeFileSync(part, JSON.stringify({ sections: session.sections ?? {}, savedAt: nowMs }) + "\n", { mode: 0o600 });
52
+ renameSync(part, abs);
53
+ for (const n of readdirSync(dir)) {
54
+ const f = join(dir, n);
55
+ if (f === abs) continue;
56
+ try { if (nowMs - savedAtOf(f) > CONVERSATION_KEEP_MS) unlinkSync(f); } catch { /* gone already */ }
57
+ }
58
+ }
59
+
60
+ // When `f` was saved: its own savedAt, or its mtime for a file written without one (a leftover
61
+ // .part, a file from before savedAt existed).
62
+ function savedAtOf(f) {
63
+ try {
64
+ const t = JSON.parse(readFileSync(f, "utf8")).savedAt;
65
+ if (Number.isFinite(t)) return t;
66
+ } catch { /* not a whole conversation file */ }
67
+ return statSync(f).mtimeMs;
68
+ }
@@ -8,9 +8,11 @@
8
8
  // every correction from a chat app was refused and lost. Doctor reads the same setting through this
9
9
  // one function, so the tool and the check cannot disagree on what a valid setting is.
10
10
 
11
- import { existsSync, statSync } from "node:fs";
12
- import { isAbsolute, relative, resolve, sep } from "node:path";
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
13
13
  import { isSealed } from "./circles.mjs";
14
+ import { readContext, mayRead, aiView } from "./readers.mjs";
15
+ import { datalessAmong } from "./sync-rules.mjs";
14
16
 
15
17
  export const CORRECTIONS_KEY = "corrections";
16
18
 
@@ -32,3 +34,76 @@ export function correctionsTarget(evda) {
32
34
  if (isSealed(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is sealed, so no agent writes a correction into it.` };
33
35
  return { rel, abs, problem: null };
34
36
  }
37
+
38
+ // What the owner taught the agent, served in the operating layer (HQ-2818). A chat app learns who
39
+ // it is only from evda_operating_layer, so a correction written to a separate corrections file was
40
+ // recorded and never read back on any chat surface. The layer now carries the newest entries, the
41
+ // same selection on every surface, so a correction made in one app reaches the next chat in any.
42
+ //
43
+ // The size is fixed, not the whole file: his file measured 671,346 characters and 445 sections on
44
+ // 2026-10-22, ten times a chat's whole budget. Entries are the file's `## ` sections in file order
45
+ // (evda_record_correction appends, so the last is the newest); the newest are kept whole while they
46
+ // fit, and the block says how many older ones the file holds and how to find one.
47
+ export const CORRECTIONS_LAYER_MAX = 8_000;
48
+ export const TAUGHT_HEADING = "What I have been taught";
49
+
50
+ // A corrections file's entries: its `## ` sections, in file order. The text before the first one is
51
+ // the file's own preamble, not an entry.
52
+ export function fileEntries(text) {
53
+ return text.split(/^(?=## )/m).filter((sec) => sec.startsWith("## ")).map((sec) => sec.replace(/\s+$/, ""));
54
+ }
55
+
56
+ // The "What I have been taught" section's entries: its `- ` lines, each with any lines under it.
57
+ export function sectionEntries(sec) {
58
+ const body = sec.split("\n").slice(1).join("\n");
59
+ return body.split(/^(?=- )/m).filter((e) => e.startsWith("- ")).map((e) => e.replace(/\s+$/, ""));
60
+ }
61
+
62
+ // The newest entries that fit in `max` characters joined by blank lines, oldest of them first.
63
+ // When even the newest is longer, it is cut at a line and says where the rest is.
64
+ export function newestEntries(entries, max, where) {
65
+ const kept = [];
66
+ let size = 0;
67
+ for (let i = entries.length - 1; i >= 0; i--) {
68
+ const grow = entries[i].length + (kept.length ? 2 : 0);
69
+ if (size + grow > max) break;
70
+ kept.unshift(entries[i]);
71
+ size += grow;
72
+ }
73
+ if (!kept.length && entries.length) {
74
+ const tail = `\n(This entry is cut here; it continues in ${where}.)`;
75
+ const e = entries[entries.length - 1];
76
+ let cut = e.slice(0, Math.max(0, max - tail.length));
77
+ const at = cut.lastIndexOf("\n");
78
+ if (at > 0) cut = cut.slice(0, at);
79
+ return { kept: [cut + tail], older: entries.length - 1 };
80
+ }
81
+ return { kept, older: entries.length - kept.length };
82
+ }
83
+
84
+ function block(path, entries, max) {
85
+ if (!entries.length) return null;
86
+ const { kept, older } = newestEntries(entries, max, path);
87
+ const more = older ? ` ${older} older ${older === 1 ? "entry is" : "entries are"} in ${path}; evda_search finds one by its words.` : "";
88
+ const head = `## ${TAUGHT_HEADING} (from ${path})\n\nWhat the owner corrected you on, newest last, word for word as ${path} keeps it: how they want you to work, learned from real misses.${more}`;
89
+ return { text: `${head}\n\n${kept.join("\n\n")}\n`, path, served: kept.length, total: entries.length };
90
+ }
91
+
92
+ // The corrections block for the layer, or null when there is nothing to add. `caller` is who reads
93
+ // the layer: a file this caller may not read (sealed, noted, or a read_by that is not theirs) is not
94
+ // served, by the same mayRead every read tool uses, and a sealed section inside it is never shown.
95
+ // A file still in the cloud is not read, since reading it would start a download.
96
+ //
97
+ // With no "corrections" setting the entries live in AGENTS.md's own section, which the layer already
98
+ // serves, so nothing is added; `taughtListed` is that section's text when a chat's budget listed it
99
+ // by heading instead, and then its newest lines are served here so they still reach the chat.
100
+ export function correctionsForLayer(evda, caller, { max = CORRECTIONS_LAYER_MAX, taughtListed = null, agentsPath = "AGENTS.md" } = {}) {
101
+ const target = correctionsTarget(evda);
102
+ if (!target) return taughtListed ? block(agentsPath, sectionEntries(taughtListed), max) : null;
103
+ if (target.problem) return null;
104
+ if (!mayRead(readContext(evda), target.abs, caller).ok) return null;
105
+ if (datalessAmong(dirname(target.abs), [basename(target.abs)]).size) return null;
106
+ let raw;
107
+ try { raw = readFileSync(target.abs, "utf8"); } catch { return null; }
108
+ return block(target.rel, fileEntries(aiView(raw).text), max);
109
+ }
package/lib/day-file.mjs CHANGED
@@ -61,6 +61,13 @@ const pad = (n) => String(n).padStart(2, "0");
61
61
  export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
62
62
  export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
63
63
 
64
+ // A conversation id a caller names so that separate evda_write calls continue one section (HQ-2823):
65
+ // the prompt hook sends Claude Code's session_id. Short and opaque, letters, digits, `_` and `-`
66
+ // only, because the writer names a state file after it. Checked here so the hook and evda_write
67
+ // read one rule.
68
+ export const CONVERSATION_MAX = 128;
69
+ export const isConversationId = (v) => typeof v === "string" && v.length <= CONVERSATION_MAX && /^[A-Za-z0-9_-]+$/.test(v);
70
+
64
71
  export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
65
72
 
66
73
  // NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
@@ -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
  }
@@ -89,7 +116,7 @@ export function decide(input, { env = process.env } = {}) {
89
116
  let paths = [];
90
117
  if (FILE_TOOLS.has(tool)) paths = [args.file_path ?? args.notebook_path].filter((p) => typeof p === "string" && p);
91
118
  else if (tool === "Bash") {
92
- paths = shellWriteTargets(args.command ?? "");
119
+ paths = shellWriteTargets(args.command ?? "", { env });
93
120
  } else return { decision: "allow" };
94
121
  const hit = paths.map(at).find(isDayFile);
95
122
  if (!hit) return { decision: "allow" };
package/lib/doctor.mjs CHANGED
@@ -46,8 +46,9 @@
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
- import { join, relative, resolve, sep } from "node:path";
51
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
51
52
  import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
52
53
  import { readGrants } from "./grants.mjs";
53
54
  import { readDevices } from "./devices.mjs";
@@ -76,7 +77,8 @@ import { FILING_MODES, filingOf } from "./day-file.mjs";
76
77
  import { correctionsTarget } from "./corrections.mjs";
77
78
  import { EVERY_CHAT_KEY } from "./every-chat.mjs";
78
79
  import { listSkills, SKILLS_REL } from "./skills.mjs";
79
- import { guardStatus } from "./connect.mjs";
80
+ import { guardStatus, GUARD_DIR_REL, GUARD_REL } from "./connect.mjs";
81
+ import { readmeCitations, missingHeadings, README_REL } from "./readme.mjs";
80
82
 
81
83
  // The dated sections of a person-profile that no observation covers (HQ-1397). An observation
82
84
  // covers a section when its text is the heading's exactly, or else when it carries the heading's
@@ -99,7 +101,10 @@ export function uncoveredSections(headings, observations) {
99
101
  // A suffix after the date is allowed (`2026-01-19 - The Scale Question.md`); no date is not.
100
102
  const DAILY_NAME = /^\d{4}-\d{2}-\d{2}(?:[ .-]|$)/;
101
103
 
102
- 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 } = {}) {
103
108
  const failures = [];
104
109
  const warnings = [];
105
110
  const checked = [];
@@ -254,6 +259,20 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
254
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.`);
255
260
  }
256
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
+ }
257
276
  }
258
277
 
259
278
  // 5b. the keep-record Stop hook (HQ-1376): whether a Claude Code session opened here is asked to
@@ -295,16 +314,24 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
295
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.");
296
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.`);
297
316
  else if (prompt.registered) ok(1, "prompt hook", `${SETTINGS_REL} runs ${PROMPT_HOOK_REL}, current: each message the owner sends is filed verbatim`);
298
- 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".`);
299
322
 
300
323
  // 5c. the read guard (HQ-1374), the other hook create-evda installs for a Claude owner (HQ-1377):
301
324
  // a PreToolUse hook in the record's own settings that stops a Claude Code session reading a noted
302
- // entry. Reported present or missing, like the Stop hook above; a registered guard whose file is
303
- // gone warns, because every read then runs a command that is not there.
325
+ // entry. A registered guard whose file is gone fails (HQ-2819): every read then runs a command
326
+ // that is not there, Claude Code lets the read through, and the owner was told it is guarded. The
327
+ // usual cause is an install that pointed into an npx cache or a checkout since removed.
304
328
  const guard = guardStatus(dir);
329
+ const posixRel = (root, abs) => { const r = relative(resolve(root), abs); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(sep).join("/") : abs; };
305
330
  if (guard.problem) warn(SETTINGS_REL, guard.problem);
306
- else if (guard.registered && !guard.file) warn(SETTINGS_REL, `registers the read guard, but ${guard.target} is missing, so Claude Code cannot run it and a noted entry is not held back there. \`evda hook install\` restores it.`);
307
- else if (guard.registered) ok(1, "read guard", `${SETTINGS_REL} runs ${guard.target ?? "the evda guard through npx"} before Read, Grep and shell reads`);
331
+ else if (guard.registered && !guard.file) fail(SETTINGS_REL, `registers the read guard, but ${guard.missing.join(" and ")} ${guard.missing.length === 1 ? "is" : "are"} missing, so Claude Code cannot run it and a noted entry is not held back there. \`evda hook install\` copies the guard into ${GUARD_DIR_REL}/ and replaces the entry.`);
332
+ else if (guard.registered && guard.count > 1) warn(SETTINGS_REL, `registers the read guard ${guard.count} times. \`evda hook install\` leaves one.`);
333
+ else if (guard.registered && !guard.current) warn(GUARD_REL, "or a file copied beside it differs from what this tooling ships. `evda hook install` updates them.");
334
+ else if (guard.registered) ok(1, "read guard", `${SETTINGS_REL} runs ${guard.target ? posixRel(dir, guard.target) : "the evda guard through npx"} before Read, Grep and shell reads`);
308
335
  else skip("read guard", `not installed. A Claude Code session here is not stopped before reading a just-noted entry (the MCP server still holds it back); \`evda hook install\` adds it to ${SETTINGS_REL}.`);
309
336
 
310
337
  // 6. record files
@@ -799,6 +826,9 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
799
826
  return finish(0);
800
827
 
801
828
  function finish(refusedExit) {
829
+ // The tool's own freshness, not the record's: printed, never counted as a failure or warning,
830
+ // so it cannot change the exit, which stays the record's verdict.
831
+ if (checkout && depth === 0) for (const line of checkoutFreshness(checkout).lines) out(` CHECKOUT ${line}`);
802
832
  if (refusedExit === 2) {
803
833
  out(`REFUSED nothing was written`);
804
834
  return { failures, warnings, checked, exit: 2 };
package/lib/folders.mjs CHANGED
@@ -25,6 +25,7 @@ import { join, relative, resolve, sep } from "node:path";
25
25
  import { resolveAll } from "./roles.mjs";
26
26
  import { SCHEMES } from "./references.mjs";
27
27
  import { EvdaError } from "./errors.mjs";
28
+ import { outsideRecord } from "./record-walk.mjs";
28
29
 
29
30
  export const FOLDER_MANIFEST = "folder.evda.json";
30
31
  export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
@@ -138,7 +139,7 @@ export function folderManifests(evda) {
138
139
  }
139
140
  const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
140
141
  for (const e of entries) {
141
- if (e.name.startsWith(".") || e.name === "node_modules") continue;
142
+ if (e.name.startsWith(".") || outsideRecord(e.name)) continue;
142
143
  if (e.isDirectory()) walk(join(dir, e.name));
143
144
  }
144
145
  };
package/lib/gather.mjs CHANGED
@@ -66,6 +66,7 @@ import { EvdaError } from "./errors.mjs";
66
66
  import { noteShape, htmlNoteToMarkdown } from "./html-notes.mjs";
67
67
  import { pairSidecars } from "./takeout.mjs";
68
68
  import { frontmatterOf, heldBy, withoutSealed, carries, NOTED } from "./circles.mjs";
69
+ import { outsideRecord } from "./record-walk.mjs";
69
70
 
70
71
  export const GATHER_VERSION = 1;
71
72
  // The person-layer kinds an item is typed with, and the folder type each lives under.
@@ -219,7 +220,7 @@ const slugId = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-
219
220
  // added to its frontmatter, a finished status) no longer hashes like its source; this is how a
220
221
  // second gather still knows it.
221
222
  export const GATHERED = ".evda/gathered.jsonl";
222
- const WALK_SKIP = new Set([".git", "node_modules", ".evda", ".obsidian", ".trash", ".DS_Store"]);
223
+ const WALK_SKIP = new Set([".git", ".evda", ".obsidian", ".trash", ".DS_Store"]); // plus outsideRecord
223
224
 
224
225
  // Every file in the record by the hash of its bytes, and the source hashes it has gathered.
225
226
  // Returns { byHash: Map<sha256, rel>, gathered: Map<sha256, rel> }.
@@ -229,7 +230,7 @@ export function presentIndex(dir) {
229
230
  if (falsified("reapply-resorts")) return { byHash, gathered };
230
231
  const go = (d) => {
231
232
  for (const name of readdirSync(d).sort()) {
232
- if (WALK_SKIP.has(name)) continue;
233
+ if (WALK_SKIP.has(name) || outsideRecord(name)) continue;
233
234
  const abs = join(d, name);
234
235
  const st = lstatSync(abs);
235
236
  if (st.isDirectory()) go(abs);
@@ -277,7 +278,7 @@ function walk(root) {
277
278
  const abs = join(dir, name);
278
279
  const rel = posix(relative(root, abs));
279
280
  if (APP_DIRS[name]) apps.add(APP_DIRS[name]);
280
- if (SKIP_NAMES.has(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
281
+ if (SKIP_NAMES.has(name) || outsideRecord(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
281
282
  const s = lstatSync(abs);
282
283
  if (s.isSymbolicLink()) { skipped.push({ rel, why: "a symlink; not followed" }); continue; }
283
284
  const junk = s.isDirectory() || s.isFile() ? junkWhy(name, s.size, s.isDirectory()) : null;
package/lib/keep-hook.mjs CHANGED
@@ -127,7 +127,9 @@ export function classify(e, facts) {
127
127
 
128
128
  // The decision, pure. `lastBlock` is the event index this hook last blocked at (0 for never).
129
129
  // `fileMtime` is the day file's modification time in ms, or null when it does not exist.
130
- export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
130
+ // `nonWriter` (HQ-2817) is the EVDA_DAY_WRITER value on a machine that does not write the day: the
131
+ // block then says evda_write, because a direct write here is the iCloud race and the guard refuses it.
132
+ export function decide(evs, facts, { lastBlock = 0, fileMtime = null, nonWriter = "" } = {}) {
131
133
  let lastWrite = 0;
132
134
  evs.forEach((e, i) => { if (classify(e, facts) === "write") lastWrite = i + 1; });
133
135
  const start = Math.max(lastWrite, lastBlock);
@@ -145,7 +147,9 @@ export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
145
147
  const why = reads && words >= OWNER_WORDS ? "read the record and the owner talked" : reads ? "read the record" : "the owner talked";
146
148
  const reason = [
147
149
  `evda: this conversation ${why}, and nothing from it is in today's record (${tools} tool call(s), ${writes} write(s), none to ${facts.fileRel}).`,
148
- `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, appended to ${facts.fileRel} under a "## HH:MM" heading or through evda_write.`,
150
+ nonWriter
151
+ ? `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, through evda_write. This machine does not write ${facts.fileRel} (EVDA_DAY_WRITER names the one that does, ${nonWriter}), so never write that file directly here.`
152
+ : `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, appended to ${facts.fileRel} under a "## HH:MM" heading or through evda_write.`,
149
153
  "Then tell them the path in the past tense. If a person, project or place with an entry came up, add a dated section to that entry too (never to a noted one); one with no entry, offer to start it and wait for their yes.",
150
154
  "If nothing they said is worth keeping, stop. This asks once per stretch.",
151
155
  ].join(" ");
@@ -189,6 +193,16 @@ function stateFile(sid) {
189
193
  return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
190
194
  }
191
195
 
196
+ // When the prompt hook (lib/prompt-hook.mjs) last filed or queued a message of this session, in ms,
197
+ // or null. Its state file is the one place both hooks can see without the record changing.
198
+ function promptFiledAt(sid) {
199
+ const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
200
+ try {
201
+ const t = Date.parse(JSON.parse(readFileSync(join(dir, `${String(sid ?? "unknown").replace(/[^A-Za-z0-9_-]/g, "_")}.json`), "utf8")).filedAt);
202
+ return Number.isFinite(t) ? t : null;
203
+ } catch { return null; }
204
+ }
205
+
192
206
  // One Stop event in, the hook's stdout out ("" for let it stop). Never throws.
193
207
  export function run(payload, { now = new Date() } = {}) {
194
208
  try {
@@ -207,8 +221,13 @@ export function run(payload, { now = new Date() } = {}) {
207
221
  try { state = JSON.parse(readFileSync(sf, "utf8")) ?? {}; } catch { /* first stop of the session */ }
208
222
  let lastBlock = Number(state.at) || 0;
209
223
  if (lastBlock > evs.length) { lastBlock = 0; state = {}; } // a different transcript under the same id
210
- const fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
211
- const d = decide(evs, facts, { lastBlock, fileMtime });
224
+ let fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
225
+ // HQ-2817: the prompt hook's last hand-off counts as a write to the day. On a machine that does
226
+ // not write the day the file here does not change when a message is filed through the writer.
227
+ const filedAt = promptFiledAt(payload.session_id);
228
+ if (filedAt !== null && (fileMtime === null || filedAt > fileMtime)) fileMtime = filedAt;
229
+ const nonWriter = (process.env.EVDA_DAY_WRITER ?? "").trim();
230
+ const d = decide(evs, facts, { lastBlock, fileMtime, nonWriter });
212
231
  const mtimeOf = (rel) => { try { return statSync(join(root, rel)).mtimeMs; } catch { return null; } };
213
232
  const e = d.block ? null : decideEntries(evs, facts, { asked: Boolean(state.entries), mtimeOf });
214
233
  if (!d.block && !e.block) return "";