create-evda 0.1.8 → 0.1.10

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 (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
@@ -0,0 +1,427 @@
1
+ // What a filing bears on (HQ-2631): after a write tool files words, the existing entries those words
2
+ // name and the shared circles they, and the path just written, reach.
3
+ //
4
+ // Two failures came from one gap. A site feature landed and no public entry ever described it,
5
+ // because nothing at the moment of filing said a public view was derived from that folder. And a
6
+ // conversation about a topic the record already had a page for was filed into the day only, and the
7
+ // page was written to after the owner asked. Code gathers the context; the agent judges. So this
8
+ // returns information and never decides or writes anything: no entry, no share mark, no circle rule.
9
+ //
10
+ // related up to RELATED_MAX entries whose name the words contain: a person's page by full name
11
+ // (or a first name no other page shares), a topic or project entry by its title, its
12
+ // aliases, the head of a dashed title ("Sexual Health - Lasting and Presence" answers to
13
+ // "sexual health"), or a folder's README by the folder's name. Longest name first, so the
14
+ // most specific entry leads. An entry the caller may not read (sealed, just noted, a part
15
+ // set to be read by a different AI) is never listed, by the read tools' own mayRead.
16
+ // circles for the written path and each related entry: a public home circle, a circle that
17
+ // gathers it by its share mark, a circle its home may release it to (a home that covers
18
+ // the whole record is the private default and says nothing), and every view entry a
19
+ // circle gathers whose derived_from names the path or a folder holding it. Only the
20
+ // shared circle's title and the entry path; nothing about any private circle.
21
+ //
22
+ // tickets when the record's work role is a board (`harlequin:` or `harlequin:<project>`, HQ-2798),
23
+ // up to TICKETS_MAX open tickets of that board the words reference: an `HQ-<n>` token
24
+ // naming an open ticket, in the order the words say them, and then a ticket whose title
25
+ // shares a run of at least SPAN_WORDS consecutive words with the words, SPAN_CONTENT of
26
+ // them not common words ("the greenhouse heater controller" names "Build the greenhouse
27
+ // heater controller with a thermostat"), longest run first. A four-word run more than
28
+ // SEED_MAX_TICKETS titles share is too common to point at one; a title shorter than
29
+ // SPAN_WORDS is reached by its number only. A done or obsolete ticket is never named,
30
+ // even by number. A miss is cheaper than noise, so the rule leans to missing.
31
+ //
32
+ // Cost: it runs on every filing, so the record's names are indexed once per INDEX_TTL_MS per record
33
+ // and the path just written is re-read each time. Reading names needs only each file's frontmatter.
34
+ // The board is read the way `evda work` reads it (lib/work.mjs: SUPABASE_URL and
35
+ // SUPABASE_SERVICE_ROLE_KEY from the environment, or the env file $EVDA_WORK_ENV_FILE names), never
36
+ // on the request: a filing is answered from the cached tickets, and a missing or stale cache starts a
37
+ // read after the answer, bounded by BOARD_TIMEOUT_MS. An unreachable board keeps what was cached.
38
+
39
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
40
+ import { join, sep } from "node:path";
41
+ import { resolveRole, workSource, fourRoleOf } from "./roles.mjs";
42
+ import { harlequinEfforts } from "./work.mjs";
43
+ import { markdownFiles, frontmatterOf, homesOf, matchesWhere, heldBy, SEALED, NOTED } from "./circles.mjs";
44
+ import { readContext, mayRead, callerFrom } from "./readers.mjs";
45
+ import { DERIVED_FROM, VIEW } from "./render.mjs";
46
+ import { relatedLine, ticketsLine } from "./day-file.mjs";
47
+
48
+ export const RELATED_MAX = 5;
49
+ export const INDEX_TTL_MS = 5 * 60 * 1000;
50
+ export const TICKETS_MAX = 5;
51
+ // The statuses that end a ticket on the board.
52
+ export const CLOSED_STATUSES = new Set(["done", "obsolete"]);
53
+ const BOARD_TIMEOUT_MS = 10 * 1000;
54
+ const SPAN_WORDS = 4;
55
+ const SPAN_CONTENT = 3;
56
+ const SEED_MAX_TICKETS = 2;
57
+ // Words too common to make a run of a title distinctive.
58
+ const COMMON = new Set("a an the and or but nor of to in on at by for with from into onto over under about as is are was were be been being it its this that these those not no so than then there their they them he she his her we our you your i me my do does did done has have had can could will would should may might must just only also when what which who whom how why where all any each every some more most other one two new now up out off too very own same such if else via per get gets got make makes made use uses used see way thing things".split(" "));
59
+ // A name shorter than this is too common to mean an entry; a single word needs NAME_MIN_WORD.
60
+ const NAME_MIN = 4;
61
+ const NAME_MIN_WORD = 6;
62
+ // A name more entries than this share is not specific enough to point at any of them.
63
+ const NAME_MAX_ENTRIES = 3;
64
+ // Stems that name a kind of file, not a subject.
65
+ const GENERIC = new Set(["readme", "index", "notes", "note", "queue", "overview", "summary", "plan", "plans", "ideas", "todo", "inbox", "untitled", "draft", "drafts", "log", "logs", "synthesis", "template", "templates", "reference", "references", "archive", "status", "changelog", "agents", "claude", "gemini", "people", "family", "projects", "areas", "resources", "research", "design", "home", "daily", "weekly", "monthly", "journal", "printing", "setup", "questions", "answers", "tasks", "spec", "specs", "docs"]);
66
+ // First names that are also ordinary words, so a lowercase message would hit them by accident.
67
+ const WORD_NAMES = new Set(["will", "grace", "june", "may", "rose", "hope", "mark", "faith", "joy", "rich", "bill", "art", "frank", "sky", "summer", "april", "dawn", "jack", "pat", "sue", "ray", "iris", "max", "drew", "chase", "hunter", "lily", "ivy", "jade", "ruby", "amber", "pearl", "sage", "skye", "reed", "wade", "dean", "gene", "guy", "nick", "rob", "don", "ben", "sam", "tom", "lee", "kim", "bo", "eve", "ali", "joe"]);
68
+ const FRONT_HEAD = 8192;
69
+ const LETTER = /[\p{L}\p{N}]/u;
70
+ const DATED = /^\d{4}-\d{2}-\d{2}(?:\s*[-\u2013\u2014]\s*)?/;
71
+
72
+ const norm = (s) => String(s).toLowerCase().replace(/\s+/g, " ").trim();
73
+ const relOf = (evda, abs) => abs.slice(evda.dir.length + 1).split(sep).join("/");
74
+
75
+ // The frontmatter of a file from its head; the whole file only when the frontmatter runs past it.
76
+ function frontOfHead(abs) {
77
+ let text;
78
+ try {
79
+ const fd = openSync(abs, "r");
80
+ try {
81
+ const buf = Buffer.alloc(FRONT_HEAD);
82
+ const n = readSync(fd, buf, 0, FRONT_HEAD, 0);
83
+ text = buf.toString("utf8", 0, n);
84
+ } finally { closeSync(fd); }
85
+ } catch { return null; }
86
+ if (!text.startsWith("---\n")) return null;
87
+ if (!/\n---(\n|$)/.test(text.slice(3))) {
88
+ try { text = readFileSync(abs, "utf8"); } catch { return null; }
89
+ }
90
+ return frontmatterOf(text);
91
+ }
92
+
93
+ // Whether a name is specific enough to stand for an entry.
94
+ function usable(name, { person = false } = {}) {
95
+ const n = norm(name);
96
+ if (n.length < (person ? 3 : NAME_MIN)) return false;
97
+ if (!LETTER.test(n) || /^[\d\s\p{P}]+$/u.test(n)) return false;
98
+ if (GENERIC.has(n)) return false;
99
+ if (!n.includes(" ") && !person && n.length < NAME_MIN_WORD) return false;
100
+ return true;
101
+ }
102
+
103
+ // The names one entry answers to, from its path and frontmatter.
104
+ function namesOf(rel, front) {
105
+ const parts = rel.split("/");
106
+ let stem = parts[parts.length - 1].replace(/\.md$/i, "");
107
+ // A folder's README or index stands for the folder.
108
+ if (/^(readme|index)$/i.test(stem) && parts.length > 1) stem = parts[parts.length - 2];
109
+ stem = stem.replace(DATED, "").trim();
110
+ const out = new Set();
111
+ const add = (s) => { if (typeof s === "string" && s.trim()) out.add(norm(s)); };
112
+ add(stem);
113
+ // The head of a dashed title, and the title without a trailing parenthetical.
114
+ const head = stem.split(/\s+[-\u2013\u2014]\s+/)[0];
115
+ if (head !== stem) add(head);
116
+ add(stem.replace(/\s*\([^)]*\)\s*$/, ""));
117
+ if (front) {
118
+ add(front.title);
119
+ if (front["kind"] === "person-profile") add(front.name);
120
+ const al = front.aliases ?? front.alias;
121
+ for (const a of Array.isArray(al) ? al : [al]) add(a);
122
+ }
123
+ return [...out].filter((n) => usable(n));
124
+ }
125
+
126
+ // The folders whose files are the day's flow, not entries a filing names.
127
+ function skippedDirs(evda) {
128
+ const out = [];
129
+ for (const role of ["record/daily", "record/captures", "journal/dreams", "journal/inbox", "ledger", "circles", "grants", "collections/archive", "collections/attachments"]) {
130
+ try {
131
+ const r = resolveRole(evda, role);
132
+ if (r.mapped && r.abs && r.abs !== evda.dir) out.push(r.abs);
133
+ } catch { /* an unmapped or escaping role skips nothing */ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function buildIndex(evda) {
139
+ const skip = skippedDirs(evda);
140
+ let people = null;
141
+ try { const r = resolveRole(evda, "record/people"); if (r.mapped && r.abs) people = r.abs; } catch { /* none */ }
142
+ const names = new Map(); // name -> Set(rel)
143
+ const fronts = new Map(); // rel -> frontmatter (only files that have one)
144
+ const derived = []; // { rel, view, from }
145
+ const firstNames = new Map(); // first name -> Set(rel)
146
+ const put = (name, rel) => { if (!names.has(name)) names.set(name, new Set()); names.get(name).add(rel); };
147
+ let files = [];
148
+ try { files = markdownFiles(evda.dir); } catch { files = []; }
149
+ for (const abs of files) {
150
+ if (skip.some((d) => abs.startsWith(d + sep))) continue;
151
+ const rel = relOf(evda, abs);
152
+ if (!rel.includes("/") && /^(agents|claude|gemini)\.md$/i.test(rel)) continue;
153
+ const front = frontOfHead(abs);
154
+ if (front) fronts.set(rel, front);
155
+ for (const n of namesOf(rel, front)) put(n, rel);
156
+ if (people && abs.startsWith(people + sep) && !abs.slice(people.length + 1).includes(sep)) {
157
+ const full = rel.split("/").pop().replace(/\.md$/i, "");
158
+ const first = norm(full.split(/\s+/)[0]);
159
+ if (full.includes(" ") && !WORD_NAMES.has(first) && usable(first, { person: true })) {
160
+ if (!firstNames.has(first)) firstNames.set(first, new Set());
161
+ firstNames.get(first).add(rel);
162
+ }
163
+ }
164
+ const from = front?.[DERIVED_FROM];
165
+ if (front && typeof front[VIEW] === "string" && Array.isArray(from)) derived.push({ rel, view: front[VIEW], from: from.filter((s) => typeof s === "string").map((s) => s.replace(/\/+$/, "")) });
166
+ }
167
+ // A first name is a person's only when no other person's page starts with it.
168
+ for (const [first, rels] of firstNames) if (rels.size === 1 && !names.has(first)) put(first, [...rels][0]);
169
+ for (const [n, rels] of [...names]) if (rels.size > NAME_MAX_ENTRIES) names.delete(n);
170
+ // Longest first, so a match on "sexual health - lasting and presence" outranks "sexual health".
171
+ const ordered = [...names.entries()].sort((a, b) => b[0].length - a[0].length);
172
+ return { names: ordered, fronts, derived, files: files.length };
173
+ }
174
+
175
+ const cache = new Map(); // evda.dir -> { at, index, rebuilding }
176
+ const boardCache = new Map(); // evda.dir -> { at, board, loading }
177
+ export function clearTouchesCache() { cache.clear(); boardCache.clear(); }
178
+
179
+ // Build off the request path: setTimeout, so the answer that scheduled it is already sent.
180
+ function rebuildLater(evda, now) {
181
+ const hit = cache.get(evda.dir);
182
+ if (hit?.rebuilding) return;
183
+ if (hit) hit.rebuilding = true;
184
+ const t = setTimeout(() => {
185
+ try { cache.set(evda.dir, { at: now, index: buildIndex(evda) }); } catch { cache.delete(evda.dir); }
186
+ }, 0);
187
+ t.unref?.();
188
+ }
189
+
190
+ // The record's names. Built once, synchronously, only when nothing is cached; a stale index is
191
+ // served as it is while a fresh one builds after the answer, so a filing never waits on the walk
192
+ // except the first in a process that was never warmed (warmTouches, at initialize).
193
+ export function touchesIndex(evda, { now = Date.now(), ttl = INDEX_TTL_MS } = {}) {
194
+ const hit = cache.get(evda.dir);
195
+ if (hit) {
196
+ if (now - hit.at >= ttl) rebuildLater(evda, now);
197
+ return hit.index;
198
+ }
199
+ const index = buildIndex(evda);
200
+ cache.set(evda.dir, { at: now, index });
201
+ return index;
202
+ }
203
+
204
+ // Build the index after the current request, so the first filing in a session finds it ready.
205
+ export function warmTouches(evda, { now = Date.now() } = {}) {
206
+ try { boardTickets(evda, { now }); } catch { /* the first filing starts it */ }
207
+ if (cache.has(evda.dir)) return;
208
+ const t = setTimeout(() => {
209
+ try { if (!cache.has(evda.dir)) cache.set(evda.dir, { at: now, index: buildIndex(evda) }); } catch { /* the first filing builds it */ }
210
+ }, 0);
211
+ t.unref?.();
212
+ }
213
+
214
+ // The board sources the record's work role names: every `work` or `work/<facet>` key whose value is
215
+ // a `harlequin` source. Empty for a record whose work is a folder or unmapped.
216
+ export function boardSources(evda) {
217
+ const out = [];
218
+ for (const [key, value] of Object.entries(evda.manifest?.mapping ?? {})) {
219
+ if (fourRoleOf(key) !== "work") continue;
220
+ const s = workSource(value);
221
+ if (s?.source === "harlequin") out.push({ project: s.project ?? null });
222
+ }
223
+ return out;
224
+ }
225
+
226
+ const words = (s) => String(s).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
227
+ const isContent = (w) => w.length >= 3 && !COMMON.has(w) && !/^\d+$/.test(w);
228
+
229
+ // The open tickets, by number and by the four-word runs of their titles.
230
+ export function ticketIndex(efforts) {
231
+ const byNumber = new Map(); // "2791" -> ticket
232
+ const seeds = new Map(); // "w1 w2 w3 w4" -> [{ t, pos }]
233
+ for (const e of efforts) {
234
+ if (!e || CLOSED_STATUSES.has(e.status)) continue;
235
+ const n = /^HQ-(\d+)$/.exec(String(e.id ?? ""))?.[1];
236
+ if (!n || typeof e.title !== "string") continue;
237
+ const t = { id: `HQ-${n}`, title: e.title, status: e.status, words: words(e.title) };
238
+ byNumber.set(n, t);
239
+ for (let i = 0; i + SPAN_WORDS <= t.words.length; i++) {
240
+ const g = t.words.slice(i, i + SPAN_WORDS).join(" ");
241
+ if (!seeds.has(g)) seeds.set(g, []);
242
+ const list = seeds.get(g);
243
+ if (!list.some((h) => h.t === t)) list.push({ t, pos: i });
244
+ }
245
+ }
246
+ for (const [g, list] of [...seeds]) if (list.length > SEED_MAX_TICKETS) seeds.delete(g);
247
+ return { byNumber, seeds, open: byNumber.size };
248
+ }
249
+
250
+ // The open tickets the words reference, numbers first in the order said, then longest run first.
251
+ export function ticketsIn(board, text) {
252
+ if (!board || !text) return [];
253
+ const out = [];
254
+ const seen = new Set();
255
+ for (const m of String(text).matchAll(/(?<![\p{L}\p{N}])hq[-\s]?(\d+)(?![\p{L}\p{N}])/giu)) {
256
+ const t = board.byNumber.get(String(Number(m[1])));
257
+ if (!t || seen.has(t)) continue;
258
+ seen.add(t);
259
+ out.push({ id: t.id, title: t.title, status: t.status, named: t.id });
260
+ }
261
+ const w = words(text);
262
+ const best = new Map(); // ticket -> { len, span }
263
+ for (let i = 0; i + SPAN_WORDS <= w.length; i++) {
264
+ const hits = board.seeds.get(w.slice(i, i + SPAN_WORDS).join(" "));
265
+ if (!hits) continue;
266
+ for (const { t, pos } of hits) {
267
+ let k = SPAN_WORDS;
268
+ while (i + k < w.length && pos + k < t.words.length && w[i + k] === t.words[pos + k]) k++;
269
+ const span = t.words.slice(pos, pos + k);
270
+ if (span.filter(isContent).length < SPAN_CONTENT) continue;
271
+ if ((best.get(t)?.len ?? 0) < k) best.set(t, { len: k, span: span.join(" ") });
272
+ }
273
+ }
274
+ const runs = [...best.entries()].filter(([t]) => !seen.has(t)).sort((a, b) => b[1].len - a[1].len || b[1].span.length - a[1].span.length || (a[0].id < b[0].id ? -1 : 1));
275
+ for (const [t, r] of runs) out.push({ id: t.id, title: t.title, status: t.status, named: r.span });
276
+ return out.slice(0, TICKETS_MAX);
277
+ }
278
+
279
+ function withTimeout(promise, ms) {
280
+ let timer;
281
+ return Promise.race([promise, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("timeout")), ms); timer.unref?.(); })]).finally(() => clearTimeout(timer));
282
+ }
283
+
284
+ // Read the board after the current request. A failure keeps what was cached and retries after the TTL.
285
+ function loadBoard(evda, sources, now) {
286
+ const hit = boardCache.get(evda.dir) ?? { at: 0, board: null };
287
+ if (hit.loading) return;
288
+ hit.loading = true;
289
+ boardCache.set(evda.dir, hit);
290
+ const env = process.env;
291
+ const envFile = env.EVDA_WORK_ENV_FILE || null;
292
+ const t = setTimeout(() => {
293
+ withTimeout((async () => {
294
+ const efforts = [];
295
+ let read = false;
296
+ for (const s of sources) {
297
+ const r = await harlequinEfforts(evda.dir, { project: s.project, env, envFile });
298
+ if (r.status === "checked") { read = true; efforts.push(...r.efforts); }
299
+ }
300
+ return read ? ticketIndex(efforts) : null;
301
+ })(), BOARD_TIMEOUT_MS)
302
+ .then((board) => { boardCache.set(evda.dir, { at: now, board: board ?? hit.board }); })
303
+ .catch(() => { boardCache.set(evda.dir, { at: now, board: hit.board }); });
304
+ }, 0);
305
+ t.unref?.();
306
+ }
307
+
308
+ // The board's open tickets for a record whose work is a board, from the cache; null otherwise or
309
+ // before the first read lands. A missing or stale cache starts a read after the answer.
310
+ export function boardTickets(evda, { now = Date.now(), ttl = INDEX_TTL_MS } = {}) {
311
+ const sources = boardSources(evda);
312
+ if (!sources.length) return null;
313
+ const hit = boardCache.get(evda.dir);
314
+ if (!hit || (!hit.loading && now - hit.at >= ttl)) loadBoard(evda, sources, now);
315
+ return hit?.board ?? null;
316
+ }
317
+
318
+ // Whether `name` occurs in `text` (both lowercased) as whole words.
319
+ function occurs(text, name) {
320
+ let i = text.indexOf(name);
321
+ while (i !== -1) {
322
+ const before = i === 0 ? "" : text[i - 1];
323
+ const after = text[i + name.length] ?? "";
324
+ if (!LETTER.test(before) && !LETTER.test(after)) return true;
325
+ i = text.indexOf(name, i + 1);
326
+ }
327
+ return false;
328
+ }
329
+
330
+ const titleOf = (c) => c.manifest?.title ?? c.name;
331
+
332
+ // The shared circles one path reaches, as sentences naming only the shared circle and the path.
333
+ function circlesFor(evda, ctx, rel, front, index) {
334
+ const abs = join(evda.dir, rel);
335
+ const out = [];
336
+ const homes = homesOf(evda, ctx.circles, abs);
337
+ const home = homes.length === 1 ? ctx.circles.find((c) => c.name === homes[0]) : null;
338
+ if (home?.manifest?.public === true) out.push(`${rel} is in the ${titleOf(home)} circle, which anyone can read.`);
339
+ const held = heldBy(front, "publish");
340
+ const sealedOrNoted = held === SEALED || held === NOTED;
341
+ if (home && home.manifest?.public !== true && !sealedOrNoted) {
342
+ for (const c of ctx.circles) {
343
+ if (c === home) continue;
344
+ const rules = Object.values(c.members).filter((m) => m.kind === "rule");
345
+ if (!front || !rules.some((m) => matchesWhere(front, m.where))) continue;
346
+ if (Array.isArray(home.release) && home.release.includes(c.name)) out.push(`${rel} is in the ${titleOf(c)} circle by its share mark.`);
347
+ }
348
+ // A home that covers the whole record is the private default; saying it may release everything
349
+ // would be said of every filing and tell the agent nothing.
350
+ const wholeRecord = Object.values(home.members).some((m) => m.kind === "path" && m.rel === "");
351
+ if (!wholeRecord && Array.isArray(home.release)) {
352
+ const targets = home.release.map((n) => ctx.circles.find((c) => c.name === n)).filter(Boolean).filter((c) => !out.some((l) => l.includes(`the ${titleOf(c)} circle by its share mark`)));
353
+ if (targets.length) out.push(`${rel} may be released to the ${targets.map(titleOf).join(", ")} circle${targets.length > 1 ? "s" : ""}; it is not there now.`);
354
+ }
355
+ }
356
+ for (const d of index.derived) {
357
+ const src = d.from.find((s) => s === rel || rel.startsWith(`${s}/`));
358
+ if (!src || d.rel === rel) continue;
359
+ const dFront = index.fronts.get(d.rel);
360
+ const dHeld = heldBy(dFront, "publish");
361
+ if (dHeld === SEALED || dHeld === NOTED) continue;
362
+ const gatherers = ctx.circles.filter((c) => {
363
+ const dHomes = homesOf(evda, ctx.circles, join(evda.dir, d.rel));
364
+ if (dHomes.length === 1 && dHomes[0] === c.name && c.manifest?.public === true) return true;
365
+ return Object.values(c.members).some((m) => m.kind === "rule" && matchesWhere(dFront ?? {}, m.where));
366
+ });
367
+ for (const c of gatherers) out.push(`the ${titleOf(c)} circle's entry ${d.rel} (view ${d.view}) is derived from ${src}${src === rel ? "" : `, which holds ${rel}`}.`);
368
+ }
369
+ return out;
370
+ }
371
+
372
+ // What the filing at `paths` (relative to the record) with `words` bears on. Returns
373
+ // { related: [{ path, name }], circles: [sentence], tickets: [{ id, title, status, named }], ms }. Never throws: a filing is never failed by
374
+ // what it would have been told.
375
+ export function touches(evda, { paths = [], words = "", caller = callerFrom(), now = Date.now() } = {}) {
376
+ const t0 = performance.now();
377
+ try {
378
+ const index = touchesIndex(evda, { now });
379
+ const ctx = readContext(evda);
380
+ const written = new Set(paths.filter(Boolean));
381
+ const text = norm(words);
382
+ const related = [];
383
+ const seen = new Set();
384
+ if (text) {
385
+ for (const [name, rels] of index.names) {
386
+ if (related.length >= RELATED_MAX) break;
387
+ if (!occurs(text, name)) continue;
388
+ // One entry per name: the shallowest, then the shortest path, the readable one nearest the
389
+ // top of the record standing for the subject.
390
+ const depth = (r) => r.split("/").length;
391
+ for (const rel of [...rels].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1))) {
392
+ if (written.has(rel) || seen.has(rel)) continue;
393
+ const abs = join(evda.dir, rel);
394
+ if (!existsSync(abs)) continue;
395
+ if (!mayRead(ctx, abs, caller).ok) continue;
396
+ seen.add(rel);
397
+ related.push({ path: rel, name });
398
+ break;
399
+ }
400
+ }
401
+ }
402
+ const circles = [];
403
+ for (const rel of [...written, ...related.map((r) => r.path)]) {
404
+ const abs = join(evda.dir, rel);
405
+ // The path just written is read fresh: its share mark may be what this filing added.
406
+ const front = written.has(rel) ? frontOfHead(abs) : index.fronts.get(rel) ?? null;
407
+ for (const line of circlesFor(evda, ctx, rel, front, index)) if (!circles.includes(line)) circles.push(line);
408
+ }
409
+ let tickets = [];
410
+ try { tickets = ticketsIn(boardTickets(evda, { now }), words); } catch { tickets = []; }
411
+ return { related, circles, tickets, ms: performance.now() - t0 };
412
+ } catch {
413
+ return { related: [], circles: [], tickets: [], ms: performance.now() - t0 };
414
+ }
415
+ }
416
+
417
+ // The block a write tool's answer carries, or "" when there is nothing to say. Information for the
418
+ // agent, never an instruction.
419
+ export function touchesText(t) {
420
+ const tickets = t?.tickets ?? [];
421
+ if (!t || (!t.related.length && !t.circles.length && !tickets.length)) return "";
422
+ const lines = [];
423
+ if (t.related.length) lines.push(relatedLine(t.related));
424
+ if (tickets.length) lines.push(ticketsLine(tickets));
425
+ if (t.circles.length) lines.push(`Shared circles this reaches: ${t.circles.join(" ")}`);
426
+ return lines.join("\n");
427
+ }
@@ -16,6 +16,9 @@
16
16
 
17
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
18
18
  import { join, dirname } from "node:path";
19
+ import { openEvda } from "./manifest.mjs";
20
+ import { agentsFile } from "./roles.mjs";
21
+ import { splitFence } from "./agents.mjs";
19
22
 
20
23
  export const THROTTLE_MS = 60 * 60 * 1000; // one hour, per the plan doc
21
24
  const REGISTRY_URL = "https://registry.npmjs.org/create-evda/latest";
@@ -87,6 +90,12 @@ function higherVersion(a, b) {
87
90
  return a;
88
91
  }
89
92
 
93
+ // True when version `a` is strictly newer than `b`, comparing components as numbers (0.10.0 > 0.9.0).
94
+ // `evda update` uses it to refuse taking an update with a tool older than the version it would take.
95
+ export function isNewer(a, b) {
96
+ return a !== b && higherVersion(a, b) === a;
97
+ }
98
+
90
99
  // The one entry point. `now` is injected so a caller (or a test) can control throttling without
91
100
  // waiting an hour; callers outside a test pass Date.now().
92
101
  //
@@ -96,7 +105,10 @@ function higherVersion(a, b) {
96
105
  // { status: "UP_TO_DATE", installed, latest }
97
106
  // { status: "UPDATE_AVAILABLE", installed, latest }
98
107
  // { status: "UNSTAMPED", latest } -- this record predates package-version stamping
108
+ // { status: "OWNER_KEPT", agents } -- the agents file has no installer block, so nothing to take
99
109
  export async function checkForUpdate(dir, { now = Date.now(), force = false } = {}) {
110
+ const kept = ownerKept(dir);
111
+ if (kept) return { status: "OWNER_KEPT", agents: kept };
100
112
  const sf = stateFile(dir);
101
113
  const state = readJsonSafe(sf);
102
114
 
@@ -127,6 +139,22 @@ export async function checkForUpdate(dir, { now = Date.now(), force = false } =
127
139
  : { status: "UP_TO_DATE", installed, latest: fetched.version };
128
140
  }
129
141
 
142
+ // HQ-2333: what `evda upgrade` replaces is the evda:generated block in the agents file. A record
143
+ // whose agents file has none (Mike's maps it to CLAUDE.md and builds its core from the spec itself)
144
+ // has nothing an update can change, and upgrade refuses it. On 10/7 the check told him to run
145
+ // upgrade anyway, inside a Claude app reply, so a record like that now reports so and asks nothing.
146
+ // Returns the agents file's path when it has no block, else null (a block, no agents role, or a
147
+ // directory that is not a record all go on to the registry as before).
148
+ export function ownerKept(dir) {
149
+ try {
150
+ const a = agentsFile(openEvda(dir));
151
+ if (!a) return null;
152
+ return splitFence(readFileSync(a.abs, "utf8")) ? null : a.rel;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
130
158
  // One line, for the preamble and for `evda check-update`'s own stdout. Never throws.
131
159
  export function summarize(result) {
132
160
  switch (result.status) {
@@ -136,6 +164,8 @@ export function summarize(result) {
136
164
  return `update check: CHECK_FAILED, update status UNKNOWN (${result.reason})`;
137
165
  case "UNSTAMPED":
138
166
  return `update check: this record predates version stamping; latest is ${result.latest}. Run "evda upgrade" once to start tracking it.`;
167
+ case "OWNER_KEPT":
168
+ return `update check: nothing to take; ${result.agents} has no installer block, so its owner keeps it, not evda upgrade`;
139
169
  case "UP_TO_DATE":
140
170
  return `update check: up to date (${result.installed})`;
141
171
  case "UPDATE_AVAILABLE":
package/lib/watch.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // Watching a record for saves (HQ-2068), so the computer sends a saved note in seconds instead of
2
+ // at the next five-minute sync.
3
+ //
4
+ // macOS reports every change under a folder through FSEvents, which Node's recursive `fs.watch`
5
+ // uses on darwin. This turns those reports into bursts: a run of saves close together is one burst,
6
+ // said once it has been quiet for `quietMs`, or once it has run `maxWaitMs`, so an editor that
7
+ // saves every keystroke still gets a sync every few seconds. What the scan would never send starts
8
+ // nothing: the sync rules' exclusions (`.evda`, `.git`, `node_modules`, `.DS_Store`, an Office owner
9
+ // file, the plan's own paths), an iCloud placeholder, and a dataless file iCloud has not brought
10
+ // down (HQ-2017), or a folder (what changed inside it is reported on its own). A path that is gone
11
+ // counts, since its removal is news.
12
+ //
13
+ // The watcher decides only WHEN a sync runs. The sync it starts is the ordinary bounded one, with
14
+ // its own lock and guards (HQ-2041); the five-minute sync stays as the backstop for anything a
15
+ // watcher misses (a change while the app was closed, an FSEvents drop).
16
+
17
+ import { lstatSync, watch } from "node:fs";
18
+ import { join, sep } from "node:path";
19
+ import { datalessAmong, excluder, isPlaceholder } from "./sync-rules.mjs";
20
+
21
+ export const WATCH_QUIET_MS = 1_000;
22
+ export const WATCH_MAX_WAIT_MS = 5_000;
23
+
24
+ /**
25
+ * Whether a change at `rel` (relative to `root`) is one a sync would act on: not excluded, not an
26
+ * iCloud placeholder, not a link or a folder, not a dataless file. A path that no longer exists counts.
27
+ */
28
+ export function watchCounts(root, rel, { exclude = excluder(), env = process.env } = {}) {
29
+ const r = String(rel).split(sep).join("/").normalize("NFC");
30
+ if (r === "" || exclude(r)) return false;
31
+ if (isPlaceholder(r.slice(r.lastIndexOf("/") + 1))) return false;
32
+ let st;
33
+ try {
34
+ st = lstatSync(join(root, r));
35
+ } catch {
36
+ return true;
37
+ }
38
+ if (st.isSymbolicLink() || st.isDirectory()) return false;
39
+ if (st.isFile() && st.size > 0 && st.blocks === 0 && datalessAmong(root, [r], { env }).has(r)) return false;
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * Watch `root` and call `onBurst(paths)` once per burst of counted changes, with the paths in it.
45
+ * Returns `{ close() }`. `watchImpl` replaces `fs.watch` for a test that feeds it events.
46
+ */
47
+ export function watchRecord(root, onBurst, { quietMs = WATCH_QUIET_MS, maxWaitMs = WATCH_MAX_WAIT_MS, exclude = excluder(), env = process.env, watchImpl = watch, now = () => Date.now() } = {}) {
48
+ let pending = new Set();
49
+ let first = 0;
50
+ let timer = null;
51
+ const fire = () => {
52
+ timer = null;
53
+ if (pending.size === 0) return;
54
+ const paths = [...pending];
55
+ pending = new Set();
56
+ first = 0;
57
+ onBurst(paths);
58
+ };
59
+ const watcher = watchImpl(root, { recursive: true, persistent: true }, (_event, name) => {
60
+ if (name == null) return;
61
+ const rel = String(name);
62
+ if (!watchCounts(root, rel, { exclude, env })) return;
63
+ pending.add(rel.split(sep).join("/").normalize("NFC"));
64
+ const t = now();
65
+ if (first === 0) first = t;
66
+ if (timer) clearTimeout(timer);
67
+ timer = setTimeout(fire, Math.max(0, Math.min(quietMs, first + maxWaitMs - t)));
68
+ });
69
+ return {
70
+ close() {
71
+ if (timer) clearTimeout(timer);
72
+ timer = null;
73
+ watcher.close();
74
+ },
75
+ };
76
+ }
77
+
78
+ // ─── A device's note, taken in within seconds (HQ-2068) ─────────────────────
79
+ //
80
+ // The file watcher sees only saves on this computer, so a note a phone sent waited for the
81
+ // five-minute sync. This looks at the relay instead: every `everyMs` it asks each mailbox this
82
+ // computer reads for its first waiting envelope, takes nothing and acknowledges nothing, and says
83
+ // so once per envelope it has not seen at the head of that mailbox. The sync it starts drains the
84
+ // box; an envelope the sync leaves where it is stays the head and starts nothing a second time.
85
+
86
+ export const RELAY_PEEK_MS = 2_000;
87
+ // The mailboxes are re-derived this often, so a device paired while the app was open is looked at too.
88
+ export const RELAY_REFRESH_MS = 60_000;
89
+
90
+ /**
91
+ * The seq of the first envelope waiting in one mailbox, or null when it is empty. Reads only:
92
+ * the relay's GET takes nothing off the box. Throws when the relay cannot be reached.
93
+ */
94
+ export async function mailboxHead(box, { fetchImpl = fetch, timeoutMs = 10_000 } = {}) {
95
+ const res = await fetchImpl(`${box.relayUrl}/mailbox/${encodeURIComponent(box.address)}?limit=1`, {
96
+ headers: { authorization: `Bearer ${box.pollCredential}` },
97
+ signal: AbortSignal.timeout(timeoutMs),
98
+ });
99
+ if (!res.ok) throw new Error(`relay answered ${res.status}`);
100
+ const body = await res.json();
101
+ const first = (body.envelopes ?? [])[0];
102
+ if (!first) return null;
103
+ return String(first.seq ?? first.id);
104
+ }
105
+
106
+ /**
107
+ * Look at the relay every `everyMs` and call `onIncoming()` once per new envelope at the head of
108
+ * any mailbox. `mailboxes()` gives the boxes (null when it cannot look now, tried again next time).
109
+ * A relay that cannot be reached is skipped until the next look; the five-minute sync covers it.
110
+ * Returns `{ close(), look() }`; `look` runs one look now, for a test.
111
+ */
112
+ export function watchRelay(mailboxes, onIncoming, { everyMs = RELAY_PEEK_MS, refreshMs = RELAY_REFRESH_MS, head = mailboxHead, now = () => Date.now() } = {}) {
113
+ let boxes = null;
114
+ let derivedAt = 0;
115
+ const seen = new Map();
116
+ let running = null;
117
+ let closed = false;
118
+ const once = async () => {
119
+ if (boxes === null || now() - derivedAt >= refreshMs) {
120
+ let next = null;
121
+ try {
122
+ next = await mailboxes();
123
+ } catch {
124
+ next = null;
125
+ }
126
+ if (next !== null) {
127
+ boxes = next;
128
+ derivedAt = now();
129
+ }
130
+ }
131
+ if (!boxes) return false;
132
+ let fresh = false;
133
+ for (const box of boxes) {
134
+ let h;
135
+ try {
136
+ h = await head(box);
137
+ } catch {
138
+ continue;
139
+ }
140
+ const prev = seen.get(box.address);
141
+ seen.set(box.address, h);
142
+ if (h !== null && h !== prev) fresh = true;
143
+ }
144
+ if (fresh && !closed) onIncoming();
145
+ return fresh;
146
+ };
147
+ const look = () => {
148
+ if (!running) running = once().finally(() => { running = null; });
149
+ return running;
150
+ };
151
+ const timer = setInterval(look, everyMs);
152
+ timer.unref?.();
153
+ return {
154
+ look,
155
+ close() {
156
+ closed = true;
157
+ clearInterval(timer);
158
+ },
159
+ };
160
+ }