create-evda 0.1.4 → 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.
- package/CHANGELOG.md +100 -0
- package/README.md +93 -8
- package/bin/evda.mjs +437 -5
- package/create-evda.mjs +68 -12
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +351 -19
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +22 -8
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +467 -35
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +379 -0
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +146 -2
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +120 -29
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +19 -0
- package/presets/projects-and-areas/preset.json +34 -0
- package/skills/evda/SKILL.md +76 -0
- package/templates/AGENTS.md +129 -15
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +32 -5
- package/LICENSE +0 -21
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
+
}
|