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
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,15 @@ 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";
57
69
 
58
70
  export const GATHER_VERSION = 1;
59
71
  // The person-layer kinds an item is typed with, and the folder type each lives under.
60
72
  export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
61
73
  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"];
74
+ 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
75
  // The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
64
76
  // what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
65
77
  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" };
@@ -75,7 +87,9 @@ export const PEOPLE_MIN_FILES = 3;
75
87
  const CRED_QUALIFIER = String.raw`(?:(?:wi-?fi|home|network|router|guest|email|e-mail|bank|account|admin|root|master|login|app|door|alarm|phone|sim|card|atm|debit|garage)\s+)*`;
76
88
  const CRED_SHORT = String.raw`pass(?:word|wd|code|phrase)?|pwd|pw|pin(?:\s*code)?|api[\s_-]*key|access[\s_-]*key|secret(?:[\s_-]*key)?|(?:auth[\s_-]*|access[\s_-]*|api[\s_-]*)?token|private[\s_-]*key|login`;
77
89
  const CRED_LONG = String.raw`(?:recovery|backup|2fa|mfa|one[\s-]*time)[\s_-]*(?:codes?|keys?|phrase)|seed[\s_-]*(?:phrase|words)|mnemonic(?:[\s_-]*phrase)?|secret[\s_-]*phrase`;
78
- const CRED_LINE = new RegExp(String.raw`^\s*(?:[-*+•>]\s+|\d+[.)]\s+)?[*_\x60]*${CRED_QUALIFIER}(?:(${CRED_LONG})|(${CRED_SHORT}))[*_\x60]*\s*(?:[:=]|\s[-\u2013]\s)[*_\x60]*\s*(.*)$`, "i");
90
+ // Exported for lib/keychain.mjs (HQ-1916): the same line shape that types a note as holding a
91
+ // credential is used again at apply, to find each value on its own line and move it off disk.
92
+ export const CRED_LINE = new RegExp(String.raw`^\s*(?:[-*+•>]\s+|\d+[.)]\s+)?[*_\x60]*${CRED_QUALIFIER}(?:(${CRED_LONG})|(${CRED_SHORT}))[*_\x60]*\s*(?:[:=]|\s[-\u2013]\s)[*_\x60]*\s*(.*)$`, "i");
79
93
  export function credentialLine(lines) {
80
94
  const all = Array.isArray(lines) ? lines : String(lines ?? "").split("\n");
81
95
  for (let n = 0; n < all.length; n++) {
@@ -100,12 +114,37 @@ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
100
114
 
101
115
  const SKIP_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "node_modules", ".git", ".obsidian", ".trash", ".Trash", "__MACOSX"]);
102
116
  const APP_DIRS = { ".obsidian": "obsidian" };
117
+ // App-state junk a real pile carries (HQ-1368, the 2026-09-27 trial placed every one of these): what
118
+ // an app wrote for itself, not something the owner kept. Each is skipped, with why, and named in the
119
+ // summary. `junkWhy(name, size, isDir)` is the reason, or null for a file the owner may want.
120
+ export const JUNK_DIRS = new Set(["recently deleted"]);
121
+ export const JUNK_FILES = new Set(["archive_browser.html"]);
122
+ export function junkWhy(name, size, isDir = false) {
123
+ if (falsified("keep-junk")) return null;
124
+ const lower = name.toLowerCase();
125
+ if (isDir) return JUNK_DIRS.has(lower) ? "what the app had already deleted" : null;
126
+ if (name.startsWith("~$")) return "an Office lock file, written while a document was open";
127
+ if (JUNK_FILES.has(lower)) return "Google Takeout's index of the export, not the owner's";
128
+ if (size === 0) return "an empty file (zero bytes)";
129
+ return null;
130
+ }
103
131
  const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
104
132
  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"]);
105
133
  const TEXT_MAX = 2 * 1024 * 1024;
106
134
  const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
107
135
  // Folder names that hold things without saying what they are about, so they are not a subject.
108
136
  const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
137
+ // The folders an export or an app names for itself (HQ-1368, the 2026-09-27 trial read Keep's `Keep/`
138
+ // as a subject and cited "My Contacts" and "Photos from 2025" in the preset's reason): they say which
139
+ // app the files came out of, never what they are about, so none is ever a subject, at any depth.
140
+ 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"]);
141
+ const EXPORT_PATTERNS = [/^photos from \d{4}$/i, /^takeout[-_]\d/i];
142
+ export function isContainer(name) {
143
+ const lower = name.toLowerCase();
144
+ if (CONTAINERS.has(lower)) return true;
145
+ if (falsified("container-subjects")) return false;
146
+ return EXPORT_CONTAINERS.has(lower) || EXPORT_PATTERNS.some((re) => re.test(name));
147
+ }
109
148
  const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
110
149
  const COURSE_WORD = /\b(syllabus|lecture|homework|problem set|midterm)\b/i;
111
150
  const THERAPY = /\b(therapy|therapist|counsell?or|counselling|counseling|psychiatrist|psychologist|CBT|EMDR)\b/i;
@@ -241,6 +280,8 @@ function walk(root) {
241
280
  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; }
242
281
  const s = lstatSync(abs);
243
282
  if (s.isSymbolicLink()) { skipped.push({ rel, why: "a symlink; not followed" }); continue; }
283
+ const junk = s.isDirectory() || s.isFile() ? junkWhy(name, s.size, s.isDirectory()) : null;
284
+ if (junk) { skipped.push({ rel: s.isDirectory() ? `${rel}/` : rel, why: junk }); continue; }
244
285
  if (s.isDirectory()) { go(abs); continue; }
245
286
  if (!s.isFile()) { skipped.push({ rel, why: "not a regular file" }); continue; }
246
287
  if (name.startsWith(".")) { skipped.push({ rel, why: "a hidden file" }); continue; }
@@ -257,7 +298,7 @@ function subjectOf(rel, projectRoot) {
257
298
  const parts = rel.split("/").slice(0, -1);
258
299
  for (let i = parts.length - 1; i >= 0; i--) {
259
300
  const p = parts[i];
260
- if (CONTAINERS.has(p.toLowerCase())) continue;
301
+ if (isContainer(p)) continue;
261
302
  if (/^(people|contacts|meetings|projects|drafts|writing|essays|clippings|reading|articles|bookmarks|logs|calendar|photos|attachments)$/i.test(p)) continue;
262
303
  return p;
263
304
  }
@@ -347,7 +388,13 @@ export function scan(sources, { now = new Date() } = {}) {
347
388
  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));
348
389
  out.sources.push({ id: `s${si}`, path: root, files: w.files.length, skipped: w.skipped.length, apps: w.apps });
349
390
  for (const s of w.skipped) out.skipped.push({ source: `s${si}`, ...s });
391
+ // Google Takeout's sidecars (HQ-1368, lib/takeout.mjs): each is paired with the photo or note it
392
+ // describes, its date and caption folded into that file's entry, and is never an entry of its own.
393
+ const side = pairSidecars(w.files, (abs) => readFileSync(abs));
394
+ const sidecarAt = new Map(side.sidecars.map((x) => [x.rel, x]));
395
+ for (const x of side.sidecars) out.skipped.push({ source: `s${si}`, ...x });
350
396
  for (const f of w.files) {
397
+ if (sidecarAt.has(f.rel)) continue;
351
398
  const buf = readFileSync(f.abs);
352
399
  const ext = extname(f.rel).toLowerCase();
353
400
  const chat = ext === ".json" ? readChatExport(buf) : null;
@@ -362,12 +409,22 @@ export function scan(sources, { now = new Date() } = {}) {
362
409
  // section is cut before any file is read for its heading, people or words. Its bytes and hash
363
410
  // are unchanged, so apply copies it with its flag.
364
411
  const seal = sealedOf(ext, buf);
365
- const facts = typeItem({ rel: f.rel, buf: seal.buf, ext, projectRoot });
412
+ // A note-shaped HTML file is read as the markdown it will become (HQ-1368), so it is typed,
413
+ // searched for people and checked for a credential by its words, not its tags.
414
+ const shape = (ext === ".html" || ext === ".htm") && !falsified("html-as-is") ? noteShape(buf.toString("utf8")) : null;
415
+ const conv = shape ? htmlNoteToMarkdown(buf.toString("utf8")) : null;
416
+ if (conv?.empty) { out.skipped.push({ source: `s${si}`, rel: f.rel, why: "an empty note: no title and no body" }); continue; }
417
+ if (conv) seal.buf = Buffer.from(conv.markdown, "utf8");
418
+ const facts = typeItem({ rel: f.rel, buf: seal.buf, ext: conv ? ".md" : ext, projectRoot });
419
+ if (conv) facts.html = shape;
366
420
  if (seal.sealed) facts.sealed = true;
367
421
  if (seal.noted) facts.noted = true;
368
422
  const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
369
423
  if (projectRoot) item.project = projectRoot;
424
+ if (side.folded.has(f.rel)) item.takeout = side.folded.get(f.rel);
370
425
  item.subject = subjectOf(f.rel, projectRoot);
426
+ // A Keep note sits in Takeout/Keep, which says nothing of what it is about; its first label does.
427
+ if (!item.subject && !projectRoot && conv?.labels.length) item.subject = conv.labels[0];
371
428
  if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
372
429
  out.items.push(item);
373
430
  }
@@ -415,9 +472,11 @@ function findPeople(items) {
415
472
  for (const p of byName.values()) {
416
473
  const first = p.name.split(" ")[0];
417
474
  const re = new RegExp(`(^|[^\\p{L}])(${escape(p.name)}${firsts.get(first) === 1 && first.length >= 3 ? `|${escape(first)}` : ""})($|[^\\p{L}])`, "u");
475
+ // Identical copies of one file are one mention, so a duplicate does not count a person twice.
476
+ const seen = new Set();
418
477
  for (const i of items) {
419
- if (i.text === undefined || p.pages.includes(i.id)) continue;
420
- if (re.test(i.text) || re.test(i.path)) p.files.add(i.id);
478
+ if (i.text === undefined || p.pages.includes(i.id) || seen.has(i.sha256)) continue;
479
+ if (re.test(i.text) || re.test(i.path)) { p.files.add(i.id); seen.add(i.sha256); }
421
480
  }
422
481
  }
423
482
  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));
@@ -482,8 +541,12 @@ function homes(preset) {
482
541
  };
483
542
  }
484
543
 
544
+ // A name a copy is given by the system that made it: "x (1)", "x copy", "x copy 2", "Copy of x".
545
+ const COPY_MARKER = /(?: \(\d+\)| copy(?: \d+)?|^copy of .+)$/i;
546
+
485
547
  // A placed text item's name: `.txt` becomes `.md`, bytes unchanged. Every other name is kept.
486
- const asMarkdown = (i, name) => (i.kind !== "blob" && /\.txt$/i.test(name) ? name.replace(/\.txt$/i, ".md") : name);
548
+ // A note-shaped `.html` becomes `.md` too, its bytes converted at apply (lib/html-notes.mjs).
549
+ 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);
487
550
 
488
551
  // The placement of every item, given the facts and the answers. Pure.
489
552
  export function place(p) {
@@ -524,7 +587,7 @@ export function place(p) {
524
587
  // Only markdown carries `sealed: true`, so a credential in any other format is never copied.
525
588
  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.` };
526
589
  } else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
527
- else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
590
+ else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file, left in the pile; unpack it and gather the folder" };
528
591
  else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
529
592
  else if (i.kind === "project") {
530
593
  // Status is a field, never a move: a finished project stays in its collection and says so.
@@ -539,18 +602,44 @@ export function place(p) {
539
602
  const course = h.courses && i.subject && COURSE.test(i.subject);
540
603
  if (course) pl = { status: "placed", to: `${h.courses}/${i.subject}/${file}` };
541
604
  else if (i.subject) pl = { status: "placed", to: `${folderFor("areas", "record")}/${i.subject}/${file}` };
542
- 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` : ""}` };
605
+ 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" };
543
606
  } 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" };
544
607
  else pl = { status: "placed", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${file}` };
545
- if (pl.to) pl.to = claim(pl.to, i.id);
546
- if (pl.to) pl.role = roleOf(pl.to);
547
608
  if (i.refs && pl.status === "placed") pl.refs = i.refs;
548
609
  if (i.kind === "person" && !i.cards) {
549
610
  const who = p.people.find((x) => x.page === i.id);
550
611
  if (who?.ref) pl.refs = [who.ref];
551
612
  }
613
+ if (i.html && pl.to && pl.status === "placed") pl.convert = "html-to-markdown";
552
614
  placements.push({ item: i.id, ...pl });
553
615
  }
616
+ // Byte-identical files are placed once (HQ-1368): the copy placed is one that has somewhere to go,
617
+ // then one whose name carries no copy marker ("ENERGY STAR (1).pdf", "Plan copy.txt"), then the
618
+ // shortest path, then the first walked; every other copy stays in the pile, named as a duplicate of
619
+ // that one. A project's own files are kept whole, so an identical file in two projects is in both.
620
+ if (!falsified("place-duplicates")) {
621
+ const items = new Map(p.items.map((i) => [i.id, i]));
622
+ const groups = new Map();
623
+ placements.forEach((pl, n) => {
624
+ const i = items.get(pl.item);
625
+ if (!["placed", "reference", "unplaced"].includes(pl.status) || i.kind === "project" || i.cards || i.credential) return;
626
+ groups.set(i.sha256, [...(groups.get(i.sha256) ?? []), n]);
627
+ });
628
+ const marked = (path) => COPY_MARKER.test(basename(path, extname(path)));
629
+ for (const g of groups.values()) {
630
+ if (g.length < 2) continue;
631
+ const rank = (n) => [placements[n].status === "unplaced" ? 1 : 0, marked(items.get(placements[n].item).path) ? 1 : 0, placements[n].item.length, n];
632
+ 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]; };
633
+ const keep = [...g].sort(order)[0];
634
+ const kept = placements[keep];
635
+ 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` };
636
+ }
637
+ }
638
+ // Names are claimed after the duplicates are out, so the copy kept never yields its name to one left behind.
639
+ for (const pl of placements) {
640
+ if (pl.to) pl.to = claim(pl.to, pl.item);
641
+ if (pl.to) pl.role = roleOf(pl.to);
642
+ }
554
643
  // Person pages the owner said yes to, made new, holding a Contacts reference when a card names them.
555
644
  for (const who of p.people) {
556
645
  if (ans[`person:${slugId(who.name)}`] !== "yes" || who.page) continue;
@@ -620,7 +709,7 @@ export function questions(p) {
620
709
  // ---- the proposal ----
621
710
 
622
711
  function counts(p) {
623
- const c = { items: p.items.length, placed: 0, reference: 0, create: 0, unplaced: 0, refused: 0, present: 0, skipped: p.skipped.length };
712
+ 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 };
624
713
  for (const pl of p.placements) c[pl.status] += 1;
625
714
  c.chats = (p.chats ?? []).length;
626
715
  c.chatMessages = (p.chats ?? []).reduce((n, x) => n + x.messages, 0);
@@ -780,11 +869,12 @@ export function checkedLines(p) {
780
869
  const chats = p.chats ?? [];
781
870
  return [
782
871
  ...(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` : ""}`] : []),
783
- `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` : ""}`,
872
+ `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)` : ""}`,
784
873
  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",
785
874
  ];
786
875
  }
787
876
 
877
+ const SKIPPED_SHOWN = 20;
788
878
  export function summaryText(p, { detail = false } = {}) {
789
879
  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.`, ""];
790
880
  lines.push(`Preset: ${p.preset.title} (${p.preset.id}), because ${p.preset.why.join("; ")}.`, "", "Folders:");
@@ -792,12 +882,24 @@ export function summaryText(p, { detail = false } = {}) {
792
882
  const byStatus = (s) => p.placements.filter((x) => x.status === s);
793
883
  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}`); }
794
884
  if (byStatus("create").length) { lines.push("", "New pages:"); for (const x of byStatus("create")) lines.push(`- ${x.to}${x.refs.length ? ` (${x.refs.join(", ")})` : ""}`); }
795
- if (byStatus("unplaced").length) { lines.push("", "Left for a person:"); for (const x of byStatus("unplaced")) lines.push(`- ${x.item}: ${x.why}`); }
885
+ 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}`); }
886
+ 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}`); }
796
887
  if (byStatus("refused").length) { lines.push("", "Refused:"); for (const x of byStatus("refused")) lines.push(`- ${x.item}: ${x.why}`); }
888
+ if (p.skipped.length) {
889
+ // Named, so the owner sees what was left out and why (HQ-1368). A long list is cut unless --all.
890
+ const shown = detail ? p.skipped : p.skipped.slice(0, SKIPPED_SHOWN);
891
+ lines.push("", "Skipped, never placed:");
892
+ for (const x of shown) lines.push(`- ${x.rel}: ${x.why}`);
893
+ if (shown.length < p.skipped.length) lines.push(`- and ${p.skipped.length - shown.length} more (show --all lists them)`);
894
+ }
797
895
  if (byStatus("present").length) lines.push("", `Already in the record, left where they are: ${byStatus("present").length} item(s).`);
798
896
  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.`);
799
897
  if (p.circles.length) { lines.push("", "Circles:"); for (const c of p.circles) lines.push(`- ${c.name} over ${c.covers}/, ${c.status}`); }
800
- if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
898
+ const converted = byStatus("placed").filter((x) => x.convert === "html-to-markdown");
899
+ 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`); }
900
+ const folded = p.items.filter((i) => i.takeout);
901
+ 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`); }
902
+ if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}${x.convert ? " (converted from HTML)" : ""}`); }
801
903
  const open = p.questions.filter((q) => q.answer === undefined);
802
904
  lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
803
905
  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
  }
@@ -134,6 +152,37 @@ export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
134
152
  return { block: true, reason, ...facts_ };
135
153
  }
136
154
 
155
+ const ANY_WRITE = /(^|__)evda_(write|note|append|append_row|append_item|record_person_note|replace|create)$/;
156
+
157
+ // The named-entries decision, pure. `mtimeOf(rel)` is an entry's modification time in ms, or null.
158
+ export function decideEntries(evs, facts, { asked = false, mtimeOf = () => null } = {}) {
159
+ if (asked || falsified("no-entries-ask")) return { block: false };
160
+ const named = [];
161
+ const tickets = [];
162
+ let since = NaN;
163
+ for (const e of evs) {
164
+ if (e.kind !== "result") continue;
165
+ if (e.asked) return { block: false };
166
+ for (const n of e.named) if (!named.some((x) => x.path === n.path)) named.push(n);
167
+ for (const k of e.tickets ?? []) if (!tickets.includes(k)) tickets.push(k);
168
+ if (e.named.length && !Number.isFinite(since)) since = e.at;
169
+ }
170
+ if (!named.length && !tickets.length) return { block: false };
171
+ const wrote = (rel) => evs.some((e) => {
172
+ if (e.kind !== "tool" || !names(e.input, rel, join(facts.root, rel))) return false;
173
+ return WRITE_TOOLS.has(e.name) || ANY_WRITE.test(e.name) || (e.name === "Bash" && BASH_WRITE.test(e.input));
174
+ }) || (Number.isFinite(since) && (mtimeOf(rel) ?? -Infinity) >= since);
175
+ // A ticket is answered by any board write naming its number: a harlequin tool other than a read,
176
+ // or the board CLI's update, done, handoff or schedule.
177
+ const updated = (id) => {
178
+ const n = id.slice(3);
179
+ const hit = new RegExp(`(HQ-${n}\\b|"number":"?${n}\\b|--number[ =]${n}\\b)`);
180
+ 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))));
181
+ };
182
+ if (named.some((n) => wrote(n.path)) || tickets.some(updated)) return { block: false };
183
+ 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)}` };
184
+ }
185
+
137
186
  function stateFile(sid) {
138
187
  const dir = process.env.EVDA_KEEP_STATE_DIR || join(tmpdir(), "evda-keep-record");
139
188
  mkdirSync(dir, { recursive: true });
@@ -154,14 +203,18 @@ export function run(payload, { now = new Date() } = {}) {
154
203
  if (!facts || facts.filing === "asked") return "";
155
204
  const evs = events(readFileSync(tpath, "utf8"));
156
205
  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
206
+ let state = {};
207
+ try { state = JSON.parse(readFileSync(sf, "utf8")) ?? {}; } catch { /* first stop of the session */ }
208
+ let lastBlock = Number(state.at) || 0;
209
+ if (lastBlock > evs.length) { lastBlock = 0; state = {}; } // a different transcript under the same id
160
210
  const fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
161
211
  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 });
212
+ const mtimeOf = (rel) => { try { return statSync(join(root, rel)).mtimeMs; } catch { return null; } };
213
+ const e = d.block ? null : decideEntries(evs, facts, { asked: Boolean(state.entries), mtimeOf });
214
+ if (!d.block && !e.block) return "";
215
+ const next = d.block ? { ...state, at: d.at, when: now.toISOString() } : { ...state, at: lastBlock, entries: now.toISOString() };
216
+ writeFileSync(sf, JSON.stringify(next) + "\n");
217
+ return JSON.stringify({ decision: "block", reason: d.block ? d.reason : e.reason });
165
218
  } catch {
166
219
  return "";
167
220
  }