create-evda 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +74 -7
- package/bin/evda.mjs +427 -9
- package/create-evda.mjs +64 -16
- 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 +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +640 -0
- package/lib/gather.mjs +858 -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 +456 -36
- package/lib/messages.mjs +205 -0
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +176 -32
- package/lib/parity.mjs +184 -0
- package/lib/presets.mjs +58 -7
- 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 +106 -1
- package/lib/routines.mjs +142 -18
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +124 -4
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +93 -0
- package/lib/tools.mjs +133 -27
- 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 +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +67 -8
- package/templates/AGENTS.md +122 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +31 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
package/lib/messages.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Dispatch through the record (HQ-1246): the owner's agent writes a message, the phone app shows it
|
|
2
|
+
// as a card, and the owner's reply comes back as a file the agent reads on its next run.
|
|
3
|
+
//
|
|
4
|
+
// The format, which the phone app (iris-mobile, the EVÐA target) reads exactly:
|
|
5
|
+
//
|
|
6
|
+
// <messages role>/YYYY-MM-DD-HHMM-<slug>.md one message, written once by the agent
|
|
7
|
+
// ---
|
|
8
|
+
// id: <the filename stem>
|
|
9
|
+
// from: "<agent name>"
|
|
10
|
+
// title: "<title>"
|
|
11
|
+
// date: "<ISO 8601 with offset>"
|
|
12
|
+
// ---
|
|
13
|
+
// <markdown body>
|
|
14
|
+
//
|
|
15
|
+
// <messages role>/<same stem>.reply.md the owner's reply, written by the app
|
|
16
|
+
// ---
|
|
17
|
+
// in_reply_to: <the message id>
|
|
18
|
+
// date: "<ISO 8601 with offset>"
|
|
19
|
+
// ---
|
|
20
|
+
// <reply text>
|
|
21
|
+
//
|
|
22
|
+
// <messages role>/.seen.json { "<id>": "<reply date seen>" }, the agent's
|
|
23
|
+
//
|
|
24
|
+
// The messages role resolves through evda.json like every other role, `messages/` when unmapped. A
|
|
25
|
+
// message is never edited after it is written: a second message in the same minute with the same
|
|
26
|
+
// slug gets `-2`, `-3`, and a file already at a name (a reply, or anything sealed) is never touched.
|
|
27
|
+
// "Answered" is the reply file existing, never a stored flag. What the agent has seen is keyed by the
|
|
28
|
+
// reply's date, so a reply the app rewrites after the agent read it shows again.
|
|
29
|
+
//
|
|
30
|
+
// Replies are read the way every other tool reads (lib/readers.mjs): a sealed reply is in no answer
|
|
31
|
+
// and a sealed section is cut out, a noted reply or one in a circle set to a different reader is held
|
|
32
|
+
// back and named, and nothing held back is marked seen.
|
|
33
|
+
//
|
|
34
|
+
// EVDA_MESSAGES_FALSIFY=<mode,...>, for the tests. Each breaks exactly one rule:
|
|
35
|
+
// overwrite a colliding name is written over instead of getting a suffix
|
|
36
|
+
// orphans a reply with no message is returned
|
|
37
|
+
// seen-by-id a reply whose id was ever seen stays hidden, whatever its date
|
|
38
|
+
// strict-seen a .seen.json that does not parse throws instead of reading as empty
|
|
39
|
+
// no-secrets the secret check is skipped
|
|
40
|
+
// default-path the evda.json mapping is ignored and messages/ is always used
|
|
41
|
+
// raw-slug the title goes into the filename unslugged
|
|
42
|
+
// no-readers replies are shown without asking lib/readers.mjs
|
|
43
|
+
|
|
44
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { join, relative, sep } from "node:path";
|
|
46
|
+
import { resolveRole, agentsFile } from "./roles.mjs";
|
|
47
|
+
import { frontmatterOf } from "./circles.mjs";
|
|
48
|
+
import { readContext, mayRead, heldBack, callerFrom, aiView } from "./readers.mjs";
|
|
49
|
+
import { localDate } from "./day-file.mjs";
|
|
50
|
+
import { EvdaError } from "./errors.mjs";
|
|
51
|
+
|
|
52
|
+
export const MESSAGES_ROLE = "messages";
|
|
53
|
+
export const MESSAGES_DEFAULT = "messages";
|
|
54
|
+
export const SEEN_FILE = ".seen.json";
|
|
55
|
+
export const REPLY_SUFFIX = ".reply.md";
|
|
56
|
+
export const DEFAULT_FROM = "Iris";
|
|
57
|
+
const TITLE_MAX = 200;
|
|
58
|
+
const FROM_MAX = 60;
|
|
59
|
+
const BODY_MAX = 200_000;
|
|
60
|
+
const SLUG_MAX = 60;
|
|
61
|
+
|
|
62
|
+
export const MESSAGES_FALSIFY_MODES = ["overwrite", "orphans", "seen-by-id", "strict-seen", "no-secrets", "default-path", "raw-slug", "no-readers"];
|
|
63
|
+
const falsified = (mode) => (process.env.EVDA_MESSAGES_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
64
|
+
|
|
65
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
66
|
+
const refuse = (message, code) => new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
67
|
+
|
|
68
|
+
// A local time as ISO 8601 with its offset: 2026-09-25T12:04:00-05:00.
|
|
69
|
+
export function isoLocal(d) {
|
|
70
|
+
const off = -d.getTimezoneOffset();
|
|
71
|
+
const sign = off >= 0 ? "+" : "-";
|
|
72
|
+
const a = Math.abs(off);
|
|
73
|
+
return `${localDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A title as a filename part: accents folded, lowercase ASCII letters and digits joined by single
|
|
77
|
+
// hyphens, at most 60 characters. A title with nothing left (all emoji, all Greek) is "message".
|
|
78
|
+
export function slugify(title) {
|
|
79
|
+
if (falsified("raw-slug")) return String(title);
|
|
80
|
+
const s = String(title).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
81
|
+
return s.slice(0, SLUG_MAX).replace(/-+$/, "") || "message";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Where messages live: the role's mapping, else messages/ at the record root. resolveRole refuses a
|
|
85
|
+
// mapping that leaves the record.
|
|
86
|
+
export function messagesDir(evda) {
|
|
87
|
+
const r = falsified("default-path") ? { mapped: false } : resolveRole(evda, MESSAGES_ROLE);
|
|
88
|
+
if (r.mapped) {
|
|
89
|
+
if (r.exists && !r.isDir) throw refuse(`the messages role is mapped to ${r.rel}, which is a file; it holds a folder.`, "BAD_MAPPING");
|
|
90
|
+
return { abs: r.abs, rel: r.rel.replace(/\/+$/, "") };
|
|
91
|
+
}
|
|
92
|
+
return { abs: join(evda.dir, MESSAGES_DEFAULT), rel: MESSAGES_DEFAULT };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The agent's name as the operating layer's owner section gives it, else Iris.
|
|
96
|
+
export function agentName(evda) {
|
|
97
|
+
try {
|
|
98
|
+
const a = agentsFile(evda);
|
|
99
|
+
if (!a) return DEFAULT_FROM;
|
|
100
|
+
const m = /^- \*\*Agent name:\*\*[ \t]*(\S.*)$/m.exec(readFileSync(a.abs, "utf8"));
|
|
101
|
+
return m?.[1]?.trim() || DEFAULT_FROM;
|
|
102
|
+
} catch {
|
|
103
|
+
return DEFAULT_FROM;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function oneLine(value, name, max) {
|
|
108
|
+
if (typeof value !== "string" || value.trim() === "") throw refuse(`evda_message needs "${name}".`, "EMPTY");
|
|
109
|
+
const v = value.trim();
|
|
110
|
+
if (/[\r\n]/.test(v)) throw refuse(`evda_message "${name}" is one line.`, "LINES");
|
|
111
|
+
if (v.length > max) throw refuse(`evda_message "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write one new message. `refuseSecrets` is passed in by lib/tools.mjs so this module and evda_write
|
|
116
|
+
// refuse with the same check and the same words.
|
|
117
|
+
export function writeMessage(evda, args, { now = new Date(), refuseSecrets } = {}) {
|
|
118
|
+
const title = oneLine(args.title, "title", TITLE_MAX);
|
|
119
|
+
if (typeof args.body !== "string" || args.body.trim() === "") throw refuse('evda_message needs "body", what you are telling the owner.', "EMPTY");
|
|
120
|
+
if (args.body.length > BODY_MAX) throw refuse(`evda_message "body" is ${args.body.length} characters; one message holds at most ${BODY_MAX}.`, "TOO_LONG");
|
|
121
|
+
const from = args.from === undefined ? agentName(evda) : oneLine(args.from, "from", FROM_MAX);
|
|
122
|
+
if (!falsified("no-secrets")) for (const s of [title, args.body, from]) refuseSecrets(s, "evda_message");
|
|
123
|
+
const dir = messagesDir(evda);
|
|
124
|
+
mkdirSync(dir.abs, { recursive: true });
|
|
125
|
+
const base = `${localDate(now)}-${pad(now.getHours())}${pad(now.getMinutes())}-${slugify(title)}`;
|
|
126
|
+
const taken = (stem) => existsSync(join(dir.abs, `${stem}.md`)) || existsSync(join(dir.abs, `${stem}${REPLY_SUFFIX}`));
|
|
127
|
+
let stem = base;
|
|
128
|
+
if (!falsified("overwrite")) for (let n = 2; taken(stem); n++) stem = `${base}-${n}`;
|
|
129
|
+
const abs = join(dir.abs, `${stem}.md`);
|
|
130
|
+
const text = ["---", `id: ${stem}`, `from: ${JSON.stringify(from)}`, `title: ${JSON.stringify(title)}`, `date: ${JSON.stringify(isoLocal(now))}`, "---", "", args.body.replace(/\n+$/, ""), ""].join("\n");
|
|
131
|
+
// "wx": never replace a file, even one that appeared between the check and the write.
|
|
132
|
+
writeFileSync(abs, text, { flag: falsified("overwrite") ? "w" : "wx" });
|
|
133
|
+
return { id: stem, path: relative(evda.dir, abs).split(sep).join("/") };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// What the agent has seen, as { id: reply date }. Missing, unreadable, or not a JSON object of
|
|
137
|
+
// strings: empty, so every reply shows and the next mark rewrites the file whole.
|
|
138
|
+
export function readSeen(dirAbs) {
|
|
139
|
+
const p = join(dirAbs, SEEN_FILE);
|
|
140
|
+
if (!existsSync(p)) return {};
|
|
141
|
+
let v;
|
|
142
|
+
try { v = JSON.parse(readFileSync(p, "utf8")); } catch (e) { if (falsified("strict-seen")) throw e; return {}; }
|
|
143
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
|
|
144
|
+
return Object.fromEntries(Object.entries(v).filter(([, d]) => typeof d === "string"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeSeen(dirAbs, seen) {
|
|
148
|
+
const p = join(dirAbs, SEEN_FILE);
|
|
149
|
+
const t = `${p}.tmp-${process.pid}`;
|
|
150
|
+
writeFileSync(t, JSON.stringify(seen, null, 2) + "\n");
|
|
151
|
+
renameSync(t, p);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The text after a file's frontmatter.
|
|
155
|
+
function bodyOf(text) {
|
|
156
|
+
const lines = text.split("\n");
|
|
157
|
+
if (lines[0] !== "---") return text;
|
|
158
|
+
const end = lines.indexOf("---", 1);
|
|
159
|
+
return end === -1 ? text : lines.slice(end + 1).join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const dateString = (v) => (v instanceof Date ? v.toISOString() : typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
|
163
|
+
|
|
164
|
+
// The owner's replies the agent has not seen: { replies: [{ id, title, reply, date, path }], heldBack,
|
|
165
|
+
// marked }. Marks each one returned as seen unless `peek`. A reply with no message is not one.
|
|
166
|
+
export function unseenReplies(evda, { peek = false } = {}, { caller = callerFrom() } = {}) {
|
|
167
|
+
const dir = messagesDir(evda);
|
|
168
|
+
if (!existsSync(dir.abs) || !statSync(dir.abs).isDirectory()) return { replies: [], heldBack: "", marked: 0, dir: dir.rel };
|
|
169
|
+
const seen = readSeen(dir.abs);
|
|
170
|
+
const ctx = readContext(evda);
|
|
171
|
+
const refused = [];
|
|
172
|
+
let sealed = false;
|
|
173
|
+
const replies = [];
|
|
174
|
+
for (const name of readdirSync(dir.abs).sort()) {
|
|
175
|
+
if (!name.endsWith(REPLY_SUFFIX) || name.startsWith(".")) continue;
|
|
176
|
+
const id = name.slice(0, -REPLY_SUFFIX.length);
|
|
177
|
+
const abs = join(dir.abs, name);
|
|
178
|
+
if (!statSync(abs).isFile()) continue;
|
|
179
|
+
const msgAbs = join(dir.abs, `${id}.md`);
|
|
180
|
+
if (!existsSync(msgAbs) && !falsified("orphans")) continue;
|
|
181
|
+
if (!falsified("no-readers")) {
|
|
182
|
+
const may = mayRead(ctx, abs, caller);
|
|
183
|
+
if (!may.ok) { refused.push(may); continue; }
|
|
184
|
+
}
|
|
185
|
+
const raw = readFileSync(abs, "utf8");
|
|
186
|
+
const front = frontmatterOf(raw) ?? {};
|
|
187
|
+
const date = dateString(front.date) ?? statSync(abs).mtime.toISOString();
|
|
188
|
+
if (falsified("seen-by-id") ? id in seen : seen[id] === date) continue;
|
|
189
|
+
const shown = falsified("no-readers") ? { text: raw, sections: 0 } : aiView(raw);
|
|
190
|
+
if (shown.sections) sealed = true;
|
|
191
|
+
let title = id;
|
|
192
|
+
if (existsSync(msgAbs)) {
|
|
193
|
+
const mraw = readFileSync(msgAbs, "utf8");
|
|
194
|
+
const mfront = frontmatterOf(mraw);
|
|
195
|
+
if (typeof mfront?.title === "string" && mfront.title.trim()) title = mfront.title.trim();
|
|
196
|
+
}
|
|
197
|
+
replies.push({ id, title, reply: bodyOf(shown.text).replace(/^\n+|\n+$/g, ""), date, path: relative(evda.dir, abs).split(sep).join("/") });
|
|
198
|
+
}
|
|
199
|
+
replies.sort((a, b) => (a.date === b.date ? a.id.localeCompare(b.id) : a.date.localeCompare(b.date)));
|
|
200
|
+
if (!peek && replies.length) {
|
|
201
|
+
for (const r of replies) seen[r.id] = r.date;
|
|
202
|
+
writeSeen(dir.abs, seen);
|
|
203
|
+
}
|
|
204
|
+
return { replies, heldBack: heldBack(refused, { sealed }), marked: peek ? 0 : replies.length, dir: dir.rel };
|
|
205
|
+
}
|
|
@@ -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
|
+
}
|