create-evda 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
@@ -12,6 +12,8 @@
12
12
  // here, so no surface can carry a different copy. No node: imports, because lib/clients.mjs is
13
13
  // rendered in the EVDA app's webview and reads this file.
14
14
 
15
+ import { appLike } from "./connecting-apps.mjs";
16
+
15
17
  export const EVERY_CHAT_KEY = "every_chat";
16
18
 
17
19
  // Bumped when the line's meaning changes. An owner who set an older version is asked once more,
@@ -66,18 +68,12 @@ export const CHAT_APPS = {
66
68
  };
67
69
  export const GENERIC_WHERE = "Add it to your app's every-chat instructions: the setting that applies to all of your conversations, not just this one.";
68
70
 
69
- // Which app is calling, from initialize's clientInfo.name. Coding agents (Claude Code, Codex,
70
- // Cursor and the like) are left out, because their every-session file is AGENTS.md or CLAUDE.md,
71
- // which create-evda already writes into the record. Matched by pattern: the names claude.ai and
72
- // ChatGPT send are not yet measured (HQ-2207 measures them), and a name that matches nothing is
73
- // "other", which is asked with GENERIC_WHERE rather than skipped, so a new app still gets the line.
74
- const CODING = /claude[-_ ]?code|codex|cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i;
71
+ // Which chat app is calling, from initialize's clientInfo.name: the every-chat key of the first row
72
+ // in lib/connecting-apps.mjs (HQ-2218) whose pattern matches it. A coding agent's row has none
73
+ // (null), and a name no row matches is "other", asked with GENERIC_WHERE rather than skipped.
75
74
  export function chatAppOf(clientName) {
76
- const n = String(clientName ?? "").trim();
77
- if (CODING.test(n)) return null;
78
- if (/claude|anthropic/i.test(n)) return "claude";
79
- if (/openai|chatgpt/i.test(n)) return "chatgpt";
80
- return "other";
75
+ const row = appLike(clientName);
76
+ return row ? row.chat : "other";
81
77
  }
82
78
 
83
79
  // What evda.json says for one app: { state: "set" | "declined", form, version, date } or null.
package/lib/folders.mjs CHANGED
@@ -25,6 +25,7 @@ import { join, relative, resolve, sep } from "node:path";
25
25
  import { resolveAll } from "./roles.mjs";
26
26
  import { SCHEMES } from "./references.mjs";
27
27
  import { EvdaError } from "./errors.mjs";
28
+ import { outsideRecord } from "./record-walk.mjs";
28
29
 
29
30
  export const FOLDER_MANIFEST = "folder.evda.json";
30
31
  export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
@@ -138,7 +139,7 @@ export function folderManifests(evda) {
138
139
  }
139
140
  const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
140
141
  for (const e of entries) {
141
- if (e.name.startsWith(".") || e.name === "node_modules") continue;
142
+ if (e.name.startsWith(".") || outsideRecord(e.name)) continue;
142
143
  if (e.isDirectory()) walk(join(dir, e.name));
143
144
  }
144
145
  };
@@ -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,28 +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";
54
58
  import { sealForKeychain, removeSecret, SERVICE } from "./keychain.mjs";
55
59
  import { EvdaError } from "./errors.mjs";
56
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";
57
66
  import { parseArgs, scaffold } from "../create-evda.mjs";
58
67
 
59
68
  export const APPLY_VERSION = 1;
60
- 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"];
61
70
  const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
62
71
  const posix = (p) => p.split(sep).join("/");
63
72
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
@@ -68,20 +77,27 @@ const within = (abs, root) => abs === root || abs.startsWith(root + sep);
68
77
 
69
78
  // Every write the apply makes goes through here, so the reversal manifest is complete by
70
79
  // construction: a file made, a file changed (its old bytes and times kept), a directory made.
71
- function tracker(root) {
72
- 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 };
73
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); };
74
88
  t.mkdirp = (rel) => {
75
89
  if (!rel || rel === ".") return;
76
90
  const segs = rel.split("/");
77
91
  for (let i = 1; i <= segs.length; i++) {
78
92
  const sub = segs.slice(0, i).join("/");
79
93
  if (existsSync(abs(sub))) continue;
94
+ t.noteDir(dirname(abs(sub)));
80
95
  mkdirSync(abs(sub));
81
96
  t.dirs.push(sub);
82
97
  }
83
98
  };
84
99
  t.writeNew = (rel, buf, times = null) => {
100
+ t.noteDir(dirname(abs(rel)));
85
101
  t.mkdirp(posix(dirname(rel)));
86
102
  writeFileSync(abs(rel), buf, { flag: "wx" });
87
103
  if (times) utimesSync(abs(rel), times.atime, times.mtime);
@@ -95,6 +111,7 @@ function tracker(root) {
95
111
  t.stash.set(rel, { path: rel, before: readFileSync(abs(rel)).toString("base64"), mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
96
112
  } else t.stash.set(rel, { path: rel, before: null });
97
113
  }
114
+ t.noteDir(dirname(abs(rel)));
98
115
  t.mkdirp(posix(dirname(rel)));
99
116
  writeFileSync(abs(rel), buf);
100
117
  };
@@ -106,6 +123,13 @@ function tracker(root) {
106
123
  return t;
107
124
  }
108
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
+
109
133
  // Every file and directory under `root`, relative, hidden ones included; symlinks are listed as files.
110
134
  function tree(root) {
111
135
  const files = [];
@@ -172,13 +196,37 @@ function ensureRecord(target, p, { owner = "", dry = false } = {}) {
172
196
  return { evda: openEvda(dir), made, existed: false, scratch: dry ? dirname(dir) : null };
173
197
  }
174
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.
175
202
  function createRecord(dir, presetId, owner) {
176
- const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : []), "--preset", presetId]);
203
+ const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : [])]);
177
204
  scaffold(opts);
178
- const plan = applyPreset(dir, presetId);
179
- return { preset: plan.id };
205
+ if (falsified("two-schemes")) applyPreset(dir, presetId);
206
+ return { preset: presetId };
207
+ }
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
+ });
180
223
  }
181
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
+
182
230
  // ---- the plan ----
183
231
 
184
232
  // The folder a proposal folder lands in, in this record.
@@ -189,11 +237,13 @@ function folderResolver(evda, p) {
189
237
  const inSub = (rel) => subs.some((s) => rel === s || rel.startsWith(`${s}/`));
190
238
  const manifests = folderManifests(evda).filter((m) => m.type && !inSub(posix(relative(evda.dir, m.dir))));
191
239
  const cache = new Map();
240
+ const own = ownLayout(evda);
192
241
  return (f) => {
193
242
  if (cache.has(f.path)) return cache.get(f.path);
194
243
  let r;
195
- const abs = join(evda.dir, f.path);
196
- 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" };
197
247
  else if (!falsified("reapply-resorts") && f.type) {
198
248
  const same = manifests
199
249
  .map((m) => ({ m, rel: posix(relative(evda.dir, m.dir)) }))
@@ -203,7 +253,7 @@ function folderResolver(evda, p) {
203
253
  }
204
254
  if (!r) {
205
255
  const pf = preset.folders.find((x) => x.path === f.path);
206
- 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 ?? [] };
207
257
  }
208
258
  cache.set(f.path, r);
209
259
  return r;
@@ -213,17 +263,26 @@ function folderResolver(evda, p) {
213
263
  // The proposal folder a destination sits under: the longest one at or above it.
214
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;
215
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
+
216
275
  function sourceAbs(p, item) {
217
276
  const s = p.sources.find((x) => x.id === item.source);
218
277
  return existsSync(s.path) && statSync(s.path).isFile() ? s.path : join(s.path, item.path);
219
278
  }
220
279
 
221
280
  // The whole apply as a list of intended writes, before any is made. Pure over the record's state.
222
- function plan(evda, p, { mode }) {
281
+ function plan(evda, p, { mode, now = new Date() }) {
223
282
  const resolveFolder = folderResolver(evda, p);
224
283
  const index = presentIndex(evda.dir);
225
284
  const items = new Map(p.items.map((i) => [i.id, i]));
226
- const out = { writes: [], present: [], changed: [], left: [], refused: [], referenceOnly: [], folders: new Map() };
285
+ const out = { writes: [], present: [], changed: [], left: [], refused: [], duplicates: [], referenceOnly: [], folders: new Map() };
227
286
  const taken = new Set();
228
287
  const claim = (to) => {
229
288
  let t = to;
@@ -256,6 +315,7 @@ function plan(evda, p, { mode }) {
256
315
  const i = items.get(pl.item);
257
316
  if (pl.status === "refused") { out.refused.push({ item: pl.item, why: pl.why }); continue; }
258
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; }
259
319
  if (pl.status === "present") { out.present.push({ item: pl.item, at: pl.at }); continue; }
260
320
  if (pl.status === "reference" && !pl.to) { out.referenceOnly.push({ item: pl.item, refs: (i.cards ?? []).map((c) => c.ref) }); continue; }
261
321
  if (pl.status === "create") {
@@ -291,13 +351,23 @@ function plan(evda, p, { mode }) {
291
351
  // A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
292
352
  // evda_read refuse it to every caller without naming it, and no tool releases it.
293
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)}`;
294
357
  // The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
295
358
  // record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
296
- 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);
297
361
  // A note that already said `sealed: false` (or anything but true) is sealed all the same.
298
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");
299
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");
300
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;
301
371
  // A note gather caught as holding a credential, newly sealed here (HQ-1916): its value(s) are
302
372
  // moved into the Keychain at apply, not left in the file. A file that already carried its own
303
373
  // seal (re-gathered, or hand-marked) is not a fresh credential detection and is left as it is.
@@ -305,7 +375,8 @@ function plan(evda, p, { mode }) {
305
375
  kind: mode === "move" ? "move" : "copy", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes, original: buf,
306
376
  credential: !!(md && pl.sealed && i.credential && !i.sealed),
307
377
  schemes: md && add.references ? pl.refs.map((r) => r.split(":")[0]) : [],
308
- 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 } : {}),
309
380
  });
310
381
  }
311
382
  // A finished project with no README placed gets one, holding only its status and its name.
@@ -314,6 +385,20 @@ function plan(evda, p, { mode }) {
314
385
  const { to, folder } = land(`${f.dir}/README.md`);
315
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: [] });
316
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
+ }
317
402
  // The folders the writes land in: made, with their manifest and role, or the record's own.
318
403
  out.roles = [];
319
404
  const mapping = evda.manifest.mapping;
@@ -322,7 +407,8 @@ function plan(evda, p, { mode }) {
322
407
  for (const f of [...out.folders.values()].filter((x) => x.how === "create" && out.writes.some((w) => w.folder === x.proposal.path))) {
323
408
  if (roleForPath(four, f.path)) continue;
324
409
  // The proposal's own key for this folder, or for the nearest folder above it.
325
- 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);
326
412
  const [key0, rel] = candidates[0] ?? [`${f.role ?? "collections"}/${slug(basename(f.path))}`, f.path];
327
413
  if (out.roles.some((r) => r.rel === rel) || Object.values(mapping).includes(rel)) continue;
328
414
  let key = key0;
@@ -354,9 +440,12 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
354
440
  const manifestAbs = dry ? null : checkOut(manifestOut ?? join(gatherDir(env), `${p.id}.apply-${now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "")}.json`), p);
355
441
  if (manifestAbs && existsSync(manifestAbs)) throw refuse(`a reversal manifest is already at ${manifestAbs}. Name another with --manifest.`, "MANIFEST_EXISTS");
356
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);
357
446
  const rec = ensureRecord(target, p, { owner, dry });
358
447
  const evda = rec.evda;
359
- const pl = plan(evda, p, { mode });
448
+ const pl = plan(evda, p, { mode, now });
360
449
  pl.chats = chatWrites(evda, p);
361
450
  const result = { dry, mode, target, made: rec.made, plan: pl, manifest: manifestAbs, proposal: p.id };
362
451
  result.counts = counts(pl, p);
@@ -366,7 +455,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
366
455
  return result;
367
456
  }
368
457
 
369
- const t = tracker(evda.dir);
458
+ const t = tracker(evda.dir, dirTimes, rec.made?.dirs ?? []);
370
459
  // 1. Folders the writes need that the record does not have, each with its manifest.
371
460
  const needSchemes = new Map();
372
461
  for (const w of pl.writes) for (const s of w.schemes) {
@@ -413,8 +502,9 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
413
502
  }
414
503
  }
415
504
  t.writeNew(w.to, bytes, w.times ?? null);
416
- if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString() });
505
+ if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString(), ...(w.takeout ? { takeout: w.takeout } : {}) });
417
506
  if (w.kind === "move") {
507
+ t.noteDir(dirname(w.source));
418
508
  unlinkSync(w.source);
419
509
  // A file the apply changed on the way in keeps its source bytes here, so revert can put them back.
420
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") } : {}) });
@@ -455,6 +545,8 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
455
545
  const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
456
546
  if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
457
547
  const before = tree(evda.dir);
548
+ t.noteDir(evda.dir);
549
+ t.noteDir(join(evda.dir, PROPOSALS));
458
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 });
459
551
  const after = tree(evda.dir);
460
552
  for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
@@ -477,6 +569,8 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
477
569
  modified: t.modified.filter((m) => !rec.made?.files.includes(m.path)),
478
570
  dirs: t.dirs,
479
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()],
480
574
  placed: pl.writes.map((w) => ({ item: w.item, kind: w.kind, from: w.source ?? null, to: w.to })),
481
575
  chats: [...pl.chats.byFile.keys()],
482
576
  counts: result.counts,
@@ -526,7 +620,7 @@ function counts(pl, p) {
526
620
  const placed = by("copy") + by("move") + by("reference") + by("page");
527
621
  return {
528
622
  placed, copied: by("copy"), moved: by("move"), references: by("reference"), pages: by("page"),
529
- 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,
530
624
  changed: pl.changed.length, referenceOnly: pl.referenceOnly.length,
531
625
  chatAdded: pl.chats?.added ?? 0, chatFiled: pl.chats?.filed ?? 0, chatSecret: pl.chats?.secret ?? 0, chatDays: pl.chats?.byFile.size ?? 0,
532
626
  };
@@ -540,7 +634,7 @@ function applyLines(r, p) {
540
634
  const c = r.counts;
541
635
  const out = [];
542
636
  const verb = r.dry ? "would" : "did";
543
- 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)).`);
544
638
  else out.push(`${r.dry ? "Would grow" : "Grew"} the EVDA at ${r.target}; nothing already in it was moved.`);
545
639
  for (const f of r.plan.folders.values()) {
546
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})`);
@@ -559,6 +653,7 @@ function applyLines(r, p) {
559
653
  for (const why of r.plan.chats.changed) out.push(` changed ${why}`);
560
654
  }
561
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`);
562
657
  for (const x of r.plan.refused) out.push(` refused ${x.item}: left where it is`);
563
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)`);
564
659
  if (!r.dry) {
@@ -569,7 +664,7 @@ function applyLines(r, p) {
569
664
  } else out.push("Nothing was written.");
570
665
  out.push(checkedLine(c));
571
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`);
572
- 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` : ""}`);
573
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`);
574
669
  return out;
575
670
  }
@@ -647,9 +742,21 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
647
742
  rmdirSync(a);
648
743
  }
649
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
+ }
650
757
  m.reverted = now.toISOString();
651
758
  writeFileSync(abs, JSON.stringify(m, null, 2) + "\n");
652
- return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0, keychainRemoved };
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 };
653
760
  }
654
761
 
655
762
  export function revertLines(r) {
@@ -660,9 +767,11 @@ export function revertLines(r) {
660
767
  if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the apply were removed: ${r.edited.join(", ")}`);
661
768
  for (const d of r.kept) out.push(` kept ${d}/, which holds files the apply did not write`);
662
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(", ")}`);
663
771
  if (r.keychainRemoved) out.push(` removed ${r.keychainRemoved} Keychain item(s) the apply added`);
664
772
  out.push(`CHECKED ${r.removed + r.restored + r.put} file(s) reverted ${r.kept.length} folder(s) kept`);
665
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");
666
775
  return out;
667
776
  }
668
777