create-evda 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ Plain-English notes on how Iris behaves differently after each version. Not a co
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ Organizing is on from the start. Setup installs the morning organize on a Mac (a launchd job that
8
+ runs your own Claude each morning at 8:00, or when the computer wakes), says so in one line, and
9
+ offers off: answer no during setup, pass `--no-routines`, or later run `evda routine morning
10
+ --uninstall`. The weekly look back is still offered, not on. When your AI's part of a routine cannot
11
+ run, the report now says why in words you can act on (Claude is not signed in on this computer: open
12
+ Claude and sign in), and the filing part still runs.
13
+
14
+ Your agent can message you. `evda_message` (and `evda message`) writes one new file in your record's
15
+ `messages/` folder, which the EVÐA phone app shows as a card, and your reply comes back as a file beside
16
+ it. `evda_replies` (and `evda replies`) hands your agent each reply once at the start of its next run,
17
+ and again if you change it. A new record has the folder; an older one gets it the first time a message
18
+ is sent, and `evda.json` can map it elsewhere. A message refuses anything shaped like a password, and a
19
+ reply you seal or just note is not shown to your agent.
20
+
7
21
  A note you asked to have just noted, or a sealed one, stays that way wherever EVDA copies it.
8
22
  `evda organize` no longer copies a just-noted capture into its day: the day gets one line saying where
9
23
  it is, and once you release it the next organize files its words. Gathering a pile quotes nothing of a
package/bin/evda.mjs CHANGED
@@ -34,7 +34,7 @@ import { parity, formatParity } from "../lib/parity.mjs";
34
34
  import { note, releaseNote, ownerSearch, readContext, readersFor, describeReaders, parseReaders, READ_BY } from "../lib/readers.mjs";
35
35
  import { runHook } from "../lib/guard.mjs";
36
36
  import { markdownFiles } from "../lib/circles.mjs";
37
- import { refuseSecrets } from "../lib/tools.mjs";
37
+ import { refuseSecrets, message as messageTool, replies as repliesTool } from "../lib/tools.mjs";
38
38
 
39
39
  const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
40
40
 
@@ -102,9 +102,10 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
102
102
  undo an apply byte for byte; refused when a placed file was edited
103
103
  evda look-back [dir] [--model M]
104
104
  the week, read back by the owner's claude -p, appended to a report
105
- evda routine <morning|weekly> [dir] [--launchd] [--install]
105
+ evda routine <morning|weekly> [dir] [--launchd] [--install] [--uninstall]
106
106
  print a scheduled routine for Claude, or a launchd fallback; --install
107
- writes the launchd job, and nothing is scheduled without it
107
+ writes the launchd job, --uninstall removes it (setup installs the
108
+ morning organize unless told no)
108
109
  evda preset list the organizations this package ships, and who each suits
109
110
  evda preset show <id> one preset's folders
110
111
  evda preset apply <id> [--dry] [dir]
@@ -134,6 +135,12 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
134
135
  or project, or a folder of effort files. CHECKED n, or SKIPPED and why
135
136
  evda note "<words>" [--in dir] just note it: a capture no AI reads until you release it (words from stdin with -)
136
137
  evda release <path> [dir] you can read that now: clear the just-noted flag on one entry
138
+ evda message --title "<title>" [--from NAME] [--in dir] "<body>"
139
+ tell the owner something: one new file in the messages role, a card
140
+ in their phone app (body from stdin with -)
141
+ evda replies [--peek] [--in dir] [--json]
142
+ the owner's replies not seen yet, marked seen unless --peek; read
143
+ under read_by as the AI $EVDA_READER names (claude, local, ...)
137
144
  evda search <words> [dir] your own search, on this device, no model; just-noted and sealed entries included and marked
138
145
  evda readers [dir] which AI reads each part of the record, from read_by
139
146
  evda local-model how to run a model on your own computer and point a circle at it
@@ -410,7 +417,7 @@ async function main(argv) {
410
417
  }
411
418
 
412
419
  case "routine": {
413
- // evda routine <morning|weekly> [dir] [--launchd] [--install]
420
+ // evda routine <morning|weekly> [dir] [--launchd] [--install] [--uninstall]
414
421
  const { routineMain } = await import("../lib/routines.mjs");
415
422
  return routineMain(args, { pickDir, flagValue });
416
423
  }
@@ -507,6 +514,32 @@ async function main(argv) {
507
514
  return 0;
508
515
  }
509
516
 
517
+ case "message": {
518
+ // Like note, the body may hold a slash, so the record is --in <dir> or the locator.
519
+ const take = (flag) => { const i = args.indexOf(flag); if (i < 0) return undefined; const v = args[i + 1]; if (v === undefined) throw new EvdaError(`${flag} needs a value`, { exit: 2 }); args.splice(i, 2); return v; };
520
+ const dirArg = take("--in");
521
+ const title = take("--title");
522
+ const from = take("--from");
523
+ const dir = locate({ explicit: dirArg }).dir;
524
+ let body = args.join(" ");
525
+ if (body === "-" || body === "") body = readFileSync(0, "utf8");
526
+ const r = messageTool(openEvda(dir), { title, body, ...(from === undefined ? {} : { from }) });
527
+ console.log(`Sent the message ${r.id}, filed at ${r.path}.`);
528
+ return 0;
529
+ }
530
+
531
+ case "replies": {
532
+ const i = args.indexOf("--in");
533
+ const dir = locate({ explicit: i >= 0 ? args[i + 1] : undefined }).dir;
534
+ const peek = args.includes("--peek");
535
+ const r = repliesTool(openEvda(dir), peek ? { peek: true } : {});
536
+ if (args.includes("--json")) { console.log(JSON.stringify({ replies: r.replies, marked: r.marked }, null, 2)); return 0; }
537
+ for (const x of r.replies) console.log(`## ${x.title} (${x.id}, ${x.date})\n\n${x.reply}\n`);
538
+ if (r.heldBack) console.log(r.heldBack);
539
+ console.log(`CHECKED ${r.dir}/ ${r.replies.length} new repl${r.replies.length === 1 ? "y" : "ies"}, ${r.marked} marked seen${peek ? " (peek)" : ""}`);
540
+ return 0;
541
+ }
542
+
510
543
  case "release": {
511
544
  const { dir, entry } = pickReleaseArgs(args);
512
545
  const r = releaseNote(openEvda(dir), entry);
package/create-evda.mjs CHANGED
@@ -30,7 +30,7 @@ import { readPointer, setPointer, pointerPath } from "./lib/locate.mjs";
30
30
  import { FOLDER_MANIFEST } from "./lib/folders.mjs";
31
31
  import { EvdaError } from "./lib/errors.mjs";
32
32
  import { clients, installSkill, installGuard } from "./lib/connect.mjs";
33
- import { desktopInstructions } from "./lib/routines.mjs";
33
+ import { defaultRoutines, installAtSetup } from "./lib/routines.mjs";
34
34
  import { installHook } from "./lib/hooks.mjs";
35
35
  import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/presets.mjs";
36
36
  import { FILING_MODES } from "./lib/day-file.mjs";
@@ -53,6 +53,7 @@ const CANONICAL = {
53
53
  circles: "circles",
54
54
  grants: "grants",
55
55
  mounts: "mounts",
56
+ messages: "messages",
56
57
  };
57
58
 
58
59
  // What each record folder holds (Spec 07, section 2), written as folder.evda.json beside the
@@ -61,6 +62,7 @@ const FOLDER_TYPES = {
61
62
  "record/daily": "record",
62
63
  "record/captures": "capture",
63
64
  "record/people": "person",
65
+ messages: "message",
64
66
  };
65
67
 
66
68
  const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--filing everything|asked] [--no-prompt-hook] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--preset ${presetIds().join("|")}|none] [--use] [--no-pointer] [--no-guard]`;
@@ -149,6 +151,9 @@ export function scaffold(opts) {
149
151
  const today = localDate();
150
152
  const mapping = { agents: CANONICAL.agents };
151
153
  for (const layer of opts.layers) for (const role of rolesForLayer(layer)) mapping[role] = CANONICAL[role];
154
+ // Messages from the owner's agent, and their replies (HQ-1246): every record has the folder, in
155
+ // whatever layers, because the phone app's Dispatch reads it.
156
+ mapping.messages = CANONICAL.messages;
152
157
 
153
158
  mkdirSync(dir, { recursive: true });
154
159
  for (const [role, rel] of Object.entries(mapping)) if (role !== "agents") mkdirSync(join(dir, rel), { recursive: true });
@@ -322,11 +327,10 @@ export async function main(argv, io = null) {
322
327
  log("");
323
328
  log(`Skill: ${r.action} ${r.path}`);
324
329
  }
325
- for (const key of opts.routines ?? []) {
326
- log("");
327
- for (const l of desktopInstructions(key, dir).split("\n")) log(l);
328
- log(`Or schedule it with launchd on a Mac: evda routine ${key} ${dir} --install`);
329
- }
330
+ // Organizing is on unless the owner said otherwise (HQ-1246): no flag and no answer means the
331
+ // morning organize is installed.
332
+ const env = io?.env ?? process.env;
333
+ for (const l of installAtSetup(opts.routines ?? defaultRoutines(env), dir, { env, platform: io?.platform ?? process.platform })) log(l);
330
334
  log("");
331
335
  for (const l of INTRO.split("\n")) log(l);
332
336
  log("");
package/lib/folders.mjs CHANGED
@@ -27,7 +27,7 @@ import { SCHEMES } from "./references.mjs";
27
27
  import { EvdaError } from "./errors.mjs";
28
28
 
29
29
  export const FOLDER_MANIFEST = "folder.evda.json";
30
- export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis"];
30
+ export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
31
31
  export const FOLDER_KEYS = ["type", "connectors", "views"];
32
32
  export const VIEW_KEYS_REQUIRED = ["view", "of", "in", "path"];
33
33
  export const VIEW_KEYS = [...VIEW_KEYS_REQUIRED, "status", "group"];
@@ -287,9 +287,14 @@ function plan(evda, p, { mode }) {
287
287
  if (md && pl.refs?.length) add.references = referencesYaml(pl.refs.map((ref) => ({ ref, label: refLabel(ref, i.heading ?? basename(to, ".md")) })));
288
288
  if (md && statusFor.has(pl.item)) add.status = `status: ${statusFor.get(pl.item)}`;
289
289
  if (md && pl.circle && falsified("apply-writes-share")) add.share = `share: [${pl.circle}]`;
290
+ // A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
291
+ // evda_read refuse it to every caller without naming it, and no tool releases it.
292
+ if (md && pl.sealed && !i.sealed) add.sealed = `${SEALED}: true`;
290
293
  // The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
291
294
  // record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
292
295
  let bytes = withFrontmatter(buf, add);
296
+ // A note that already said `sealed: false` (or anything but true) is sealed all the same.
297
+ if (add.sealed && !/^sealed:[ \t]*true[ \t]*$/m.test(bytes.toString("utf8").split(/\n---/)[0])) bytes = Buffer.from(bytes.toString("utf8").replace(/^sealed:.*$/m, `${SEALED}: true`), "utf8");
293
298
  for (const flag of [NOTED, SEALED]) if (md && !carries("gather-apply", flag)) bytes = Buffer.from(bytes.toString("utf8").replace(new RegExp(`^${flag}:[ \\t]*true[ \\t]*\\n`, "m"), ""), "utf8");
294
299
  const st = statSync(src);
295
300
  out.writes.push({
@@ -424,7 +429,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
424
429
  const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
425
430
  if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
426
431
  const before = tree(evda.dir);
427
- const r = proposeCircle(evda, { name: c.name, who: "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
432
+ const r = proposeCircle(evda, { name: c.name, who: c.readBy === "none" ? "Only the owner, on their own devices. No AI reads it (read_by: none), and no agent host holds it." : "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
428
433
  const after = tree(evda.dir);
429
434
  for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
430
435
  t.created.push({ path: r.path, sha256: sha256(readFileSync(join(evda.dir, r.path))), size: statSync(join(evda.dir, r.path)).size });
package/lib/gather.mjs CHANGED
@@ -59,13 +59,41 @@ export const GATHER_VERSION = 1;
59
59
  // The person-layer kinds an item is typed with, and the folder type each lives under.
60
60
  export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
61
61
  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", "keep-secrets", "examine-nothing", "no-role"];
62
+ export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role"];
63
63
  // The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
64
64
  // what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
65
65
  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" };
66
66
  // A person is asked about once this many files mention them and no page for them exists.
67
67
  export const PEOPLE_MIN_FILES = 3;
68
68
 
69
+ // A credential written as a labeled line in a note (HQ-1368): "password: ...", "pw: ...", "PIN: ...",
70
+ // "wifi password: ...", "api key = ...", "token: ...", "secret: ...", recovery or backup codes, a seed
71
+ // phrase. A label at the start of the line, a separator, and a value after it; prose that only
72
+ // mentions a password ("I forgot my password again") has no value after a separator and is not one.
73
+ // A short value for a password-like label (a sentence after "Password:" is prose, not a password);
74
+ // a code or phrase label may carry many words, or its value on the next line.
75
+ 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
+ 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
+ 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");
79
+ export function credentialLine(lines) {
80
+ const all = Array.isArray(lines) ? lines : String(lines ?? "").split("\n");
81
+ for (let n = 0; n < all.length; n++) {
82
+ const m = CRED_LINE.exec(all[n]);
83
+ if (!m) continue;
84
+ const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
85
+ const value = m[3].replace(/[*_\x60]+$/g, "").trim();
86
+ if (m[1]) {
87
+ if (value || (all[n + 1] ?? "").trim()) return label;
88
+ continue;
89
+ }
90
+ if (!value) continue;
91
+ if (value.split(/\s+/).length > 3) continue;
92
+ return label;
93
+ }
94
+ return null;
95
+ }
96
+
69
97
  const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
70
98
  const posix = (p) => p.split(sep).join("/");
71
99
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
@@ -75,6 +103,7 @@ const APP_DIRS = { ".obsidian": "obsidian" };
75
103
  const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
76
104
  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"]);
77
105
  const TEXT_MAX = 2 * 1024 * 1024;
106
+ const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
78
107
  // Folder names that hold things without saying what they are about, so they are not a subject.
79
108
  const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
80
109
  const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
@@ -256,8 +285,11 @@ function typeItem({ rel, buf, ext, projectRoot }) {
256
285
  if (ARCHIVE_EXT.has(ext)) return { ...item, kind: "blob", archive: true, why: ["an archive file"] };
257
286
  if (BINARY_EXT.has(ext) || isBinary(buf) || buf.length > TEXT_MAX) return { ...item, kind: "blob", why: [BINARY_EXT.has(ext) ? `a ${ext.slice(1)} file` : "binary bytes"] };
258
287
  const raw = buf.toString("utf8");
259
- const secret = raw.split("\n").map(secretShape).find(Boolean);
260
- if (secret && !falsified("keep-secrets")) return { ...item, kind: "note", refused: `holds ${secret}`, why: [`holds ${secret}`] };
288
+ // A file holding a secret or a credential line is never refused (his call, 2026-09-27: "just put
289
+ // it in a safer place"): it is typed from its path alone, and placed sealed in a Credentials
290
+ // folder that no AI reads or is told of, unless the owner says no. Nothing of it is quoted.
291
+ const secret = raw.split("\n").map(secretShape).find(Boolean) ?? (credentialLine(raw) ? `a ${credentialLine(raw)} line` : null);
292
+ if (secret) return { ...item, kind: "note", credential: `holds ${secret}`, why: [`holds ${secret}`], ...(DAY.test(name) ? { daily: true, dated: name } : {}) };
261
293
  if (ext === ".vcf") {
262
294
  const cards = vcards(raw);
263
295
  return { ...item, kind: "person", mode: "reference", why: [`a Contacts export of ${cards.length} card(s)`], cards: cards.map((c) => ({ name: c.name, ref: `contacts:${c.uid ?? slugId(c.name)}`, ...(falsified("copy-contacts") ? { payload: c.payload } : {}) })) };
@@ -277,7 +309,10 @@ function typeItem({ rel, buf, ext, projectRoot }) {
277
309
  const heading = headingOf(body);
278
310
  const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
279
311
  const facts = { heading, dated: LEADING_DAY.exec(name)?.[1] ?? (DAY.test(fm.date ?? "") ? fm.date : null), daily: DAY.test(name) };
280
- if (THERAPY.test(body)) facts.sensitive = { kind: "therapy", word: THERAPY.exec(body)[1] };
312
+ // A therapy note says so in its name or heading, or in the words of something note-sized. One mention deep in a book or a
313
+ // long export (a Gutenberg Meditations says "counsellor" once, 5,800 lines in) does not make it a session note.
314
+ const said = THERAPY.exec(basename(rel)) ?? THERAPY.exec(heading ?? "") ?? (body.length <= NOTE_SIZED ? THERAPY.exec(body) : null);
315
+ if (said) facts.sensitive = { kind: "therapy", word: said[1] };
281
316
  const att = ATTENDEES.exec(body);
282
317
  if (att) facts.attendees = att[1].split(/,|\band\b/).map((s) => s.trim()).filter((s) => /^[A-Z][\p{L}'-]+(?: [A-Z][\p{L}'-]+)+$/u.test(s));
283
318
  const declared = (fm.kind ?? fm.type ?? "").toLowerCase();
@@ -333,7 +368,7 @@ export function scan(sources, { now = new Date() } = {}) {
333
368
  const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
334
369
  if (projectRoot) item.project = projectRoot;
335
370
  item.subject = subjectOf(f.rel, projectRoot);
336
- if (!item.refused && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
371
+ if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
337
372
  out.items.push(item);
338
373
  }
339
374
  });
@@ -415,6 +450,9 @@ export function choosePreset(items, projects) {
415
450
 
416
451
  // Where a kind lives under a preset: the preset's own folder when it has one, else a folder the
417
452
  // pile needs and the preset does not have, named here and marked as such.
453
+ // The folder credentials are kept in: every file sealed, so no AI reads it, no tool releases it, and
454
+ // no AI is told it is there (HQ-1368, his call 2026-09-27).
455
+ export const CREDENTIALS = "Credentials";
418
456
  const NEEDED = { note: "Notes", capture: "Captures", person: "People", project: "Projects", event: "Events", log: "Logs", source: "Sources", piece: "Writing", blob: "Attachments", areas: "Notes" };
419
457
 
420
458
  // The role a path under the preset fills: the longest mapping path at or above it.
@@ -462,21 +500,30 @@ export function place(p) {
462
500
  return NEEDED[key];
463
501
  };
464
502
  const therapyOwn = ans["circle:therapy"] === "yes";
503
+ // Credentials go to their own folder unless the owner says no; yes is the default.
504
+ const credentialsOwn = ans.credentials !== "no" && !falsified("credentials-ordinary");
465
505
  const placements = [];
466
506
  const taken = new Map();
467
507
  // The role a destination fills: the preset's mapping, else the needed folder's own role.
468
508
  const roleOf = (to) => presetRole(preset, to) ?? (falsified("no-role") ? null : undefined) ?? [...needed.values()].find((n) => to === n.path || to.startsWith(`${n.path}/`))?.role ?? null;
509
+ // Names are claimed without case: on a Mac "wifi.md" and "Wifi.md" are one file.
469
510
  const claim = (to, id) => {
470
511
  let t = to;
471
- for (let n = 2; taken.has(t) && taken.get(t) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
472
- taken.set(t, id);
512
+ for (let n = 2; taken.has(t.toLowerCase()) && taken.get(t.toLowerCase()) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
513
+ taken.set(t.toLowerCase(), id);
473
514
  return t;
474
515
  };
475
516
  for (const i of p.items) {
476
517
  const file = asMarkdown(i, basename(i.path));
477
518
  let pl;
478
519
  if (i.refused) pl = { status: "refused", to: null, why: `${i.refused}; left where it is and quoted nowhere. Keep the secret in a password manager.` };
479
- else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
520
+ else if (i.credential && credentialsOwn && /\.md$/i.test(file)) {
521
+ pl = { status: "placed", to: `${CREDENTIALS}/${file}`, sealed: true, why: `${i.credential}; kept in ${CREDENTIALS}/, sealed, so only the owner opens it on their own devices, no AI reads it and no tool releases it` };
522
+ needed.set(CREDENTIALS, { path: CREDENTIALS, type: "record", role: "collections" });
523
+ } else if (i.credential && credentialsOwn) {
524
+ // Only markdown carries `sealed: true`, so a credential in any other format is never copied.
525
+ 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
+ } else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
480
527
  else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
481
528
  else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
482
529
  else if (i.kind === "project") {
@@ -511,6 +558,7 @@ export function place(p) {
511
558
  placements.push({ item: `new:person:${who.name}`, status: "create", to, role: roleOf(to), refs: who.ref ? [who.ref] : [], mentions: who.files.length });
512
559
  }
513
560
  const circles = therapyOwn ? [{ name: "therapy", covers: h.areas ? `${h.areas}/Therapy` : "Therapy", status: "proposed only: a proposal file at apply, never a circle" }] : [];
561
+ if (credentialsOwn && placements.some((x) => x.sealed)) circles.push({ name: "credentials", covers: CREDENTIALS, readBy: "none", status: "proposed only: a proposal file at apply, never a circle; every file in it is sealed already" });
514
562
  const used = new Map();
515
563
  for (const pl of placements) {
516
564
  if (!pl.to) continue;
@@ -542,6 +590,8 @@ function candidates(p) {
542
590
  if (!proj.finished && !falsified("ask-everything")) continue;
543
591
  out.push({ id: `finished:${proj.id}`, kind: "finished", options: ["finished", "live"], text: `${proj.name} looks finished (${basename(proj.files.find((f) => f === proj.finished?.file) ?? proj.root)} says "${proj.finished?.word ?? "nothing"}"). Is it finished, or live? It stays where it is; finished is its status.`, about: proj.name });
544
592
  }
593
+ const creds = p.items.filter((i) => i.credential && !i.present);
594
+ if (creds.length) out.push({ id: "credentials", kind: "credentials", options: ["yes", "no"], default: "yes", text: `${creds.length} note(s) look like they hold passwords or keys: keep them sealed in a Credentials folder, so only you open them on your own devices and no AI reads them?`, about: "credentials" });
545
595
  const therapy = p.items.filter((i) => i.sensitive?.kind === "therapy");
546
596
  if (therapy.length) out.push({ id: "circle:therapy", kind: "circle", options: ["yes", "no"], text: `${therapy.length} file(s) read like therapy notes. Give them their own circle, so only who you choose reads them?`, about: "therapy" });
547
597
  return out;
@@ -651,7 +701,7 @@ export function answer(p, questionId, value, { now = new Date() } = {}) {
651
701
 
652
702
  // Items it could not place, or typed only by elimination, handed to the owner's claude -p.
653
703
  export function withOwnersAi(p, { model = null, env = process.env } = {}) {
654
- const unsure = p.items.filter((i) => !i.refused && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
704
+ const unsure = p.items.filter((i) => !i.refused && !i.credential && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
655
705
  if (!unsure.length) { p.ai = { status: "SKIPPED", why: "nothing was unsure" }; return { ok: true, p }; }
656
706
  const kinds = GATHER_KINDS.join(", ");
657
707
  const lines = unsure.map((i) => `- ${i.id} (typed ${i.kind}${i.heading ? `, titled "${i.heading}"` : ""})`);
@@ -750,7 +800,7 @@ export function summaryText(p, { detail = false } = {}) {
750
800
  if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
751
801
  const open = p.questions.filter((q) => q.answer === undefined);
752
802
  lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
753
- for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")})`);
803
+ for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")}${q.default ? `, default ${q.default}` : ""})`);
754
804
  return lines.join("\n");
755
805
  }
756
806
 
package/lib/mcp.mjs CHANGED
@@ -54,6 +54,7 @@ export function instructions(filing) {
54
54
  "Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
55
55
  "Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
56
56
  "When they point you at a pile they already have (a folder, an export), call evda_gather, then ask the questions from evda_gather_questions one at a time in your own words, and record each answer with evda_gather_answer. When every question is answered and the owner says yes to the summary, apply it with evda_gather_apply and confirmed: true; tell them the reversal manifest it names.",
57
+ DISPATCH_LINE,
57
58
  PRIVACY_INSTRUCTIONS,
58
59
  ];
59
60
  return lines.join(" ");
@@ -88,6 +89,12 @@ export const PRIVACY_SECTION = [
88
89
  "- To keep a conversation out of training, the option is the provider's incognito or temporary chat. Claude's incognito chats are never used for training; ChatGPT keeps a Temporary Chat up to 30 days; others may offer the same.",
89
90
  ].join("\n");
90
91
 
92
+ // Dispatch (HQ-1246), one line on every surface an agent reads first: the server instructions and
93
+ // the operating layer. The vault's AGENTS-template.md is the core's source; until it carries this
94
+ // line, this is where it lives, and evda_operating_layer serves it above AGENTS.md.
95
+ export const DISPATCH_LINE = "Call evda_replies at the start of a run: it returns the owner's replies to your messages that you have not seen yet. When the owner should hear something from you (what you did, a question), send it with evda_message, which shows it on their phone as a card; a thing only filed waits until they go looking.";
96
+ export const DISPATCH_SECTION = `## Messages to the owner\n\n${DISPATCH_LINE}`;
97
+
91
98
  const str = (description) => ({ type: "string", description });
92
99
 
93
100
  // summarize() already names the command for the two statuses that ask the owner to run one. Over
@@ -165,7 +172,7 @@ export const TOOLS = [
165
172
  annotations: { readOnlyHint: true, openWorldHint: false },
166
173
  run: (evda, args) => {
167
174
  const r = tools.operatingLayer(evda, args);
168
- return { text: `${chatSection(evda, r.text)}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
175
+ return { text: `${chatSection(evda, r.text)}\n\n${DISPATCH_SECTION}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
169
176
  },
170
177
  },
171
178
  {
@@ -245,6 +252,30 @@ export const TOOLS = [
245
252
  return { text: `Noted at ${r.path}. No AI reads it until the owner says it can.`, data: r };
246
253
  },
247
254
  },
255
+ {
256
+ name: "evda_message",
257
+ title: "Tell the owner something",
258
+ description: "Sends the owner a message: writes it as one new file in the record's messages folder, which their phone app shows as a card, and returns its id and path. Use it for what they should hear from you (what you did, a question for them); a reply comes back through evda_replies. Never edits a message once written. Refuses anything shaped like a password, key or token.",
259
+ inputSchema: { type: "object", properties: { title: str("One line: what the card says at a glance."), body: str("The message, in markdown."), from: str("The agent's name. Default: the name in the operating layer.") }, required: ["title", "body"], additionalProperties: false },
260
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
261
+ run: (evda, args) => {
262
+ const r = tools.message(evda, args);
263
+ return { text: `Sent the message ${r.id}, filed at ${r.path}. The owner's reply, when there is one, comes back through evda_replies.`, data: r };
264
+ },
265
+ },
266
+ {
267
+ name: "evda_replies",
268
+ title: "Read the owner's replies",
269
+ description: "Call at the start of a run: returns the owner's replies to your messages that you have not seen yet (message id, title, reply, date) and marks them seen, so each shows once; a reply the owner changes shows again. peek: true returns them without marking.",
270
+ inputSchema: { type: "object", properties: { peek: { type: "boolean", description: "true to read without marking them seen." } }, additionalProperties: false },
271
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
272
+ run: (evda, args, ctx) => {
273
+ const r = tools.replies(evda, args, { caller: ctx.caller });
274
+ const body = r.replies.map((x) => `<reply to="${x.id}" title=${JSON.stringify(x.title)} date="${x.date}">\n${x.reply}\n</reply>`).join("\n\n");
275
+ const main = r.replies.length ? `${r.replies.length} new repl${r.replies.length === 1 ? "y" : "ies"} from the owner${args.peek ? " (peek: not marked seen)" : ""}.\n\n${body}` : "No new replies from the owner.";
276
+ return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { replies: r.replies, marked: r.marked } };
277
+ },
278
+ },
248
279
  {
249
280
  name: "evda_filing",
250
281
  title: "Record how filing works",
@@ -511,7 +542,7 @@ export const TOOLS = [
511
542
  ];
512
543
 
513
544
  export const RESOURCES = [
514
- { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${t}`; } },
545
+ { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${DISPATCH_SECTION}\n\n${t}`; } },
515
546
  {
516
547
  uri: "evda://today",
517
548
  name: "today",
@@ -0,0 +1,205 @@
1
+ // Dispatch through the record (HQ-1246): the owner's agent writes a message, the phone app shows it
2
+ // as a card, and the owner's reply comes back as a file the agent reads on its next run.
3
+ //
4
+ // The format, which the phone app (iris-mobile, the EVÐA target) reads exactly:
5
+ //
6
+ // <messages role>/YYYY-MM-DD-HHMM-<slug>.md one message, written once by the agent
7
+ // ---
8
+ // id: <the filename stem>
9
+ // from: "<agent name>"
10
+ // title: "<title>"
11
+ // date: "<ISO 8601 with offset>"
12
+ // ---
13
+ // <markdown body>
14
+ //
15
+ // <messages role>/<same stem>.reply.md the owner's reply, written by the app
16
+ // ---
17
+ // in_reply_to: <the message id>
18
+ // date: "<ISO 8601 with offset>"
19
+ // ---
20
+ // <reply text>
21
+ //
22
+ // <messages role>/.seen.json { "<id>": "<reply date seen>" }, the agent's
23
+ //
24
+ // The messages role resolves through evda.json like every other role, `messages/` when unmapped. A
25
+ // message is never edited after it is written: a second message in the same minute with the same
26
+ // slug gets `-2`, `-3`, and a file already at a name (a reply, or anything sealed) is never touched.
27
+ // "Answered" is the reply file existing, never a stored flag. What the agent has seen is keyed by the
28
+ // reply's date, so a reply the app rewrites after the agent read it shows again.
29
+ //
30
+ // Replies are read the way every other tool reads (lib/readers.mjs): a sealed reply is in no answer
31
+ // and a sealed section is cut out, a noted reply or one in a circle set to a different reader is held
32
+ // back and named, and nothing held back is marked seen.
33
+ //
34
+ // EVDA_MESSAGES_FALSIFY=<mode,...>, for the tests. Each breaks exactly one rule:
35
+ // overwrite a colliding name is written over instead of getting a suffix
36
+ // orphans a reply with no message is returned
37
+ // seen-by-id a reply whose id was ever seen stays hidden, whatever its date
38
+ // strict-seen a .seen.json that does not parse throws instead of reading as empty
39
+ // no-secrets the secret check is skipped
40
+ // default-path the evda.json mapping is ignored and messages/ is always used
41
+ // raw-slug the title goes into the filename unslugged
42
+ // no-readers replies are shown without asking lib/readers.mjs
43
+
44
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
45
+ import { join, relative, sep } from "node:path";
46
+ import { resolveRole, agentsFile } from "./roles.mjs";
47
+ import { frontmatterOf } from "./circles.mjs";
48
+ import { readContext, mayRead, heldBack, callerFrom, aiView } from "./readers.mjs";
49
+ import { localDate } from "./day-file.mjs";
50
+ import { EvdaError } from "./errors.mjs";
51
+
52
+ export const MESSAGES_ROLE = "messages";
53
+ export const MESSAGES_DEFAULT = "messages";
54
+ export const SEEN_FILE = ".seen.json";
55
+ export const REPLY_SUFFIX = ".reply.md";
56
+ export const DEFAULT_FROM = "Iris";
57
+ const TITLE_MAX = 200;
58
+ const FROM_MAX = 60;
59
+ const BODY_MAX = 200_000;
60
+ const SLUG_MAX = 60;
61
+
62
+ export const MESSAGES_FALSIFY_MODES = ["overwrite", "orphans", "seen-by-id", "strict-seen", "no-secrets", "default-path", "raw-slug", "no-readers"];
63
+ const falsified = (mode) => (process.env.EVDA_MESSAGES_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
64
+
65
+ const pad = (n) => String(n).padStart(2, "0");
66
+ const refuse = (message, code) => new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
67
+
68
+ // A local time as ISO 8601 with its offset: 2026-09-25T12:04:00-05:00.
69
+ export function isoLocal(d) {
70
+ const off = -d.getTimezoneOffset();
71
+ const sign = off >= 0 ? "+" : "-";
72
+ const a = Math.abs(off);
73
+ return `${localDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
74
+ }
75
+
76
+ // A title as a filename part: accents folded, lowercase ASCII letters and digits joined by single
77
+ // hyphens, at most 60 characters. A title with nothing left (all emoji, all Greek) is "message".
78
+ export function slugify(title) {
79
+ if (falsified("raw-slug")) return String(title);
80
+ const s = String(title).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
81
+ return s.slice(0, SLUG_MAX).replace(/-+$/, "") || "message";
82
+ }
83
+
84
+ // Where messages live: the role's mapping, else messages/ at the record root. resolveRole refuses a
85
+ // mapping that leaves the record.
86
+ export function messagesDir(evda) {
87
+ const r = falsified("default-path") ? { mapped: false } : resolveRole(evda, MESSAGES_ROLE);
88
+ if (r.mapped) {
89
+ if (r.exists && !r.isDir) throw refuse(`the messages role is mapped to ${r.rel}, which is a file; it holds a folder.`, "BAD_MAPPING");
90
+ return { abs: r.abs, rel: r.rel.replace(/\/+$/, "") };
91
+ }
92
+ return { abs: join(evda.dir, MESSAGES_DEFAULT), rel: MESSAGES_DEFAULT };
93
+ }
94
+
95
+ // The agent's name as the operating layer's owner section gives it, else Iris.
96
+ export function agentName(evda) {
97
+ try {
98
+ const a = agentsFile(evda);
99
+ if (!a) return DEFAULT_FROM;
100
+ const m = /^- \*\*Agent name:\*\*[ \t]*(\S.*)$/m.exec(readFileSync(a.abs, "utf8"));
101
+ return m?.[1]?.trim() || DEFAULT_FROM;
102
+ } catch {
103
+ return DEFAULT_FROM;
104
+ }
105
+ }
106
+
107
+ function oneLine(value, name, max) {
108
+ if (typeof value !== "string" || value.trim() === "") throw refuse(`evda_message needs "${name}".`, "EMPTY");
109
+ const v = value.trim();
110
+ if (/[\r\n]/.test(v)) throw refuse(`evda_message "${name}" is one line.`, "LINES");
111
+ if (v.length > max) throw refuse(`evda_message "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
112
+ return v;
113
+ }
114
+
115
+ // Write one new message. `refuseSecrets` is passed in by lib/tools.mjs so this module and evda_write
116
+ // refuse with the same check and the same words.
117
+ export function writeMessage(evda, args, { now = new Date(), refuseSecrets } = {}) {
118
+ const title = oneLine(args.title, "title", TITLE_MAX);
119
+ if (typeof args.body !== "string" || args.body.trim() === "") throw refuse('evda_message needs "body", what you are telling the owner.', "EMPTY");
120
+ if (args.body.length > BODY_MAX) throw refuse(`evda_message "body" is ${args.body.length} characters; one message holds at most ${BODY_MAX}.`, "TOO_LONG");
121
+ const from = args.from === undefined ? agentName(evda) : oneLine(args.from, "from", FROM_MAX);
122
+ if (!falsified("no-secrets")) for (const s of [title, args.body, from]) refuseSecrets(s, "evda_message");
123
+ const dir = messagesDir(evda);
124
+ mkdirSync(dir.abs, { recursive: true });
125
+ const base = `${localDate(now)}-${pad(now.getHours())}${pad(now.getMinutes())}-${slugify(title)}`;
126
+ const taken = (stem) => existsSync(join(dir.abs, `${stem}.md`)) || existsSync(join(dir.abs, `${stem}${REPLY_SUFFIX}`));
127
+ let stem = base;
128
+ if (!falsified("overwrite")) for (let n = 2; taken(stem); n++) stem = `${base}-${n}`;
129
+ const abs = join(dir.abs, `${stem}.md`);
130
+ const text = ["---", `id: ${stem}`, `from: ${JSON.stringify(from)}`, `title: ${JSON.stringify(title)}`, `date: ${JSON.stringify(isoLocal(now))}`, "---", "", args.body.replace(/\n+$/, ""), ""].join("\n");
131
+ // "wx": never replace a file, even one that appeared between the check and the write.
132
+ writeFileSync(abs, text, { flag: falsified("overwrite") ? "w" : "wx" });
133
+ return { id: stem, path: relative(evda.dir, abs).split(sep).join("/") };
134
+ }
135
+
136
+ // What the agent has seen, as { id: reply date }. Missing, unreadable, or not a JSON object of
137
+ // strings: empty, so every reply shows and the next mark rewrites the file whole.
138
+ export function readSeen(dirAbs) {
139
+ const p = join(dirAbs, SEEN_FILE);
140
+ if (!existsSync(p)) return {};
141
+ let v;
142
+ try { v = JSON.parse(readFileSync(p, "utf8")); } catch (e) { if (falsified("strict-seen")) throw e; return {}; }
143
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
144
+ return Object.fromEntries(Object.entries(v).filter(([, d]) => typeof d === "string"));
145
+ }
146
+
147
+ function writeSeen(dirAbs, seen) {
148
+ const p = join(dirAbs, SEEN_FILE);
149
+ const t = `${p}.tmp-${process.pid}`;
150
+ writeFileSync(t, JSON.stringify(seen, null, 2) + "\n");
151
+ renameSync(t, p);
152
+ }
153
+
154
+ // The text after a file's frontmatter.
155
+ function bodyOf(text) {
156
+ const lines = text.split("\n");
157
+ if (lines[0] !== "---") return text;
158
+ const end = lines.indexOf("---", 1);
159
+ return end === -1 ? text : lines.slice(end + 1).join("\n");
160
+ }
161
+
162
+ const dateString = (v) => (v instanceof Date ? v.toISOString() : typeof v === "string" && v.trim() !== "" ? v.trim() : null);
163
+
164
+ // The owner's replies the agent has not seen: { replies: [{ id, title, reply, date, path }], heldBack,
165
+ // marked }. Marks each one returned as seen unless `peek`. A reply with no message is not one.
166
+ export function unseenReplies(evda, { peek = false } = {}, { caller = callerFrom() } = {}) {
167
+ const dir = messagesDir(evda);
168
+ if (!existsSync(dir.abs) || !statSync(dir.abs).isDirectory()) return { replies: [], heldBack: "", marked: 0, dir: dir.rel };
169
+ const seen = readSeen(dir.abs);
170
+ const ctx = readContext(evda);
171
+ const refused = [];
172
+ let sealed = false;
173
+ const replies = [];
174
+ for (const name of readdirSync(dir.abs).sort()) {
175
+ if (!name.endsWith(REPLY_SUFFIX) || name.startsWith(".")) continue;
176
+ const id = name.slice(0, -REPLY_SUFFIX.length);
177
+ const abs = join(dir.abs, name);
178
+ if (!statSync(abs).isFile()) continue;
179
+ const msgAbs = join(dir.abs, `${id}.md`);
180
+ if (!existsSync(msgAbs) && !falsified("orphans")) continue;
181
+ if (!falsified("no-readers")) {
182
+ const may = mayRead(ctx, abs, caller);
183
+ if (!may.ok) { refused.push(may); continue; }
184
+ }
185
+ const raw = readFileSync(abs, "utf8");
186
+ const front = frontmatterOf(raw) ?? {};
187
+ const date = dateString(front.date) ?? statSync(abs).mtime.toISOString();
188
+ if (falsified("seen-by-id") ? id in seen : seen[id] === date) continue;
189
+ const shown = falsified("no-readers") ? { text: raw, sections: 0 } : aiView(raw);
190
+ if (shown.sections) sealed = true;
191
+ let title = id;
192
+ if (existsSync(msgAbs)) {
193
+ const mraw = readFileSync(msgAbs, "utf8");
194
+ const mfront = frontmatterOf(mraw);
195
+ if (typeof mfront?.title === "string" && mfront.title.trim()) title = mfront.title.trim();
196
+ }
197
+ replies.push({ id, title, reply: bodyOf(shown.text).replace(/^\n+|\n+$/g, ""), date, path: relative(evda.dir, abs).split(sep).join("/") });
198
+ }
199
+ replies.sort((a, b) => (a.date === b.date ? a.id.localeCompare(b.id) : a.date.localeCompare(b.date)));
200
+ if (!peek && replies.length) {
201
+ for (const r of replies) seen[r.id] = r.date;
202
+ writeSeen(dir.abs, seen);
203
+ }
204
+ return { replies, heldBack: heldBack(refused, { sealed }), marked: peek ? 0 : replies.length, dir: dir.rel };
205
+ }