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
@@ -27,7 +27,9 @@
27
27
  //
28
28
  // Revert restores the record and the sources to their bytes before the apply: moved files go back
29
29
  // with their mode and modification time, written files are removed, changed manifests get their
30
- // old bytes and times back, and directories the apply made are removed when empty. A written file
30
+ // old bytes and times back, and directories the apply made are removed when empty. Every folder that
31
+ // existed before the apply and that it wrote into or moved a file out of (in the pile or the record)
32
+ // gets its modification time back (HQ-1368); one removed since is named and left gone. A written file
31
33
  // edited since the apply is named and the revert refused, unless --force.
32
34
  //
33
35
  // EVDA_GATHER_FALSIFY=<mode,...>, for the tests (APPLY_FALSIFY_MODES):
@@ -36,27 +38,35 @@
36
38
  // reapply-resorts nothing in the record counts as present, so a second gather places it all again
37
39
  // move-without-flag apply moves the sources when --move was not given
38
40
  // revert-ignores-edits revert removes a placed file the owner edited, without --force
41
+ // revert-dir-times revert leaves the folders' modification times as its own writes made them
42
+ // two-schemes a create-evda record grows the preset's Title Case folders beside record/
39
43
 
40
44
  import { createHash } from "node:crypto";
41
45
  import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
42
46
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
43
47
  import { tmpdir } from "node:os";
44
- import { openEvda, readManifest, findRoot } from "./manifest.mjs";
48
+ import { openEvda, readManifest, findRoot, holderOf } from "./manifest.mjs";
45
49
  import { fourRoles, roleForPath, fourRoleOf } from "./roles.mjs";
46
50
  import { subRecords } from "./records.mjs";
47
51
  import { folderManifests, folderFor, FOLDER_MANIFEST } from "./folders.mjs";
48
52
  import { loadPreset, applyPreset, manifestFor } from "./presets.mjs";
49
53
  import { reference } from "./references.mjs";
50
- import { proposeCircle, onlyKeys, refuse } from "./tools.mjs";
54
+ import { proposeCircle, onlyKeys, refuse, PROPOSALS } from "./tools.mjs";
51
55
  import { doctor } from "./doctor.mjs";
52
56
  import { gatherDir, loadProposal, checkOut, presentIndex, presentAt, GATHERED, chatPlan, dayDirOf } from "./gather.mjs";
53
57
  import { dayAppend } from "./chat-export.mjs";
58
+ import { sealForKeychain, removeSecret, SERVICE } from "./keychain.mjs";
54
59
  import { EvdaError } from "./errors.mjs";
55
60
  import { carries, NOTED, SEALED } from "./circles.mjs";
61
+ import { planProfile } from "./migrate-people.mjs";
62
+ import { README_NAME } from "./person-folders.mjs";
63
+ import { localDate } from "./day-file.mjs";
64
+ import { htmlNoteToMarkdown } from "./html-notes.mjs";
65
+ import { captionOf } from "./takeout.mjs";
56
66
  import { parseArgs, scaffold } from "../create-evda.mjs";
57
67
 
58
68
  export const APPLY_VERSION = 1;
59
- export const APPLY_FALSIFY_MODES = ["apply-writes-share", "revert-not-exact", "reapply-resorts", "move-without-flag", "revert-ignores-edits"];
69
+ export const APPLY_FALSIFY_MODES = ["apply-writes-share", "revert-not-exact", "reapply-resorts", "move-without-flag", "revert-ignores-edits", "revert-dir-times", "two-schemes"];
60
70
  const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
61
71
  const posix = (p) => p.split(sep).join("/");
62
72
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
@@ -67,20 +77,27 @@ const within = (abs, root) => abs === root || abs.startsWith(root + sep);
67
77
 
68
78
  // Every write the apply makes goes through here, so the reversal manifest is complete by
69
79
  // construction: a file made, a file changed (its old bytes and times kept), a directory made.
70
- function tracker(root) {
71
- const t = { root, created: [], modified: [], dirs: [], moved: [], stash: new Map() };
80
+ function tracker(root, times = new Map(), made = []) {
81
+ const t = { root, created: [], modified: [], dirs: [], moved: [], stash: new Map(), times };
72
82
  const abs = (rel) => join(root, rel);
83
+ // A folder that was there before the apply is about to gain or lose an entry: its times are kept
84
+ // first, once, so revert can put them back. One the apply made (or made the record with) is removed
85
+ // by revert instead. `times` is shared with the apply, which notes the pile's folders the same way.
86
+ const ours = new Set(made.map((d) => abs(d)));
87
+ t.noteDir = (dirAbs) => { if (!ours.has(dirAbs) && !t.dirs.some((d) => abs(d) === dirAbs)) noteDirTimes(times, dirAbs); };
73
88
  t.mkdirp = (rel) => {
74
89
  if (!rel || rel === ".") return;
75
90
  const segs = rel.split("/");
76
91
  for (let i = 1; i <= segs.length; i++) {
77
92
  const sub = segs.slice(0, i).join("/");
78
93
  if (existsSync(abs(sub))) continue;
94
+ t.noteDir(dirname(abs(sub)));
79
95
  mkdirSync(abs(sub));
80
96
  t.dirs.push(sub);
81
97
  }
82
98
  };
83
99
  t.writeNew = (rel, buf, times = null) => {
100
+ t.noteDir(dirname(abs(rel)));
84
101
  t.mkdirp(posix(dirname(rel)));
85
102
  writeFileSync(abs(rel), buf, { flag: "wx" });
86
103
  if (times) utimesSync(abs(rel), times.atime, times.mtime);
@@ -94,6 +111,7 @@ function tracker(root) {
94
111
  t.stash.set(rel, { path: rel, before: readFileSync(abs(rel)).toString("base64"), mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
95
112
  } else t.stash.set(rel, { path: rel, before: null });
96
113
  }
114
+ t.noteDir(dirname(abs(rel)));
97
115
  t.mkdirp(posix(dirname(rel)));
98
116
  writeFileSync(abs(rel), buf);
99
117
  };
@@ -105,6 +123,13 @@ function tracker(root) {
105
123
  return t;
106
124
  }
107
125
 
126
+ // Keep a folder's times before the apply changes what is in it: { path, atimeMs, mtimeMs }, by path.
127
+ function noteDirTimes(times, dirAbs) {
128
+ if (times.has(dirAbs) || !existsSync(dirAbs)) return;
129
+ const st = statSync(dirAbs);
130
+ if (st.isDirectory()) times.set(dirAbs, { path: dirAbs, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
131
+ }
132
+
108
133
  // Every file and directory under `root`, relative, hidden ones included; symlinks are listed as files.
109
134
  function tree(root) {
110
135
  const files = [];
@@ -171,13 +196,37 @@ function ensureRecord(target, p, { owner = "", dry = false } = {}) {
171
196
  return { evda: openEvda(dir), made, existed: false, scratch: dry ? dirname(dir) : null };
172
197
  }
173
198
 
199
+ // A new record is create-evda's own layout and nothing more (HQ-1368, naming slice): the preset's
200
+ // Title Case folders are not made, so the folders the apply needs are made in the record's own
201
+ // lowercase scheme under record/, beside record/daily, and only those the items need.
174
202
  function createRecord(dir, presetId, owner) {
175
- const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : []), "--preset", presetId]);
203
+ const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : [])]);
176
204
  scaffold(opts);
177
- const plan = applyPreset(dir, presetId);
178
- return { preset: plan.id };
205
+ if (falsified("two-schemes")) applyPreset(dir, presetId);
206
+ return { preset: presetId };
179
207
  }
180
208
 
209
+ // Folders other code finds by their name, kept as they are in every layout.
210
+ const NAMED_FOLDERS = new Set(["Credentials"]);
211
+
212
+ // True when the record is laid out the way create-evda makes one: its record layer under record/
213
+ // and no typed folder of a preset beside it. A record a preset grew (Journal/, 01_Projects/) keeps
214
+ // its own scheme. (HQ-1368, naming slice; Phronesis 2026-10-21.)
215
+ export function ownLayout(evda) {
216
+ if (falsified("two-schemes")) return false;
217
+ if (evda.manifest.mapping?.["record/daily"] !== "record/daily") return false;
218
+ const subs = subRecords(evda.dir);
219
+ return folderManifests(evda).every((m) => {
220
+ const rel = posix(relative(evda.dir, m.dir));
221
+ return !m.type || rel === "" || rel === "record" || rel.startsWith("record/") || rel === "messages" || rel.startsWith("messages/") || NAMED_FOLDERS.has(rel.split("/")[0]) || subs.some((s) => rel === s || rel.startsWith(`${s}/`));
222
+ });
223
+ }
224
+
225
+ // A proposal folder's name in a create-evda record: lowercase under record/, any number prefix
226
+ // dropped, so 01_Projects is record/projects and Events is record/events. Only the proposal's own
227
+ // folder is renamed; a subject below it ("02_Areas/COMP 318") keeps its spaces and case.
228
+ export const ownName = (path) => (NAMED_FOLDERS.has(path.split("/")[0]) ? path : `record/${path.split("/").map((s) => s.replace(/^\d+_/, "").toLowerCase()).join("/")}`);
229
+
181
230
  // ---- the plan ----
182
231
 
183
232
  // The folder a proposal folder lands in, in this record.
@@ -188,11 +237,13 @@ function folderResolver(evda, p) {
188
237
  const inSub = (rel) => subs.some((s) => rel === s || rel.startsWith(`${s}/`));
189
238
  const manifests = folderManifests(evda).filter((m) => m.type && !inSub(posix(relative(evda.dir, m.dir))));
190
239
  const cache = new Map();
240
+ const own = ownLayout(evda);
191
241
  return (f) => {
192
242
  if (cache.has(f.path)) return cache.get(f.path);
193
243
  let r;
194
- const abs = join(evda.dir, f.path);
195
- if (existsSync(abs) && statSync(abs).isDirectory()) r = { path: f.path, how: "exists" };
244
+ const name = own ? ownName(f.path) : f.path;
245
+ const abs = join(evda.dir, name);
246
+ if (existsSync(abs) && statSync(abs).isDirectory()) r = { path: name, how: "exists" };
196
247
  else if (!falsified("reapply-resorts") && f.type) {
197
248
  const same = manifests
198
249
  .map((m) => ({ m, rel: posix(relative(evda.dir, m.dir)) }))
@@ -202,7 +253,7 @@ function folderResolver(evda, p) {
202
253
  }
203
254
  if (!r) {
204
255
  const pf = preset.folders.find((x) => x.path === f.path);
205
- r = { path: f.path, how: "create", type: f.type ?? null, role: f.role ?? null, connectors: pf?.connectors ?? [] };
256
+ r = { path: name, how: "create", type: f.type ?? null, role: f.role ?? null, connectors: pf?.connectors ?? [] };
206
257
  }
207
258
  cache.set(f.path, r);
208
259
  return r;
@@ -212,17 +263,26 @@ function folderResolver(evda, p) {
212
263
  // The proposal folder a destination sits under: the longest one at or above it.
213
264
  const topOf = (p, to) => p.folders.filter((f) => to === f.path || to.startsWith(`${f.path}/`)).sort((a, b) => b.path.length - a.path.length)[0] ?? null;
214
265
 
266
+ // The caption a photo's Takeout sidecar holds, read at apply because the proposal quotes none; null
267
+ // when the sidecar is gone or changed since the gather.
268
+ function sidecarCaption(p, item) {
269
+ const abs = sourceAbs(p, { ...item, path: item.takeout.sidecar });
270
+ let buf;
271
+ try { buf = readFileSync(abs); } catch { return null; }
272
+ return sha256(buf) === item.takeout.sha256 ? captionOf(buf) : null;
273
+ }
274
+
215
275
  function sourceAbs(p, item) {
216
276
  const s = p.sources.find((x) => x.id === item.source);
217
277
  return existsSync(s.path) && statSync(s.path).isFile() ? s.path : join(s.path, item.path);
218
278
  }
219
279
 
220
280
  // The whole apply as a list of intended writes, before any is made. Pure over the record's state.
221
- function plan(evda, p, { mode }) {
281
+ function plan(evda, p, { mode, now = new Date() }) {
222
282
  const resolveFolder = folderResolver(evda, p);
223
283
  const index = presentIndex(evda.dir);
224
284
  const items = new Map(p.items.map((i) => [i.id, i]));
225
- const out = { writes: [], present: [], changed: [], left: [], refused: [], referenceOnly: [], folders: new Map() };
285
+ const out = { writes: [], present: [], changed: [], left: [], refused: [], duplicates: [], referenceOnly: [], folders: new Map() };
226
286
  const taken = new Set();
227
287
  const claim = (to) => {
228
288
  let t = to;
@@ -255,6 +315,7 @@ function plan(evda, p, { mode }) {
255
315
  const i = items.get(pl.item);
256
316
  if (pl.status === "refused") { out.refused.push({ item: pl.item, why: pl.why }); continue; }
257
317
  if (pl.status === "unplaced") { out.left.push({ item: pl.item, why: pl.why }); continue; }
318
+ if (pl.status === "duplicate") { out.duplicates.push({ item: pl.item, of: pl.of }); continue; }
258
319
  if (pl.status === "present") { out.present.push({ item: pl.item, at: pl.at }); continue; }
259
320
  if (pl.status === "reference" && !pl.to) { out.referenceOnly.push({ item: pl.item, refs: (i.cards ?? []).map((c) => c.ref) }); continue; }
260
321
  if (pl.status === "create") {
@@ -290,17 +351,32 @@ function plan(evda, p, { mode }) {
290
351
  // A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
291
352
  // evda_read refuse it to every caller without naming it, and no tool releases it.
292
353
  if (md && pl.sealed && !i.sealed) add.sealed = `${SEALED}: true`;
354
+ // A Keep note's Takeout sidecar gives its created and edited times (HQ-1368, lib/takeout.mjs).
355
+ if (md && i.takeout?.created) add.created = `created: ${q(i.takeout.created)}`;
356
+ if (md && i.takeout?.edited) add.edited = `edited: ${q(i.takeout.edited)}`;
293
357
  // The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
294
358
  // record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
295
- let bytes = withFrontmatter(buf, add);
359
+ // A note-shaped HTML file becomes the markdown the proposal named (HQ-1368); revert puts the .html back.
360
+ let bytes = withFrontmatter(pl.convert === "html-to-markdown" ? Buffer.from(htmlNoteToMarkdown(buf.toString("utf8")).markdown, "utf8") : buf, add);
296
361
  // A note that already said `sealed: false` (or anything but true) is sealed all the same.
297
362
  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");
298
363
  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");
299
364
  const st = statSync(src);
365
+ // A photo's date taken, from its Takeout sidecar, is its copy's modification time, to the second:
366
+ // a photo carries no frontmatter, and the time Takeout wrote the file says nothing of the moment.
367
+ // The caption is read again from the sidecar, unchanged since the gather, for the gathered list.
368
+ const taken = !md && Number.isInteger(i.takeout?.takenEpoch) ? i.takeout.takenEpoch : null;
369
+ const takeout = i.takeout ? { ...(i.takeout.taken ? { taken: i.takeout.taken } : {}), ...(i.takeout.created ? { created: i.takeout.created } : {}), ...(i.takeout.caption ? { caption: sidecarCaption(p, i) } : {}) } : null;
370
+ if (takeout && takeout.caption === null) delete takeout.caption;
371
+ // A note gather caught as holding a credential, newly sealed here (HQ-1916): its value(s) are
372
+ // moved into the Keychain at apply, not left in the file. A file that already carried its own
373
+ // seal (re-gathered, or hand-marked) is not a fresh credential detection and is left as it is.
300
374
  out.writes.push({
301
375
  kind: mode === "move" ? "move" : "copy", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes, original: buf,
376
+ credential: !!(md && pl.sealed && i.credential && !i.sealed),
302
377
  schemes: md && add.references ? pl.refs.map((r) => r.split(":")[0]) : [],
303
- times: { atime: st.atimeMs / 1000, mtime: st.mtimeMs / 1000 }, mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs,
378
+ times: { atime: st.atimeMs / 1000, mtime: taken ?? st.mtimeMs / 1000 }, mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs,
379
+ ...(takeout && Object.keys(takeout).length ? { takeout } : {}),
304
380
  });
305
381
  }
306
382
  // A finished project with no README placed gets one, holding only its status and its name.
@@ -309,6 +385,20 @@ function plan(evda, p, { mode }) {
309
385
  const { to, folder } = land(`${f.dir}/README.md`);
310
386
  out.writes.push({ kind: "page", item: `status:${f.name}`, to: claim(to), folder, bytes: Buffer.from(`---\nstatus: ${f.status}\n---\n# ${f.name}\n`, "utf8"), schemes: [] });
311
387
  }
388
+ // A page that lands directly in a person folder is that person's profile (HQ-2024), so it opens
389
+ // with person-profile frontmatter, planned the way `evda migrate-people` plans one: keys the page
390
+ // already has stay, a dated heading becomes an observation, and a page with none gets one dated
391
+ // the day of the apply. A sealed or just-noted page is left as it is, and doctor names it.
392
+ const typeAt = new Map(folderManifests(evda).map((m) => [posix(relative(evda.dir, m.dir)), m.type]));
393
+ const owner = (() => { const h = holderOf(evda.manifest); return h.id ?? h.name ?? "owner"; })();
394
+ for (const w of out.writes) {
395
+ const f = out.folders.get(w.folder);
396
+ if (!f || !/\.md$/i.test(w.to) || basename(w.to) === README_NAME || posix(dirname(w.to)) !== f.path) continue;
397
+ if ((typeAt.has(f.path) ? typeAt.get(f.path) : f.proposal.type) !== "person") continue;
398
+ const prof = planProfile({ fileName: basename(w.to), text: w.bytes.toString("utf8"), owner, date: localDate(now) });
399
+ if (prof.skip || prof.problems.length) continue;
400
+ w.bytes = Buffer.concat([Buffer.from(prof.frontmatter, "utf8"), w.bytes.subarray(prof.bodyAt)]);
401
+ }
312
402
  // The folders the writes land in: made, with their manifest and role, or the record's own.
313
403
  out.roles = [];
314
404
  const mapping = evda.manifest.mapping;
@@ -317,7 +407,8 @@ function plan(evda, p, { mode }) {
317
407
  for (const f of [...out.folders.values()].filter((x) => x.how === "create" && out.writes.some((w) => w.folder === x.proposal.path))) {
318
408
  if (roleForPath(four, f.path)) continue;
319
409
  // The proposal's own key for this folder, or for the nearest folder above it.
320
- const candidates = Object.entries(p.mapping ?? {}).filter(([, v]) => f.path === v || f.path.startsWith(`${v}/`)).sort((a, b) => b[1].length - a[1].length);
410
+ // A folder renamed into the record's own scheme takes the folder's own role under its new name.
411
+ const candidates = f.path !== f.proposal.path ? [] : Object.entries(p.mapping ?? {}).filter(([, v]) => f.path === v || f.path.startsWith(`${v}/`)).sort((a, b) => b[1].length - a[1].length);
321
412
  const [key0, rel] = candidates[0] ?? [`${f.role ?? "collections"}/${slug(basename(f.path))}`, f.path];
322
413
  if (out.roles.some((r) => r.rel === rel) || Object.values(mapping).includes(rel)) continue;
323
414
  let key = key0;
@@ -349,9 +440,12 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
349
440
  const manifestAbs = dry ? null : checkOut(manifestOut ?? join(gatherDir(env), `${p.id}.apply-${now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "")}.json`), p);
350
441
  if (manifestAbs && existsSync(manifestAbs)) throw refuse(`a reversal manifest is already at ${manifestAbs}. Name another with --manifest.`, "MANIFEST_EXISTS");
351
442
 
443
+ // The folders whose times revert puts back: the target's own, when it is there before the apply.
444
+ const dirTimes = new Map();
445
+ if (!dry) noteDirTimes(dirTimes, target);
352
446
  const rec = ensureRecord(target, p, { owner, dry });
353
447
  const evda = rec.evda;
354
- const pl = plan(evda, p, { mode });
448
+ const pl = plan(evda, p, { mode, now });
355
449
  pl.chats = chatWrites(evda, p);
356
450
  const result = { dry, mode, target, made: rec.made, plan: pl, manifest: manifestAbs, proposal: p.id };
357
451
  result.counts = counts(pl, p);
@@ -361,7 +455,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
361
455
  return result;
362
456
  }
363
457
 
364
- const t = tracker(evda.dir);
458
+ const t = tracker(evda.dir, dirTimes, rec.made?.dirs ?? []);
365
459
  // 1. Folders the writes need that the record does not have, each with its manifest.
366
460
  const needSchemes = new Map();
367
461
  for (const w of pl.writes) for (const s of w.schemes) {
@@ -385,13 +479,35 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
385
479
  }
386
480
  // 3. The files.
387
481
  const ledger = [];
482
+ // HQ-1916: a credential's value moves into the Keychain here, before its file ever touches disk;
483
+ // the tracker only ever sees the redacted bytes. Not macOS, or the write fails: the value stays in
484
+ // the sealed file exactly as HQ-1368 left it, and the fallback says why in one plain line.
485
+ const keychainPath = env.EVDA_KEYCHAIN_PATH || null;
486
+ const keychainAccounts = [];
487
+ const keychainLines = [];
488
+ let keychainFilesMoved = 0;
489
+ let keychainFilesKept = 0;
388
490
  for (const w of pl.writes) {
389
- t.writeNew(w.to, w.bytes, w.times ?? null);
390
- if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString() });
491
+ let bytes = w.bytes;
492
+ if (w.credential) {
493
+ const sealed = sealForKeychain(bytes.toString("utf8"), basename(w.to), { keychainPath });
494
+ if (sealed.ok) {
495
+ bytes = Buffer.from(sealed.text, "utf8");
496
+ keychainAccounts.push(...sealed.accounts);
497
+ keychainFilesMoved += 1;
498
+ keychainLines.push(` kept ${w.to}: ${sealed.accounts.length} value(s) moved to the Keychain (${SERVICE}); the file holds a pointer, not the value`);
499
+ } else {
500
+ keychainFilesKept += 1;
501
+ keychainLines.push(` kept ${w.to}: the value stays in the sealed file (${sealed.why})`);
502
+ }
503
+ }
504
+ t.writeNew(w.to, bytes, w.times ?? null);
505
+ if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString(), ...(w.takeout ? { takeout: w.takeout } : {}) });
391
506
  if (w.kind === "move") {
507
+ t.noteDir(dirname(w.source));
392
508
  unlinkSync(w.source);
393
509
  // A file the apply changed on the way in keeps its source bytes here, so revert can put them back.
394
- t.moved.push({ source: w.source, to: w.to, sha256: w.sourceSha, mode: w.mode, atimeMs: w.atimeMs, mtimeMs: w.mtimeMs, ...(sha256(w.bytes) !== w.sourceSha ? { bytes: w.original.toString("base64") } : {}) });
510
+ t.moved.push({ source: w.source, to: w.to, sha256: w.sourceSha, mode: w.mode, atimeMs: w.atimeMs, mtimeMs: w.mtimeMs, ...(sha256(bytes) !== w.sourceSha ? { bytes: w.original.toString("base64") } : {}) });
395
511
  }
396
512
  }
397
513
  // 3b. The owner's chat messages no agent filed (HQ-1385), appended to their days.
@@ -429,6 +545,8 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
429
545
  const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
430
546
  if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
431
547
  const before = tree(evda.dir);
548
+ t.noteDir(evda.dir);
549
+ t.noteDir(join(evda.dir, PROPOSALS));
432
550
  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 });
433
551
  const after = tree(evda.dir);
434
552
  for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
@@ -451,9 +569,15 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
451
569
  modified: t.modified.filter((m) => !rec.made?.files.includes(m.path)),
452
570
  dirs: t.dirs,
453
571
  moved: t.moved,
572
+ // The times of every folder that was there before the apply and that it changed (HQ-1368).
573
+ dirTimes: [...dirTimes.values()],
454
574
  placed: pl.writes.map((w) => ({ item: w.item, kind: w.kind, from: w.source ?? null, to: w.to })),
455
575
  chats: [...pl.chats.byFile.keys()],
456
576
  counts: result.counts,
577
+ // HQ-1916: the Keychain items this apply added, and the keychain they went into (null is the
578
+ // login keychain), so revert can remove exactly these and nothing else.
579
+ keychain: keychainAccounts,
580
+ keychainPath,
457
581
  };
458
582
  mkdirSync(dirname(manifestAbs), { recursive: true });
459
583
  writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" });
@@ -461,6 +585,9 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
461
585
  const lines = [];
462
586
  const d = doctor(evda.dir, { out: (l) => lines.push(l), via: "gather apply" });
463
587
  result.doctor = { exit: d.exit, lines, failures: d.failures, warnings: d.warnings.length };
588
+ result.keychainLines = keychainLines;
589
+ result.keychainMoved = keychainFilesMoved;
590
+ result.keychainKept = keychainFilesKept;
464
591
  result.lines = applyLines(result, p);
465
592
  return result;
466
593
  }
@@ -493,7 +620,7 @@ function counts(pl, p) {
493
620
  const placed = by("copy") + by("move") + by("reference") + by("page");
494
621
  return {
495
622
  placed, copied: by("copy"), moved: by("move"), references: by("reference"), pages: by("page"),
496
- asked: (p.questions ?? []).length, left: pl.left.length, refused: pl.refused.length, present: pl.present.length,
623
+ asked: (p.questions ?? []).length, left: pl.left.length, refused: pl.refused.length, present: pl.present.length, duplicates: pl.duplicates.length,
497
624
  changed: pl.changed.length, referenceOnly: pl.referenceOnly.length,
498
625
  chatAdded: pl.chats?.added ?? 0, chatFiled: pl.chats?.filed ?? 0, chatSecret: pl.chats?.secret ?? 0, chatDays: pl.chats?.byFile.size ?? 0,
499
626
  };
@@ -507,7 +634,7 @@ function applyLines(r, p) {
507
634
  const c = r.counts;
508
635
  const out = [];
509
636
  const verb = r.dry ? "would" : "did";
510
- if (r.made) out.push(`${r.dry ? "Would make" : "Made"} a new EVDA at ${r.target} with the ${p.preset.title} preset (${r.made.files.length} file(s)).`);
637
+ if (r.made) out.push(`${r.dry ? "Would make" : "Made"} a new EVDA at ${r.target} in create-evda's own layout, its new folders under record/ (${r.made.files.length} file(s)).`);
511
638
  else out.push(`${r.dry ? "Would grow" : "Grew"} the EVDA at ${r.target}; nothing already in it was moved.`);
512
639
  for (const f of r.plan.folders.values()) {
513
640
  if (f.how === "adopted") out.push(` adopted ${f.proposal.path}/ as the record's own ${f.path}/ (type ${f.proposal.type}, ${f.proposal.role})`);
@@ -519,12 +646,14 @@ function applyLines(r, p) {
519
646
  if (c.pages) out.push(` ${verb === "did" ? "wrote " : "write "} ${c.pages} page(s) the answers asked for`);
520
647
  if (c.referenceOnly) out.push(` kept ${c.referenceOnly} export(s) as references only, never copied`);
521
648
  if (c.present) out.push(` present ${c.present} item(s) already in the record, left where they are`);
649
+ if (r.keychainLines?.length) out.push(...r.keychainLines);
522
650
  for (const x of r.plan.changed) out.push(` changed ${x.item} ${x.why}`);
523
651
  if ((p.chats ?? []).length) {
524
652
  out.push(` ${verb === "did" ? "added " : "add "} ${c.chatAdded} message(s) of the owner's from ${p.chats.length} chat export(s) to ${c.chatDays} day file(s), each conversation under its date and time; ${c.chatFiled} already in the record${c.chatSecret ? `, ${c.chatSecret} left out for holding something shaped like a secret` : ""}`);
525
653
  for (const why of r.plan.chats.changed) out.push(` changed ${why}`);
526
654
  }
527
655
  for (const x of r.plan.left) out.push(` left ${x.item}: ${x.why}`);
656
+ for (const x of r.plan.duplicates) out.push(` duplicate ${x.item}: the same bytes as ${x.of}, placed once; this copy is left in the pile`);
528
657
  for (const x of r.plan.refused) out.push(` refused ${x.item}: left where it is`);
529
658
  for (const x of r.proposed ?? []) out.push(` proposed ${x} (a proposal file; no circle was created, and no file was marked for sharing)`);
530
659
  if (!r.dry) {
@@ -535,7 +664,8 @@ function applyLines(r, p) {
535
664
  } else out.push("Nothing was written.");
536
665
  out.push(checkedLine(c));
537
666
  if ((p.chats ?? []).length) out.push(`CHECKED ${c.chatAdded + c.chatFiled + c.chatSecret} chat message(s) of the owner's ${c.chatAdded} added, ${c.chatFiled} already filed, ${c.chatSecret} secret-shaped`);
538
- out.push(`CHECKED ${c.copied + c.moved} file(s) ${r.mode === "move" ? "moved" : "copied"}, ${c.references} reference entr${c.references === 1 ? "y" : "ies"}, ${c.pages} page(s), ${c.present} already in the record, ${c.refused} refused, ${c.changed} changed since the gather`);
667
+ out.push(`CHECKED ${c.copied + c.moved} file(s) ${r.mode === "move" ? "moved" : "copied"}, ${c.references} reference entr${c.references === 1 ? "y" : "ies"}, ${c.pages} page(s), ${c.present} already in the record, ${c.refused} refused, ${c.changed} changed since the gather${c.duplicates ? `, ${c.duplicates} duplicate(s) left in the pile` : ""}`);
668
+ if (r.keychainLines?.length) out.push(`CHECKED ${r.keychainMoved + r.keychainKept} credential file(s) ${r.keychainMoved} with their value(s) moved to the Keychain, ${r.keychainKept} kept in the sealed file`);
539
669
  return out;
540
670
  }
541
671
 
@@ -587,6 +717,10 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
587
717
  }
588
718
  let removed = 0;
589
719
  for (const f of files) { const a = join(root, f.path); if (existsSync(a)) { unlinkSync(a); removed += 1; } }
720
+ // HQ-1916: every Keychain item this apply added, removed again. Never blocks the revert: a value
721
+ // already gone from the Keychain (the owner deleted it by hand) is simply not there to remove.
722
+ let keychainRemoved = 0;
723
+ for (const account of m.keychain ?? []) if (removeSecret({ account, keychainPath: m.keychainPath ?? null })) keychainRemoved += 1;
590
724
  let put = 0;
591
725
  if (!falsified("revert-not-exact")) {
592
726
  for (const f of m.modified) {
@@ -608,9 +742,21 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
608
742
  rmdirSync(a);
609
743
  }
610
744
  if (m.made?.root && existsSync(root) && readdirSync(root).length === 0) rmdirSync(root);
745
+ // Last, once nothing more is written: every folder the apply changed gets its times back, deepest
746
+ // first. One the owner removed since is named and not made again for its time.
747
+ const dirTimes = Array.isArray(m.dirTimes) ? m.dirTimes : null;
748
+ const timesSet = [];
749
+ const dirsGone = [];
750
+ if (dirTimes && !falsified("revert-dir-times")) {
751
+ for (const d of [...dirTimes].sort((a, b) => b.path.split(sep).length - a.path.split(sep).length)) {
752
+ if (!existsSync(d.path) || !statSync(d.path).isDirectory()) { dirsGone.push(d.path); continue; }
753
+ utimesSync(d.path, d.atimeMs / 1000, d.mtimeMs / 1000);
754
+ timesSet.push(d.path);
755
+ }
756
+ }
611
757
  m.reverted = now.toISOString();
612
758
  writeFileSync(abs, JSON.stringify(m, null, 2) + "\n");
613
- return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0 };
759
+ return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0, keychainRemoved, dirTimes: dirTimes ? { set: timesSet.length, gone: dirsGone } : null };
614
760
  }
615
761
 
616
762
  export function revertLines(r) {
@@ -621,7 +767,11 @@ export function revertLines(r) {
621
767
  if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the apply were removed: ${r.edited.join(", ")}`);
622
768
  for (const d of r.kept) out.push(` kept ${d}/, which holds files the apply did not write`);
623
769
  if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
770
+ if (r.dirTimes?.gone.length) out.push(` gone ${r.dirTimes.gone.length} folder(s) removed since the apply, left as they are: ${r.dirTimes.gone.map((d) => (d.startsWith(r.target + sep) ? posix(relative(r.target, d)) : d)).join(", ")}`);
771
+ if (r.keychainRemoved) out.push(` removed ${r.keychainRemoved} Keychain item(s) the apply added`);
624
772
  out.push(`CHECKED ${r.removed + r.restored + r.put} file(s) reverted ${r.kept.length} folder(s) kept`);
773
+ if (r.keychainRemoved) out.push(`CHECKED ${r.keychainRemoved} Keychain item(s) removed`);
774
+ out.push(r.dirTimes ? `CHECKED ${r.dirTimes.set} folder time(s) restored, ${r.dirTimes.gone.length} folder(s) gone` : "SKIPPED folder times: this reversal manifest is older than HQ-1368's folder times, so none were kept");
625
775
  return out;
626
776
  }
627
777