create-evda 0.1.9 → 0.1.11

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 (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
package/lib/gather.mjs CHANGED
@@ -27,9 +27,19 @@
27
27
  // and placed nowhere, and a person who already has a page there is not asked about. So a second
28
28
  // gather asks only about what is new. Applying the proposal is lib/gather-apply.mjs.
29
29
  //
30
+ // Byte-identical files are placed once and every other copy is named as a duplicate of that one; an
31
+ // item nobody placed is left in the pile, where it is, and the summary says so (HQ-1368).
32
+ //
30
33
  // A placed `.txt` note is named `.md` on the way in, its bytes unchanged: plain text is already
31
- // markdown, and every reader in EVDA (the occurrence index, doctor, search) reads `.md`. Nothing
32
- // else is renamed; an `.html` export keeps its name, because making it markdown would change it.
34
+ // markdown, and every reader in EVDA (the occurrence index, doctor, search) reads `.md`. A note-shaped
35
+ // `.html` file (Google Keep's Takeout, an Apple Notes export) is converted to markdown at apply and
36
+ // named `.md`, its title a heading, its checklist `- [x]` items and Keep's labels `tags:`; the
37
+ // proposal marks it `convert: "html-to-markdown"` so the owner sees the change before saying yes
38
+ // (lib/html-notes.mjs). Any other `.html` (a saved web page) keeps its name and its bytes.
39
+ //
40
+ // Google Takeout's `.json` sidecars are paired with the photo or note each describes and never placed
41
+ // themselves: a photo's date taken and caption, a Keep note's created and edited times, are folded into
42
+ // that file's entry (lib/takeout.mjs), and the sidecar is named in the summary with the file it describes.
33
43
  //
34
44
  // A chat export (HQ-1385: Claude.ai's or ChatGPT's conversations.json) is not a file to place. It is
35
45
  // the backstop for what an agent did not file while the conversation happened: the owner's own
@@ -53,13 +63,16 @@ import { askOwnersAi } from "./organize.mjs";
53
63
  import { onlyKeys, refuse, dayFile } from "./tools.mjs";
54
64
  import { readChatExport, conversations, filedSections, backfillPlan, CHAT_FORMATS } from "./chat-export.mjs";
55
65
  import { EvdaError } from "./errors.mjs";
66
+ import { noteShape, htmlNoteToMarkdown } from "./html-notes.mjs";
67
+ import { pairSidecars } from "./takeout.mjs";
56
68
  import { frontmatterOf, heldBy, withoutSealed, carries, NOTED } from "./circles.mjs";
69
+ import { outsideRecord } from "./record-walk.mjs";
57
70
 
58
71
  export const GATHER_VERSION = 1;
59
72
  // The person-layer kinds an item is typed with, and the folder type each lives under.
60
73
  export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
61
74
  export const FOLDER_TYPE_FOR = { note: "record", person: "person", project: "project", event: "event", log: "log", source: "source", piece: "piece", blob: "blob", capture: "capture" };
62
- export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role"];
75
+ export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role", "keep-junk", "container-subjects", "place-duplicates", "html-as-is"];
63
76
  // The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
64
77
  // what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
65
78
  export const ROLE_FOR_KIND = { note: "journal", capture: "journal", log: "journal", person: "collections", project: "collections", event: "collections", piece: "collections", blob: "collections", course: "collections", source: "knowledge", areas: "collections" };
@@ -102,12 +115,37 @@ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
102
115
 
103
116
  const SKIP_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "node_modules", ".git", ".obsidian", ".trash", ".Trash", "__MACOSX"]);
104
117
  const APP_DIRS = { ".obsidian": "obsidian" };
118
+ // App-state junk a real pile carries (HQ-1368, the 2026-09-27 trial placed every one of these): what
119
+ // an app wrote for itself, not something the owner kept. Each is skipped, with why, and named in the
120
+ // summary. `junkWhy(name, size, isDir)` is the reason, or null for a file the owner may want.
121
+ export const JUNK_DIRS = new Set(["recently deleted"]);
122
+ export const JUNK_FILES = new Set(["archive_browser.html"]);
123
+ export function junkWhy(name, size, isDir = false) {
124
+ if (falsified("keep-junk")) return null;
125
+ const lower = name.toLowerCase();
126
+ if (isDir) return JUNK_DIRS.has(lower) ? "what the app had already deleted" : null;
127
+ if (name.startsWith("~$")) return "an Office lock file, written while a document was open";
128
+ if (JUNK_FILES.has(lower)) return "Google Takeout's index of the export, not the owner's";
129
+ if (size === 0) return "an empty file (zero bytes)";
130
+ return null;
131
+ }
105
132
  const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
106
133
  const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".heic", ".webp", ".pdf", ".mov", ".mp4", ".m4a", ".mp3", ".wav", ".xlsx", ".xls", ".docx", ".doc", ".pptx", ".key", ".pages", ".numbers", ".sqlite", ".db"]);
107
134
  const TEXT_MAX = 2 * 1024 * 1024;
108
135
  const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
109
136
  // Folder names that hold things without saying what they are about, so they are not a subject.
110
137
  const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
138
+ // The folders an export or an app names for itself (HQ-1368, the 2026-09-27 trial read Keep's `Keep/`
139
+ // as a subject and cited "My Contacts" and "Photos from 2025" in the preset's reason): they say which
140
+ // app the files came out of, never what they are about, so none is ever a subject, at any depth.
141
+ export const EXPORT_CONTAINERS = new Set(["takeout", "google takeout", "keep", "google keep", "drive", "my drive", "google drive", "shared with me", "google photos", "my contacts", "contacts", "apple notes", "icloud", "onedrive", "dropbox", "evernote", "notion", "exports", "backup", "backups"]);
142
+ const EXPORT_PATTERNS = [/^photos from \d{4}$/i, /^takeout[-_]\d/i];
143
+ export function isContainer(name) {
144
+ const lower = name.toLowerCase();
145
+ if (CONTAINERS.has(lower)) return true;
146
+ if (falsified("container-subjects")) return false;
147
+ return EXPORT_CONTAINERS.has(lower) || EXPORT_PATTERNS.some((re) => re.test(name));
148
+ }
111
149
  const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
112
150
  const COURSE_WORD = /\b(syllabus|lecture|homework|problem set|midterm)\b/i;
113
151
  const THERAPY = /\b(therapy|therapist|counsell?or|counselling|counseling|psychiatrist|psychologist|CBT|EMDR)\b/i;
@@ -182,7 +220,7 @@ const slugId = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-
182
220
  // added to its frontmatter, a finished status) no longer hashes like its source; this is how a
183
221
  // second gather still knows it.
184
222
  export const GATHERED = ".evda/gathered.jsonl";
185
- const WALK_SKIP = new Set([".git", "node_modules", ".evda", ".obsidian", ".trash", ".DS_Store"]);
223
+ const WALK_SKIP = new Set([".git", ".evda", ".obsidian", ".trash", ".DS_Store"]); // plus outsideRecord
186
224
 
187
225
  // Every file in the record by the hash of its bytes, and the source hashes it has gathered.
188
226
  // Returns { byHash: Map<sha256, rel>, gathered: Map<sha256, rel> }.
@@ -192,7 +230,7 @@ export function presentIndex(dir) {
192
230
  if (falsified("reapply-resorts")) return { byHash, gathered };
193
231
  const go = (d) => {
194
232
  for (const name of readdirSync(d).sort()) {
195
- if (WALK_SKIP.has(name)) continue;
233
+ if (WALK_SKIP.has(name) || outsideRecord(name)) continue;
196
234
  const abs = join(d, name);
197
235
  const st = lstatSync(abs);
198
236
  if (st.isDirectory()) go(abs);
@@ -240,9 +278,11 @@ function walk(root) {
240
278
  const abs = join(dir, name);
241
279
  const rel = posix(relative(root, abs));
242
280
  if (APP_DIRS[name]) apps.add(APP_DIRS[name]);
243
- if (SKIP_NAMES.has(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
281
+ if (SKIP_NAMES.has(name) || outsideRecord(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
244
282
  const s = lstatSync(abs);
245
283
  if (s.isSymbolicLink()) { skipped.push({ rel, why: "a symlink; not followed" }); continue; }
284
+ const junk = s.isDirectory() || s.isFile() ? junkWhy(name, s.size, s.isDirectory()) : null;
285
+ if (junk) { skipped.push({ rel: s.isDirectory() ? `${rel}/` : rel, why: junk }); continue; }
246
286
  if (s.isDirectory()) { go(abs); continue; }
247
287
  if (!s.isFile()) { skipped.push({ rel, why: "not a regular file" }); continue; }
248
288
  if (name.startsWith(".")) { skipped.push({ rel, why: "a hidden file" }); continue; }
@@ -259,7 +299,7 @@ function subjectOf(rel, projectRoot) {
259
299
  const parts = rel.split("/").slice(0, -1);
260
300
  for (let i = parts.length - 1; i >= 0; i--) {
261
301
  const p = parts[i];
262
- if (CONTAINERS.has(p.toLowerCase())) continue;
302
+ if (isContainer(p)) continue;
263
303
  if (/^(people|contacts|meetings|projects|drafts|writing|essays|clippings|reading|articles|bookmarks|logs|calendar|photos|attachments)$/i.test(p)) continue;
264
304
  return p;
265
305
  }
@@ -349,7 +389,13 @@ export function scan(sources, { now = new Date() } = {}) {
349
389
  const readmeDirs = new Set(w.files.filter((f) => /^readme\.(md|txt)$/i.test(basename(f.rel)) && f.rel.includes("/")).map((f) => dirname(f.rel)).filter((d) => w.files.filter((f) => dirname(f.rel) === d).length >= 2));
350
390
  out.sources.push({ id: `s${si}`, path: root, files: w.files.length, skipped: w.skipped.length, apps: w.apps });
351
391
  for (const s of w.skipped) out.skipped.push({ source: `s${si}`, ...s });
392
+ // Google Takeout's sidecars (HQ-1368, lib/takeout.mjs): each is paired with the photo or note it
393
+ // describes, its date and caption folded into that file's entry, and is never an entry of its own.
394
+ const side = pairSidecars(w.files, (abs) => readFileSync(abs));
395
+ const sidecarAt = new Map(side.sidecars.map((x) => [x.rel, x]));
396
+ for (const x of side.sidecars) out.skipped.push({ source: `s${si}`, ...x });
352
397
  for (const f of w.files) {
398
+ if (sidecarAt.has(f.rel)) continue;
353
399
  const buf = readFileSync(f.abs);
354
400
  const ext = extname(f.rel).toLowerCase();
355
401
  const chat = ext === ".json" ? readChatExport(buf) : null;
@@ -364,12 +410,22 @@ export function scan(sources, { now = new Date() } = {}) {
364
410
  // section is cut before any file is read for its heading, people or words. Its bytes and hash
365
411
  // are unchanged, so apply copies it with its flag.
366
412
  const seal = sealedOf(ext, buf);
367
- const facts = typeItem({ rel: f.rel, buf: seal.buf, ext, projectRoot });
413
+ // A note-shaped HTML file is read as the markdown it will become (HQ-1368), so it is typed,
414
+ // searched for people and checked for a credential by its words, not its tags.
415
+ const shape = (ext === ".html" || ext === ".htm") && !falsified("html-as-is") ? noteShape(buf.toString("utf8")) : null;
416
+ const conv = shape ? htmlNoteToMarkdown(buf.toString("utf8")) : null;
417
+ if (conv?.empty) { out.skipped.push({ source: `s${si}`, rel: f.rel, why: "an empty note: no title and no body" }); continue; }
418
+ if (conv) seal.buf = Buffer.from(conv.markdown, "utf8");
419
+ const facts = typeItem({ rel: f.rel, buf: seal.buf, ext: conv ? ".md" : ext, projectRoot });
420
+ if (conv) facts.html = shape;
368
421
  if (seal.sealed) facts.sealed = true;
369
422
  if (seal.noted) facts.noted = true;
370
423
  const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
371
424
  if (projectRoot) item.project = projectRoot;
425
+ if (side.folded.has(f.rel)) item.takeout = side.folded.get(f.rel);
372
426
  item.subject = subjectOf(f.rel, projectRoot);
427
+ // A Keep note sits in Takeout/Keep, which says nothing of what it is about; its first label does.
428
+ if (!item.subject && !projectRoot && conv?.labels.length) item.subject = conv.labels[0];
373
429
  if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
374
430
  out.items.push(item);
375
431
  }
@@ -417,9 +473,11 @@ function findPeople(items) {
417
473
  for (const p of byName.values()) {
418
474
  const first = p.name.split(" ")[0];
419
475
  const re = new RegExp(`(^|[^\\p{L}])(${escape(p.name)}${firsts.get(first) === 1 && first.length >= 3 ? `|${escape(first)}` : ""})($|[^\\p{L}])`, "u");
476
+ // Identical copies of one file are one mention, so a duplicate does not count a person twice.
477
+ const seen = new Set();
420
478
  for (const i of items) {
421
- if (i.text === undefined || p.pages.includes(i.id)) continue;
422
- if (re.test(i.text) || re.test(i.path)) p.files.add(i.id);
479
+ if (i.text === undefined || p.pages.includes(i.id) || seen.has(i.sha256)) continue;
480
+ if (re.test(i.text) || re.test(i.path)) { p.files.add(i.id); seen.add(i.sha256); }
423
481
  }
424
482
  }
425
483
  return [...byName.values()].map((p) => ({ name: p.name, ref: p.cards[0] ?? null, page: p.pages[0] ?? null, files: [...p.files].sort() })).sort((a, b) => a.name.localeCompare(b.name));
@@ -484,8 +542,12 @@ function homes(preset) {
484
542
  };
485
543
  }
486
544
 
545
+ // A name a copy is given by the system that made it: "x (1)", "x copy", "x copy 2", "Copy of x".
546
+ const COPY_MARKER = /(?: \(\d+\)| copy(?: \d+)?|^copy of .+)$/i;
547
+
487
548
  // A placed text item's name: `.txt` becomes `.md`, bytes unchanged. Every other name is kept.
488
- const asMarkdown = (i, name) => (i.kind !== "blob" && /\.txt$/i.test(name) ? name.replace(/\.txt$/i, ".md") : name);
549
+ // A note-shaped `.html` becomes `.md` too, its bytes converted at apply (lib/html-notes.mjs).
550
+ const asMarkdown = (i, name) => (i.kind === "blob" ? name : /\.txt$/i.test(name) ? name.replace(/\.txt$/i, ".md") : i.html && /\.html?$/i.test(name) ? name.replace(/\.html?$/i, ".md") : name);
489
551
 
490
552
  // The placement of every item, given the facts and the answers. Pure.
491
553
  export function place(p) {
@@ -526,7 +588,7 @@ export function place(p) {
526
588
  // Only markdown carries `sealed: true`, so a credential in any other format is never copied.
527
589
  pl = { status: "refused", to: null, why: `${i.credential} and is not a note that can be sealed; left where it is and quoted nowhere. Keep the secret in a password manager.` };
528
590
  } else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
529
- else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
591
+ else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file, left in the pile; unpack it and gather the folder" };
530
592
  else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
531
593
  else if (i.kind === "project") {
532
594
  // Status is a field, never a move: a finished project stays in its collection and says so.
@@ -541,18 +603,44 @@ export function place(p) {
541
603
  const course = h.courses && i.subject && COURSE.test(i.subject);
542
604
  if (course) pl = { status: "placed", to: `${h.courses}/${i.subject}/${file}` };
543
605
  else if (i.subject) pl = { status: "placed", to: `${folderFor("areas", "record")}/${i.subject}/${file}` };
544
- else pl = { status: "unplaced", to: null, why: `no subject: it sits in no folder that says what it is about${h.captures ? `; ${h.captures}/ holds it until someone says` : ""}` };
606
+ else pl = { status: "unplaced", to: null, why: "no subject: it sits in no folder that says what it is about; left in the pile, where it is, until someone says where it goes" };
545
607
  } else if (i.mode === "reference") pl = { status: "reference", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${basename(i.path, extname(i.path))}.md`, refs: i.refs, why: "a new entry holding the reference; the file stays where it is" };
546
608
  else pl = { status: "placed", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${file}` };
547
- if (pl.to) pl.to = claim(pl.to, i.id);
548
- if (pl.to) pl.role = roleOf(pl.to);
549
609
  if (i.refs && pl.status === "placed") pl.refs = i.refs;
550
610
  if (i.kind === "person" && !i.cards) {
551
611
  const who = p.people.find((x) => x.page === i.id);
552
612
  if (who?.ref) pl.refs = [who.ref];
553
613
  }
614
+ if (i.html && pl.to && pl.status === "placed") pl.convert = "html-to-markdown";
554
615
  placements.push({ item: i.id, ...pl });
555
616
  }
617
+ // Byte-identical files are placed once (HQ-1368): the copy placed is one that has somewhere to go,
618
+ // then one whose name carries no copy marker ("ENERGY STAR (1).pdf", "Plan copy.txt"), then the
619
+ // shortest path, then the first walked; every other copy stays in the pile, named as a duplicate of
620
+ // that one. A project's own files are kept whole, so an identical file in two projects is in both.
621
+ if (!falsified("place-duplicates")) {
622
+ const items = new Map(p.items.map((i) => [i.id, i]));
623
+ const groups = new Map();
624
+ placements.forEach((pl, n) => {
625
+ const i = items.get(pl.item);
626
+ if (!["placed", "reference", "unplaced"].includes(pl.status) || i.kind === "project" || i.cards || i.credential) return;
627
+ groups.set(i.sha256, [...(groups.get(i.sha256) ?? []), n]);
628
+ });
629
+ const marked = (path) => COPY_MARKER.test(basename(path, extname(path)));
630
+ for (const g of groups.values()) {
631
+ if (g.length < 2) continue;
632
+ const rank = (n) => [placements[n].status === "unplaced" ? 1 : 0, marked(items.get(placements[n].item).path) ? 1 : 0, placements[n].item.length, n];
633
+ const order = (a, b) => { const x = rank(a), y = rank(b); const k = x.findIndex((v, j) => v !== y[j]); return k === -1 ? 0 : x[k] - y[k]; };
634
+ const keep = [...g].sort(order)[0];
635
+ const kept = placements[keep];
636
+ for (const n of g) if (n !== keep) placements[n] = { item: placements[n].item, status: "duplicate", to: null, of: kept.item, why: `the same bytes as ${kept.item}, which is ${kept.status === "unplaced" ? "also left in the pile" : "placed"}; this copy is left in the pile` };
637
+ }
638
+ }
639
+ // Names are claimed after the duplicates are out, so the copy kept never yields its name to one left behind.
640
+ for (const pl of placements) {
641
+ if (pl.to) pl.to = claim(pl.to, pl.item);
642
+ if (pl.to) pl.role = roleOf(pl.to);
643
+ }
556
644
  // Person pages the owner said yes to, made new, holding a Contacts reference when a card names them.
557
645
  for (const who of p.people) {
558
646
  if (ans[`person:${slugId(who.name)}`] !== "yes" || who.page) continue;
@@ -622,7 +710,7 @@ export function questions(p) {
622
710
  // ---- the proposal ----
623
711
 
624
712
  function counts(p) {
625
- const c = { items: p.items.length, placed: 0, reference: 0, create: 0, unplaced: 0, refused: 0, present: 0, skipped: p.skipped.length };
713
+ const c = { items: p.items.length, placed: 0, reference: 0, create: 0, unplaced: 0, refused: 0, present: 0, duplicate: 0, skipped: p.skipped.length, sidecars: p.skipped.filter((x) => x.sidecar).length };
626
714
  for (const pl of p.placements) c[pl.status] += 1;
627
715
  c.chats = (p.chats ?? []).length;
628
716
  c.chatMessages = (p.chats ?? []).reduce((n, x) => n + x.messages, 0);
@@ -782,11 +870,12 @@ export function checkedLines(p) {
782
870
  const chats = p.chats ?? [];
783
871
  return [
784
872
  ...(chats.length ? [`CHECKED ${c.chatMessages} message(s) of the owner's in ${chats.reduce((n, x) => n + x.conversations, 0)} conversation(s) from ${chats.length} chat export(s)${p.into ? ` ${chats.reduce((n, x) => n + (x.add ?? 0), 0)} not yet in the record` : ""}`] : []),
785
- `CHECKED ${c.items} items in ${p.sources.length} source(s) ${c.placed} placed, ${c.reference} by reference, ${c.create} to create, ${c.unplaced} left for a person, ${c.refused} refused, ${c.skipped} skipped${p.into ? `, ${c.present} already in the record` : ""}`,
873
+ `CHECKED ${c.items} items in ${p.sources.length} source(s) ${c.placed} placed, ${c.reference} by reference, ${c.create} to create, ${c.unplaced} left for a person, ${c.refused} refused, ${c.skipped} skipped${p.into ? `, ${c.present} already in the record` : ""}${c.duplicate ? `, ${c.duplicate} duplicate(s) left in the pile` : ""}${c.sidecars ? ` (${c.sidecars} of them Takeout sidecars, ${p.items.filter((i) => i.takeout).length} folded into their files)` : ""}`,
786
874
  c.questions ? `CHECKED ${c.questions} question(s) each changes where at least one item goes; ${c.open} open` : "SKIPPED questions: nothing in this pile needs the owner to decide a placement",
787
875
  ];
788
876
  }
789
877
 
878
+ const SKIPPED_SHOWN = 20;
790
879
  export function summaryText(p, { detail = false } = {}) {
791
880
  const lines = [`A proposal for ${p.into ? `growing the EVDA at ${p.into} from` : "turning"} ${p.sources.map((s) => s.path).join(", ")}${p.into ? "" : " into an EVDA"}. Nothing was moved, copied or changed.`, ""];
792
881
  lines.push(`Preset: ${p.preset.title} (${p.preset.id}), because ${p.preset.why.join("; ")}.`, "", "Folders:");
@@ -794,12 +883,24 @@ export function summaryText(p, { detail = false } = {}) {
794
883
  const byStatus = (s) => p.placements.filter((x) => x.status === s);
795
884
  if (byStatus("reference").length) { lines.push("", "Kept as references, never copied:"); for (const x of byStatus("reference")) lines.push(`- ${x.item}${x.to ? ` -> ${x.to}` : ""}: ${x.why}`); }
796
885
  if (byStatus("create").length) { lines.push("", "New pages:"); for (const x of byStatus("create")) lines.push(`- ${x.to}${x.refs.length ? ` (${x.refs.join(", ")})` : ""}`); }
797
- if (byStatus("unplaced").length) { lines.push("", "Left for a person:"); for (const x of byStatus("unplaced")) lines.push(`- ${x.item}: ${x.why}`); }
886
+ if (byStatus("unplaced").length) { lines.push("", "Left in the pile, where they are, for a person to place:"); for (const x of byStatus("unplaced")) lines.push(`- ${x.item}: ${x.why}`); }
887
+ if (byStatus("duplicate").length) { lines.push("", "Duplicates, left in the pile; one copy of each is placed:"); for (const x of byStatus("duplicate")) lines.push(`- ${x.item}: the same bytes as ${x.of}`); }
798
888
  if (byStatus("refused").length) { lines.push("", "Refused:"); for (const x of byStatus("refused")) lines.push(`- ${x.item}: ${x.why}`); }
889
+ if (p.skipped.length) {
890
+ // Named, so the owner sees what was left out and why (HQ-1368). A long list is cut unless --all.
891
+ const shown = detail ? p.skipped : p.skipped.slice(0, SKIPPED_SHOWN);
892
+ lines.push("", "Skipped, never placed:");
893
+ for (const x of shown) lines.push(`- ${x.rel}: ${x.why}`);
894
+ if (shown.length < p.skipped.length) lines.push(`- and ${p.skipped.length - shown.length} more (show --all lists them)`);
895
+ }
799
896
  if (byStatus("present").length) lines.push("", `Already in the record, left where they are: ${byStatus("present").length} item(s).`);
800
897
  for (const c of p.chats ?? []) lines.push("", `A ${CHAT_FORMATS[c.format]} export (${c.path}): ${c.messages} message(s) of the owner's in ${c.conversations} conversation(s)${c.add !== undefined ? `, ${c.add} of them not in the record yet, to be added under each conversation's date and time` : ", each added to its day unless the record already holds it"}. Only their messages; the model's replies are never written.`);
801
898
  if (p.circles.length) { lines.push("", "Circles:"); for (const c of p.circles) lines.push(`- ${c.name} over ${c.covers}/, ${c.status}`); }
802
- if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
899
+ const converted = byStatus("placed").filter((x) => x.convert === "html-to-markdown");
900
+ if (converted.length) { lines.push("", `Converted from HTML to markdown on the way in, so they read as notes: ${converted.length} note(s); the pile keeps its .html files.`); for (const x of converted.slice(0, 20)) lines.push(`- ${x.item} -> ${x.to}`); if (converted.length > 20) lines.push(`- and ${converted.length - 20} more`); }
901
+ const folded = p.items.filter((i) => i.takeout);
902
+ if (folded.length) { lines.push("", `Google Takeout's details folded in, so each sidecar is no entry of its own: ${folded.length} file(s); a photo's date taken becomes its file's date, a note's times its frontmatter. No place and no tagged person is read.`); for (const i of folded.slice(0, 20)) lines.push(`- ${i.id}${i.takeout.taken ? `, taken ${i.takeout.taken}` : ""}${i.takeout.created ? `, created ${i.takeout.created}` : ""}${i.takeout.caption ? ", with a caption" : ""} (from ${i.takeout.sidecar})`); if (folded.length > 20) lines.push(`- and ${folded.length - 20} more`); }
903
+ if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}${x.convert ? " (converted from HTML)" : ""}`); }
803
904
  const open = p.questions.filter((q) => q.answer === undefined);
804
905
  lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
805
906
  for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")}${q.default ? `, default ${q.default}` : ""})`);
@@ -0,0 +1,124 @@
1
+ // Note-shaped HTML to markdown (HQ-1368). Google Keep's Takeout export and Apple Notes' HTML export
2
+ // write each note as a small HTML file, and every reader in EVDA (search, the occurrence index,
3
+ // doctor) reads markdown, so a note left as HTML is invisible to all of them. A gather converts a
4
+ // note-shaped HTML file into a `.md` note at apply, and says so in the proposal before anyone says yes.
5
+ //
6
+ // Only note-shaped HTML is converted. A saved web page (scripts, stylesheet links, navigation, forms,
7
+ // a "saved from url" comment) is not a note the owner wrote: it keeps its `.html` name and its bytes.
8
+ //
9
+ // noteShape(html) "keep", "apple-notes", "html-note", or null when the file is not a note
10
+ // htmlNoteToMarkdown(html) { markdown, title, labels, empty }: the note as markdown, frontmatter
11
+ // `tags:` holding Keep's labels; `empty` when it has neither a title nor a body
12
+ //
13
+ // EVDA_GATHER_FALSIFY=html-as-is turns the conversion off (lib/gather.mjs), so the tests can show
14
+ // their checks fail when it is gone.
15
+
16
+ const NAMED = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", ndash: "-", mdash: "-", hellip: "...", lsquo: "'", rsquo: "'", ldquo: "\"", rdquo: "\"", bull: "*", copy: "(c)", reg: "(R)", trade: "(TM)" };
17
+ export function decodeEntities(s) {
18
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
19
+ if (e[0] === "#") {
20
+ const n = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
21
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m;
22
+ }
23
+ return NAMED[e.toLowerCase()] ?? m;
24
+ }).replace(/ /g, " ");
25
+ }
26
+
27
+ const KEEP = /<div\s+class="note\b[^"]*"/i;
28
+ const KEEP_PART = /class="(?:title|content|heading|chips|list(?:item)?)"/i;
29
+ // What a saved web page carries and a note never does.
30
+ const NOT_A_NOTE = /<!--\s*saved from url|<(?:nav|form|iframe|input|button|select|textarea|header|footer|aside|noscript|frameset|object|embed)\b|<link\b[^>]*rel=["']?stylesheet|<script\b|<meta\b[^>]*property=["']?og:/i;
31
+ // The tags a note's body is made of. Anything else and it is a page, not a note.
32
+ const NOTE_TAGS = new Set(["html", "head", "meta", "title", "style", "body", "div", "p", "br", "span", "font", "b", "strong", "i", "em", "u", "s", "strike", "del", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "img", "tt", "code", "pre", "blockquote", "table", "thead", "tbody", "tr", "td", "th", "hr", "sup", "sub", "center", "small", "big"]);
33
+ const NOTE_SIZED = 20000;
34
+
35
+ export function noteShape(html) {
36
+ const raw = String(html ?? "");
37
+ if (!/<(?:html|body|div|p|br|h1)\b/i.test(raw)) return null;
38
+ if (KEEP.test(raw) && KEEP_PART.test(raw)) return "keep";
39
+ if (NOT_A_NOTE.test(raw)) return null;
40
+ const withoutStyle = raw.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<!doctype[^>]*>/gi, "");
41
+ for (const m of withoutStyle.matchAll(/<\/?([a-z][a-z0-9]*)\b/gi)) if (!NOTE_TAGS.has(m[1].toLowerCase())) return null;
42
+ const text = decodeEntities(withoutStyle.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
43
+ if (text.length > NOTE_SIZED) return null;
44
+ if (/<meta\b[^>]*(?:name=["']?generator["']?[^>]*content=["'][^"']*notes|apple-notes)/i.test(raw) || /<div>[\s\S]*<\/div>/i.test(withoutStyle) && !/<p\b/i.test(withoutStyle)) return "apple-notes";
45
+ return "html-note";
46
+ }
47
+
48
+ // The element whose opening tag matches `open`, with everything up to its balanced closing tag.
49
+ function element(html, open) {
50
+ const m = open.exec(html);
51
+ if (!m) return null;
52
+ const tag = /^<([a-z0-9]+)/i.exec(m[0])[1].toLowerCase();
53
+ const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
54
+ re.lastIndex = m.index + m[0].length;
55
+ let depth = 1;
56
+ for (let t; (t = re.exec(html));) {
57
+ depth += t[1] ? -1 : 1;
58
+ if (depth === 0) return { inner: html.slice(m.index + m[0].length, t.index), start: m.index, end: re.lastIndex };
59
+ }
60
+ return { inner: html.slice(m.index + m[0].length), start: m.index, end: html.length };
61
+ }
62
+
63
+ // Inline tags to markdown. Entities are decoded once, at the end of a block pass, so a decoded "&lt;b&gt;"
64
+ // is never read again as a tag.
65
+ const tags = (s) => (s
66
+ .replace(/<br\s*\/?>/gi, "\n")
67
+ .replace(/<(b|strong)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi, (_, __, t) => (t.replace(/<[^>]+>/g, "").trim() ? `**${t.trim()}**` : t))
68
+ .replace(/<(i|em)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi, (_, __, t) => (t.replace(/<[^>]+>/g, "").trim() ? `*${t.trim()}*` : t))
69
+ .replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi, (_, href, t) => { const label = t.replace(/<[^>]+>/g, "").trim(); return label && label !== href ? `[${label}](${href})` : href; })
70
+ .replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi, (_, src) => (src.startsWith("data:") ? "" : `![](${src})`))
71
+ .replace(/<[^>]+>/g, ""));
72
+ const inline = (s) => decodeEntities(tags(s));
73
+
74
+ // A body of HTML to markdown lines: blocks become lines, list items bullets, Keep's checked items `- [x]`.
75
+ function blocks(html) {
76
+ let s = html
77
+ .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
78
+ .replace(/<!--[\s\S]*?-->/g, "")
79
+ .replace(/<head\b[\s\S]*?<\/head\s*>/gi, "");
80
+ s = s.replace(/<li\b([^>]*)>([\s\S]*?)<\/li\s*>/gi, (_, attrs, inner) => {
81
+ const keep = /class="[^"]*listitem/i.test(attrs);
82
+ const checked = keep && /class="[^"]*\bchecked\b/i.test(attrs);
83
+ const text = keep ? (/<span\s+class="text"[^>]*>([\s\S]*?)<\/span>/i.exec(inner)?.[1] ?? inner.replace(/<span\s+class="bullet"[^>]*>[\s\S]*?<\/span>/gi, "")) : inner;
84
+ const t = tags(text).replace(/\s*\n\s*/g, " ").trim();
85
+ return `\n${keep ? (checked ? "- [x] " : "- [ ] ") : "- "}${t}\n`;
86
+ });
87
+ s = s.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi, (_, n, t) => `\n${"#".repeat(Number(n))} ${tags(t).replace(/\s+/g, " ").trim()}\n\n`);
88
+ s = s.replace(/<hr\b[^>]*>/gi, "\n---\n");
89
+ s = s.replace(/<\/(div|p|tr|blockquote|pre|ul|ol|table)\s*>/gi, "\n").replace(/<(div|p|tr|blockquote|pre|ul|ol|table)\b[^>]*>/gi, "\n").replace(/<\/t[dh]\s*>/gi, " ");
90
+ const lines = inline(s).split("\n").map((l) => l.replace(/[ \t]+/g, " ").trim());
91
+ // A list is tight: no blank line between two items of it.
92
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^(- .*)\n\n(?=- )/gm, "$1\n").replace(/^(- .*)\n\n(?=- )/gm, "$1\n").trim();
93
+ }
94
+
95
+ const yamlString = (s) => JSON.stringify(s);
96
+
97
+ export function htmlNoteToMarkdown(html) {
98
+ const raw = String(html ?? "");
99
+ const shape = noteShape(raw);
100
+ let title = "";
101
+ let body = "";
102
+ let labels = [];
103
+ if (shape === "keep") {
104
+ const t = element(raw, /<div\s+class="title"[^>]*>/i);
105
+ title = t ? inline(t.inner).replace(/\s+/g, " ").trim() : "";
106
+ const c = element(raw, /<div\s+class="content"[^>]*>/i);
107
+ body = c ? blocks(c.inner) : "";
108
+ labels = [...raw.matchAll(/<span\s+class="label-name"[^>]*>([\s\S]*?)<\/span>/gi)].map((m) => inline(m[1]).trim()).filter(Boolean);
109
+ } else {
110
+ const b = element(raw, /<body\b[^>]*>/i);
111
+ body = blocks(b ? b.inner : raw);
112
+ const docTitle = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(raw);
113
+ const first = body.split("\n")[0] ?? "";
114
+ const h = /^#{1,6} (.+)$/.exec(first);
115
+ const bold = /^\*\*(.+)\*\*$/.exec(first);
116
+ if (h || bold) { title = (h ?? bold)[1].trim(); body = body.split("\n").slice(1).join("\n").trim(); }
117
+ else if (docTitle) title = inline(docTitle[1]).replace(/\s+/g, " ").trim();
118
+ if (title && body.split("\n")[0].trim() === title) body = body.split("\n").slice(1).join("\n").trim();
119
+ }
120
+ const empty = !title && !body;
121
+ const front = labels.length ? `---\ntags: [${labels.map(yamlString).join(", ")}]\n---\n` : "";
122
+ const markdown = empty ? "" : `${front}${title ? `# ${title}\n` : ""}${title && body ? "\n" : ""}${body ? `${body}\n` : ""}`;
123
+ return { markdown, title: title || null, labels, empty, shape };
124
+ }
package/lib/keep-hook.mjs CHANGED
@@ -29,14 +29,23 @@
29
29
  // anything. A block stores the event index it blocked at, so the next block needs a new stretch.
30
30
  // Any error of its own exits 0 without output: a broken hook must never trap a session.
31
31
  //
32
+ // NAMED ENTRIES (HQ-2799). When the day check lets the session stop, a second check runs: a tool
33
+ // result in the transcript named existing entries (lib/touches.mjs's line, parsed by day-file.mjs's
34
+ // namedIn), and nothing in the session wrote to any of them (a write tool or an evda_* call naming the
35
+ // entry's path, or the entry's file modified since it was first named). Then it blocks once per
36
+ // session, naming them and saying that adding nothing is fine. Not when the MCP server already asked
37
+ // in a tool result of this session. Open tickets a result named (HQ-2798's line) count the same way,
38
+ // answered by a board write naming the ticket's number. The transcript is the state; the state file only remembers the
39
+ // ask.
40
+ //
32
41
  // EVDA_RECORD_FALSIFY=no-block makes decide() never block, for the tests that show the block is
33
- // what the pass depends on.
42
+ // what the pass depends on; no-entries-ask turns off the named-entries check alone.
34
43
 
35
44
  import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
36
45
  import { dirname, join } from "node:path";
37
46
  import { tmpdir } from "node:os";
38
47
  import { fileURLToPath } from "node:url";
39
- import { dayRole, findRecord, filingOf, localDate } from "./day-file.mjs";
48
+ import { dayRole, findRecord, filingOf, localDate, namedIn, ticketsNamedIn, askedIn, entriesAsk } from "./day-file.mjs";
40
49
 
41
50
  export { dayRole, findRecord };
42
51
 
@@ -85,6 +94,15 @@ export function events(transcriptText) {
85
94
  const said = userText(d);
86
95
  if (said.trim()) out.push({ kind: "owner", words: said.trim().split(/\s+/).length, at });
87
96
  const c = d.message?.content;
97
+ if (d.type === "user" && Array.isArray(c)) {
98
+ for (const x of c) {
99
+ if (x?.type !== "tool_result") continue;
100
+ const text = typeof x.content === "string" ? x.content : Array.isArray(x.content) ? x.content.filter((y) => y?.type === "text").map((y) => y.text ?? "").join("\n") : "";
101
+ const named = namedIn(text);
102
+ const tickets = ticketsNamedIn(text);
103
+ if (named.length || tickets.length || askedIn(text)) out.push({ kind: "result", named, tickets, asked: askedIn(text), at });
104
+ }
105
+ }
88
106
  if (d.type === "assistant" && Array.isArray(c)) {
89
107
  for (const x of c) if (x?.type === "tool_use") out.push({ kind: "tool", name: String(x.name ?? ""), input: JSON.stringify(x.input ?? {}), at });
90
108
  }
@@ -109,7 +127,9 @@ export function classify(e, facts) {
109
127
 
110
128
  // The decision, pure. `lastBlock` is the event index this hook last blocked at (0 for never).
111
129
  // `fileMtime` is the day file's modification time in ms, or null when it does not exist.
112
- export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
130
+ // `nonWriter` (HQ-2817) is the EVDA_DAY_WRITER value on a machine that does not write the day: the
131
+ // block then says evda_write, because a direct write here is the iCloud race and the guard refuses it.
132
+ export function decide(evs, facts, { lastBlock = 0, fileMtime = null, nonWriter = "" } = {}) {
113
133
  let lastWrite = 0;
114
134
  evs.forEach((e, i) => { if (classify(e, facts) === "write") lastWrite = i + 1; });
115
135
  const start = Math.max(lastWrite, lastBlock);
@@ -127,19 +147,62 @@ export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
127
147
  const why = reads && words >= OWNER_WORDS ? "read the record and the owner talked" : reads ? "read the record" : "the owner talked";
128
148
  const reason = [
129
149
  `evda: this conversation ${why}, and nothing from it is in today's record (${tools} tool call(s), ${writes} write(s), none to ${facts.fileRel}).`,
130
- `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, appended to ${facts.fileRel} under a "## HH:MM" heading or through evda_write.`,
150
+ nonWriter
151
+ ? `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, through evda_write. This machine does not write ${facts.fileRel} (EVDA_DAY_WRITER names the one that does, ${nonWriter}), so never write that file directly here.`
152
+ : `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, appended to ${facts.fileRel} under a "## HH:MM" heading or through evda_write.`,
131
153
  "Then tell them the path in the past tense. If a person, project or place with an entry came up, add a dated section to that entry too (never to a noted one); one with no entry, offer to start it and wait for their yes.",
132
154
  "If nothing they said is worth keeping, stop. This asks once per stretch.",
133
155
  ].join(" ");
134
156
  return { block: true, reason, ...facts_ };
135
157
  }
136
158
 
159
+ const ANY_WRITE = /(^|__)evda_(write|note|append|append_row|append_item|record_person_note|replace|create)$/;
160
+
161
+ // The named-entries decision, pure. `mtimeOf(rel)` is an entry's modification time in ms, or null.
162
+ export function decideEntries(evs, facts, { asked = false, mtimeOf = () => null } = {}) {
163
+ if (asked || falsified("no-entries-ask")) return { block: false };
164
+ const named = [];
165
+ const tickets = [];
166
+ let since = NaN;
167
+ for (const e of evs) {
168
+ if (e.kind !== "result") continue;
169
+ if (e.asked) return { block: false };
170
+ for (const n of e.named) if (!named.some((x) => x.path === n.path)) named.push(n);
171
+ for (const k of e.tickets ?? []) if (!tickets.includes(k)) tickets.push(k);
172
+ if (e.named.length && !Number.isFinite(since)) since = e.at;
173
+ }
174
+ if (!named.length && !tickets.length) return { block: false };
175
+ const wrote = (rel) => evs.some((e) => {
176
+ if (e.kind !== "tool" || !names(e.input, rel, join(facts.root, rel))) return false;
177
+ return WRITE_TOOLS.has(e.name) || ANY_WRITE.test(e.name) || (e.name === "Bash" && BASH_WRITE.test(e.input));
178
+ }) || (Number.isFinite(since) && (mtimeOf(rel) ?? -Infinity) >= since);
179
+ // A ticket is answered by any board write naming its number: a harlequin tool other than a read,
180
+ // or the board CLI's update, done, handoff or schedule.
181
+ const updated = (id) => {
182
+ const n = id.slice(3);
183
+ const hit = new RegExp(`(HQ-${n}\\b|"number":"?${n}\\b|--number[ =]${n}\\b)`);
184
+ return evs.some((e) => e.kind === "tool" && hit.test(e.input) && ((/harlequin/i.test(e.name) && !/_(show|list|status)$/.test(e.name)) || (e.name === "Bash" && /\bboard\b.*\b(update|done|handoff|schedule)\b/.test(e.input))));
185
+ };
186
+ if (named.some((n) => wrote(n.path)) || tickets.some(updated)) return { block: false };
187
+ return { block: true, reason: `evda: ${entriesAsk(named.map((n) => n.path), "a dated section at the end, by Edit or evda_append; a person's page with evda_record_person_note", tickets)}` };
188
+ }
189
+
137
190
  function stateFile(sid) {
138
191
  const dir = process.env.EVDA_KEEP_STATE_DIR || join(tmpdir(), "evda-keep-record");
139
192
  mkdirSync(dir, { recursive: true });
140
193
  return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
141
194
  }
142
195
 
196
+ // When the prompt hook (lib/prompt-hook.mjs) last filed or queued a message of this session, in ms,
197
+ // or null. Its state file is the one place both hooks can see without the record changing.
198
+ function promptFiledAt(sid) {
199
+ const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
200
+ try {
201
+ const t = Date.parse(JSON.parse(readFileSync(join(dir, `${String(sid ?? "unknown").replace(/[^A-Za-z0-9_-]/g, "_")}.json`), "utf8")).filedAt);
202
+ return Number.isFinite(t) ? t : null;
203
+ } catch { return null; }
204
+ }
205
+
143
206
  // One Stop event in, the hook's stdout out ("" for let it stop). Never throws.
144
207
  export function run(payload, { now = new Date() } = {}) {
145
208
  try {
@@ -154,14 +217,23 @@ export function run(payload, { now = new Date() } = {}) {
154
217
  if (!facts || facts.filing === "asked") return "";
155
218
  const evs = events(readFileSync(tpath, "utf8"));
156
219
  const sf = stateFile(payload.session_id ?? "unknown");
157
- let lastBlock = 0;
158
- try { lastBlock = Number(JSON.parse(readFileSync(sf, "utf8")).at) || 0; } catch { /* first stop of the session */ }
159
- if (lastBlock > evs.length) lastBlock = 0; // a different transcript under the same id
160
- const fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
161
- const d = decide(evs, facts, { lastBlock, fileMtime });
162
- if (!d.block) return "";
163
- writeFileSync(sf, JSON.stringify({ at: d.at, when: now.toISOString() }) + "\n");
164
- return JSON.stringify({ decision: "block", reason: d.reason });
220
+ let state = {};
221
+ try { state = JSON.parse(readFileSync(sf, "utf8")) ?? {}; } catch { /* first stop of the session */ }
222
+ let lastBlock = Number(state.at) || 0;
223
+ if (lastBlock > evs.length) { lastBlock = 0; state = {}; } // a different transcript under the same id
224
+ let fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
225
+ // HQ-2817: the prompt hook's last hand-off counts as a write to the day. On a machine that does
226
+ // not write the day the file here does not change when a message is filed through the writer.
227
+ const filedAt = promptFiledAt(payload.session_id);
228
+ if (filedAt !== null && (fileMtime === null || filedAt > fileMtime)) fileMtime = filedAt;
229
+ const nonWriter = (process.env.EVDA_DAY_WRITER ?? "").trim();
230
+ const d = decide(evs, facts, { lastBlock, fileMtime, nonWriter });
231
+ const mtimeOf = (rel) => { try { return statSync(join(root, rel)).mtimeMs; } catch { return null; } };
232
+ const e = d.block ? null : decideEntries(evs, facts, { asked: Boolean(state.entries), mtimeOf });
233
+ if (!d.block && !e.block) return "";
234
+ const next = d.block ? { ...state, at: d.at, when: now.toISOString() } : { ...state, at: lastBlock, entries: now.toISOString() };
235
+ writeFileSync(sf, JSON.stringify(next) + "\n");
236
+ return JSON.stringify({ decision: "block", reason: d.block ? d.reason : e.reason });
165
237
  } catch {
166
238
  return "";
167
239
  }