create-evda 0.1.6 → 0.1.8
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 +23 -0
- package/README.md +5 -2
- package/bin/evda.mjs +98 -7
- package/create-evda.mjs +26 -11
- package/dist/pairing.mjs +4246 -0
- package/lib/circle-plan.mjs +24 -1
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +6 -1
- package/lib/gather.mjs +60 -10
- package/lib/mcp.mjs +81 -6
- package/lib/messages.mjs +205 -0
- package/lib/organize.mjs +124 -20
- package/lib/pairing.mjs +165 -0
- package/lib/parity.mjs +80 -7
- package/lib/roles.mjs +4 -0
- package/lib/routines.mjs +142 -18
- package/lib/setup.mjs +47 -4
- package/lib/sync-rules.mjs +10 -4
- package/lib/tools.mjs +15 -0
- package/package.json +6 -3
- package/skills/evda/SKILL.md +10 -10
- package/templates/AGENTS.md +4 -0
- package/templates/evda-digest.md +1 -0
package/lib/routines.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Starter routines: a morning organize and a weekly look back
|
|
2
|
-
//
|
|
1
|
+
// Starter routines: a morning organize and a weekly look back. The morning organize is on by default
|
|
2
|
+
// (HQ-1246, his call 2026-09-27: "We know that it needs to be organizing and stuff 100%"); setup says
|
|
3
|
+
// so in one line and offers off. The weekly look back stays offered, never on without a yes.
|
|
3
4
|
//
|
|
4
5
|
// The first choice is Claude's own scheduler. A local scheduled task in the Claude desktop app runs
|
|
5
6
|
// on this machine with access to this folder (https://code.claude.com/docs/en/desktop-scheduled-tasks).
|
|
@@ -10,8 +11,8 @@
|
|
|
10
11
|
// calendar, through a login shell so the owner's `claude` is on PATH. `--install` writes it and
|
|
11
12
|
// loads it; nothing else here schedules anything.
|
|
12
13
|
|
|
13
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
-
import { join, resolve } from "node:path";
|
|
14
|
+
import { mkdirSync, writeFileSync, existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
15
|
+
import { delimiter, join, resolve, sep } from "node:path";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
17
|
import { spawnSync } from "node:child_process";
|
|
17
18
|
import { openEvda, holderOf } from "./manifest.mjs";
|
|
@@ -38,6 +39,43 @@ export const ROUTINES = {
|
|
|
38
39
|
};
|
|
39
40
|
export const ROUTINE_KEYS = Object.keys(ROUTINES);
|
|
40
41
|
|
|
42
|
+
// EVDA_ROUTINES_FALSIFY=opt-in makes the default nothing, the state before HQ-1246, for the test
|
|
43
|
+
// that shows the default is what installs it.
|
|
44
|
+
export function defaultRoutines(env = process.env) {
|
|
45
|
+
return (env.EVDA_ROUTINES_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("opt-in") ? [] : ["morning"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The owner's claude, found once at setup: EVDA_CLAUDE, then PATH, then where the installers put it.
|
|
49
|
+
// A launchd job runs a login shell, which does not read .zshrc, so a claude on PATH only through
|
|
50
|
+
// .zshrc is invisible to it; the job carries the path found here instead.
|
|
51
|
+
export function findClaude(env = process.env) {
|
|
52
|
+
if (env.EVDA_CLAUDE) return env.EVDA_CLAUDE;
|
|
53
|
+
const home = env.HOME ?? homedir();
|
|
54
|
+
const dirs = [...(env.PATH ?? "").split(delimiter), join(home, ".local", "bin"), join(home, ".claude", "local"), "/opt/homebrew/bin", "/usr/local/bin"];
|
|
55
|
+
for (const d of dirs) {
|
|
56
|
+
if (!d) continue;
|
|
57
|
+
const p = join(d, "claude");
|
|
58
|
+
try { if (statSync(p).isFile()) return p; } catch {}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// macOS keeps a scheduled job out of these folders unless the shell running it has Full Disk Access.
|
|
64
|
+
export function protectedLocation(dir, env = process.env) {
|
|
65
|
+
const home = env.HOME ?? homedir();
|
|
66
|
+
let real = resolve(dir);
|
|
67
|
+
try { real = realpathSync(real); } catch {}
|
|
68
|
+
let realHome = home;
|
|
69
|
+
try { realHome = realpathSync(home); } catch {}
|
|
70
|
+
for (const h of new Set([home, realHome])) {
|
|
71
|
+
for (const f of ["Documents", "Desktop", "Downloads", join("Library", "Mobile Documents")]) {
|
|
72
|
+
const base = join(h, f);
|
|
73
|
+
if (real === base || real.startsWith(base + sep) || resolve(dir).startsWith(base + sep)) return f === join("Library", "Mobile Documents") ? "iCloud Drive" : f;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
41
79
|
const q = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
|
|
42
80
|
|
|
43
81
|
// The shell command a routine runs, with this machine's node and this package's bin.
|
|
@@ -58,7 +96,7 @@ export function desktopInstructions(key, dir) {
|
|
|
58
96
|
` Name: ${r.id}`,
|
|
59
97
|
` Schedule: ${r.when}`,
|
|
60
98
|
` Folder: ${resolve(dir)}`,
|
|
61
|
-
" Instructions: Run this command in the folder, then tell me the report path it prints and anything
|
|
99
|
+
" Instructions: Run this command in the folder, then tell me the report path it prints and anything that did not run:",
|
|
62
100
|
` ${routineCommand(key, dir)}`,
|
|
63
101
|
"",
|
|
64
102
|
"A local routine runs while the app is open and the computer is awake.",
|
|
@@ -72,7 +110,7 @@ export function label(key, evda) {
|
|
|
72
110
|
|
|
73
111
|
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
74
112
|
|
|
75
|
-
export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
113
|
+
export function launchdPlist(key, dir, { home = homedir(), claude = null } = {}) {
|
|
76
114
|
const evda = openEvda(dir);
|
|
77
115
|
const r = ROUTINES[key];
|
|
78
116
|
const log = join(home, "Library", "Logs", "evda", `${r.id}.log`);
|
|
@@ -93,6 +131,7 @@ export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
|
93
131
|
" <dict>",
|
|
94
132
|
cal,
|
|
95
133
|
" </dict>",
|
|
134
|
+
...(claude ? [" <key>EnvironmentVariables</key>", " <dict>", ` <key>EVDA_CLAUDE</key><string>${xml(claude)}</string>`, " </dict>"] : []),
|
|
96
135
|
` <key>StandardOutPath</key><string>${xml(log)}</string>`,
|
|
97
136
|
` <key>StandardErrorPath</key><string>${xml(log)}</string>`,
|
|
98
137
|
"</dict>",
|
|
@@ -101,31 +140,116 @@ export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
|
101
140
|
].join("\n");
|
|
102
141
|
}
|
|
103
142
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
143
|
+
// launchctl is off when the env handed in says so OR this process's own env does. A caller that
|
|
144
|
+
// hands in a partial env (a test's `{ HOME }`) no longer drops the guard and loads a real job, which
|
|
145
|
+
// is how io.evda.evda-morning-organize.*.atlas jobs stayed loaded after test runs.
|
|
146
|
+
export const noLaunchctl = (env = process.env) => env.EVDA_NO_LAUNCHCTL === "1" || process.env.EVDA_NO_LAUNCHCTL === "1";
|
|
147
|
+
|
|
148
|
+
// How launchctl is run. EVDA_LAUNCHCTL names a stand-in binary so the tests can drive the whole CLI
|
|
149
|
+
// without the real launchctl; `launchctl` in the options is the same seam for in-process callers.
|
|
150
|
+
export function runLaunchctl(args, env = process.env) {
|
|
151
|
+
const r = spawnSync(env.EVDA_LAUNCHCTL || "launchctl", args, { encoding: "utf8" });
|
|
152
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const falsified = (env, mode) => (env.EVDA_ROUTINES_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
|
|
156
|
+
|
|
157
|
+
// Unload a job by its label, not by its file: a job whose file is already gone, or was loaded from
|
|
158
|
+
// another path, is still unloaded. EVDA_ROUTINES_FALSIFY=skip-bootout skips it (the HQ-1246 bug:
|
|
159
|
+
// the file went, the job stayed loaded), for the tests.
|
|
160
|
+
function bootout(target, env, launchctl) {
|
|
161
|
+
if (!falsified(env, "skip-bootout")) launchctl(["bootout", target]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Loaded or not, asked of launchd itself: `launchctl print gui/<uid>/<label>` exits 0 only for a
|
|
165
|
+
// loaded job (113 when there is none).
|
|
166
|
+
const isLoaded = (target, launchctl) => launchctl(["print", target]).status === 0;
|
|
167
|
+
|
|
168
|
+
// Write the launchd job and load it. `agentsDir`, `load` and `launchctl` exist so tests never touch
|
|
169
|
+
// the real ~/Library/LaunchAgents or launchctl. A job already loaded under this label is unloaded
|
|
170
|
+
// first, so a second install replaces it rather than failing to load a duplicate.
|
|
171
|
+
export function installLaunchd(key, dir, { env = process.env, home = env.HOME ?? homedir(), agentsDir = env.EVDA_LAUNCH_AGENTS ?? process.env.EVDA_LAUNCH_AGENTS ?? join(home, "Library", "LaunchAgents"), load = !noLaunchctl(env), claude = findClaude(env), launchctl = (a) => runLaunchctl(a, env) } = {}) {
|
|
172
|
+
if (process.platform !== "darwin" && load && !env.EVDA_LAUNCHCTL) throw new EvdaError("REFUSED: launchd is macOS only. Use the Claude desktop routine instead. Nothing was written.", { exit: 2 });
|
|
108
173
|
const evda = openEvda(dir);
|
|
109
|
-
const plist = launchdPlist(key, dir, { home });
|
|
174
|
+
const plist = launchdPlist(key, dir, { home, claude });
|
|
110
175
|
mkdirSync(agentsDir, { recursive: true });
|
|
111
|
-
|
|
112
|
-
const path = join(agentsDir, `${
|
|
176
|
+
const name = label(key, evda);
|
|
177
|
+
const path = join(agentsDir, `${name}.plist`);
|
|
113
178
|
const existed = existsSync(path);
|
|
114
179
|
writeFileSync(path, plist);
|
|
115
180
|
let loaded = "not loaded (EVDA_NO_LAUNCHCTL=1)";
|
|
116
181
|
if (load) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
182
|
+
// The log folder is made only for a job that is loaded, so an unloaded install writes nothing
|
|
183
|
+
// into the owner's home but the job file.
|
|
184
|
+
mkdirSync(join(home, "Library", "Logs", "evda"), { recursive: true });
|
|
185
|
+
const domain = `gui/${process.getuid?.() ?? ""}`;
|
|
186
|
+
bootout(`${domain}/${name}`, env, launchctl);
|
|
187
|
+
const r = launchctl(["bootstrap", domain, path]);
|
|
120
188
|
loaded = r.status === 0 ? "loaded" : `launchctl bootstrap exited ${r.status}: ${(r.stderr ?? "").trim()}`;
|
|
121
189
|
}
|
|
122
|
-
return { path, existed, loaded };
|
|
190
|
+
return { path, label: name, existed, loaded, claude };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Turn a routine off: unload the job, remove its file, then look again. `off` is true only when
|
|
194
|
+
// launchd has no job under the label and the file is gone; anything left is named in `left`.
|
|
195
|
+
// Nothing there at all is not an error.
|
|
196
|
+
export function uninstallLaunchd(key, dir, { env = process.env, home = env.HOME ?? homedir(), agentsDir = env.EVDA_LAUNCH_AGENTS ?? process.env.EVDA_LAUNCH_AGENTS ?? join(home, "Library", "LaunchAgents"), load = !noLaunchctl(env), launchctl = (a) => runLaunchctl(a, env) } = {}) {
|
|
197
|
+
const name = label(key, openEvda(dir));
|
|
198
|
+
const path = join(agentsDir, `${name}.plist`);
|
|
199
|
+
const target = `gui/${process.getuid?.() ?? ""}/${name}`;
|
|
200
|
+
const hadFile = existsSync(path);
|
|
201
|
+
const wasLoaded = load && isLoaded(target, launchctl);
|
|
202
|
+
if (!hadFile && !wasLoaded) return { path, label: name, removed: false, off: true, left: [] };
|
|
203
|
+
if (wasLoaded) bootout(target, env, launchctl);
|
|
204
|
+
rmSync(path, { force: true });
|
|
205
|
+
const left = [];
|
|
206
|
+
if (load && isLoaded(target, launchctl)) left.push(`launchd still has ${name} loaded (remove it: launchctl bootout ${target})`);
|
|
207
|
+
if (existsSync(path)) left.push(`the job file is still at ${path}`);
|
|
208
|
+
return { path, label: name, removed: true, off: left.length === 0, left };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// What setup does with the routines it was given (HQ-1246). On a Mac each is installed as a launchd
|
|
212
|
+
// job; elsewhere nothing can be scheduled from here, so it prints the Claude desktop steps and says
|
|
213
|
+
// so. Returns the lines to print.
|
|
214
|
+
export function installAtSetup(keys, dir, { env = process.env, platform = process.platform } = {}) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
for (const key of keys) {
|
|
217
|
+
const r = ROUTINES[key];
|
|
218
|
+
lines.push("");
|
|
219
|
+
if (platform === "darwin") {
|
|
220
|
+
const done = installLaunchd(key, dir, { env });
|
|
221
|
+
const lead = key === "morning" ? "Organizing is on" : `${r.title} is on`;
|
|
222
|
+
lines.push(`${lead}: ${r.when.toLowerCase()} your own Claude ${key === "morning" ? "files your notes into their day and writes what belongs where" : "reads your week back to you"}, in reports/. Turn it off: evda routine ${key} ${resolve(dir)} --uninstall`);
|
|
223
|
+
lines.push(` launchd job ${done.path}: ${done.loaded}. It runs while this computer is on; asleep at that hour, it runs when it wakes.`);
|
|
224
|
+
if (!done.claude) lines.push(" Claude Code was not found on this computer, so the filing runs and the report says the AI part did not. Install Claude Code and sign in to add it.");
|
|
225
|
+
} else {
|
|
226
|
+
for (const l of desktopInstructions(key, dir).split("\n")) lines.push(l);
|
|
227
|
+
lines.push("Nothing was scheduled on this computer: add the routine above in the Claude desktop app.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const where = keys.length && platform === "darwin" ? protectedLocation(dir, env) : null;
|
|
231
|
+
if (where) lines.push(`This record is in ${where}. A scheduled run cannot read it there unless the shell has Full Disk Access (System Settings, Privacy & Security, Full Disk Access), or move the record to a folder of its own.`);
|
|
232
|
+
return lines;
|
|
123
233
|
}
|
|
124
234
|
|
|
125
235
|
export function routineMain(args, { pickDir }) {
|
|
126
236
|
const key = args[0];
|
|
127
|
-
if (!ROUTINE_KEYS.includes(key)) throw new EvdaError(`usage: evda routine <${ROUTINE_KEYS.join("|")}> [dir] [--launchd] [--install]`, { exit: 2 });
|
|
237
|
+
if (!ROUTINE_KEYS.includes(key)) throw new EvdaError(`usage: evda routine <${ROUTINE_KEYS.join("|")}> [dir] [--launchd] [--install] [--uninstall]`, { exit: 2 });
|
|
128
238
|
const { dir, flags } = pickDir(args.slice(1));
|
|
239
|
+
if (flags.includes("--uninstall")) {
|
|
240
|
+
const r = uninstallLaunchd(key, dir);
|
|
241
|
+
if (!r.removed) {
|
|
242
|
+
console.log(`nothing to remove: no ${ROUTINES[key].id} job at ${r.path}`);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
const what = key === "morning" ? "organizing" : ROUTINES[key].title;
|
|
246
|
+
if (!r.off) {
|
|
247
|
+
console.log(`${what} is not off: ${r.left.join("; ")}.`);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
console.log(`removed ${r.path}: ${what} is off. Turn it back on: evda routine ${key} ${resolve(dir)} --install`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
129
253
|
if (flags.includes("--install")) {
|
|
130
254
|
const r = installLaunchd(key, dir);
|
|
131
255
|
console.log(`${r.existed ? "replaced" : "wrote"} ${r.path}: ${r.loaded}. ${ROUTINES[key].when}, it runs: ${routineCommand(key, dir)}`);
|
package/lib/setup.mjs
CHANGED
|
@@ -283,7 +283,7 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
283
283
|
{ key: "filing", text: `Should ${agent} file everything you say automatically, or only when you ask? (everything)` },
|
|
284
284
|
{ key: "import", text: "Bring in what your AI already knows about you?" },
|
|
285
285
|
{ key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
|
|
286
|
-
{ key: "morning", text: "
|
|
286
|
+
{ key: "morning", text: "Organizing is on: each morning your own Claude files your notes into their day and writes what belongs where. Keep it on?" },
|
|
287
287
|
{ key: "weekly", text: "Add a weekly look back routine, which reads your week back to you?" },
|
|
288
288
|
];
|
|
289
289
|
}
|
|
@@ -397,9 +397,52 @@ export async function interview(opts, io, { presets = [] } = {}) {
|
|
|
397
397
|
}
|
|
398
398
|
if (a.routines == null) {
|
|
399
399
|
out("");
|
|
400
|
-
out("Routines
|
|
401
|
-
|
|
402
|
-
|
|
400
|
+
out("Routines run through your own AI, never an API key in EVÐA.");
|
|
401
|
+
// Organizing is on by default (HQ-1246): only an explicit no turns it off.
|
|
402
|
+
const keep = String(await ask(`${q("morning", agent)} (yes/no) `)).trim().toLowerCase();
|
|
403
|
+
a.routines = ["no", "n", "off"].includes(keep) ? [] : ["morning"];
|
|
404
|
+
if (await yes("weekly")) a.routines.push("weekly");
|
|
403
405
|
}
|
|
404
406
|
return a;
|
|
405
407
|
}
|
|
408
|
+
|
|
409
|
+
// ─── The parts a record starts with (HQ-1925) ────────────────────────
|
|
410
|
+
//
|
|
411
|
+
// Decided by the owner: privacy is set per folder when the record is made, everything is
|
|
412
|
+
// accessible by default, and the AI reads on request. So a new record is split into two parts on
|
|
413
|
+
// day one, and a phone can be added with no terminal step:
|
|
414
|
+
// journal the day files and captures, wherever the manifest maps them
|
|
415
|
+
// private the record root: everything else, including any folder made later
|
|
416
|
+
// The computer holds both as owner. A phone is offered the journal and nothing else.
|
|
417
|
+
// Pure, like the rest of this file, so the app's window reads the same words; circle-plan.mjs
|
|
418
|
+
// `defaultParts` writes the plan.
|
|
419
|
+
|
|
420
|
+
export const JOURNAL_PART = "journal";
|
|
421
|
+
export const PRIVATE_PART = "private";
|
|
422
|
+
/** What Add a phone offers a phone. */
|
|
423
|
+
export const PHONE_PARTS = [JOURNAL_PART];
|
|
424
|
+
/** The roles whose folders are the journal. */
|
|
425
|
+
const JOURNAL_ROLES = ["record/daily", "record/captures"];
|
|
426
|
+
|
|
427
|
+
/** The circle plan for a record's default parts, from its manifest mapping and its id. */
|
|
428
|
+
export function defaultPartsPlan(mapping = {}, recordId) {
|
|
429
|
+
const roots = [...new Set(JOURNAL_ROLES.map((r) => mapping[r]).filter((p) => typeof p === "string").map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean))];
|
|
430
|
+
// Each starts with an empty policy: the owner's corrections about a part land there.
|
|
431
|
+
const circles = roots.length ? [{ name: JOURNAL_PART, roots, policy: [] }] : [];
|
|
432
|
+
circles.push({ name: PRIVATE_PART, roots: [""], policy: [] });
|
|
433
|
+
return { circles, devices: { computer: { atlasId: recordId, grants: { "*": "owner" } } } };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** One line for create-evda's report. */
|
|
437
|
+
export const PARTS_LINE = "parts: journal (your daily pages and captures) and private (everything else). A phone you add gets the journal.";
|
|
438
|
+
|
|
439
|
+
/** What the app shows when an adopted record has no parts, and the one button that splits it. */
|
|
440
|
+
export const PARTS_OFFER = {
|
|
441
|
+
title: "Your EVÐA is not split into parts yet",
|
|
442
|
+
lines: [
|
|
443
|
+
"A phone only gets the parts of your EVÐA you give it, so this Mac needs to know which part is your journal.",
|
|
444
|
+
"This makes two parts. Journal is your daily pages and captures, and it is what a phone gets. Private is everything else, and it stays on this Mac.",
|
|
445
|
+
"Nothing in your files moves or changes, and nothing reaches a phone until you add one.",
|
|
446
|
+
],
|
|
447
|
+
button: "Split it into journal and private",
|
|
448
|
+
};
|
package/lib/sync-rules.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Three kinds of thing never sync, and each is counted rather than dropped:
|
|
9
9
|
//
|
|
10
|
-
// excluded by name anywhere (.DS_Store, node_modules, .git), by name
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// excluded by name anywhere (.DS_Store, node_modules, .git), by name prefix anywhere (~$, the
|
|
11
|
+
// owner file Word, Excel and PowerPoint keep beside a document while it is open), by
|
|
12
|
+
// name at the root (.evda, the derived index every device rebuilds), or by a path the
|
|
13
|
+
// plan names. Reported as EXCLUDED n.
|
|
13
14
|
// symlink never followed and never carried: sync moves bytes, and a link's target may be
|
|
14
15
|
// outside the record, missing, or itself. A symlink inside a circle fails the
|
|
15
16
|
// coverage check until the plan excludes it.
|
|
@@ -24,6 +25,11 @@ import { lstatSync, readdirSync } from "node:fs";
|
|
|
24
25
|
import { join } from "node:path";
|
|
25
26
|
|
|
26
27
|
export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
|
|
28
|
+
// A name starting with one of these is one machine's state about a file, not the record. `~$` is
|
|
29
|
+
// the Office owner file: it exists only while a document is open on that machine, and iCloud never
|
|
30
|
+
// carries one, so a shadow fed by the laptop held one Hestia's record could not (HQ-1219,
|
|
31
|
+
// 2026-09-25, `~$e Lantern Version 3.docx`, EXTRA).
|
|
32
|
+
export const EXCLUDE_PREFIXES = ["~$"];
|
|
27
33
|
export const EXCLUDE_ROOT_NAMES = [".evda"];
|
|
28
34
|
// The relay refuses a blob over 64 MiB (`xania-relay/src/relay.mjs`, MAX_BLOB_BYTES); the seal adds
|
|
29
35
|
// a nonce, a tag and an ephemeral key, so leave a margin.
|
|
@@ -40,7 +46,7 @@ export function excluder(extra = []) {
|
|
|
40
46
|
const paths = extra.map(norm).filter((p) => p !== "");
|
|
41
47
|
return (rel) => {
|
|
42
48
|
const parts = norm(rel).split("/");
|
|
43
|
-
if (parts.some((p) => EXCLUDE_NAMES.includes(p))) return true;
|
|
49
|
+
if (parts.some((p) => EXCLUDE_NAMES.includes(p) || EXCLUDE_PREFIXES.some((x) => p.startsWith(x)))) return true;
|
|
44
50
|
if (EXCLUDE_ROOT_NAMES.includes(parts[0])) return true;
|
|
45
51
|
const r = parts.join("/");
|
|
46
52
|
return paths.some((p) => r === p || r.startsWith(`${p}/`));
|
package/lib/tools.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { doctor } from "./doctor.mjs";
|
|
|
35
35
|
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
|
|
36
36
|
import { EvdaError } from "./errors.mjs";
|
|
37
37
|
import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
38
|
+
import { writeMessage, unseenReplies } from "./messages.mjs";
|
|
38
39
|
|
|
39
40
|
export { localDate, localTime, CONTEXT_LABEL };
|
|
40
41
|
|
|
@@ -296,6 +297,20 @@ export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
|
|
|
296
297
|
return releaseNote(evda, args.path, { now });
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
// 5d. Tell the owner something (HQ-1246): one new message file under the messages role, which the
|
|
301
|
+
// phone app shows as a card. Never edits one; refuses anything secret-shaped, like every write.
|
|
302
|
+
export function message(evda, args = {}, { now = new Date() } = {}) {
|
|
303
|
+
onlyKeys("evda_message", args, ["title", "body", "from"], "It writes one new message for the owner and nothing else: it takes no path, and it never edits a message once written.");
|
|
304
|
+
return writeMessage(evda, args, { now, refuseSecrets });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5e. The owner's replies to messages, the ones not seen yet, marked seen unless peek is true.
|
|
308
|
+
export function replies(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
309
|
+
onlyKeys("evda_replies", args, ["peek"]);
|
|
310
|
+
if (args.peek !== undefined && typeof args.peek !== "boolean") throw new EvdaError("evda_replies peek is true or false.", { code: "BAD_ARGUMENT", exit: 2 });
|
|
311
|
+
return unseenReplies(evda, { peek: args.peek === true }, { caller });
|
|
312
|
+
}
|
|
313
|
+
|
|
299
314
|
// 6. Circles and their policies, as the record declares them. Read only.
|
|
300
315
|
export function circles(evda, args = {}) {
|
|
301
316
|
onlyKeys("evda_circles", args, []);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-evda",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The person layer over the substrate: scaffold and operate an EVDA, a directory that knows what it is. Plain files are the truth, the tooling is optional, and there are no runtime dependencies.",
|
|
6
6
|
"bin": {
|
|
@@ -12,22 +12,25 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"test": "node --test test/*.test.mjs",
|
|
15
|
-
"lint": "node scripts/dashes-check.mjs && node scripts/build-templates.mjs --check && node scripts/build-kinds.mjs --check",
|
|
15
|
+
"lint": "node scripts/dashes-check.mjs && node scripts/build-templates.mjs --check && node scripts/build-kinds.mjs --check && node scripts/check-installed-core.mjs",
|
|
16
16
|
"templates:build": "node scripts/build-templates.mjs",
|
|
17
17
|
"templates:check": "node scripts/build-templates.mjs --check",
|
|
18
|
+
"installed-core:check": "node scripts/check-installed-core.mjs",
|
|
18
19
|
"kinds:build": "node scripts/build-kinds.mjs",
|
|
19
20
|
"kinds:check": "node scripts/build-kinds.mjs --check",
|
|
20
21
|
"dashes:check": "node scripts/dashes-check.mjs",
|
|
21
22
|
"vectors:both": "bash scripts/vectors-both.sh",
|
|
22
23
|
"doctor": "node bin/evda.mjs doctor",
|
|
23
24
|
"publish:dry": "node scripts/publish.mjs",
|
|
24
|
-
"publish:live": "node scripts/publish.mjs --publish"
|
|
25
|
+
"publish:live": "node scripts/publish.mjs --publish",
|
|
26
|
+
"pairing:build": "node scripts/build-pairing.mjs"
|
|
25
27
|
},
|
|
26
28
|
"files": [
|
|
27
29
|
"bin",
|
|
28
30
|
"hooks",
|
|
29
31
|
"docs",
|
|
30
32
|
"lib",
|
|
33
|
+
"dist",
|
|
31
34
|
"templates",
|
|
32
35
|
"skills",
|
|
33
36
|
"migrations",
|
package/skills/evda/SKILL.md
CHANGED
|
@@ -20,8 +20,7 @@ calls no model; you are their own AI, and everything you keep goes through the t
|
|
|
20
20
|
Say nothing if it is current or was skipped. If an update is available or the check failed, say
|
|
21
21
|
the one line it returns, briefly, and hand them the command it names to run in a terminal. The
|
|
22
22
|
tool only tells: nothing in this server installs an update. It asks the npm registry for a
|
|
23
|
-
version number and sends nothing from the record
|
|
24
|
-
network at all. `evda check-update` is the same check from a shell, for a session that has one.
|
|
23
|
+
version number and sends nothing from the record. `evda check-update` is the same check from a shell, for a session that has one.
|
|
25
24
|
|
|
26
25
|
If the tools are not there, say so plainly and point them at `evda connect`, which prints the
|
|
27
26
|
setup for each client. Do not keep the record some other way.
|
|
@@ -128,16 +127,17 @@ move). Tell them what it placed, what it left for them, and the reversal manifes
|
|
|
128
127
|
file of its date and writes a report under `reports/organize/`. With `--with-ai` it then asks you,
|
|
129
128
|
through `claude -p`, for the part that needs judgment, and appends your answer to that report.
|
|
130
129
|
|
|
131
|
-
When you are asked for that judgment
|
|
130
|
+
When you are asked for that judgment, you are given their entries from the last two weeks, each
|
|
131
|
+
under its date. Hand back one thing that comes back across different days, in their own words:
|
|
132
132
|
|
|
133
|
-
1.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
1. One or two plain sentences naming what comes back, then each place it came from as
|
|
134
|
+
`- YYYY-MM-DD: "words copied exactly from that day"`, from two days or more. A quote that is not
|
|
135
|
+
in the entry of its date drops the whole answer, so copy, never paraphrase.
|
|
136
|
+
2. Say only what the entries say: no advice, no question back, no score, no count, and nothing about
|
|
137
|
+
filing, the report or yourself. The filing is already listed above your answer.
|
|
138
138
|
3. You write nothing yourself here. Move nothing, rename nothing, delete nothing, and never touch
|
|
139
|
-
`circles/` or `grants/`.
|
|
140
|
-
4. If nothing
|
|
139
|
+
`circles/` or `grants/`.
|
|
140
|
+
4. If nothing honestly comes back across two days, answer `NOTHING`; the report then says nothing.
|
|
141
141
|
|
|
142
142
|
## Look back (weekly)
|
|
143
143
|
|
package/templates/AGENTS.md
CHANGED
|
@@ -161,6 +161,10 @@ They are mechanisms, never rules about their life.
|
|
|
161
161
|
- **A goal that was never theirs does close to nothing when reached.** Whose goal it was matters more
|
|
162
162
|
than reaching it. When hard work toward something returns nothing, ask where the goal came from, not
|
|
163
163
|
about effort or strategy.
|
|
164
|
+
- **A view is built for a self who will not stay.** People expect to change less than they do, so a
|
|
165
|
+
conclusion about their life is fitted to a person who will not be there in ten years. Asking what the
|
|
166
|
+
view assumes stays fixed, and what happens if it does not, finds where it breaks. Some futures change
|
|
167
|
+
the one who would judge them, and no thinking settles those in advance.
|
|
164
168
|
- **Most of the good you do comes from making them get specific.** A vague claim about their life,
|
|
165
169
|
negative or positive, shapes how they think, and having to say exactly what they mean is what moves
|
|
166
170
|
them. So go for insight, not more reflection: going over a feeling without getting anywhere new
|
package/templates/evda-digest.md
CHANGED
|
@@ -23,6 +23,7 @@ Instruction-only digest of the generated block of AGENTS.md. Read AGENTS.md in f
|
|
|
23
23
|
- A want can be sincerely felt and still not be theirs
|
|
24
24
|
- Getting the thing under-delivers, for two reasons
|
|
25
25
|
- A goal that was never theirs does close to nothing when reached
|
|
26
|
+
- A view is built for a self who will not stay
|
|
26
27
|
- Most of the good you do comes from making them get specific
|
|
27
28
|
## Meet them where they are
|
|
28
29
|
## From recognition to action
|