create-evda 0.1.4 → 0.1.6

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 (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +93 -8
  3. package/bin/evda.mjs +437 -5
  4. package/create-evda.mjs +68 -12
  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 +351 -19
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +22 -8
  17. package/lib/gather-apply.mjs +635 -0
  18. package/lib/gather.mjs +808 -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 +467 -35
  29. package/lib/migrate-people.mjs +231 -0
  30. package/lib/organize.mjs +52 -12
  31. package/lib/parity.mjs +111 -0
  32. package/lib/presets.mjs +379 -0
  33. package/lib/prompt-hook.mjs +88 -0
  34. package/lib/publish.mjs +264 -0
  35. package/lib/readers.mjs +319 -0
  36. package/lib/records.mjs +52 -0
  37. package/lib/references.mjs +5 -51
  38. package/lib/render.mjs +158 -0
  39. package/lib/roles.mjs +102 -1
  40. package/lib/secret-shape.mjs +58 -0
  41. package/lib/setup.mjs +146 -2
  42. package/lib/skills.mjs +266 -0
  43. package/lib/subrecord.mjs +121 -0
  44. package/lib/sync-rules.mjs +87 -0
  45. package/lib/tools.mjs +120 -29
  46. package/lib/update-check.mjs +146 -0
  47. package/lib/work.mjs +180 -0
  48. package/lib/yaml.mjs +117 -42
  49. package/package.json +25 -15
  50. package/presets/journal/preset.json +19 -0
  51. package/presets/projects-and-areas/preset.json +34 -0
  52. package/skills/evda/SKILL.md +76 -0
  53. package/templates/AGENTS.md +129 -15
  54. package/templates/README.md +15 -1
  55. package/templates/circles-README.md +3 -3
  56. package/templates/evda-digest.md +32 -5
  57. package/LICENSE +0 -21
package/bin/evda.mjs CHANGED
@@ -6,9 +6,9 @@
6
6
  // ~/.evda/current. Nothing assumes it was launched inside one.
7
7
 
8
8
  import { resolve, join, relative } from "node:path";
9
- import { readFileSync, writeFileSync, statSync } from "node:fs";
9
+ import { readFileSync, writeFileSync, statSync, existsSync } from "node:fs";
10
10
  import { fileURLToPath } from "node:url";
11
- import { openEvda, readManifest, readVersion, writeVersion, findRoot, SCHEMA_VERSION } from "../lib/manifest.mjs";
11
+ import { openEvda, readManifest, readVersion, writeVersion, findRoot, SCHEMA_VERSION, manifestPath } from "../lib/manifest.mjs";
12
12
  import { locate, setPointer, pointerPath } from "../lib/locate.mjs";
13
13
  import { resolveAll, agentsFile } from "../lib/roles.mjs";
14
14
  import { doctor } from "../lib/doctor.mjs";
@@ -24,6 +24,17 @@ import { resolveReference } from "../lib/resolvers.mjs";
24
24
  import { grant, revoke, readGrants, access } from "../lib/grants.mjs";
25
25
  import { holderOf, HOLDER_KINDS } from "../lib/manifest.mjs";
26
26
  import { EvdaError, isEntry } from "../lib/errors.mjs";
27
+ import { publishCommand } from "../lib/publish.mjs";
28
+ import { checkForUpdate, summarize, stampInstalledVersion } from "../lib/update-check.mjs";
29
+ import { planCircles, coverage, declare, deviceCircles, planBeside } from "../lib/circle-plan.mjs";
30
+ import { circleIndex, grantView } from "../lib/filechanges.mjs";
31
+ import { mountRoot } from "../lib/declarations.mjs";
32
+ import { excluder } from "../lib/sync-rules.mjs";
33
+ import { parity, formatParity } from "../lib/parity.mjs";
34
+ import { note, releaseNote, ownerSearch, readContext, readersFor, describeReaders, parseReaders, READ_BY } from "../lib/readers.mjs";
35
+ import { runHook } from "../lib/guard.mjs";
36
+ import { markdownFiles } from "../lib/circles.mjs";
37
+ import { refuseSecrets } from "../lib/tools.mjs";
27
38
 
28
39
  const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
29
40
 
@@ -36,6 +47,12 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
36
47
  evda upgrade [dir] replace the installer-owned block of the operating layer; touch nothing else
37
48
  evda digest [dir] print the instruction-only digest of the operating layer
38
49
  evda migrate [dir] forward migrations, each saying what it cannot reverse
50
+ evda check-update [dir] [--force]
51
+ ask npm for the latest create-evda, throttled to once an hour;
52
+ never silent, a failure prints CHECK_FAILED rather than looking current
53
+ evda update [dir] check-update, then upgrade and migrate if a newer version exists.
54
+ Run this through npx so the CLI itself is current first:
55
+ npx -y -p create-evda@latest evda update
39
56
  evda vector <vdir> [--check] the conformance projection of <vdir>/ledger.md (Spec 04 vectors)
40
57
  evda references [dir] [--resolve] [--timeout ms]
41
58
  every reference in the record, its folder's connector, and with
@@ -50,22 +67,82 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
50
67
  may that record read the circle? exit 0 yes, 2 refused, with why
51
68
  evda mcp [dir] serve the record over MCP on stdio, for Claude Code, Codex or the
52
69
  Claude desktop app. No key and no model call: the AI is the owner's
70
+ evda mcp --http --public-url <https url> [--port 8378] [--host 127.0.0.1] [--log file] [dir]
71
+ the same server over streamable HTTP, behind OAuth, for the Claude
72
+ apps through a tunnel. Binds to localhost; the tunnel is the TLS
73
+ evda mcp token --new [--ttl 24h] | --revoke-all | --status [dir]
74
+ a one-time owner code for the authorization page, or end every
75
+ connection, or say what is connected
53
76
  evda connect [dir] how to add that server to each client, with this record's path
54
77
  evda skill install [--project <dir>]
55
78
  install the evda skill for Claude Code (~/.claude/skills, or a project)
79
+ evda hook install [dir] add the keep-record Stop hook and the read guard to the record's .claude/settings.json,
80
+ and the prompt hook that files each message when evda.json says "filing": "everything"
81
+ evda hook status [dir] whether each is installed and current
56
82
  evda organize [dir] [--dry-run] [--with-ai] [--model M]
57
83
  file captures into their day and write a report of what belongs where.
58
84
  Moves nothing. --with-ai hands the judgment to the owner's claude -p
85
+ evda gather scan <source>... [--into <record>] [--out <file>] [--with-ai] [--model M]
86
+ walk a pile (folders, export files), type each file, and write a
87
+ proposal: a preset, folders, a placement per item. Writes nothing
88
+ into a source or a record; the proposal goes to --out, or
89
+ $EVDA_GATHER_DIR, or ~/.evda/gather/. --into names the record it
90
+ would grow: what it already holds is left out of the proposal
91
+ evda gather questions <proposal>
92
+ what the owner has to decide, only where it changes a placement
93
+ evda gather answer <proposal> <question> <value>
94
+ record one answer on the proposal and say what it re-placed
95
+ evda gather show <proposal> [--all]
96
+ the proposal in plain words
97
+ evda gather apply <proposal> --into <record> [--copy|--move] [--dry] [--manifest <file>]
98
+ after the owner's yes: write the placements into a record (a new
99
+ one is made with the proposal's preset), copying by default, then
100
+ run doctor. Writes a reversal manifest beside the proposal
101
+ evda gather revert <manifest> [--force]
102
+ undo an apply byte for byte; refused when a placed file was edited
59
103
  evda look-back [dir] [--model M]
60
104
  the week, read back by the owner's claude -p, appended to a report
61
105
  evda routine <morning|weekly> [dir] [--launchd] [--install]
62
106
  print a scheduled routine for Claude, or a launchd fallback; --install
63
107
  writes the launchd job, and nothing is scheduled without it
108
+ evda preset list the organizations this package ships, and who each suits
109
+ evda preset show <id> one preset's folders
110
+ evda preset apply <id> [--dry] [dir]
111
+ add the preset's folders and manifests. Only adds: an existing
112
+ manifest that differs is a CONFLICT and is left as it is
113
+ evda preset diff <id> [dir] what the preset has that this record does not
64
114
  evda status [dir] one screen summary
115
+ evda publish public [dir] --out <dir> --key <seed file>
116
+ the public circle as a signed plaintext export; refuses (exit 2) on a
117
+ path another circle covers or a private name or path in the bytes
118
+ evda circles plan --plan <file> [dir]
119
+ which folders each circle of the plan gets on this disk. Writes nothing
120
+ evda circles declare --plan <file> [--record-id <id>] [dir]
121
+ write the plan's circles and device grants into the record. Adds the
122
+ circles and grants roles and their folders; moves and changes no file.
123
+ A circle rooted at "" is written as the root itself, and a circle
124
+ already declared keeps its id, release, rules, mount and policy
125
+ evda circles check [--plan <file>] [dir]
126
+ every syncable file in exactly one circle (CHECKED n files); fails on a
127
+ file no circle covers, and on a symlink, iCloud stub or oversize file.
128
+ Without --plan, reads <circles role>/plan.json for the excludes
129
+ evda parity --plan <file> --device <name> --shadow <dir> [dir]
130
+ the device's shadow equals the record for the circles it holds, byte for
131
+ byte, and holds nothing of a circle it may never hold (Spec 08 S1)
132
+ evda work [dir] [--env-file F] [--json]
133
+ the record's efforts from its work role: THE HARLEQUIN by vault_path
134
+ or project, or a folder of effort files. CHECKED n, or SKIPPED and why
135
+ evda note "<words>" [--in dir] just note it: a capture no AI reads until you release it (words from stdin with -)
136
+ evda release <path> [dir] you can read that now: clear the just-noted flag on one entry
137
+ evda search <words> [dir] your own search, on this device, no model; just-noted and sealed entries included and marked
138
+ evda readers [dir] which AI reads each part of the record, from read_by
139
+ evda local-model how to run a model on your own computer and point a circle at it
140
+ evda guard the Claude Code read guard (a PreToolUse hook; reads the tool call on stdin)
65
141
  evda where which EVDA the locator resolves to, and by which rule
66
142
  evda use <dir> point ~/.evda/current at a record
67
143
  evda init <dir> [--yes] [--agent-name N] [--owner O] [--purpose P] [--voice V]
68
144
  [--platform claude|chatgpt|gemini|cursor|other] [--training-off] [--layers record,ledger]
145
+ [--preset <id>|none]
69
146
  the same as create-evda: asks on a terminal, runs from flags otherwise
70
147
 
71
148
  Locator order: directory argument, $EVDA_HOME, the working directory's ancestors, ~/.evda/current.
@@ -88,7 +165,7 @@ function isDirArg(a) {
88
165
  }
89
166
 
90
167
  // `--name value` or `--name=value`. The value of a `--name value` pair is not a positional.
91
- const VALUE_FLAGS = ["to", "kind", "name", "role", "by", "as", "model", "project", "hour", "weekday"];
168
+ const VALUE_FLAGS = ["to", "kind", "name", "role", "by", "as", "model", "project", "hour", "weekday", "out", "key", "plan", "device", "shadow", "record-id", "env-file", "port", "host", "public-url", "log", "lifeline", "ttl"];
92
169
  function flagValue(args, name) {
93
170
  const eq = args.find((a) => a.startsWith(`--${name}=`));
94
171
  if (eq) return eq.slice(name.length + 3);
@@ -106,6 +183,63 @@ function pickDir(args) {
106
183
  return { dir: found.dir, via: found.via, flags, rest };
107
184
  }
108
185
 
186
+ // `release`'s own positional is a printed entry path, which always contains a "/" (Spec: entries
187
+ // live under record/), so pickDir's "any positional with a slash is the record" rule always ate it.
188
+ // The record here comes from the working directory (or the pointer) by default; a positional is
189
+ // only ever the record when it is a SECOND positional that is itself an EVDA on disk, never the
190
+ // entry path itself.
191
+ function pickReleaseArgs(args) {
192
+ const positional = args.filter((a) => !a.startsWith("--"));
193
+ let explicit = null;
194
+ let entryParts = positional;
195
+ if (positional.length > 1) {
196
+ const last = positional[positional.length - 1];
197
+ try {
198
+ if (statSync(last).isDirectory() && existsSync(manifestPath(resolve(last)))) {
199
+ explicit = last;
200
+ entryParts = positional.slice(0, -1);
201
+ }
202
+ } catch { /* not a directory on disk -> it's part of the entry path */ }
203
+ }
204
+ const found = locate({ explicit });
205
+ return { dir: found.dir, entry: entryParts.join(" ") };
206
+ }
207
+
208
+ // `evda mcp token`: the owner's side of the remote server's authorization. Printed here, on the
209
+ // machine that holds the record, and typed into the authorization page: never in a URL or a log.
210
+ async function mcpToken(args) {
211
+ const { dir, flags } = pickDir(args);
212
+ const auth = await import("../lib/mcp-auth.mjs");
213
+ const store = auth.openStore(auth.authDir(openEvda(dir)));
214
+ if (flags.includes("--new")) {
215
+ const ttl = parseDuration(flagValue(args, "ttl") ?? "24h");
216
+ const { code, expires } = auth.newOwnerCode(store, { ttlMs: ttl });
217
+ console.log(code);
218
+ console.error(`One owner code, good once, until ${new Date(expires).toISOString()}. Type it into the page Claude opens when you connect.`);
219
+ return 0;
220
+ }
221
+ if (flags.includes("--revoke-all")) {
222
+ const n = auth.revokeAll(store);
223
+ console.log(`Revoked ${n.clients} client(s), ${n.access} access token(s), ${n.refresh} refresh token(s) and ${n.owner} owner code(s). A connector stops at its next call and has to connect again.`);
224
+ return 0;
225
+ }
226
+ if (flags.includes("--status")) {
227
+ const s = auth.status(store);
228
+ console.log(`${s.clients.length} client(s), ${s.access} live access token(s), ${s.refresh} live refresh token(s), ${s.ownerCodes.length} open owner code(s). State: ${store.file}`);
229
+ for (const c of s.clients) console.log(` ${c.client_id} ${c.client_name ?? "(no name)"} registered ${new Date(c.created).toISOString()} -> ${c.redirects.join(", ")}`);
230
+ return 0;
231
+ }
232
+ throw new EvdaError("REFUSED: evda mcp token needs --new [--ttl 24h], --revoke-all or --status.", { code: "USAGE", exit: 2 });
233
+ }
234
+
235
+ function parseDuration(s) {
236
+ const m = /^(\d+)(m|h|d)$/.exec(String(s));
237
+ if (!m) throw new EvdaError(`REFUSED: --ttl is a number and m, h or d (got ${JSON.stringify(s)}).`, { code: "BAD_TTL", exit: 2 });
238
+ const ms = Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
239
+ if (ms < 60_000 || ms > 7 * 86_400_000) throw new EvdaError("REFUSED: --ttl is between 1m and 7d.", { code: "BAD_TTL", exit: 2 });
240
+ return ms;
241
+ }
242
+
109
243
  async function main(argv) {
110
244
  const [cmd, ...args] = argv;
111
245
  switch (cmd) {
@@ -127,8 +261,19 @@ async function main(argv) {
127
261
  }
128
262
 
129
263
  case "mcp": {
264
+ if (args[0] === "token") return mcpToken(args.slice(1));
265
+ const { dir, flags } = pickDir(args);
266
+ if (flags.includes("--http")) {
267
+ // The record over streamable HTTP with OAuth in front (HQ-1378), for a tunnel to publish.
268
+ const port = Number(flagValue(args, "port") ?? 8378);
269
+ const publicUrl = flagValue(args, "public-url");
270
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new EvdaError("REFUSED: --port must be a port number.", { code: "BAD_PORT", exit: 2 });
271
+ if (!publicUrl) throw new EvdaError("REFUSED: --http needs --public-url, the https address the tunnel serves this on (every URL in the OAuth metadata is built from it).", { code: "NO_PUBLIC_URL", exit: 2 });
272
+ const { serveHttp } = await import("../lib/mcp-http.mjs");
273
+ await serveHttp({ dir, version: packageVersion(), port, host: flagValue(args, "host") ?? "127.0.0.1", publicUrl, logFile: flagValue(args, "log"), lifeline: flagValue(args, "lifeline") });
274
+ return 0;
275
+ }
130
276
  // The record as an MCP server on stdio. stdout is the protocol; everything else is stderr.
131
- const { dir } = pickDir(args);
132
277
  const { serve } = await import("../lib/mcp.mjs");
133
278
  await serve({ dir, version: packageVersion() });
134
279
  return 0;
@@ -142,6 +287,68 @@ async function main(argv) {
142
287
  return r.exit;
143
288
  }
144
289
 
290
+ case "gather": {
291
+ const g = await import("../lib/gather.mjs");
292
+ const [sub, ...rest] = args;
293
+ const values = new Set(["out", "model", "into", "manifest", "owner"].map((n) => rest.indexOf(`--${n}`)).filter((i) => i >= 0).map((i) => i + 1));
294
+ const positional = rest.filter((a, i) => !a.startsWith("--") && !values.has(i));
295
+ if (sub === "scan") {
296
+ if (!positional.length) throw new EvdaError("usage: evda gather scan <source>... [--into <record>] [--out <file>] [--with-ai] [--model M]", { exit: 2 });
297
+ let p = g.gather(positional, { into: flagValue(rest, "into") ?? null });
298
+ let exit = 0;
299
+ if (rest.includes("--with-ai")) {
300
+ const r = g.withOwnersAi(p, { model: flagValue(rest, "model") ?? null });
301
+ p = r.p;
302
+ if (!r.ok) exit = 1;
303
+ }
304
+ const out = g.saveProposal(p, flagValue(rest, "out") ?? join(g.gatherDir(), `${p.id}.json`));
305
+ console.log(g.summaryText(p));
306
+ if (p.ai) console.log(`\nYour AI: ${p.ai.status}${p.ai.why ? `, ${p.ai.why}` : `, ${p.ai.applied} of ${p.ai.asked} typed, ${p.ai.ignored} line(s) not read`}`);
307
+ console.log(`\nProposal written to ${out}`);
308
+ for (const l of g.checkedLines(p)) console.log(l);
309
+ return p.counts.items === 0 && !p.counts.chats ? 1 : exit;
310
+ }
311
+ if (sub === "questions" || sub === "show") {
312
+ if (!positional[0]) throw new EvdaError(`usage: evda gather ${sub} <proposal>`, { exit: 2 });
313
+ const { p, abs } = g.loadProposal(positional[0]);
314
+ if (sub === "show") { console.log(g.summaryText(p, { detail: rest.includes("--all") })); console.log(`\n${abs}`); for (const l of g.checkedLines(p)) console.log(l); return 0; }
315
+ const open = p.questions.filter((q) => q.answer === undefined);
316
+ for (const q of open) console.log(`[${q.id}] ${q.text} (${q.options.join(" / ")}) moves ${q.affects.length} item(s)`);
317
+ for (const q of p.questions.filter((x) => x.answer !== undefined)) console.log(`answered [${q.id}] ${q.answer}`);
318
+ console.log(open.length ? `CHECKED ${p.questions.length} question(s) ${open.length} open` : `SKIPPED questions: none open on ${abs}`);
319
+ return 0;
320
+ }
321
+ if (sub === "answer") {
322
+ const [ref, question, value] = positional;
323
+ if (!value) throw new EvdaError("usage: evda gather answer <proposal> <question> <value>", { exit: 2 });
324
+ const { p, abs } = g.loadProposal(ref);
325
+ const r = g.answer(p, question, value);
326
+ g.saveProposal(r.proposal, abs);
327
+ console.log(`Recorded ${question} = ${value} on ${abs}`);
328
+ for (const l of g.answerLines(r)) console.log(l);
329
+ console.log(`CHECKED ${r.proposal.items.length} items ${r.moved.length} re-placed`);
330
+ return 0;
331
+ }
332
+ if (sub === "apply") {
333
+ const into = flagValue(rest, "into");
334
+ if (!positional[0] || !into) throw new EvdaError("usage: evda gather apply <proposal> --into <record> [--copy|--move] [--dry] [--manifest <file>] [--owner NAME]", { exit: 2 });
335
+ if (rest.includes("--copy") && rest.includes("--move")) throw new EvdaError("REFUSED: --copy and --move together. Copy is the default; pass --move to take the files out of the sources.", { exit: 2 });
336
+ const a = await import("../lib/gather-apply.mjs");
337
+ const { p, abs } = g.loadProposal(positional[0]);
338
+ const r = await a.applyGather(p, into, { mode: rest.includes("--move") ? "move" : "copy", dry: rest.includes("--dry"), manifestOut: flagValue(rest, "manifest") ?? null, owner: flagValue(rest, "owner") ?? "", proposalPath: abs });
339
+ for (const l of r.lines) console.log(l);
340
+ return r.dry ? 0 : r.doctor.exit === 0 ? 0 : 1;
341
+ }
342
+ if (sub === "revert") {
343
+ if (!positional[0]) throw new EvdaError("usage: evda gather revert <manifest> [--force]", { exit: 2 });
344
+ const a = await import("../lib/gather-apply.mjs");
345
+ const r = a.revertGather(positional[0], { force: rest.includes("--force") });
346
+ for (const l of a.revertLines(r)) console.log(l);
347
+ return 0;
348
+ }
349
+ throw new EvdaError("usage: evda gather scan <source>... | questions <proposal> | answer <proposal> <question> <value> | show <proposal> | apply <proposal> --into <record> | revert <manifest>", { exit: 2 });
350
+ }
351
+
145
352
  case "look-back": {
146
353
  const { dir } = pickDir(args);
147
354
  const { lookBack } = await import("../lib/organize.mjs");
@@ -160,6 +367,40 @@ async function main(argv) {
160
367
  return 0;
161
368
  }
162
369
 
370
+ case "hook": {
371
+ // evda hook install [dir] | status [dir] the keep-record Stop hook for Claude Code (HQ-1376)
372
+ const [sub, ...rest] = args;
373
+ if (!["install", "status"].includes(sub)) throw new EvdaError("usage: evda hook install [dir] | status [dir]", { exit: 2 });
374
+ const { dir } = pickDir(rest);
375
+ // Every hook create-evda installs for a Claude owner (HQ-1377): the keep-record Stop hook and
376
+ // the read guard, both in the record's own settings and nowhere else.
377
+ const hooks = await import("../lib/hooks.mjs");
378
+ const { installGuard, guardStatus } = await import("../lib/connect.mjs");
379
+ if (sub === "install") {
380
+ const r = hooks.installHook(dir);
381
+ console.log(`${r.action}: ${r.settings} runs ${r.hook} in this record's Claude Code sessions, and nowhere else`);
382
+ const g = installGuard(dir);
383
+ console.log(`read guard ${g.action} ${g.path}`);
384
+ // The prompt hook (HQ-1385) files every message, so only the owner's "everything" installs it.
385
+ const { filingOf } = await import("../lib/day-file.mjs");
386
+ const { readManifest } = await import("../lib/manifest.mjs");
387
+ const filing = filingOf(readManifest(dir));
388
+ if (filing === "everything") {
389
+ const p = hooks.installPromptHook(dir);
390
+ console.log(`prompt hook ${p.action}: ${p.settings} runs ${p.hook}, which files each message the owner sends`);
391
+ } else console.log(`prompt hook not installed: evda.json says ${filing ? `"filing": "${filing}"` : "nothing about filing yet"}, and it is installed only for "everything"`);
392
+ return 0;
393
+ }
394
+ const st = hooks.hookStatus(dir);
395
+ if (st.problem) { console.log(st.problem); return 1; }
396
+ console.log(st.registered ? (st.file ? (st.current ? `installed and current: ${hooks.SETTINGS_REL} runs ${hooks.HOOK_REL}` : `installed, older than the shipped hook: run \`evda hook install\``) : `registered, but ${hooks.HOOK_REL} is missing: run \`evda hook install\``) : "not installed: run `evda hook install`");
397
+ const ph = hooks.promptHookStatus(dir);
398
+ console.log(ph.registered ? (ph.file ? (ph.current ? `prompt hook installed and current: ${hooks.SETTINGS_REL} runs ${hooks.PROMPT_HOOK_REL}` : "prompt hook installed, older than the shipped hook: run `evda hook install`") : `prompt hook registered, but ${hooks.PROMPT_HOOK_REL} is missing: run \`evda hook install\``) : "prompt hook not installed (it is only for an owner who chose to file everything)");
399
+ const g = guardStatus(dir);
400
+ console.log(g.registered ? (g.file ? `read guard installed: ${hooks.SETTINGS_REL} runs ${g.target ?? "evda guard through npx"}` : `read guard registered, but ${g.target} is missing: run \`evda hook install\``) : "read guard not installed: run `evda hook install`");
401
+ return st.registered && st.file && st.current && g.registered && g.file && (!ph.registered || (ph.file && ph.current)) ? 0 : 1;
402
+ }
403
+
163
404
  case "connect": {
164
405
  // How to add this record's MCP server to each client, with the record's path filled in.
165
406
  const { dir } = pickDir(args);
@@ -174,6 +415,62 @@ async function main(argv) {
174
415
  return routineMain(args, { pickDir, flagValue });
175
416
  }
176
417
 
418
+ case "preset": {
419
+ // evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]
420
+ const presets = await import("../lib/presets.mjs");
421
+ const [sub, ...rest] = args;
422
+ if (sub === "list") {
423
+ const all = presets.listPresets();
424
+ for (const p of all) console.log(`${p.id.padEnd(20)} ${p.title}: ${p.for}`);
425
+ console.log(`CHECKED ${all.length} presets`);
426
+ return all.length ? 0 : 1;
427
+ }
428
+ const id = rest.find((a) => !a.startsWith("--"));
429
+ if (!["show", "apply", "diff"].includes(sub) || !id) throw new EvdaError("usage: evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]", { exit: 2 });
430
+ if (sub === "show") {
431
+ const p = presets.loadPreset(id);
432
+ console.log(`${p.title} (${p.id}): ${p.for}`);
433
+ for (const f of p.folders) console.log(` ${`${f.path}/`.padEnd(32)} ${(f.type ?? "untyped").padEnd(8)}${f.connectors?.length ? ` connectors ${f.connectors.join(", ")}` : ""}${f.note ? ` ${f.note}` : ""}`);
434
+ for (const c of p.circles ?? []) console.log(` circle ${c.name} (${c.public ? "public" : "private"}, proposed only): ${c.description}`);
435
+ for (const [k, v] of Object.entries(p.mapping ?? {})) console.log(` role ${k.padEnd(24)} ${v}/`);
436
+ for (const r of p.routines ?? []) console.log(` routine ${r} (offered, never scheduled without a yes)`);
437
+ return 0;
438
+ }
439
+ const { dir } = pickDir(rest.filter((a, i) => i !== rest.indexOf(id)));
440
+ if (sub === "diff") {
441
+ const plan = presets.diffPreset(dir, id);
442
+ console.log(`evda preset diff ${id} ${plan.root}`);
443
+ for (const l of presets.planLines(plan, { diff: true })) console.log(l);
444
+ if (presets.planIsEmpty(plan)) console.log(`SKIPPED preset diff: this record already has everything ${id} makes`);
445
+ console.log(`CHECKED ${plan.folders} preset folders ${plan.create.length} missing, ${plan.write.length} manifest(s) missing, ${plan.matched.length} matching, ${plan.conflicts.length} conflict(s)`);
446
+ return 0;
447
+ }
448
+ const dry = args.includes("--dry");
449
+ const plan = presets.applyPreset(dir, id, { dry });
450
+ console.log(`evda preset apply ${id}${dry ? " --dry" : ""} ${plan.root}`);
451
+ for (const l of presets.planLines(plan)) console.log(l);
452
+ const wrote = plan.create.length + plan.write.length + plan.views.length + plan.proposed.length + plan.roles.length;
453
+ console.log(`CHECKED ${plan.folders} preset folders ${plan.create.length} ${dry ? "to create" : "created"}, ${plan.write.length} manifest(s) ${dry ? "to write" : "written"}, ${plan.matched.length} skipped, ${plan.conflicts.length} conflict(s)`);
454
+ if (!dry && wrote === 0 && !plan.conflicts.length) console.log("Nothing was written: the record already has this preset.");
455
+ if (dry) console.log("Dry run: nothing was written.");
456
+ return plan.conflicts.length ? 1 : 0;
457
+ }
458
+
459
+ case "work": {
460
+ // evda work [dir] [--env-file <file>] [--json]: the record's efforts, from THE HARLEQUIN or a
461
+ // folder of effort files (HQ-1371). Read only.
462
+ const { readWork } = await import("../lib/work.mjs");
463
+ const { dir } = pickDir(args);
464
+ const envFile = flagValue(args, "env-file");
465
+ const r = await readWork(openEvda(dir), { envFile: envFile ? resolve(envFile) : null });
466
+ if (args.includes("--json")) console.log(JSON.stringify({ efforts: r.efforts, lines: r.lines }, null, 2));
467
+ else {
468
+ for (const e of r.efforts) console.log(` ${e.id.padEnd(12)} ${String(e.status).padEnd(16)} ${(e.planned_for ?? "").padEnd(10)} ${e.title}`);
469
+ for (const l of r.lines) console.log(l);
470
+ }
471
+ return r.exit;
472
+ }
473
+
177
474
  case "where": {
178
475
  const found = locate({});
179
476
  console.log(`${found.dir} (via ${found.via})`);
@@ -193,6 +490,62 @@ async function main(argv) {
193
490
  return doctor(dir, { via }).exit;
194
491
  }
195
492
 
493
+ case "guard":
494
+ return runHook();
495
+
496
+ case "note": {
497
+ // The words may hold a slash ("50/50"), so the record is never guessed from them: --in <dir>,
498
+ // or the locator.
499
+ const i = args.indexOf("--in");
500
+ const dir = locate({ explicit: i >= 0 ? args[i + 1] : undefined }).dir;
501
+ let words = args.filter((_, j) => i < 0 || (j !== i && j !== i + 1)).join(" ");
502
+ if (words === "-" || words === "") words = readFileSync(0, "utf8");
503
+ if (!words.trim()) throw new EvdaError('REFUSED: note needs the words, as an argument or on stdin. Nothing was written.', { exit: 2 });
504
+ refuseSecrets(words, "evda note");
505
+ const r = note(openEvda(dir), words);
506
+ console.log(`Noted at ${r.path}. No AI reads it until you say it can: evda release ${JSON.stringify(r.path)}`);
507
+ return 0;
508
+ }
509
+
510
+ case "release": {
511
+ const { dir, entry } = pickReleaseArgs(args);
512
+ const r = releaseNote(openEvda(dir), entry);
513
+ console.log(`Released ${r.path}. It reads like any other entry now.`);
514
+ return 0;
515
+ }
516
+
517
+ case "search": {
518
+ const { dir, rest } = pickDir(args);
519
+ const query = rest.join(" ").trim();
520
+ if (!query) throw new EvdaError("search needs words to find", { exit: 2 });
521
+ const evda = openEvda(dir);
522
+ const files = markdownFiles(evda.dir);
523
+ const hits = ownerSearch(evda, query, { files });
524
+ for (const h of hits) console.log(`${h.sealed ? "[sealed] " : ""}${h.noted ? "[noted] " : ""}${h.path}:${h.line}: ${h.text.length > 300 ? `${h.text.slice(0, 300)}...` : h.text}`);
525
+ console.log(`CHECKED ${files.length} files ${hits.length} match(es), ${hits.filter((h) => h.noted).length} in just-noted entries, ${hits.filter((h) => h.sealed).length} in sealed entries; on this device, no model`);
526
+ return 0;
527
+ }
528
+
529
+ case "readers": {
530
+ const { dir } = pickDir(args);
531
+ const evda = openEvda(dir);
532
+ const ctx = readContext(evda);
533
+ const rec = parseReaders(evda.manifest[READ_BY], "evda.json read_by");
534
+ console.log(`the record: Read by: ${describeReaders(rec.problems.length ? null : rec.readers)}${ctx.circles.length ? " (every part without its own)" : ""}`);
535
+ for (const c of ctx.circles) {
536
+ const any = Object.values(c.members).find((m) => m.kind === "path");
537
+ const probe = any ? (any.isDir ? join(any.abs, ".probe.md") : any.abs) : null;
538
+ const r = probe ? readersFor(ctx, probe) : { readers: null, isPublic: c.manifest?.public === true, source: null };
539
+ console.log(`${c.name}: Read by: ${describeReaders(r.readers, { isPublic: r.isPublic })}${r.source === "evda.json" ? " (from evda.json)" : ""}`);
540
+ }
541
+ return 0;
542
+ }
543
+
544
+ case "local-model": {
545
+ console.log(readFileSync(fileURLToPath(new URL("../docs/local-model.md", import.meta.url)), "utf8"));
546
+ return 0;
547
+ }
548
+
196
549
  case "reindex": {
197
550
  const { dir, flags } = pickDir(args);
198
551
  if (flags.includes("--adopt")) {
@@ -267,13 +620,36 @@ async function main(argv) {
267
620
  const parts = splitFence(text);
268
621
  if (!parts) throw new EvdaError(`${a.rel} has no evda:generated fence. The owner has taken the whole file; there is nothing the installer owns to replace, and nothing was changed.`);
269
622
  const shipped = shippedGenerated();
270
- if (parts.generated === shipped) { console.log("upgrade: the generated block already matches what this tool ships. Nothing outside it was read."); return 0; }
623
+ if (parts.generated === shipped) {
624
+ stampInstalledVersion(dir, packageVersion());
625
+ console.log("upgrade: the generated block already matches what this tool ships. Nothing outside it was read.");
626
+ return 0;
627
+ }
271
628
  writeFileSync(a.abs, replaceFence(text, shipped));
272
629
  reindex(evda);
630
+ stampInstalledVersion(dir, packageVersion());
273
631
  console.log(`upgrade: replaced the generated block of ${a.rel} (${Buffer.byteLength(parts.generated)} -> ${Buffer.byteLength(shipped)} bytes). Everything outside the fence is untouched. Digest rebuilt.`);
274
632
  return 0;
275
633
  }
276
634
 
635
+ case "check-update": {
636
+ const { dir } = pickDir(args);
637
+ const result = await checkForUpdate(dir, { force: args.includes("--force") });
638
+ console.log(summarize(result));
639
+ return result.status === "CHECK_FAILED" ? 1 : 0;
640
+ }
641
+
642
+ case "update": {
643
+ const { dir } = pickDir(args);
644
+ const result = await checkForUpdate(dir, { force: true });
645
+ console.log(summarize(result));
646
+ if (result.status === "CHECK_FAILED") return 1;
647
+ if (result.status !== "UPDATE_AVAILABLE" && result.status !== "UNSTAMPED") return 0;
648
+ const upgradeRc = await main(["upgrade", dir]);
649
+ if (upgradeRc !== 0) return upgradeRc;
650
+ return main(["migrate", dir]);
651
+ }
652
+
277
653
  case "digest": {
278
654
  const { dir } = pickDir(args);
279
655
  const evda = openEvda(dir);
@@ -408,6 +784,62 @@ async function main(argv) {
408
784
  return 0;
409
785
  }
410
786
 
787
+ case "publish": {
788
+ const { dir, rest } = pickDir(args);
789
+ return publishCommand(dir, rest[0], { out: flagValue(args, "out"), key: flagValue(args, "key") });
790
+ }
791
+
792
+ case "circles": {
793
+ const sub = args[0];
794
+ const { dir } = pickDir(args.slice(1));
795
+ const planFile = flagValue(args, "plan");
796
+ const plan = planFile ? JSON.parse(readFileSync(resolve(planFile), "utf8")) : null;
797
+ if (sub === "plan" || sub === "declare") {
798
+ if (!plan) throw new EvdaError(`circles ${sub} needs --plan <file>`, { exit: 2 });
799
+ if (sub === "plan") {
800
+ const { circles, missing } = planCircles(dir, plan);
801
+ for (const c of circles) console.log(`${c.name}${c.public ? " (public)" : ""}: ${c.members.length} member(s)\n${c.members.map((m) => ` ${m.path === "" ? "(the record root: everything no other circle claims)" : `${m.path}${m.isDir ? "/" : ""}`}`).join("\n")}`);
802
+ for (const m of missing) console.log(`MISSING root ${m}: nothing is there yet; declared so the path has a circle when it appears`);
803
+ return 0;
804
+ }
805
+ const { written } = declare(dir, plan, { recordId: flagValue(args, "record-id") });
806
+ console.log(`wrote ${written.length} files: ${written.map((p) => relative(dir, p)).join(", ")}`);
807
+ return 0;
808
+ }
809
+ if (sub === "check") {
810
+ const evda = openEvda(dir);
811
+ // Without --plan, the plan kept beside the circles (`<circles role>/plan.json`) says what is
812
+ // excluded, so this check and doctor exclude what the scan excludes.
813
+ const beside = plan ? null : planBeside(evda);
814
+ const excludes = plan?.exclude ?? beside?.plan?.exclude ?? [];
815
+ // A bounded record's circles stop at its sub-records (HQ-1371); each is checked as its own.
816
+ const { boundaries } = await import("../lib/records.mjs");
817
+ const stops = boundaries(evda);
818
+ if (stops.length) console.log(`STOPS at ${stops.length} sub-record(s): ${stops.join(", ")}`);
819
+ const r = coverage(dir, circleIndex(evda), { exclude: excluder([...excludes, ...stops]), mountRoot: mountRoot(evda) });
820
+ for (const p of r.problems) console.log(`FAIL ${p.code} ${p.rel}: ${p.why}`);
821
+ console.log(`CHECKED ${r.checked} files in ${Object.keys(r.perCircle).length} circles: ${Object.entries(r.perCircle).map(([n, k]) => `${n} ${k}`).join(", ")}; EXCLUDED ${r.excluded} (plan: ${planFile ? relative(dir, resolve(planFile)) : beside ? beside.path : "none, built-in excludes only"})`);
822
+ if (r.checked === 0) { console.log("FAIL CHECKED 0: nothing was examined"); return 1; }
823
+ return r.problems.length ? 1 : 0;
824
+ }
825
+ throw new EvdaError("circles needs plan, declare or check", { exit: 2 });
826
+ }
827
+
828
+ case "parity": {
829
+ const { dir } = pickDir(args);
830
+ const planFile = flagValue(args, "plan");
831
+ const device = flagValue(args, "device");
832
+ const shadow = flagValue(args, "shadow");
833
+ if (!planFile || !device || !shadow) throw new EvdaError("parity needs --plan <file> --device <name> --shadow <dir>", { exit: 2 });
834
+ const plan = JSON.parse(readFileSync(resolve(planFile), "utf8"));
835
+ const evda = openEvda(dir);
836
+ const { holds, never, drift } = deviceCircles(plan, device, readGrants(evda) === null ? null : grantView(evda));
837
+ for (const d of drift) console.log(`NOTE plan drift ${d}; the grant files decide`);
838
+ const r = parity({ source: dir, shadow: resolve(shadow), index: circleIndex(evda), holds, never, exclude: excluder(plan.exclude) });
839
+ for (const line of formatParity(r)) console.log(line);
840
+ return r.ok ? 0 : 1;
841
+ }
842
+
411
843
  case "status": {
412
844
  const { dir, via } = pickDir(args);
413
845
  const evda = openEvda(dir);