create-evda 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +427 -9
  4. package/create-evda.mjs +64 -16
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +1 -1
  17. package/lib/gather-apply.mjs +640 -0
  18. package/lib/gather.mjs +858 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +456 -36
  29. package/lib/messages.mjs +205 -0
  30. package/lib/migrate-people.mjs +231 -0
  31. package/lib/organize.mjs +176 -32
  32. package/lib/parity.mjs +184 -0
  33. package/lib/presets.mjs +58 -7
  34. package/lib/prompt-hook.mjs +88 -0
  35. package/lib/publish.mjs +264 -0
  36. package/lib/readers.mjs +319 -0
  37. package/lib/records.mjs +52 -0
  38. package/lib/references.mjs +5 -51
  39. package/lib/render.mjs +158 -0
  40. package/lib/roles.mjs +106 -1
  41. package/lib/routines.mjs +142 -18
  42. package/lib/secret-shape.mjs +58 -0
  43. package/lib/setup.mjs +124 -4
  44. package/lib/skills.mjs +266 -0
  45. package/lib/subrecord.mjs +121 -0
  46. package/lib/sync-rules.mjs +93 -0
  47. package/lib/tools.mjs +133 -27
  48. package/lib/update-check.mjs +146 -0
  49. package/lib/work.mjs +180 -0
  50. package/lib/yaml.mjs +117 -42
  51. package/package.json +25 -15
  52. package/presets/journal/preset.json +9 -1
  53. package/presets/projects-and-areas/preset.json +16 -9
  54. package/skills/evda/SKILL.md +67 -8
  55. package/templates/AGENTS.md +122 -14
  56. package/templates/README.md +15 -1
  57. package/templates/circles-README.md +3 -3
  58. package/templates/evda-digest.md +31 -6
  59. package/LICENSE +0 -21
  60. package/presets/student/preset.json +0 -13
package/lib/roles.mjs CHANGED
@@ -22,8 +22,56 @@ export const ROLES = {
22
22
  // Who may read each circle: one grant file per (reader, circle), kept outside the circles as the
23
23
  // substrate keeps members/ outside ecos/. The access answer cannot live inside what it protects.
24
24
  grants: { layer: "circles", kind: "dir" },
25
+ // Where circles held FROM OTHER HOLDERS land (HQ-835): `<mounts>/<owner record id>/<circle>/`, one
26
+ // directory per owner and one subfolder per circle, so several circles from one owner are held
27
+ // side by side. Unmapped, a record holds one circle per owner at `mounts/<owner record id>`.
28
+ mounts: { layer: "circles", kind: "dir" },
29
+ // Where the owner's agent leaves messages for them and their replies come back (HQ-1246,
30
+ // lib/messages.mjs). Not part of any layer, like agents: a record without the mapping still has
31
+ // one, at `messages/`, which is what the phone app reads too.
32
+ messages: { layer: null, kind: "dir" },
33
+ // The four roles every record has at any scale (Spec 07 section 2, "Four roles, at every scale",
34
+ // HQ-1371). Each maps to one or more paths: the role's own key and any `<role>/<facet>` key, so a
35
+ // record with three journal folders writes `journal`, `journal/captures`, `journal/dreams`. Every
36
+ // value stays a string, so a reader that only knows string mappings still opens the record.
37
+ // `work` may instead name a live source, `harlequin:` or `harlequin:<project>` (or the object
38
+ // `{ "source": "harlequin", "project": "<id>" }`), read by lib/work.mjs.
39
+ journal: { layer: null, kind: "dir", four: true },
40
+ work: { layer: null, kind: "dir", four: true, source: true },
41
+ collections: { layer: null, kind: "dir", four: true },
42
+ knowledge: { layer: null, kind: "dir", four: true },
25
43
  };
26
44
 
45
+ export const FOUR_ROLES = ["journal", "work", "collections", "knowledge"];
46
+
47
+ // Keys named before the four roles, each a facet of one of them. They keep working under their
48
+ // own names (every tool that reads `record/daily` still does); the four-role reader counts them.
49
+ export const LEGACY_FACETS = { "record/daily": "journal", "record/captures": "journal", "record/people": "collections", ledger: "knowledge" };
50
+
51
+ // The sources `work` can name. Anything else is refused by doctor rather than read as a path.
52
+ export const WORK_SOURCES = ["harlequin"];
53
+
54
+ // The four role a mapping key fills, or null: `journal`, `journal/dreams`, `record/daily` all fill
55
+ // journal.
56
+ export function fourRoleOf(key) {
57
+ if (LEGACY_FACETS[key]) return LEGACY_FACETS[key];
58
+ const base = key.split("/")[0];
59
+ return FOUR_ROLES.includes(base) && (key === base || key.length > base.length + 1) ? base : null;
60
+ }
61
+
62
+ // The spec a key is read with: its own, or its four role's for a facet key.
63
+ const specOf = (role) => ROLES[role] ?? (fourRoleOf(role) && role.includes("/") ? ROLES[fourRoleOf(role)] : undefined);
64
+
65
+ // A `work` value that names a source rather than a folder, as { source, project }, or null for a
66
+ // path. The string form is `<source>:` or `<source>:<project>`, the same scheme-prefix shape a
67
+ // reference uses (Spec 07 section 4).
68
+ export function workSource(value) {
69
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) return { source: value.source, project: value.project ?? null, form: "object" };
70
+ if (typeof value !== "string") return null;
71
+ const m = /^([a-z][a-z0-9-]*):(.*)$/.exec(value);
72
+ return m ? { source: m[1], project: m[2] === "" ? null : m[2], form: "string" } : null;
73
+ }
74
+
27
75
  export const ROLE_NAMES = Object.keys(ROLES);
28
76
 
29
77
  export function rolesForLayer(layer) {
@@ -33,11 +81,15 @@ export function rolesForLayer(layer) {
33
81
  // Resolve one role. Never throws for an absent or missing role; the caller decides how loud
34
82
  // to be. Throws only when a mapped path escapes the record, which is refused everywhere.
35
83
  export function resolveRole(evda, role) {
36
- const spec = ROLES[role];
84
+ const spec = specOf(role);
37
85
  const rel = evda.manifest.mapping[role];
38
86
  if (rel === undefined) {
39
87
  return { role, layer: spec?.layer ?? null, known: !!spec, mapped: false, rel: null, abs: null, exists: false };
40
88
  }
89
+ if (spec?.source) {
90
+ const source = workSource(rel);
91
+ if (source) return { role, layer: null, kind: "source", known: true, mapped: true, rel: null, abs: null, exists: true, isDir: false, source };
92
+ }
41
93
  if (isAbsolute(rel)) {
42
94
  throw new EvdaError(`REFUSED: mapping for "${role}" is absolute (${rel}). Mappings are relative to the record root.`, {
43
95
  code: "BAD_MAPPING",
@@ -87,3 +139,56 @@ export function ledgerFile(evda) {
87
139
  }
88
140
  return r;
89
141
  }
142
+
143
+ // The four roles of a record (HQ-1371), each a list of what fills it. A path entry is
144
+ // { key, role, rel, abs, exists, isDir }; a source entry (work only) is { key, role, source }.
145
+ // `problems` are sentences for doctor, never thrown: a path that is missing or is a file, one path
146
+ // filling two roles, a path inside a sub-record (which has its own four roles), and a work source
147
+ // this tool cannot read. `subRecords` is the list of sub-record roots relative to the record.
148
+ // EVDA_ROLES_FALSIFY=<mode,...>, for the tests: two-roles skips the one-path-one-role check,
149
+ // missing-path skips the existence check, into-subrecord skips the sub-record check.
150
+ export const ROLES_FALSIFY_MODES = ["two-roles", "missing-path", "into-subrecord"];
151
+ const rolesFalsified = (mode) => (process.env.EVDA_ROLES_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
152
+
153
+ export function fourRoles(evda, { subRecords = [] } = {}) {
154
+ const roles = Object.fromEntries(FOUR_ROLES.map((r) => [r, []]));
155
+ const problems = [];
156
+ for (const key of Object.keys(evda.manifest.mapping)) {
157
+ const role = fourRoleOf(key);
158
+ if (!role) continue;
159
+ let r;
160
+ try { r = resolveRole(evda, key); } catch (e) { problems.push(e.message); continue; }
161
+ if (r.kind === "source") {
162
+ const { source, project } = r.source;
163
+ if (typeof source !== "string" || !WORK_SOURCES.includes(source)) { problems.push(`"${key}" names the source ${JSON.stringify(source)}; this tool reads ${WORK_SOURCES.join(", ")}, or a folder of effort files.`); continue; }
164
+ if (project !== null && (typeof project !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(project))) { problems.push(`"${key}" names the project ${JSON.stringify(project)}, which is not a board project id.`); continue; }
165
+ roles.work.push({ key, role, source: { source, project } });
166
+ continue;
167
+ }
168
+ if (typeof evda.manifest.mapping[key] !== "string") { problems.push(`"${key}" must be a path relative to the record${role === "work" ? `, or a source such as "harlequin:"` : ""}.`); continue; }
169
+ const rel = r.rel.replace(/\/+$/, "");
170
+ if (!r.exists) { if (!rolesFalsified("missing-path")) problems.push(`"${key}" is mapped to ${rel}, which does not exist.`); }
171
+ else if (!r.isDir) problems.push(`"${key}" is mapped to ${rel}, which is a file; a role holds a folder.`);
172
+ const inside = subRecords.find((s) => rel === s || rel.startsWith(`${s}/`));
173
+ if (inside !== undefined && !rolesFalsified("into-subrecord")) problems.push(`"${key}" is mapped to ${rel}, inside the sub-record at ${inside}, which declares its own roles. A parent reaches a sub-record through a mount, never by mapping its folders.`);
174
+ roles[role].push({ key, role, rel, abs: r.abs, exists: r.exists, isDir: r.isDir });
175
+ }
176
+ // One path, one role. The same folder under two roles would give its files two answers.
177
+ const byPath = new Map();
178
+ for (const role of FOUR_ROLES) for (const e of roles[role]) if (e.rel !== undefined) {
179
+ const was = byPath.get(e.rel);
180
+ if (was && was.role !== role && !rolesFalsified("two-roles")) problems.push(`${e.rel} fills two roles, ${was.role} ("${was.key}") and ${role} ("${e.key}"). A folder fills one.`);
181
+ else if (!was) byPath.set(e.rel, e);
182
+ }
183
+ return { roles, problems, declared: FOUR_ROLES.filter((r) => roles[r].length) };
184
+ }
185
+
186
+ // The four role a path in the record is in, by the longest role path above it, or null.
187
+ export function roleForPath(four, rel) {
188
+ let best = null;
189
+ for (const role of FOUR_ROLES) for (const e of four.roles[role]) {
190
+ if (e.rel === undefined) continue;
191
+ if (e.rel === "" || rel === e.rel || rel.startsWith(`${e.rel}/`)) if (!best || e.rel.length > best.rel.length) best = e;
192
+ }
193
+ return best;
194
+ }
package/lib/routines.mjs CHANGED
@@ -1,5 +1,6 @@
1
- // Starter routines: a morning organize and a weekly look back, each optional, each scheduled only on
2
- // the owner's yes.
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 it SKIPPED:",
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- // Write the launchd job and load it. `agentsDir` and `load` exist so tests never touch the real
105
- // ~/Library/LaunchAgents or launchctl.
106
- export function installLaunchd(key, dir, { agentsDir = process.env.EVDA_LAUNCH_AGENTS ?? join(homedir(), "Library", "LaunchAgents"), load = process.env.EVDA_NO_LAUNCHCTL !== "1", home = homedir() } = {}) {
107
- if (process.platform !== "darwin" && load) throw new EvdaError("REFUSED: launchd is macOS only. Use the Claude desktop routine instead. Nothing was written.", { exit: 2 });
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
- mkdirSync(join(home, "Library", "Logs", "evda"), { recursive: true });
112
- const path = join(agentsDir, `${label(key, evda)}.plist`);
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
- const uid = process.getuid?.() ?? "";
118
- spawnSync("launchctl", ["bootout", `gui/${uid}`, path], { encoding: "utf8" });
119
- const r = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, path], { encoding: "utf8" });
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)}`);
@@ -0,0 +1,58 @@
1
+ // Whether a string is shaped like a secret (a password, key or token), for every write that refuses
2
+ // one: a reference, evda_write, a gather, and the Claude Code prompt hook (HQ-1385).
3
+ //
4
+ // node: built-ins only, and no imports at all: `evda hook install` copies this file byte for byte
5
+ // beside the prompt hook in a record's .claude/hooks/, so the hook refuses exactly what the MCP
6
+ // server refuses. lib/references.mjs re-exports it; `evda doctor` says when the copy is older.
7
+ //
8
+ // A value shaped like a secret refuses the write.
9
+ // The shapes are the obvious ones: a private key block, a JWT, the prefixes the common services
10
+ // stamp on their tokens, a bearer header, a URL carrying a password or a credential in its query,
11
+ // and as a last net a long mixed-case high-entropy token that is not a UUID.
12
+ const SECRET_PARAM = /^(key|token|access_token|api_key|apikey|auth|password|secret|sig|signature|session|x-amz-signature|x-amz-credential)$/i;
13
+ const SECRET_VALUE = [
14
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key block"],
15
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, "a JWT"],
16
+ [/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/, "an API key (sk-...)"],
17
+ [/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}\b/, "an API key (sk_live_...)"],
18
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/, "a GitHub token"],
19
+ [/\bglpat-[A-Za-z0-9_-]{20,}\b/, "a GitLab token"],
20
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, "a Slack token"],
21
+ [/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key id"],
22
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, "a Google API key"],
23
+ [/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/i, "a bearer token"],
24
+ ];
25
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+
27
+ function entropy(s) {
28
+ const counts = new Map();
29
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
30
+ let h = 0;
31
+ for (const n of counts.values()) {
32
+ const p = n / s.length;
33
+ h -= p * Math.log2(p);
34
+ }
35
+ return h;
36
+ }
37
+
38
+ // Why a string looks like a secret, or null.
39
+ export function secretShape(value) {
40
+ if (typeof value !== "string") return null;
41
+ for (const [re, what] of SECRET_VALUE) if (re.test(value)) return what;
42
+ const m = /^(?:url:)?([a-z][a-z0-9+.-]*:\/\/[^\s]+)$/i.exec(value);
43
+ if (m) {
44
+ let u = null;
45
+ try { u = new URL(m[1]); } catch { u = null; }
46
+ if (u) {
47
+ if (u.password !== "") return "a password in the URL";
48
+ for (const k of u.searchParams.keys()) if (SECRET_PARAM.test(k)) return `a credential in the URL query (${k})`;
49
+ }
50
+ }
51
+ for (const tok of value.split(/[\s"'<>]+/)) {
52
+ if (tok.length < 40 || UUID.test(tok)) continue;
53
+ if (!/[A-Z]/.test(tok) || !/[a-z]/.test(tok) || !/\d/.test(tok)) continue;
54
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(tok)) continue;
55
+ if (entropy(tok) >= 4.5) return "a long high-entropy token";
56
+ }
57
+ return null;
58
+ }
package/lib/setup.mjs CHANGED
@@ -58,7 +58,105 @@ export const PLATFORMS = {
58
58
 
59
59
  export const PLATFORM_KEYS = Object.keys(PLATFORMS);
60
60
 
61
+ // What each company's own pages say about training and retention, for its consumer apps and for its
62
+ // API or business plans. Quoted, never paraphrased inside the quotes, each with the page it came from
63
+ // and the date it was read. This is what an agent answers with when someone asks which AI is safest:
64
+ // the table and its date, never a ranking of which company is likelier to misuse data. A page that
65
+ // could not be read says so rather than being filled in from memory.
66
+ export const TERMS_CHECKED = "2026-09-22";
67
+ export const PROVIDER_TERMS = {
68
+ claude: {
69
+ company: "Anthropic",
70
+ consumer: [
71
+ { says: "If you decide to turn off the model training setting, we will not use any new chats and coding sessions you have with Claude for future model training.", url: "https://privacy.claude.com/en/articles/12109829-how-do-i-change-my-model-improvement-privacy-settings" },
72
+ { says: "Deleted from our back-end storage systems within 30 days", about: "a chat you delete", url: "https://privacy.claude.com/en/articles/10023548-how-long-do-you-store-my-data" },
73
+ { says: "we may retain your data in a de-identified format for up to 5 years in our model training pipelines.", about: "with training on", url: "https://privacy.claude.com/en/articles/10023548-how-long-do-you-store-my-data" },
74
+ ],
75
+ business: [
76
+ { says: "Anthropic may not train models on Customer Content from Services.", url: "https://www.anthropic.com/legal/commercial-terms" },
77
+ { says: "For Anthropic API users, we automatically delete inputs and outputs on our backend within 30 days of receipt or generation", url: "https://privacy.claude.com/en/articles/7996866-how-long-do-you-store-my-organization-s-data" },
78
+ { says: "Prompts submitted to, and outputs generated by, covered models are retained for 30 days to support our safety work, on every platform where these models are offered.", about: "the models that page names as covered", url: "https://privacy.claude.com/en/articles/15425996-data-retention-practices-for-covered-models" },
79
+ ],
80
+ },
81
+ chatgpt: {
82
+ company: "OpenAI",
83
+ consumer: [
84
+ { says: null, unread: "The page refused automated reads when checked. Read it yourself before relying on it.", url: "https://help.openai.com/en/articles/7730893-data-controls-faq" },
85
+ ],
86
+ business: [
87
+ { says: "As of March 1, 2023, data sent to the OpenAI API is not used to train or improve OpenAI models (unless you explicitly opt in to share data with us).", url: "https://platform.openai.com/docs/guides/your-data" },
88
+ { says: "By default, abuse monitoring logs are generated for all API feature usage and retained for up to 30 days, unless longer retention is required by law", url: "https://platform.openai.com/docs/guides/your-data" },
89
+ { says: null, unread: "ChatGPT Business and Enterprise: the page refused automated reads when checked.", url: "https://openai.com/enterprise-privacy/" },
90
+ ],
91
+ },
92
+ gemini: {
93
+ company: "Google",
94
+ consumer: [
95
+ { says: "If the Keep Activity setting is off: Your future chats won't appear in your Activity, and won't be used to train our AI models, unless you choose to send Google feedback.", url: "https://support.google.com/gemini/answer/13594961" },
96
+ { says: "Temporary chats and chats you have when Keep Activity is off are retained with your account for 72 hours", url: "https://support.google.com/gemini/answer/13594961" },
97
+ { says: "Reviewed chats are retained for up to three years.", about: "chats a person reviewed", url: "https://support.google.com/gemini/answer/13594961" },
98
+ ],
99
+ business: [
100
+ { says: "Google doesn't use your prompts (including associated system instructions, cached content, and files such as images, videos, or documents) or responses to improve our products", about: "the paid Gemini API", url: "https://ai.google.dev/gemini-api/terms" },
101
+ { says: "For Paid Services, Google logs prompts and responses for a limited period of time, solely for detecting and preventing violations of the Prohibited Use Policy", url: "https://ai.google.dev/gemini-api/terms" },
102
+ { says: "Workspace does not use customer data for training models without customer's prior permission or instruction.", url: "https://support.google.com/a/answer/15706919" },
103
+ ],
104
+ },
105
+ cursor: {
106
+ company: "Cursor (Anysphere)",
107
+ consumer: [
108
+ { says: "With Privacy Mode enabled, your code is never used for training.", url: "https://cursor.com/help/security-and-privacy/privacy" },
109
+ { says: "Anysphere retains your personal data only for as long as necessary to operate the Service effectively and to support legitimate business needs", url: "https://cursor.com/privacy" },
110
+ ],
111
+ business: [
112
+ { says: "For teams, Privacy Mode is enabled by default for all team members.", url: "https://cursor.com/help/security-and-privacy/privacy" },
113
+ { says: "ZDR doesn't apply when you use your own API keys.", url: "https://cursor.com/help/security-and-privacy/privacy" },
114
+ ],
115
+ },
116
+ };
117
+
118
+ // The "What each company says" section of the scaffolded README, generated from PROVIDER_TERMS.
119
+ export function termsSection() {
120
+ const line = (t) => (t.says === null ? ` - ${t.unread} <${t.url}>` : ` - "${t.says}"${t.about ? ` (${t.about})` : ""} <${t.url}>`);
121
+ return [
122
+ `Quoted from each company's own pages, read on ${TERMS_CHECKED}. Terms change; each line links the page to check.`,
123
+ "",
124
+ ...Object.entries(PROVIDER_TERMS).flatMap(([k, p]) => [
125
+ `- **${p.company}** (${SHORT_NAMES_FOR_TERMS[k]})`,
126
+ " - Personal plans:",
127
+ ...p.consumer.map((t) => ` ${line(t)}`),
128
+ " - API and business plans:",
129
+ ...p.business.map((t) => ` ${line(t)}`),
130
+ ]),
131
+ ].join("\n");
132
+ }
133
+
134
+ // Who reads a new record, written into evda.json at setup: the AI the owner chose. The default is
135
+ // not local, because most people have no local model; restriction is something they opt into.
136
+ // Training is written as off only when they confirmed it.
137
+ export function readerFor({ platform, platformName, trainingOff, date }) {
138
+ if (!platform) return null;
139
+ const r = { ai: platform };
140
+ if (platform === "other" && oneLineName(platformName)) r.name = oneLineName(platformName);
141
+ r.training = trainingOff ? "off" : "unknown";
142
+ if (trainingOff) r.confirmed = date;
143
+ return [r];
144
+ }
145
+ const oneLineName = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
146
+
147
+ // What a new owner is told about where their words go. In create-evda's finish screen, the
148
+ // scaffolded README, and Spec 07 section 8; scripts/build-templates.mjs --check fails when the
149
+ // spec's copy differs from this one.
150
+ export const INTRO = [
151
+ "Your information is stored locally on your own devices as files you can open with anything. When your devices sync, what goes between them is locked with keys only your devices have, so EVÐA can't read it.",
152
+ "",
153
+ "The AI you choose reads what it needs to think with you. With training turned off, the company behind it says it won't use your information, and that promise is backed by their terms and by law. We can't prove it for them, and I don't want to pretend we can.",
154
+ "",
155
+ "If you want more than that, you have two options. You can run a model locally on your own computer, and you can just ask how to set it up. Or you can say something is just to be noted, and no AI reads it until you say it can.",
156
+ ].join("\n");
157
+
61
158
  const SHORT_NAMES = { claude: "Claude", chatgpt: "ChatGPT", gemini: "Gemini", cursor: "Cursor", other: "" };
159
+ const SHORT_NAMES_FOR_TERMS = { claude: "Claude", chatgpt: "ChatGPT and Codex", gemini: "Gemini", cursor: "Cursor" };
62
160
 
63
161
  // The "Turning training off" section of the scaffolded README, generated from PLATFORMS so the
64
162
  // owner's copy and the setup flow say the same thing.
@@ -161,6 +259,10 @@ export const WELCOME = [
161
259
  "",
162
260
  "Freewrite with Iris about anything: what stresses you out, what you are unsure of, the things you do every day that feel slightly off. You will get better at seeing your thoughts, understanding why you do what you do, why certain people matter to you and why you react the way you do. Iris cannot give you the right answer for your life, but it is good at suggestions: take them, push back on what you disagree with, get specific, and get a new response back. Iris keeps it all in your EVÐA as you talk.",
163
261
  "",
262
+ "What it actually does is keep your words, not a summary of them. What you say goes in the way you said it, filed into the day it happened, the person it was about, or just the thought as it arrived. Nothing gets compressed into a takeaway, because the takeaway is the part that turns out to be wrong. Alongside the record sits a ledger: a claim about you, and what actually happened to it. A claim that does not hold gets marked as not holding, so it stops coming back.",
263
+ "",
264
+ "That is why it gets better the more you use it. In the first week Iris is working from almost nothing and will ask you things you have already answered. By the second month it is not asking those, and it can see things one day cannot show: the same frustration turning up in three places you thought were unrelated, a person you keep circling back to, a rule you set for yourself in March and quietly stopped following in June. You get that by writing, not by filling anything in.",
265
+ "",
164
266
  "It is not easy to put your deepest thoughts into a system, especially ones you have not admitted to yourself, never mind a friend or a therapist. EVÐA is built for exactly that, and it is open source so you can check. Your data is always yours, and never accessible to anyone at EVÐA: the company never receives your record. The AI you use does receive what Iris reads, which is why setup asks you to turn training off first.",
165
267
  "",
166
268
  "The only requirement is that you want to, and you are open to it. Iris is the default name; call it whatever you like.",
@@ -178,13 +280,23 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
178
280
  { key: "voice", text: `How do you want ${agent} to talk to you?` },
179
281
  { key: "platform", text: "Which AI will you use it with?" },
180
282
  { key: "trainingOff", text: "Before anything personal goes in, turn model training off:" },
283
+ { key: "filing", text: `Should ${agent} file everything you say automatically, or only when you ask? (everything)` },
181
284
  { key: "import", text: "Bring in what your AI already knows about you?" },
182
285
  { key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
183
- { key: "morning", text: "Add a morning organize routine, which files your captures into their day and reports what belongs where?" },
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?" },
184
287
  { key: "weekly", text: "Add a weekly look back routine, which reads your week back to you?" },
185
288
  ];
186
289
  }
187
290
 
291
+ // The filing answer (HQ-1385): "everything" or "asked", from what the owner typed. Empty is the
292
+ // default the question shows, everything. null when the answer names neither.
293
+ export function filingKey(answer) {
294
+ const a = String(answer ?? "").trim().toLowerCase().replace(/[.!]+$/, "");
295
+ if (a === "" || ["everything", "every", "all", "e", "1", "yes", "y", "automatically", "auto"].includes(a)) return "everything";
296
+ if (["asked", "ask", "a", "2", "no", "n", "only when i ask", "when i ask", "only when asked", "manual", "manually"].includes(a)) return "asked";
297
+ return null;
298
+ }
299
+
188
300
  // What a yes to the import offer shows: the line before IMPORT_PROMPT and the line after it.
189
301
  export function importLines(agent = DEFAULT_AGENT_NAME) {
190
302
  return {
@@ -259,6 +371,12 @@ export async function interview(opts, io, { presets = [] } = {}) {
259
371
  }
260
372
  a.trainingOff = true;
261
373
  }
374
+ if (!a.filing) {
375
+ out("");
376
+ let key = null;
377
+ while (!key) key = filingKey(await ask(`${q("filing", agent)} `));
378
+ a.filing = key;
379
+ }
262
380
  out("");
263
381
  out(DICTATION);
264
382
  out("");
@@ -279,9 +397,11 @@ export async function interview(opts, io, { presets = [] } = {}) {
279
397
  }
280
398
  if (a.routines == null) {
281
399
  out("");
282
- out("Routines are optional and nothing is scheduled without a yes. Each runs through your own AI, never an API key in EVÐA.");
283
- a.routines = [];
284
- for (const key of ["morning", "weekly"]) if (await yes(key)) a.routines.push(key);
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");
285
405
  }
286
406
  return a;
287
407
  }