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/CHANGELOG.md +34 -0
- package/README.md +2 -2
- package/bin/evda.mjs +8 -2
- package/create-evda.mjs +3 -8
- package/lib/adopt.mjs +43 -15
- package/lib/attested-host.mjs +25 -7
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/connect.mjs +94 -30
- package/lib/conversation.mjs +68 -0
- package/lib/corrections.mjs +77 -2
- package/lib/day-file.mjs +7 -0
- package/lib/day-writer.mjs +31 -4
- package/lib/doctor.mjs +38 -8
- package/lib/folders.mjs +2 -1
- package/lib/gather.mjs +4 -3
- package/lib/keep-hook.mjs +23 -4
- package/lib/mcp.mjs +22 -8
- package/lib/person-folders.mjs +2 -1
- package/lib/plugin-hook.mjs +3 -1
- package/lib/presets.mjs +2 -1
- package/lib/prompt-hook.mjs +140 -6
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/tdx-attestation.mjs +28 -8
- package/lib/tools.mjs +21 -7
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
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
|
+
}
|
package/lib/corrections.mjs
CHANGED
|
@@ -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
|
package/lib/day-writer.mjs
CHANGED
|
@@ -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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
303
|
-
//
|
|
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)
|
|
307
|
-
else if (guard.registered)
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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 "";
|