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/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, message as messageTool, replies as repliesTool } 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,17 +67,45 @@ 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
- evda routine <morning|weekly> [dir] [--launchd] [--install]
105
+ evda routine <morning|weekly> [dir] [--launchd] [--install] [--uninstall]
62
106
  print a scheduled routine for Claude, or a launchd fallback; --install
63
- writes the launchd job, and nothing is scheduled without it
107
+ writes the launchd job, --uninstall removes it (setup installs the
108
+ morning organize unless told no)
64
109
  evda preset list the organizations this package ships, and who each suits
65
110
  evda preset show <id> one preset's folders
66
111
  evda preset apply <id> [--dry] [dir]
@@ -68,6 +113,38 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
68
113
  manifest that differs is a CONFLICT and is left as it is
69
114
  evda preset diff <id> [dir] what the preset has that this record does not
70
115
  evda status [dir] one screen summary
116
+ evda publish public [dir] --out <dir> --key <seed file>
117
+ the public circle as a signed plaintext export; refuses (exit 2) on a
118
+ path another circle covers or a private name or path in the bytes
119
+ evda circles plan --plan <file> [dir]
120
+ which folders each circle of the plan gets on this disk. Writes nothing
121
+ evda circles declare --plan <file> [--record-id <id>] [dir]
122
+ write the plan's circles and device grants into the record. Adds the
123
+ circles and grants roles and their folders; moves and changes no file.
124
+ A circle rooted at "" is written as the root itself, and a circle
125
+ already declared keeps its id, release, rules, mount and policy
126
+ evda circles check [--plan <file>] [dir]
127
+ every syncable file in exactly one circle (CHECKED n files); fails on a
128
+ file no circle covers, and on a symlink, iCloud stub or oversize file.
129
+ Without --plan, reads <circles role>/plan.json for the excludes
130
+ evda parity --plan <file> --device <name> --shadow <dir> [dir]
131
+ the device's shadow equals the record for the circles it holds, byte for
132
+ byte, and holds nothing of a circle it may never hold (Spec 08 S1)
133
+ evda work [dir] [--env-file F] [--json]
134
+ the record's efforts from its work role: THE HARLEQUIN by vault_path
135
+ or project, or a folder of effort files. CHECKED n, or SKIPPED and why
136
+ evda note "<words>" [--in dir] just note it: a capture no AI reads until you release it (words from stdin with -)
137
+ evda release <path> [dir] you can read that now: clear the just-noted flag on one entry
138
+ evda message --title "<title>" [--from NAME] [--in dir] "<body>"
139
+ tell the owner something: one new file in the messages role, a card
140
+ in their phone app (body from stdin with -)
141
+ evda replies [--peek] [--in dir] [--json]
142
+ the owner's replies not seen yet, marked seen unless --peek; read
143
+ under read_by as the AI $EVDA_READER names (claude, local, ...)
144
+ evda search <words> [dir] your own search, on this device, no model; just-noted and sealed entries included and marked
145
+ evda readers [dir] which AI reads each part of the record, from read_by
146
+ evda local-model how to run a model on your own computer and point a circle at it
147
+ evda guard the Claude Code read guard (a PreToolUse hook; reads the tool call on stdin)
71
148
  evda where which EVDA the locator resolves to, and by which rule
72
149
  evda use <dir> point ~/.evda/current at a record
73
150
  evda init <dir> [--yes] [--agent-name N] [--owner O] [--purpose P] [--voice V]
@@ -95,7 +172,7 @@ function isDirArg(a) {
95
172
  }
96
173
 
97
174
  // `--name value` or `--name=value`. The value of a `--name value` pair is not a positional.
98
- const VALUE_FLAGS = ["to", "kind", "name", "role", "by", "as", "model", "project", "hour", "weekday"];
175
+ 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"];
99
176
  function flagValue(args, name) {
100
177
  const eq = args.find((a) => a.startsWith(`--${name}=`));
101
178
  if (eq) return eq.slice(name.length + 3);
@@ -113,6 +190,63 @@ function pickDir(args) {
113
190
  return { dir: found.dir, via: found.via, flags, rest };
114
191
  }
115
192
 
193
+ // `release`'s own positional is a printed entry path, which always contains a "/" (Spec: entries
194
+ // live under record/), so pickDir's "any positional with a slash is the record" rule always ate it.
195
+ // The record here comes from the working directory (or the pointer) by default; a positional is
196
+ // only ever the record when it is a SECOND positional that is itself an EVDA on disk, never the
197
+ // entry path itself.
198
+ function pickReleaseArgs(args) {
199
+ const positional = args.filter((a) => !a.startsWith("--"));
200
+ let explicit = null;
201
+ let entryParts = positional;
202
+ if (positional.length > 1) {
203
+ const last = positional[positional.length - 1];
204
+ try {
205
+ if (statSync(last).isDirectory() && existsSync(manifestPath(resolve(last)))) {
206
+ explicit = last;
207
+ entryParts = positional.slice(0, -1);
208
+ }
209
+ } catch { /* not a directory on disk -> it's part of the entry path */ }
210
+ }
211
+ const found = locate({ explicit });
212
+ return { dir: found.dir, entry: entryParts.join(" ") };
213
+ }
214
+
215
+ // `evda mcp token`: the owner's side of the remote server's authorization. Printed here, on the
216
+ // machine that holds the record, and typed into the authorization page: never in a URL or a log.
217
+ async function mcpToken(args) {
218
+ const { dir, flags } = pickDir(args);
219
+ const auth = await import("../lib/mcp-auth.mjs");
220
+ const store = auth.openStore(auth.authDir(openEvda(dir)));
221
+ if (flags.includes("--new")) {
222
+ const ttl = parseDuration(flagValue(args, "ttl") ?? "24h");
223
+ const { code, expires } = auth.newOwnerCode(store, { ttlMs: ttl });
224
+ console.log(code);
225
+ console.error(`One owner code, good once, until ${new Date(expires).toISOString()}. Type it into the page Claude opens when you connect.`);
226
+ return 0;
227
+ }
228
+ if (flags.includes("--revoke-all")) {
229
+ const n = auth.revokeAll(store);
230
+ 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.`);
231
+ return 0;
232
+ }
233
+ if (flags.includes("--status")) {
234
+ const s = auth.status(store);
235
+ 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}`);
236
+ 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(", ")}`);
237
+ return 0;
238
+ }
239
+ throw new EvdaError("REFUSED: evda mcp token needs --new [--ttl 24h], --revoke-all or --status.", { code: "USAGE", exit: 2 });
240
+ }
241
+
242
+ function parseDuration(s) {
243
+ const m = /^(\d+)(m|h|d)$/.exec(String(s));
244
+ if (!m) throw new EvdaError(`REFUSED: --ttl is a number and m, h or d (got ${JSON.stringify(s)}).`, { code: "BAD_TTL", exit: 2 });
245
+ const ms = Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
246
+ if (ms < 60_000 || ms > 7 * 86_400_000) throw new EvdaError("REFUSED: --ttl is between 1m and 7d.", { code: "BAD_TTL", exit: 2 });
247
+ return ms;
248
+ }
249
+
116
250
  async function main(argv) {
117
251
  const [cmd, ...args] = argv;
118
252
  switch (cmd) {
@@ -134,8 +268,19 @@ async function main(argv) {
134
268
  }
135
269
 
136
270
  case "mcp": {
271
+ if (args[0] === "token") return mcpToken(args.slice(1));
272
+ const { dir, flags } = pickDir(args);
273
+ if (flags.includes("--http")) {
274
+ // The record over streamable HTTP with OAuth in front (HQ-1378), for a tunnel to publish.
275
+ const port = Number(flagValue(args, "port") ?? 8378);
276
+ const publicUrl = flagValue(args, "public-url");
277
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new EvdaError("REFUSED: --port must be a port number.", { code: "BAD_PORT", exit: 2 });
278
+ 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 });
279
+ const { serveHttp } = await import("../lib/mcp-http.mjs");
280
+ await serveHttp({ dir, version: packageVersion(), port, host: flagValue(args, "host") ?? "127.0.0.1", publicUrl, logFile: flagValue(args, "log"), lifeline: flagValue(args, "lifeline") });
281
+ return 0;
282
+ }
137
283
  // The record as an MCP server on stdio. stdout is the protocol; everything else is stderr.
138
- const { dir } = pickDir(args);
139
284
  const { serve } = await import("../lib/mcp.mjs");
140
285
  await serve({ dir, version: packageVersion() });
141
286
  return 0;
@@ -149,6 +294,68 @@ async function main(argv) {
149
294
  return r.exit;
150
295
  }
151
296
 
297
+ case "gather": {
298
+ const g = await import("../lib/gather.mjs");
299
+ const [sub, ...rest] = args;
300
+ const values = new Set(["out", "model", "into", "manifest", "owner"].map((n) => rest.indexOf(`--${n}`)).filter((i) => i >= 0).map((i) => i + 1));
301
+ const positional = rest.filter((a, i) => !a.startsWith("--") && !values.has(i));
302
+ if (sub === "scan") {
303
+ if (!positional.length) throw new EvdaError("usage: evda gather scan <source>... [--into <record>] [--out <file>] [--with-ai] [--model M]", { exit: 2 });
304
+ let p = g.gather(positional, { into: flagValue(rest, "into") ?? null });
305
+ let exit = 0;
306
+ if (rest.includes("--with-ai")) {
307
+ const r = g.withOwnersAi(p, { model: flagValue(rest, "model") ?? null });
308
+ p = r.p;
309
+ if (!r.ok) exit = 1;
310
+ }
311
+ const out = g.saveProposal(p, flagValue(rest, "out") ?? join(g.gatherDir(), `${p.id}.json`));
312
+ console.log(g.summaryText(p));
313
+ 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`}`);
314
+ console.log(`\nProposal written to ${out}`);
315
+ for (const l of g.checkedLines(p)) console.log(l);
316
+ return p.counts.items === 0 && !p.counts.chats ? 1 : exit;
317
+ }
318
+ if (sub === "questions" || sub === "show") {
319
+ if (!positional[0]) throw new EvdaError(`usage: evda gather ${sub} <proposal>`, { exit: 2 });
320
+ const { p, abs } = g.loadProposal(positional[0]);
321
+ 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; }
322
+ const open = p.questions.filter((q) => q.answer === undefined);
323
+ for (const q of open) console.log(`[${q.id}] ${q.text} (${q.options.join(" / ")}) moves ${q.affects.length} item(s)`);
324
+ for (const q of p.questions.filter((x) => x.answer !== undefined)) console.log(`answered [${q.id}] ${q.answer}`);
325
+ console.log(open.length ? `CHECKED ${p.questions.length} question(s) ${open.length} open` : `SKIPPED questions: none open on ${abs}`);
326
+ return 0;
327
+ }
328
+ if (sub === "answer") {
329
+ const [ref, question, value] = positional;
330
+ if (!value) throw new EvdaError("usage: evda gather answer <proposal> <question> <value>", { exit: 2 });
331
+ const { p, abs } = g.loadProposal(ref);
332
+ const r = g.answer(p, question, value);
333
+ g.saveProposal(r.proposal, abs);
334
+ console.log(`Recorded ${question} = ${value} on ${abs}`);
335
+ for (const l of g.answerLines(r)) console.log(l);
336
+ console.log(`CHECKED ${r.proposal.items.length} items ${r.moved.length} re-placed`);
337
+ return 0;
338
+ }
339
+ if (sub === "apply") {
340
+ const into = flagValue(rest, "into");
341
+ if (!positional[0] || !into) throw new EvdaError("usage: evda gather apply <proposal> --into <record> [--copy|--move] [--dry] [--manifest <file>] [--owner NAME]", { exit: 2 });
342
+ 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 });
343
+ const a = await import("../lib/gather-apply.mjs");
344
+ const { p, abs } = g.loadProposal(positional[0]);
345
+ 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 });
346
+ for (const l of r.lines) console.log(l);
347
+ return r.dry ? 0 : r.doctor.exit === 0 ? 0 : 1;
348
+ }
349
+ if (sub === "revert") {
350
+ if (!positional[0]) throw new EvdaError("usage: evda gather revert <manifest> [--force]", { exit: 2 });
351
+ const a = await import("../lib/gather-apply.mjs");
352
+ const r = a.revertGather(positional[0], { force: rest.includes("--force") });
353
+ for (const l of a.revertLines(r)) console.log(l);
354
+ return 0;
355
+ }
356
+ throw new EvdaError("usage: evda gather scan <source>... | questions <proposal> | answer <proposal> <question> <value> | show <proposal> | apply <proposal> --into <record> | revert <manifest>", { exit: 2 });
357
+ }
358
+
152
359
  case "look-back": {
153
360
  const { dir } = pickDir(args);
154
361
  const { lookBack } = await import("../lib/organize.mjs");
@@ -167,6 +374,40 @@ async function main(argv) {
167
374
  return 0;
168
375
  }
169
376
 
377
+ case "hook": {
378
+ // evda hook install [dir] | status [dir] the keep-record Stop hook for Claude Code (HQ-1376)
379
+ const [sub, ...rest] = args;
380
+ if (!["install", "status"].includes(sub)) throw new EvdaError("usage: evda hook install [dir] | status [dir]", { exit: 2 });
381
+ const { dir } = pickDir(rest);
382
+ // Every hook create-evda installs for a Claude owner (HQ-1377): the keep-record Stop hook and
383
+ // the read guard, both in the record's own settings and nowhere else.
384
+ const hooks = await import("../lib/hooks.mjs");
385
+ const { installGuard, guardStatus } = await import("../lib/connect.mjs");
386
+ if (sub === "install") {
387
+ const r = hooks.installHook(dir);
388
+ console.log(`${r.action}: ${r.settings} runs ${r.hook} in this record's Claude Code sessions, and nowhere else`);
389
+ const g = installGuard(dir);
390
+ console.log(`read guard ${g.action} ${g.path}`);
391
+ // The prompt hook (HQ-1385) files every message, so only the owner's "everything" installs it.
392
+ const { filingOf } = await import("../lib/day-file.mjs");
393
+ const { readManifest } = await import("../lib/manifest.mjs");
394
+ const filing = filingOf(readManifest(dir));
395
+ if (filing === "everything") {
396
+ const p = hooks.installPromptHook(dir);
397
+ console.log(`prompt hook ${p.action}: ${p.settings} runs ${p.hook}, which files each message the owner sends`);
398
+ } else console.log(`prompt hook not installed: evda.json says ${filing ? `"filing": "${filing}"` : "nothing about filing yet"}, and it is installed only for "everything"`);
399
+ return 0;
400
+ }
401
+ const st = hooks.hookStatus(dir);
402
+ if (st.problem) { console.log(st.problem); return 1; }
403
+ 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`");
404
+ const ph = hooks.promptHookStatus(dir);
405
+ 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)");
406
+ const g = guardStatus(dir);
407
+ 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`");
408
+ return st.registered && st.file && st.current && g.registered && g.file && (!ph.registered || (ph.file && ph.current)) ? 0 : 1;
409
+ }
410
+
170
411
  case "connect": {
171
412
  // How to add this record's MCP server to each client, with the record's path filled in.
172
413
  const { dir } = pickDir(args);
@@ -176,7 +417,7 @@ async function main(argv) {
176
417
  }
177
418
 
178
419
  case "routine": {
179
- // evda routine <morning|weekly> [dir] [--launchd] [--install]
420
+ // evda routine <morning|weekly> [dir] [--launchd] [--install] [--uninstall]
180
421
  const { routineMain } = await import("../lib/routines.mjs");
181
422
  return routineMain(args, { pickDir, flagValue });
182
423
  }
@@ -198,6 +439,7 @@ async function main(argv) {
198
439
  console.log(`${p.title} (${p.id}): ${p.for}`);
199
440
  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}` : ""}`);
200
441
  for (const c of p.circles ?? []) console.log(` circle ${c.name} (${c.public ? "public" : "private"}, proposed only): ${c.description}`);
442
+ for (const [k, v] of Object.entries(p.mapping ?? {})) console.log(` role ${k.padEnd(24)} ${v}/`);
201
443
  for (const r of p.routines ?? []) console.log(` routine ${r} (offered, never scheduled without a yes)`);
202
444
  return 0;
203
445
  }
@@ -214,13 +456,28 @@ async function main(argv) {
214
456
  const plan = presets.applyPreset(dir, id, { dry });
215
457
  console.log(`evda preset apply ${id}${dry ? " --dry" : ""} ${plan.root}`);
216
458
  for (const l of presets.planLines(plan)) console.log(l);
217
- const wrote = plan.create.length + plan.write.length + plan.views.length + plan.proposed.length;
459
+ const wrote = plan.create.length + plan.write.length + plan.views.length + plan.proposed.length + plan.roles.length;
218
460
  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)`);
219
461
  if (!dry && wrote === 0 && !plan.conflicts.length) console.log("Nothing was written: the record already has this preset.");
220
462
  if (dry) console.log("Dry run: nothing was written.");
221
463
  return plan.conflicts.length ? 1 : 0;
222
464
  }
223
465
 
466
+ case "work": {
467
+ // evda work [dir] [--env-file <file>] [--json]: the record's efforts, from THE HARLEQUIN or a
468
+ // folder of effort files (HQ-1371). Read only.
469
+ const { readWork } = await import("../lib/work.mjs");
470
+ const { dir } = pickDir(args);
471
+ const envFile = flagValue(args, "env-file");
472
+ const r = await readWork(openEvda(dir), { envFile: envFile ? resolve(envFile) : null });
473
+ if (args.includes("--json")) console.log(JSON.stringify({ efforts: r.efforts, lines: r.lines }, null, 2));
474
+ else {
475
+ for (const e of r.efforts) console.log(` ${e.id.padEnd(12)} ${String(e.status).padEnd(16)} ${(e.planned_for ?? "").padEnd(10)} ${e.title}`);
476
+ for (const l of r.lines) console.log(l);
477
+ }
478
+ return r.exit;
479
+ }
480
+
224
481
  case "where": {
225
482
  const found = locate({});
226
483
  console.log(`${found.dir} (via ${found.via})`);
@@ -240,6 +497,88 @@ async function main(argv) {
240
497
  return doctor(dir, { via }).exit;
241
498
  }
242
499
 
500
+ case "guard":
501
+ return runHook();
502
+
503
+ case "note": {
504
+ // The words may hold a slash ("50/50"), so the record is never guessed from them: --in <dir>,
505
+ // or the locator.
506
+ const i = args.indexOf("--in");
507
+ const dir = locate({ explicit: i >= 0 ? args[i + 1] : undefined }).dir;
508
+ let words = args.filter((_, j) => i < 0 || (j !== i && j !== i + 1)).join(" ");
509
+ if (words === "-" || words === "") words = readFileSync(0, "utf8");
510
+ if (!words.trim()) throw new EvdaError('REFUSED: note needs the words, as an argument or on stdin. Nothing was written.', { exit: 2 });
511
+ refuseSecrets(words, "evda note");
512
+ const r = note(openEvda(dir), words);
513
+ console.log(`Noted at ${r.path}. No AI reads it until you say it can: evda release ${JSON.stringify(r.path)}`);
514
+ return 0;
515
+ }
516
+
517
+ case "message": {
518
+ // Like note, the body may hold a slash, so the record is --in <dir> or the locator.
519
+ const take = (flag) => { const i = args.indexOf(flag); if (i < 0) return undefined; const v = args[i + 1]; if (v === undefined) throw new EvdaError(`${flag} needs a value`, { exit: 2 }); args.splice(i, 2); return v; };
520
+ const dirArg = take("--in");
521
+ const title = take("--title");
522
+ const from = take("--from");
523
+ const dir = locate({ explicit: dirArg }).dir;
524
+ let body = args.join(" ");
525
+ if (body === "-" || body === "") body = readFileSync(0, "utf8");
526
+ const r = messageTool(openEvda(dir), { title, body, ...(from === undefined ? {} : { from }) });
527
+ console.log(`Sent the message ${r.id}, filed at ${r.path}.`);
528
+ return 0;
529
+ }
530
+
531
+ case "replies": {
532
+ const i = args.indexOf("--in");
533
+ const dir = locate({ explicit: i >= 0 ? args[i + 1] : undefined }).dir;
534
+ const peek = args.includes("--peek");
535
+ const r = repliesTool(openEvda(dir), peek ? { peek: true } : {});
536
+ if (args.includes("--json")) { console.log(JSON.stringify({ replies: r.replies, marked: r.marked }, null, 2)); return 0; }
537
+ for (const x of r.replies) console.log(`## ${x.title} (${x.id}, ${x.date})\n\n${x.reply}\n`);
538
+ if (r.heldBack) console.log(r.heldBack);
539
+ console.log(`CHECKED ${r.dir}/ ${r.replies.length} new repl${r.replies.length === 1 ? "y" : "ies"}, ${r.marked} marked seen${peek ? " (peek)" : ""}`);
540
+ return 0;
541
+ }
542
+
543
+ case "release": {
544
+ const { dir, entry } = pickReleaseArgs(args);
545
+ const r = releaseNote(openEvda(dir), entry);
546
+ console.log(`Released ${r.path}. It reads like any other entry now.`);
547
+ return 0;
548
+ }
549
+
550
+ case "search": {
551
+ const { dir, rest } = pickDir(args);
552
+ const query = rest.join(" ").trim();
553
+ if (!query) throw new EvdaError("search needs words to find", { exit: 2 });
554
+ const evda = openEvda(dir);
555
+ const files = markdownFiles(evda.dir);
556
+ const hits = ownerSearch(evda, query, { files });
557
+ 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}`);
558
+ 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`);
559
+ return 0;
560
+ }
561
+
562
+ case "readers": {
563
+ const { dir } = pickDir(args);
564
+ const evda = openEvda(dir);
565
+ const ctx = readContext(evda);
566
+ const rec = parseReaders(evda.manifest[READ_BY], "evda.json read_by");
567
+ console.log(`the record: Read by: ${describeReaders(rec.problems.length ? null : rec.readers)}${ctx.circles.length ? " (every part without its own)" : ""}`);
568
+ for (const c of ctx.circles) {
569
+ const any = Object.values(c.members).find((m) => m.kind === "path");
570
+ const probe = any ? (any.isDir ? join(any.abs, ".probe.md") : any.abs) : null;
571
+ const r = probe ? readersFor(ctx, probe) : { readers: null, isPublic: c.manifest?.public === true, source: null };
572
+ console.log(`${c.name}: Read by: ${describeReaders(r.readers, { isPublic: r.isPublic })}${r.source === "evda.json" ? " (from evda.json)" : ""}`);
573
+ }
574
+ return 0;
575
+ }
576
+
577
+ case "local-model": {
578
+ console.log(readFileSync(fileURLToPath(new URL("../docs/local-model.md", import.meta.url)), "utf8"));
579
+ return 0;
580
+ }
581
+
243
582
  case "reindex": {
244
583
  const { dir, flags } = pickDir(args);
245
584
  if (flags.includes("--adopt")) {
@@ -314,13 +653,36 @@ async function main(argv) {
314
653
  const parts = splitFence(text);
315
654
  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.`);
316
655
  const shipped = shippedGenerated();
317
- if (parts.generated === shipped) { console.log("upgrade: the generated block already matches what this tool ships. Nothing outside it was read."); return 0; }
656
+ if (parts.generated === shipped) {
657
+ stampInstalledVersion(dir, packageVersion());
658
+ console.log("upgrade: the generated block already matches what this tool ships. Nothing outside it was read.");
659
+ return 0;
660
+ }
318
661
  writeFileSync(a.abs, replaceFence(text, shipped));
319
662
  reindex(evda);
663
+ stampInstalledVersion(dir, packageVersion());
320
664
  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.`);
321
665
  return 0;
322
666
  }
323
667
 
668
+ case "check-update": {
669
+ const { dir } = pickDir(args);
670
+ const result = await checkForUpdate(dir, { force: args.includes("--force") });
671
+ console.log(summarize(result));
672
+ return result.status === "CHECK_FAILED" ? 1 : 0;
673
+ }
674
+
675
+ case "update": {
676
+ const { dir } = pickDir(args);
677
+ const result = await checkForUpdate(dir, { force: true });
678
+ console.log(summarize(result));
679
+ if (result.status === "CHECK_FAILED") return 1;
680
+ if (result.status !== "UPDATE_AVAILABLE" && result.status !== "UNSTAMPED") return 0;
681
+ const upgradeRc = await main(["upgrade", dir]);
682
+ if (upgradeRc !== 0) return upgradeRc;
683
+ return main(["migrate", dir]);
684
+ }
685
+
324
686
  case "digest": {
325
687
  const { dir } = pickDir(args);
326
688
  const evda = openEvda(dir);
@@ -455,6 +817,62 @@ async function main(argv) {
455
817
  return 0;
456
818
  }
457
819
 
820
+ case "publish": {
821
+ const { dir, rest } = pickDir(args);
822
+ return publishCommand(dir, rest[0], { out: flagValue(args, "out"), key: flagValue(args, "key") });
823
+ }
824
+
825
+ case "circles": {
826
+ const sub = args[0];
827
+ const { dir } = pickDir(args.slice(1));
828
+ const planFile = flagValue(args, "plan");
829
+ const plan = planFile ? JSON.parse(readFileSync(resolve(planFile), "utf8")) : null;
830
+ if (sub === "plan" || sub === "declare") {
831
+ if (!plan) throw new EvdaError(`circles ${sub} needs --plan <file>`, { exit: 2 });
832
+ if (sub === "plan") {
833
+ const { circles, missing } = planCircles(dir, plan);
834
+ 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")}`);
835
+ for (const m of missing) console.log(`MISSING root ${m}: nothing is there yet; declared so the path has a circle when it appears`);
836
+ return 0;
837
+ }
838
+ const { written } = declare(dir, plan, { recordId: flagValue(args, "record-id") });
839
+ console.log(`wrote ${written.length} files: ${written.map((p) => relative(dir, p)).join(", ")}`);
840
+ return 0;
841
+ }
842
+ if (sub === "check") {
843
+ const evda = openEvda(dir);
844
+ // Without --plan, the plan kept beside the circles (`<circles role>/plan.json`) says what is
845
+ // excluded, so this check and doctor exclude what the scan excludes.
846
+ const beside = plan ? null : planBeside(evda);
847
+ const excludes = plan?.exclude ?? beside?.plan?.exclude ?? [];
848
+ // A bounded record's circles stop at its sub-records (HQ-1371); each is checked as its own.
849
+ const { boundaries } = await import("../lib/records.mjs");
850
+ const stops = boundaries(evda);
851
+ if (stops.length) console.log(`STOPS at ${stops.length} sub-record(s): ${stops.join(", ")}`);
852
+ const r = coverage(dir, circleIndex(evda), { exclude: excluder([...excludes, ...stops]), mountRoot: mountRoot(evda) });
853
+ for (const p of r.problems) console.log(`FAIL ${p.code} ${p.rel}: ${p.why}`);
854
+ 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"})`);
855
+ if (r.checked === 0) { console.log("FAIL CHECKED 0: nothing was examined"); return 1; }
856
+ return r.problems.length ? 1 : 0;
857
+ }
858
+ throw new EvdaError("circles needs plan, declare or check", { exit: 2 });
859
+ }
860
+
861
+ case "parity": {
862
+ const { dir } = pickDir(args);
863
+ const planFile = flagValue(args, "plan");
864
+ const device = flagValue(args, "device");
865
+ const shadow = flagValue(args, "shadow");
866
+ if (!planFile || !device || !shadow) throw new EvdaError("parity needs --plan <file> --device <name> --shadow <dir>", { exit: 2 });
867
+ const plan = JSON.parse(readFileSync(resolve(planFile), "utf8"));
868
+ const evda = openEvda(dir);
869
+ const { holds, never, drift } = deviceCircles(plan, device, readGrants(evda) === null ? null : grantView(evda));
870
+ for (const d of drift) console.log(`NOTE plan drift ${d}; the grant files decide`);
871
+ const r = parity({ source: dir, shadow: resolve(shadow), index: circleIndex(evda), holds, never, exclude: excluder(plan.exclude) });
872
+ for (const line of formatParity(r)) console.log(line);
873
+ return r.ok ? 0 : 1;
874
+ }
875
+
458
876
  case "status": {
459
877
  const { dir, via } = pickDir(args);
460
878
  const evda = openEvda(dir);