create-evda 0.1.5 → 0.1.6

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 (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. package/presets/student/preset.json +0 -13
@@ -0,0 +1,231 @@
1
+ // Migrate an untyped People doc to a person-profile entry (HQ-1189), by adding frontmatter only.
2
+ //
3
+ // The six rules, each implemented once below:
4
+ // 1. The file is copied to <record root>/.backup/people-migration-<YYYY-MM-DD>/ first.
5
+ // 2. Frontmatter is added and nothing below it changes: kind, id (filename slug), schemaVersion,
6
+ // name (the H1), observations. A file that already has frontmatter is skipped and reported.
7
+ // 3. One observation per dated H2/H3 heading: text is the heading text, since is its date at the
8
+ // precision written (day or month). A range uses its start. A year-only heading stays prose.
9
+ // 4. supersededBy only when a heading says it replaces an earlier one AND exactly one earlier
10
+ // observation fits. Otherwise it stays unlinked and is listed.
11
+ // 5. A quote is attached only when its line names the speaker (`Name: "..."`, `"..." (Name, ...)`
12
+ // or `"..." -- Name`), only when the section has exactly one such line (an observation holds one
13
+ // quote), and only when the speaker is the profiled person or the owner, by first or full name.
14
+ // The owner is provenance owner; the person is provenance person with by naming them.
15
+ // 6. Undated sections stay prose.
16
+ // Before a file is reported done, the bytes after the frontmatter are hashed against the original.
17
+ // A doc with no dated heading is left untouched and reported: a profile needs one observation.
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { basename, dirname, join, relative } from "node:path";
22
+ import { findRoot, holderOf, readManifest } from "./manifest.mjs";
23
+ import { readEntry } from "./kinds.mjs";
24
+ import { parseYaml, toYaml } from "./yaml.mjs";
25
+ import { carries, frontmatterOf, heldBy, sealedSections, NOTED, SEALED } from "./circles.mjs";
26
+
27
+ // The lines of a body with every line of a sealed section blanked, so nothing in one becomes an
28
+ // observation, a name or a quote in frontmatter every reader shows (HQ-1414). Line numbers are kept.
29
+ // EVDA_CARRY_FALSIFY=migrate-people:sealed reads sealed sections like any other line.
30
+ function openLines(text) {
31
+ const lines = text.split("\n");
32
+ if (!carries("migrate-people", SEALED)) return lines;
33
+ for (const s of sealedSections(text)) for (let i = s.start; i <= s.end; i++) lines[i] = "";
34
+ return lines;
35
+ }
36
+
37
+ const MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
38
+ const MONTH_WORD = "(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\\.?";
39
+ const RANGE_SEP = "\\s*(?:-|\\u2013|\\u2014|to|through)\\s*";
40
+
41
+ const monthNumber = (word) => MONTHS.findIndex((m) => m.startsWith(word.toLowerCase().replace(/\.$/, "").slice(0, 3))) + 1;
42
+ const two = (n) => String(n).padStart(2, "0");
43
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
44
+
45
+ function realDay(y, m, d) {
46
+ const t = new Date(Date.UTC(y, m - 1, d));
47
+ return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
48
+ }
49
+
50
+ // The date a heading carries, at the precision written, or null. Returns { since, at, length }.
51
+ export function headingDate(text) {
52
+ const found = [];
53
+ const add = (re, make) => {
54
+ const m = re.exec(text);
55
+ if (m) { const since = make(m); if (since) found.push({ since, at: m.index, length: m[0].length }); }
56
+ };
57
+ add(/\b(\d{4})-(\d{2})-(\d{2})\b/, (m) => (realDay(+m[1], +m[2], +m[3]) ? `${m[1]}-${m[2]}-${m[3]}` : null));
58
+ add(/\b(\d{4})-(0[1-9]|1[0-2])\b(?!-\d)/, (m) => `${m[1]}-${m[2]}`);
59
+ add(new RegExp(`\\b${MONTH_WORD}\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:${RANGE_SEP}\\d{1,2}(?:st|nd|rd|th)?)?,?\\s+(\\d{4})\\b`, "i"), (m) => {
60
+ const mo = monthNumber(m[1]);
61
+ return realDay(+m[3], mo, +m[2]) ? `${m[3]}-${two(mo)}-${two(+m[2])}` : null;
62
+ });
63
+ add(new RegExp(`\\b${MONTH_WORD}${RANGE_SEP}${MONTH_WORD}\\s+(\\d{4})\\b`, "i"), (m) => `${m[3]}-${two(monthNumber(m[1]))}`);
64
+ add(new RegExp(`\\b${MONTH_WORD}\\s+(\\d{4})\\b`, "i"), (m) => `${m[2]}-${two(monthNumber(m[1]))}`);
65
+ if (found.length === 0) return null;
66
+ // Earliest in the heading wins; at the same position the longest (most specific) match wins.
67
+ found.sort((a, b) => a.at - b.at || b.length - a.length);
68
+ return found[0];
69
+ }
70
+
71
+ // Every H2/H3 heading in a body that carries a date: the same definition planProfile uses for "a
72
+ // dated section" (the rule in CLAUDE.md this migration implements). Shared with doctor.mjs's
73
+ // heading-coverage check, so the two can never disagree about what counts as one.
74
+ export function datedHeadings(text) {
75
+ const out = [];
76
+ for (const l of openLines(text)) {
77
+ const m = /^(#{1,3})\s+(.*?)\s*#*\s*$/.exec(l);
78
+ if (!m || m[1].length === 1) continue;
79
+ const date = headingDate(m[2]);
80
+ if (date) out.push({ text: m[2], since: date.since });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ const REPLACES = /\b(?:no longer (?:holds?|true|applies|stands)|replaces|supersedes|superseded|revises|revised|corrects|corrected|correction to|overturns|instead of)\b/i;
86
+
87
+ export const slug = (s) => s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
88
+
89
+ // A line that names its speaker and holds exactly one quoted span. Returns { speaker, words } or null.
90
+ const QUOTED = /["\u201c]([^"\u201c\u201d]+)["\u201d]/g;
91
+ export function speakerQuote(line) {
92
+ const spans = [...line.matchAll(QUOTED)];
93
+ if (spans.length !== 1) return null;
94
+ const words = spans[0][1].trim();
95
+ if (!words) return null;
96
+ const before = line.slice(0, spans[0].index);
97
+ const after = line.slice(spans[0].index + spans[0][0].length);
98
+ const NAME = "([A-Z][A-Za-z'.]*(?: [A-Z][A-Za-z'.]*)?)";
99
+ let m = new RegExp(`^\\s*(?:>\\s*)?(?:[-*]\\s+)?\\**${NAME}\\**\\s*:\\s*\\**_?\\*?$`).exec(before);
100
+ if (m) return { speaker: m[1], words };
101
+ m = new RegExp(`^\\s*\\*?_?\\s*\\(${NAME}(?:,[^)]*)?\\)`).exec(after);
102
+ if (m) return { speaker: m[1], words };
103
+ m = new RegExp(`^\\s*\\*?_?\\s*(?:\\u2014|\\u2013|--?)\\s*${NAME}\\b`).exec(after);
104
+ if (m) return { speaker: m[1], words };
105
+ return null;
106
+ }
107
+
108
+ // Plan the frontmatter for one doc. Pure: reads nothing from disk.
109
+ export function planProfile({ fileName, text, owner }) {
110
+ const notes = [];
111
+ // A sealed or just-noted file always has frontmatter, so it is skipped here and never rewritten.
112
+ // EVDA_CARRY_FALSIFY=migrate-people:noted lets a noted one through, as a migration that read past
113
+ // its frontmatter would, to show this skip is what keeps it noted.
114
+ const falsifiedNoted = !carries("migrate-people", NOTED) && frontmatterOf(text)?.[NOTED] === true && !heldBy(frontmatterOf(text), "migrate-people");
115
+ if (/^---\r?\n/.test(text) && !falsifiedNoted) return { skip: "already has frontmatter" };
116
+ const lines = openLines(text);
117
+ const h1 = lines.find((l) => /^# \S/.test(l));
118
+ if (!h1) return { skip: "has no H1 to take the name from" };
119
+ const name = h1.slice(2).trim();
120
+ const first = name.split(/\s+/)[0];
121
+ const ownerFirst = String(owner).split(/\s+/)[0];
122
+
123
+ const headings = [];
124
+ lines.forEach((l, i) => { const m = /^(#{1,3})\s+(.*?)\s*#*\s*$/.exec(l); if (m) headings.push({ line: i, level: m[1].length, text: m[2] }); });
125
+
126
+ const observations = [];
127
+ const ids = new Set();
128
+ headings.forEach((h, k) => {
129
+ if (h.level === 1) return;
130
+ const date = headingDate(h.text);
131
+ if (!date) return;
132
+ let id = slug(h.text) || `o${observations.length + 1}`;
133
+ for (let n = 2; ids.has(id); n++) id = `${slug(h.text)}-${n}`;
134
+ ids.add(id);
135
+ const end = k + 1 < headings.length ? headings[k + 1].line : lines.length;
136
+ const quotes = [];
137
+ for (let i = h.line + 1; i < end; i++) { const q = speakerQuote(lines[i]); if (q) quotes.push({ ...q, line: i + 1 }); }
138
+ observations.push({ obs: { id, since: date.since, text: h.text }, heading: h, date, quotes, order: observations.length });
139
+ });
140
+
141
+ // Rule 5.
142
+ for (const o of observations) {
143
+ if (o.quotes.length === 0) continue;
144
+ if (o.quotes.length > 1) { notes.push(`quote not attached: "${o.heading.text}" has ${o.quotes.length} lines naming a speaker (lines ${o.quotes.map((q) => q.line).join(", ")}), and an observation holds one`); continue; }
145
+ const q = o.quotes[0];
146
+ const isOwner = q.speaker === owner || q.speaker === ownerFirst;
147
+ const isPerson = q.speaker === name || q.speaker === first;
148
+ if (!isOwner && !isPerson) { notes.push(`quote not attached: "${o.heading.text}" line ${q.line} names ${q.speaker}, who is neither ${name} nor the owner`); continue; }
149
+ const by = isOwner ? owner : name;
150
+ o.obs.quote = { words: q.words, provenance: isOwner ? "owner" : "person", by };
151
+ }
152
+
153
+ // Ordering: a month sorts as its first day, for ordering only.
154
+ const key = (s) => (s.length === 7 ? `${s}-01` : s);
155
+ // Rule 4.
156
+ for (const o of observations) {
157
+ const rest = o.heading.text.slice(0, o.date.at) + " " + o.heading.text.slice(o.date.at + o.date.length);
158
+ if (!REPLACES.test(rest)) continue;
159
+ const named = MONTHS.map((m, i) => (new RegExp(`\\b${m[0].toUpperCase()}${m.slice(1)}\\b`).test(rest) ? i + 1 : 0)).filter(Boolean);
160
+ const earlier = observations.filter((e) => e !== o && key(e.obs.since) < key(o.obs.since));
161
+ const fits = named.length ? earlier.filter((e) => named.includes(Number(e.obs.since.slice(5, 7)))) : earlier;
162
+ if (fits.length === 1) {
163
+ if (fits[0].obs.supersededBy) { notes.push(`unlinked: "${o.heading.text}" fits "${fits[0].heading.text}", which is already superseded`); continue; }
164
+ fits[0].obs.supersededBy = o.obs.id;
165
+ } else {
166
+ notes.push(`unlinked: "${o.heading.text}" says it replaces an earlier section, and ${fits.length} fit${fits.length ? `: ${fits.map((f) => `"${f.heading.text}"`).join(", ")}` : ""}`);
167
+ }
168
+ }
169
+
170
+ if (observations.length === 0) return { skip: "has no dated H2 or H3 heading, so there is no observation; left as prose" };
171
+
172
+ observations.sort((a, b) => (key(a.obs.since) < key(b.obs.since) ? -1 : key(a.obs.since) > key(b.obs.since) ? 1 : a.order - b.order));
173
+ const entry = {
174
+ kind: "person-profile",
175
+ id: slug(fileName.replace(/\.md$/i, "")),
176
+ schemaVersion: "0.1",
177
+ name,
178
+ observations: observations.map((o) => o.obs),
179
+ };
180
+ const frontmatter = `---\n${toYaml(entry)}---\n`;
181
+ const problems = readEntry("person-profile", parseYaml(frontmatter.slice(4, -4)), { owner }).problems;
182
+ return { entry, frontmatter, notes, problems };
183
+ }
184
+
185
+ // The body below a written frontmatter is the original, byte for byte.
186
+ export function bodyMatches(original, written, frontmatter) {
187
+ const head = Buffer.from(frontmatter, "utf8");
188
+ if (!written.subarray(0, head.length).equals(head)) return false;
189
+ return sha256(written.subarray(head.length)) === sha256(original);
190
+ }
191
+
192
+ const today = () => { const d = new Date(); return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; };
193
+
194
+ // Migrate files. Returns one result per file: { file, status: done|would|skipped|failed, ... }.
195
+ export function migratePeople(files, { dry = false, date = today(), root = null } = {}) {
196
+ const results = [];
197
+ for (const file of files) {
198
+ const r = { file };
199
+ results.push(r);
200
+ if (basename(file) === "README.md") { Object.assign(r, { status: "skipped", why: "is a folder README, not a person profile" }); continue; }
201
+ const recordRoot = root ?? findRoot(dirname(file));
202
+ if (!recordRoot) { Object.assign(r, { status: "failed", why: "no evda.json above this file" }); continue; }
203
+ const owner = (() => { const h = holderOf(readManifest(recordRoot)); return h.id ?? h.name; })();
204
+ if (!owner) { Object.assign(r, { status: "failed", why: "evda.json names no owner" }); continue; }
205
+ const original = readFileSync(file);
206
+ const plan = planProfile({ fileName: basename(file), text: original.toString("utf8"), owner });
207
+ if (plan.skip) { Object.assign(r, { status: "skipped", why: plan.skip }); continue; }
208
+ Object.assign(r, { frontmatter: plan.frontmatter, notes: plan.notes, observations: plan.entry.observations.length });
209
+ if (plan.problems.length) { Object.assign(r, { status: "failed", why: `the frontmatter would not read as a person-profile: ${plan.problems.join("; ")}` }); continue; }
210
+ const backupDir = join(recordRoot, ".backup", `people-migration-${date}`);
211
+ r.backup = join(backupDir, basename(file));
212
+ if (dry) { r.status = "would"; continue; }
213
+ mkdirSync(backupDir, { recursive: true });
214
+ if (existsSync(r.backup) && sha256(readFileSync(r.backup)) !== sha256(original)) {
215
+ Object.assign(r, { status: "failed", why: `a different backup already exists at ${relative(recordRoot, r.backup)}; nothing written` });
216
+ continue;
217
+ }
218
+ copyFileSync(file, r.backup);
219
+ if (sha256(readFileSync(r.backup)) !== sha256(original)) { Object.assign(r, { status: "failed", why: "the backup does not match the original; nothing written" }); continue; }
220
+ writeFileSync(file, Buffer.concat([Buffer.from(plan.frontmatter, "utf8"), original]));
221
+ const written = readFileSync(file);
222
+ if (!bodyMatches(original, written, plan.frontmatter)) {
223
+ writeFileSync(file, original);
224
+ Object.assign(r, { status: "failed", why: "the body below the frontmatter did not hash to the original; restored the original" });
225
+ continue;
226
+ }
227
+ r.bodySha256 = sha256(original);
228
+ r.status = "done";
229
+ }
230
+ return results;
231
+ }
package/lib/organize.mjs CHANGED
@@ -3,7 +3,10 @@
3
3
  // The mechanical part is deterministic and done here:
4
4
  // - each dated capture is copied, verbatim, into the daily file of its date, once. A marker comment
5
5
  // in the daily file records the copy, so a second run files nothing twice and a rebuilt .evda/
6
- // cannot make it forget.
6
+ // cannot make it forget. The copy carries the capture's flags (HQ-1414): a sealed capture is
7
+ // filed inside a sealed fence, and a just-noted one is not copied at all. Its day gets one
8
+ // pointer line naming the capture, so the words stay in one file under one flag that
9
+ // `evda_release_note` clears, and the first run after its owner releases it files the words.
7
10
  // - the report lists what needs a person's judgment: people a recent note mentions whose file has
8
11
  // nothing from that note, imports to check, undated notes, and notes that sit under no role.
9
12
  // The report is appended to reports/organize/<date>.md. Nothing is moved, renamed or deleted, and
@@ -16,6 +19,7 @@
16
19
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
17
20
  import { basename, dirname, join, relative } from "node:path";
18
21
  import { spawnSync } from "node:child_process";
22
+ import { tmpdir } from "node:os";
19
23
  import { resolveRole, resolveAll } from "./roles.mjs";
20
24
  import { mdFiles, fileDate } from "./record.mjs";
21
25
  import { secretShape } from "./references.mjs";
@@ -23,9 +27,13 @@ import { importsDir, localDate, localTime } from "./tools.mjs";
23
27
  import { serverCommand, SKILL_SOURCE } from "./connect.mjs";
24
28
  import { SERVER_NAME } from "./clients.mjs";
25
29
  import { EvdaError } from "./errors.mjs";
30
+ import { frontmatterOf, heldBy, withoutSealed, SEALED, NOTED } from "./circles.mjs";
26
31
 
27
32
  export const REPORTS = "reports";
28
33
  const marker = (rel) => `<!-- evda:capture ${rel} -->`;
34
+ // The pointer a just-noted capture leaves in its day in place of its words. A different marker, so
35
+ // once the capture is released the next run still files its words.
36
+ const notedMarker = (rel) => `<!-- evda:noted-capture ${rel} -->`;
29
37
  const posix = (p) => p.split("\\").join("/");
30
38
 
31
39
  function stripFrontmatter(text) {
@@ -74,11 +82,21 @@ export function plan(evda, { now = new Date(), window = 7 } = {}) {
74
82
  const text = readFileSync(abs, "utf8");
75
83
  const date = fileDate(abs, text);
76
84
  if (!date) { out.undated.push(rel(abs)); continue; }
85
+ const held = heldBy(frontmatterOf(text), "organize");
86
+ const target = join(daily.abs, `${date}.md`);
87
+ const dayText = existsSync(target) ? readFileSync(target, "utf8") : "";
88
+ // Just noted: its words are read for nothing, a secret included, and only a pointer is filed.
89
+ if (held === NOTED) {
90
+ if (!dayText.includes(marker(rel(abs))) && !dayText.includes(notedMarker(rel(abs)))) out.filings.push({ from: rel(abs), to: rel(target), date, noted: true });
91
+ continue;
92
+ }
77
93
  const secret = secretLine(text);
78
94
  if (secret) { out.refused.push({ path: rel(abs), why: `holds ${secret}; not copied` }); continue; }
79
- const target = join(daily.abs, `${date}.md`);
80
- if (existsSync(target) && readFileSync(target, "utf8").includes(marker(rel(abs)))) continue;
81
- out.filings.push({ from: rel(abs), to: rel(target), date, body: stripFrontmatter(text).replace(/\n+$/, "") });
95
+ if (dayText.includes(marker(rel(abs)))) continue;
96
+ // A sealed capture (HQ-1220) is filed inside a sealed fence, heading and marker included, so the
97
+ // copy in the day is as sealed as the capture and names nothing outside the fence.
98
+ const sealed = held === SEALED;
99
+ out.filings.push({ from: rel(abs), to: rel(target), date, body: stripFrontmatter(text).replace(/\n+$/, ""), ...(sealed ? { sealed: true } : {}) });
82
100
  }
83
101
  }
84
102
  for (const abs of mdFiles(daily.abs)) {
@@ -90,7 +108,11 @@ export function plan(evda, { now = new Date(), window = 7 } = {}) {
90
108
  if (people.mapped && people.exists) {
91
109
  const recent = [...mdFiles(daily.abs), ...(captures.mapped && captures.exists ? mdFiles(captures.abs) : [])]
92
110
  .map((abs) => ({ abs, text: readFileSync(abs, "utf8") }))
93
- .filter((f) => { const d = fileDate(f.abs, f.text); return d && d >= from; });
111
+ .filter((f) => { const d = fileDate(f.abs, f.text); return d && d >= from; })
112
+ // Who a sealed or just-noted note mentions is held back with it: it is not read for people, and
113
+ // a sealed section is cut before the rest is (HQ-1220, HQ-1414).
114
+ .filter((f) => !heldBy(frontmatterOf(f.text), "organize"))
115
+ .map((f) => ({ ...f, text: withoutSealed(f.text).text }));
94
116
  for (const p of mdFiles(people.abs)) {
95
117
  out.examined += 1;
96
118
  const ptext = readFileSync(p, "utf8");
@@ -138,7 +160,7 @@ export function reportText(p, { now = new Date(), window = 7 } = {}) {
138
160
  const lines = [`## Run ${localTime(now)}`, ""];
139
161
  lines.push(`Examined ${p.examined} note(s). Moved nothing.`, "");
140
162
  lines.push("### Filed");
141
- if (p.filings.length) for (const f of p.filings) lines.push(`- ${f.from} copied verbatim into ${f.to}`);
163
+ if (p.filings.length) for (const f of p.filings) lines.push(f.sealed ? `- a sealed capture copied verbatim into ${f.to}, still sealed` : f.noted ? `- ${f.from} is just noted: ${f.to} points to it, and its words stay where they are until its owner releases it` : `- ${f.from} copied verbatim into ${f.to}`);
142
164
  else lines.push("- Nothing new to file.");
143
165
  lines.push("", `### People mentioned in the last ${window} days`);
144
166
  if (p.people.length) for (const x of p.people) lines.push(`- ${x.note} mentions ${x.name}; ${x.person} has nothing from it yet.`);
@@ -163,16 +185,24 @@ function appendReport(evda, kind, date, block) {
163
185
  }
164
186
 
165
187
  // Run the owner's claude -p with the skill and read-only EVDA tools. Returns { ok, text, why }.
188
+ // With `evda` null (a gather, which has no record yet) it gets no EVDA tools and runs from the
189
+ // system's temporary directory, so it reads nothing but the prompt.
166
190
  export function askOwnersAi(evda, prompt, { model = null, env = process.env, timeoutMs = 600_000 } = {}) {
167
191
  const bin = env.EVDA_CLAUDE || "claude";
168
- const { command, args } = serverCommand(evda.dir);
169
- const mcp = JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } });
170
- const allowed = ["evda_operating_layer", "evda_recent", "evda_search", "evda_circles"].map((t) => `mcp__${SERVER_NAME}__${t}`);
192
+ const argv = ["-p", prompt];
193
+ if (evda) {
194
+ const { command, args } = serverCommand(evda.dir);
195
+ const mcp = JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } });
196
+ const allowed = ["evda_operating_layer", "evda_recent", "evda_search", "evda_circles"].map((t) => `mcp__${SERVER_NAME}__${t}`);
197
+ argv.push("--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", allowed.join(","));
198
+ } else {
199
+ argv.push("--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: {} }), "--allowedTools", "");
200
+ }
201
+ argv.push("--output-format", "text");
171
202
  const skill = existsSync(SKILL_SOURCE) ? readFileSync(SKILL_SOURCE, "utf8") : "";
172
- const argv = ["-p", prompt, "--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", allowed.join(","), "--output-format", "text"];
173
203
  if (skill) argv.push("--append-system-prompt", skill);
174
204
  if (model) argv.push("--model", model);
175
- const r = spawnSync(bin, argv, { cwd: evda.dir, env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
205
+ const r = spawnSync(bin, argv, { cwd: evda ? evda.dir : tmpdir(), env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
176
206
  if (r.error) return { ok: false, why: r.error.code === "ENOENT" ? `${bin} is not installed or not on PATH` : String(r.error.message) };
177
207
  if (r.status !== 0) return { ok: false, why: `${bin} -p exited ${r.status}: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" ")}` };
178
208
  const text = (r.stdout ?? "").trim();
@@ -190,7 +220,17 @@ export async function organize(evda, { now = new Date(), window = 7, withAi = fa
190
220
  const abs = join(evda.dir, f.to);
191
221
  const head = existsSync(abs) ? "" : `# ${f.date}\n`;
192
222
  const lead = existsSync(abs) && !readFileSync(abs, "utf8").endsWith("\n") ? "\n" : "";
193
- appendFileSync(abs, `${lead}${head}\n## Capture: ${basename(f.from, ".md")}\n\n${marker(f.from)}\n\n${f.body}\n`);
223
+ if (f.noted) {
224
+ appendFileSync(abs, `${lead}${head}\n## Capture: ${basename(f.from, ".md")}\n\n${notedMarker(f.from)}\n\nJust noted, so its words stay in ${f.from}, read by no AI. Once its owner releases it, the next organize files them here.\n`);
225
+ continue;
226
+ }
227
+ const block = `## Capture: ${basename(f.from, ".md")}\n\n${marker(f.from)}\n\n${f.body}\n`;
228
+ if (f.sealed) {
229
+ // A fence longer than any backtick run at the start of a line in the block, so nothing in it closes it.
230
+ const longest = Math.max(2, ...block.split("\n").map((l) => /^ {0,3}(`+)/.exec(l)?.[1].length ?? 0));
231
+ const fence = "`".repeat(longest + 1);
232
+ appendFileSync(abs, `${lead}${head}\n${fence}sealed\n${block}${fence}\n`);
233
+ } else appendFileSync(abs, `${lead}${head}\n${block}`);
194
234
  }
195
235
  let exit = 0;
196
236
  if (withAi) {
package/lib/parity.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // Shadow parity (Spec 08, stage S1): the shadow a device folds received Changes into must equal the
2
+ // record it shadows, byte for byte, for exactly the circles that device holds, and must hold nothing
3
+ // of a circle it may never hold.
4
+ //
5
+ // The gate to S2 is this check holding every night for a week, so it is written to fail:
6
+ //
7
+ // MISSING a file of a held circle is in the record and not in the shadow
8
+ // EXTRA a file is in the shadow and not in the record (a rename is one MISSING and one EXTRA)
9
+ // DIFFERS the same path, different bytes (sha256)
10
+ // FORBIDDEN the shadow holds a file of a circle this device must never hold
11
+ // UNCOVERED the shadow holds a file no circle covers
12
+ // UNVERIFIABLE a file of a held circle is a symlink, an iCloud stub, or over the blob lane, so the
13
+ // record side has no bytes the shadow could be compared with
14
+ // CHECKED 0 nothing was compared, which is a broken discovery step and never a pass
15
+ //
16
+ // Two things are not differences, and each is counted rather than dropped:
17
+ //
18
+ // a name stored in another Unicode form paths are compared in NFC. iCloud delivered 23 of the
19
+ // vault's Greek and accented names to Hestia in the other
20
+ // form (measured 2026-09-22); the bytes are the same file,
21
+ // and APFS opens either spelling.
22
+ // a path iCloud never carries a component ending in .tmp or .nosync. During S1 the
23
+ // record on the shadow device arrived by iCloud, so the
24
+ // laptop's .tmp/ (65 files, never on Hestia) reaches the
25
+ // shadow by the relay and could never match. NOT_CARRIED n.
26
+ //
27
+ // It reads both trees and writes nothing.
28
+
29
+ import { createHash } from "node:crypto";
30
+ import { readFileSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { circleForPath } from "./filechanges.mjs";
33
+ import { excluder, walk } from "./sync-rules.mjs";
34
+
35
+ const nfc = (rel) => rel.normalize("NFC");
36
+ // A path iCloud Drive does not sync, so it cannot be compared across devices while iCloud carries
37
+ // the record. Exported for the test.
38
+ export const notCarried = (rel) => rel.split("/").some((p) => p.endsWith(".tmp") || p.endsWith(".nosync"));
39
+
40
+ const sha = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
41
+
42
+ function circleOf(index, rel) {
43
+ try {
44
+ return circleForPath(index, rel);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ // `index` is circleIndex(evda). `holds` and `never` are circle names. `exclude` is the plan's
51
+ // excluder, the same one the scan used.
52
+ export function parity({ source, shadow, index, holds, never = [], exclude = excluder() }) {
53
+ const held = new Set(holds);
54
+ const forbidden = new Set(never);
55
+ const problems = [];
56
+ const perCircle = Object.fromEntries(holds.map((n) => [n, 0]));
57
+ const expected = new Map();
58
+ let notHeld = 0;
59
+ let excluded = 0;
60
+ let notCarriedCount = 0;
61
+
62
+ for (const e of walk(source, { exclude })) {
63
+ if (e.kind === "excluded") { excluded += 1; continue; }
64
+ if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
65
+ const c = circleOf(index, e.rel);
66
+ if (!c || !held.has(c.name)) { notHeld += 1; continue; }
67
+ if (e.kind !== "file") {
68
+ problems.push({ code: "UNVERIFIABLE", rel: e.rel, circle: c.name, why: `a ${e.kind} on the record side` });
69
+ continue;
70
+ }
71
+ expected.set(nfc(e.rel), { rel: e.rel, circle: c.name, sha: sha(join(source, e.rel)) });
72
+ }
73
+
74
+ const seen = new Set();
75
+ let shadowFiles = 0;
76
+ for (const e of walk(shadow, { exclude: excluder() })) {
77
+ if (e.kind === "excluded") continue;
78
+ if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
79
+ shadowFiles += 1;
80
+ const c = circleOf(index, e.rel);
81
+ if (c && forbidden.has(c.name)) { problems.push({ code: "FORBIDDEN", rel: e.rel, circle: c.name, why: `this device must never hold circle ${c.name}` }); continue; }
82
+ if (!c) { problems.push({ code: "UNCOVERED", rel: e.rel, why: "in the shadow and in no circle" }); continue; }
83
+ const want = expected.get(nfc(e.rel));
84
+ if (!want) { problems.push({ code: "EXTRA", rel: e.rel, circle: c.name, why: "in the shadow and not in the record" }); continue; }
85
+ seen.add(nfc(e.rel));
86
+ if (e.kind !== "file" || sha(join(shadow, e.rel)) !== want.sha) {
87
+ problems.push({ code: "DIFFERS", rel: e.rel, circle: c.name, why: "the shadow's bytes are not the record's" });
88
+ continue;
89
+ }
90
+ perCircle[want.circle] += 1;
91
+ }
92
+ for (const [key, want] of expected) {
93
+ if (!seen.has(key)) problems.push({ code: "MISSING", rel: want.rel, circle: want.circle, why: "in the record and not in the shadow" });
94
+ }
95
+
96
+ const checked = Object.values(perCircle).reduce((a, b) => a + b, 0);
97
+ if (checked === 0) problems.push({ code: "CHECKED_0", rel: "", why: "nothing was compared; zero is a broken discovery step, not a pass" });
98
+ return { ok: problems.length === 0, checked, perCircle, expected: expected.size, shadowFiles, notHeld, excluded, notCarried: notCarriedCount, problems };
99
+ }
100
+
101
+ // The lines a person or a log reads. The first line always says what was examined.
102
+ export function formatParity(r, { limit = 20 } = {}) {
103
+ const lines = [`CHECKED ${r.checked} files: ${Object.entries(r.perCircle).map(([n, k]) => `${n} ${k}`).join(", ")}`];
104
+ lines.push(` record side: ${r.expected} expected, ${r.notHeld} in circles this device does not hold, ${r.excluded} excluded${r.notCarried ? `, NOT_CARRIED ${r.notCarried} (paths iCloud does not sync, both sides)` : ""}; shadow side: ${r.shadowFiles} files`);
105
+ const byCode = {};
106
+ for (const p of r.problems) byCode[p.code] = (byCode[p.code] ?? 0) + 1;
107
+ for (const p of r.problems.slice(0, limit)) lines.push(`FAIL ${p.code} ${p.rel}${p.circle ? ` [${p.circle}]` : ""}: ${p.why}`);
108
+ if (r.problems.length > limit) lines.push(`... and ${r.problems.length - limit} more`);
109
+ lines.push(r.ok ? "PARITY OK" : `PARITY FAILED: ${Object.entries(byCode).map(([c, n]) => `${c} ${n}`).join(", ")}`);
110
+ return lines;
111
+ }
package/lib/presets.mjs CHANGED
@@ -7,9 +7,17 @@
7
7
  // "for": "One sentence: who it suits.",
8
8
  // "folders": [{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." }],
9
9
  // "circles": [{ "name": "family", "public": false, "description": "...", "members": { "people": { "path": "People" } } }],
10
- // "routines": ["weekly"]
10
+ // "routines": ["weekly"],
11
+ // "mapping": { "journal": "Journal", "collections": "People" }
11
12
  // }
12
13
  //
14
+ // `mapping` is what the preset declares for the four roles (Spec 07 section 2, HQ-1371): each key
15
+ // a role or a `<role>/<facet>`, each value a folder the preset makes. Every folder the preset makes
16
+ // fills a role, by lying at or under one of these paths; a folder that fills none is refused, so
17
+ // no preset ships a folder an agent cannot say the purpose of. Applying writes the role paths the
18
+ // record does not have into its evda.json and never replaces a key it has: when the key is taken,
19
+ // the folder fills the same role under a facet key named for it (`collections/people`).
20
+ //
13
21
  // `folders` are paths relative to the record root. A folder with a `type` gets folder.evda.json
14
22
  // (type, connectors, views), checked by the same validator doctor uses; a folder with no type is a
15
23
  // plain directory and nothing more. `circles` are starter circles, in the substrate's eco shape with
@@ -26,8 +34,8 @@
26
34
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
27
35
  import { dirname, join, resolve, sep } from "node:path";
28
36
  import { fileURLToPath } from "node:url";
29
- import { openEvda } from "./manifest.mjs";
30
- import { resolveRole } from "./roles.mjs";
37
+ import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
38
+ import { resolveRole, fourRoleOf, FOUR_ROLES, LEGACY_FACETS } from "./roles.mjs";
31
39
  import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders.mjs";
32
40
  import { ROUTINE_KEYS } from "./routines.mjs";
33
41
  import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
@@ -35,7 +43,7 @@ import { EvdaError } from "./errors.mjs";
35
43
 
36
44
  export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
37
45
  export const PRESET_FILE = "preset.json";
38
- export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines"];
46
+ export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines", "mapping"];
39
47
  export const PRESET_FOLDER_KEYS = ["path", "type", "connectors", "views", "note"];
40
48
  export const PRESET_CIRCLE_KEYS = ["name", "public", "description", "members"];
41
49
  export const NONE = "none";
@@ -98,6 +106,24 @@ export function validatePreset(p, dirName = null) {
98
106
  }
99
107
  });
100
108
  }
109
+ // The four roles. Required: a preset is a way to fill them.
110
+ if (!isObject(p.mapping) || Object.keys(p.mapping).length === 0) fail(`"mapping" must name the folders that fill the four roles (${FOUR_ROLES.join(", ")}).`);
111
+ else {
112
+ const rolePaths = [];
113
+ for (const [key, rel] of Object.entries(p.mapping)) {
114
+ const role = fourRoleOf(key);
115
+ if (!role || LEGACY_FACETS[key]) { fail(`mapping key "${key}" is not one of the four roles or a <role>/<facet> of one.`); continue; }
116
+ if (!nonEmpty(rel) || !paths.has(rel)) { fail(`mapping "${key}" is ${JSON.stringify(rel)}, which is not a folder this preset makes.`); continue; }
117
+ rolePaths.push({ role, rel });
118
+ }
119
+ const missing = FOUR_ROLES.filter((r) => !rolePaths.some((x) => x.role === r));
120
+ if (missing.length) fail(`fills no folder for ${missing.join(", ")}. Every record has the four roles.`);
121
+ for (const fp of paths) {
122
+ if (!rolePaths.some((x) => fp === x.rel || fp.startsWith(`${x.rel}/`))) fail(`folder ${fp} fills no role: it is not at or under any path in "mapping".`);
123
+ }
124
+ const seen = new Map();
125
+ for (const x of rolePaths) { if (seen.has(x.rel) && seen.get(x.rel) !== x.role) fail(`folder ${x.rel} fills two roles.`); seen.set(x.rel, x.role); }
126
+ }
101
127
  if (p.circles !== undefined) {
102
128
  if (!Array.isArray(p.circles)) fail(`"circles" must be a list.`);
103
129
  else {
@@ -205,7 +231,23 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
205
231
  export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
206
232
  const evda = openEvda(resolve(root));
207
233
  const preset = loadPreset(id, { dir });
208
- const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length };
234
+ const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
235
+ // The four roles the record does not declare yet. A key it has with another path is a conflict,
236
+ // left as it is; a path it already maps under a key of the same role is already there.
237
+ const has = evda.manifest.mapping;
238
+ for (const [key, rel] of Object.entries(preset.mapping ?? {})) {
239
+ if (has[key] === rel) continue;
240
+ if (Object.entries(has).some(([k, v]) => v === rel && fourRoleOf(k) === fourRoleOf(key))) continue;
241
+ // A key the record already uses for another path keeps it; the preset's folder fills the same
242
+ // role under a facet key named for the folder, so two presets compose and nothing is replaced.
243
+ let k = key;
244
+ if (has[k] !== undefined || plan.roles.some((r) => r.key === k)) {
245
+ const slug = rel.split("/").pop().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "folder";
246
+ k = `${fourRoleOf(key)}/${slug}`;
247
+ for (let n = 2; has[k] !== undefined || plan.roles.some((r) => r.key === k); n++) k = `${fourRoleOf(key)}/${slug}-${n}`;
248
+ }
249
+ plan.roles.push({ key: k, rel });
250
+ }
209
251
  const blocked = new Set();
210
252
  for (const f of preset.folders) {
211
253
  const abs = resolve(evda.dir, f.path);
@@ -234,6 +276,8 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
234
276
  if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
235
277
  }
236
278
  }
279
+ // A role is declared only over a folder this apply leaves in place; one a file blocks is not.
280
+ plan.roles = plan.roles.filter((r) => blockedBy(evda.dir, r.rel) === null);
237
281
  const circlesRole = resolveRole(evda, "circles");
238
282
  const proposals = join(evda.dir, PROPOSALS);
239
283
  const proposalNames = existsSync(proposals) ? readdirSync(proposals) : [];
@@ -244,6 +288,11 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
244
288
  }
245
289
  if (dry) return plan;
246
290
 
291
+ if (plan.roles.length) {
292
+ const m = readManifest(evda.dir);
293
+ for (const r of plan.roles) m.mapping[r.key] ??= r.rel;
294
+ writeManifest(evda.dir, m);
295
+ }
247
296
  for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
248
297
  for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
249
298
  for (const v of plan.views) {
@@ -268,17 +317,18 @@ export function diffPreset(root, id, opts = {}) {
268
317
  return applyPreset(root, id, { ...opts, dry: true });
269
318
  }
270
319
 
271
- export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length;
320
+ export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
272
321
 
273
322
  // The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
274
323
  export function summary(plan, preset = loadPreset(plan.id)) {
275
324
  const lines = [];
276
325
  const adds = preset.folders.filter((f) => plan.create.includes(f.path) || plan.write.some((w) => w.folder.path === f.path));
277
- if (!adds.length && !plan.circles.length && !plan.conflicts.length) {
326
+ if (!adds.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length) {
278
327
  lines.push(`This record already has everything the ${plan.title} preset makes. Nothing would be written.`);
279
328
  } else {
280
329
  lines.push(`The ${plan.title} preset would add ${adds.length} folder(s) to this record:`);
281
330
  for (const f of adds) lines.push(`- ${f.path}/${f.type ? ` (${f.type})` : ""}${f.note ? `: ${f.note}` : ""}`);
331
+ if (plan.roles?.length) lines.push(`It would declare ${plan.roles.length} role path(s) in evda.json: ${plan.roles.map((r) => `${r.key} -> ${r.rel}`).join(", ")}.`);
282
332
  if (plan.circles.length) lines.push(`It would propose ${plan.circles.length} circle(s), as proposal files only: ${plan.circles.map((c) => c.name).join(", ")}.`);
283
333
  if (plan.matched.length) lines.push(`${plan.matched.length} folder(s) are already set up the same way and would be left alone.`);
284
334
  for (const c of plan.conflicts) lines.push(`Left as it is: ${c.path} ${c.why}.`);
@@ -294,6 +344,7 @@ export function planLines(plan, { diff = false } = {}) {
294
344
  const verb = plan.dry || diff ? "would" : "did";
295
345
  for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
296
346
  for (const w of plan.write) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} type ${w.manifest.type}${w.manifest.connectors.length ? `, connectors ${w.manifest.connectors.join(", ")}` : ""}`);
347
+ for (const r of plan.roles ?? []) out.push(` ${verb === "did" ? "declared" : "declare "} ${r.key} -> ${r.rel} in evda.json`);
297
348
  for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
298
349
  if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
299
350
  for (const c of plan.conflicts) out.push(` CONFLICT ${c.path} ${c.why}. Left as it is.`);