create-evda 0.1.5 → 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 +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  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/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. 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 } 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,12 +67,39 @@ 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]
@@ -68,6 +112,32 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
68
112
  manifest that differs is a CONFLICT and is left as it is
69
113
  evda preset diff <id> [dir] what the preset has that this record does not
70
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)
71
141
  evda where which EVDA the locator resolves to, and by which rule
72
142
  evda use <dir> point ~/.evda/current at a record
73
143
  evda init <dir> [--yes] [--agent-name N] [--owner O] [--purpose P] [--voice V]
@@ -95,7 +165,7 @@ function isDirArg(a) {
95
165
  }
96
166
 
97
167
  // `--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"];
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"];
99
169
  function flagValue(args, name) {
100
170
  const eq = args.find((a) => a.startsWith(`--${name}=`));
101
171
  if (eq) return eq.slice(name.length + 3);
@@ -113,6 +183,63 @@ function pickDir(args) {
113
183
  return { dir: found.dir, via: found.via, flags, rest };
114
184
  }
115
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
+
116
243
  async function main(argv) {
117
244
  const [cmd, ...args] = argv;
118
245
  switch (cmd) {
@@ -134,8 +261,19 @@ async function main(argv) {
134
261
  }
135
262
 
136
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
+ }
137
276
  // The record as an MCP server on stdio. stdout is the protocol; everything else is stderr.
138
- const { dir } = pickDir(args);
139
277
  const { serve } = await import("../lib/mcp.mjs");
140
278
  await serve({ dir, version: packageVersion() });
141
279
  return 0;
@@ -149,6 +287,68 @@ async function main(argv) {
149
287
  return r.exit;
150
288
  }
151
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
+
152
352
  case "look-back": {
153
353
  const { dir } = pickDir(args);
154
354
  const { lookBack } = await import("../lib/organize.mjs");
@@ -167,6 +367,40 @@ async function main(argv) {
167
367
  return 0;
168
368
  }
169
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
+
170
404
  case "connect": {
171
405
  // How to add this record's MCP server to each client, with the record's path filled in.
172
406
  const { dir } = pickDir(args);
@@ -198,6 +432,7 @@ async function main(argv) {
198
432
  console.log(`${p.title} (${p.id}): ${p.for}`);
199
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}` : ""}`);
200
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}/`);
201
436
  for (const r of p.routines ?? []) console.log(` routine ${r} (offered, never scheduled without a yes)`);
202
437
  return 0;
203
438
  }
@@ -214,13 +449,28 @@ async function main(argv) {
214
449
  const plan = presets.applyPreset(dir, id, { dry });
215
450
  console.log(`evda preset apply ${id}${dry ? " --dry" : ""} ${plan.root}`);
216
451
  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;
452
+ const wrote = plan.create.length + plan.write.length + plan.views.length + plan.proposed.length + plan.roles.length;
218
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)`);
219
454
  if (!dry && wrote === 0 && !plan.conflicts.length) console.log("Nothing was written: the record already has this preset.");
220
455
  if (dry) console.log("Dry run: nothing was written.");
221
456
  return plan.conflicts.length ? 1 : 0;
222
457
  }
223
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
+
224
474
  case "where": {
225
475
  const found = locate({});
226
476
  console.log(`${found.dir} (via ${found.via})`);
@@ -240,6 +490,62 @@ async function main(argv) {
240
490
  return doctor(dir, { via }).exit;
241
491
  }
242
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
+
243
549
  case "reindex": {
244
550
  const { dir, flags } = pickDir(args);
245
551
  if (flags.includes("--adopt")) {
@@ -314,13 +620,36 @@ async function main(argv) {
314
620
  const parts = splitFence(text);
315
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.`);
316
622
  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; }
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
+ }
318
628
  writeFileSync(a.abs, replaceFence(text, shipped));
319
629
  reindex(evda);
630
+ stampInstalledVersion(dir, packageVersion());
320
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.`);
321
632
  return 0;
322
633
  }
323
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
+
324
653
  case "digest": {
325
654
  const { dir } = pickDir(args);
326
655
  const evda = openEvda(dir);
@@ -455,6 +784,62 @@ async function main(argv) {
455
784
  return 0;
456
785
  }
457
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
+
458
843
  case "status": {
459
844
  const { dir, via } = pickDir(args);
460
845
  const evda = openEvda(dir);