create-evda 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
@@ -0,0 +1,249 @@
1
+ // Is this enclave image's measurement in Sigstore's public log, signed by the workflow that built it?
2
+ // (HQ-2083, Spec 08 section 8: "The client checks inclusion in that log, never a list EVÐA hosts.")
3
+ //
4
+ // The phone runs this before it trusts a hosted EVÐA: the attested VM reports a measurement, and the
5
+ // phone accepts it only if a Sigstore bundle proves that exact digest was logged by EVÐA's CI. This
6
+ // file is the reference the Swift port follows, and its tests run against a real bundle from the
7
+ // public log, so the port can replay the same fixture.
8
+ //
9
+ // What it checks, all offline against the pinned trust root (lib/sigstore-trust.json):
10
+ // 1. the DSSE envelope holds an in-toto statement whose subject carries the digest asked about;
11
+ // 2. the envelope's signature verifies under the leaf certificate's key;
12
+ // 3. the leaf chains to Fulcio's intermediate and root, is for code signing, and was valid when the
13
+ // log integrated the entry; its SAN and OIDC issuer are exactly the signer asked about;
14
+ // 4. the log entry (Rekor `dsse` 0.0.1) names this payload, this signature and this certificate;
15
+ // 5. an RFC 6962 inclusion proof takes that entry to a root hash, and that root and tree size are
16
+ // the ones in a checkpoint signed by Rekor's key.
17
+ //
18
+ // Not checked here, on purpose: the signed entry timestamp (the inclusion proof plus a signed
19
+ // checkpoint is the stronger claim), certificate transparency SCTs, and Rekor v2 logs, which use other
20
+ // entry kinds. A bundle from a log this file does not know is refused rather than half-checked.
21
+ //
22
+ // Every failure returns { ok: false, reason } in words; nothing a bundle contains can make it throw.
23
+
24
+ import { createHash, createPublicKey, verify as verifySig, X509Certificate } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+
27
+ export const SIGSTORE_TRUST = JSON.parse(readFileSync(new URL("./sigstore-trust.json", import.meta.url), "utf8"));
28
+
29
+ /** The in-toto predicate type EVÐA's CI logs an enclave image under. */
30
+ export const ENCLAVE_STATEMENT_TYPE = "https://evda.io/enclave-measurement/v1";
31
+
32
+ /** The only signer whose enclave measurements the phone accepts: EVÐA's image workflow on main. */
33
+ export const ENCLAVE_SIGNER = Object.freeze({
34
+ issuer: "https://token.actions.githubusercontent.com",
35
+ san: "https://github.com/mikedouzinas/evda/.github/workflows/enclave-image.yml@refs/heads/main",
36
+ });
37
+
38
+ const OID_ISSUER_V1 = "1.3.6.1.4.1.57264.1.1";
39
+ const OID_ISSUER_V2 = "1.3.6.1.4.1.57264.1.8";
40
+ const OID_SAN = "2.5.29.17";
41
+ const EKU_CODE_SIGNING = "1.3.6.1.5.5.7.3.3";
42
+
43
+ const sha256 = (b) => createHash("sha256").update(b).digest();
44
+ const fromB64 = (s) => (typeof s === "string" && s.length ? Buffer.from(s, "base64") : null);
45
+ const fail = (reason) => ({ ok: false, reason });
46
+
47
+ // ─── DER, only as much as a certificate's extensions need ────────────────────────────────────────
48
+
49
+ function tlv(buf, at) {
50
+ if (at + 2 > buf.length) throw new Error("short");
51
+ const tag = buf[at];
52
+ let len = buf[at + 1];
53
+ let hdr = 2;
54
+ if (len & 0x80) {
55
+ const n = len & 0x7f;
56
+ if (n === 0 || n > 4 || at + 2 + n > buf.length) throw new Error("length");
57
+ len = 0;
58
+ for (let i = 0; i < n; i++) len = len * 256 + buf[at + 2 + i];
59
+ hdr += n;
60
+ }
61
+ const start = at + hdr;
62
+ if (start + len > buf.length) throw new Error("overrun");
63
+ return { tag, start, end: start + len, next: start + len };
64
+ }
65
+
66
+ function children(buf, node) {
67
+ const out = [];
68
+ for (let at = node.start; at < node.end;) { const c = tlv(buf, at); out.push(c); at = c.next; }
69
+ return out;
70
+ }
71
+
72
+ function oid(bytes) {
73
+ const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40];
74
+ let v = 0;
75
+ for (let i = 1; i < bytes.length; i++) { v = v * 128 + (bytes[i] & 0x7f); if (!(bytes[i] & 0x80)) { parts.push(v); v = 0; } }
76
+ return parts.join(".");
77
+ }
78
+
79
+ // The TDX verifier (tdx-attestation.mjs) reads Intel's PCK certificate extensions with these.
80
+ export { tlv as derTlv, children as derChildren, oid as derOid };
81
+
82
+ /** The OIDC issuer and the SAN URIs and emails of a Fulcio leaf, read from its DER. */
83
+ export function certificateIdentity(der) {
84
+ const buf = Buffer.from(der);
85
+ const tbs = children(buf, tlv(buf, 0))[0];
86
+ const extWrap = children(buf, tbs).find((c) => c.tag === 0xa3);
87
+ const out = { issuer: null, sans: [] };
88
+ if (!extWrap) return out;
89
+ let v1 = null;
90
+ for (const ext of children(buf, children(buf, extWrap)[0])) {
91
+ const parts = children(buf, ext);
92
+ const id = oid(buf.subarray(parts[0].start, parts[0].end));
93
+ const value = parts.at(-1);
94
+ const raw = buf.subarray(value.start, value.end);
95
+ if (id === OID_ISSUER_V2) { const s = tlv(raw, 0); out.issuer = raw.subarray(s.start, s.end).toString("utf8"); }
96
+ else if (id === OID_ISSUER_V1) v1 = raw.toString("utf8");
97
+ else if (id === OID_SAN) {
98
+ for (const g of children(raw, tlv(raw, 0))) if (g.tag === 0x86 || g.tag === 0x81) out.sans.push(raw.subarray(g.start, g.end).toString("utf8"));
99
+ }
100
+ }
101
+ out.issuer ??= v1;
102
+ return out;
103
+ }
104
+
105
+ // ─── RFC 6962 inclusion, and the checkpoint that signs its root ──────────────────────────────────
106
+
107
+ const node = (l, r) => sha256(Buffer.concat([Buffer.from([1]), l, r]));
108
+
109
+ /** True when `proof` takes `leaf` (a leaf hash) at `index` to `root` in a tree of `size`. */
110
+ export function verifyInclusion({ index, size, leaf, proof, root }) {
111
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(size) || index < 0 || index >= size) return false;
112
+ if (!Buffer.isBuffer(leaf) || !Buffer.isBuffer(root) || !Array.isArray(proof)) return false;
113
+ // BigInt, because a log index passes 2^31 and JavaScript's bitwise operators stop at 32 bits.
114
+ const i = BigInt(index);
115
+ const x = i ^ BigInt(size - 1);
116
+ const inner = x === 0n ? 0 : x.toString(2).length;
117
+ const border = (i >> BigInt(inner)).toString(2).replace(/0/g, "").length;
118
+ if (proof.length !== inner + border) return false;
119
+ let h = leaf;
120
+ for (let k = 0; k < inner; k++) h = ((i >> BigInt(k)) & 1n) === 0n ? node(h, proof[k]) : node(proof[k], h);
121
+ for (let k = inner; k < proof.length; k++) h = node(proof[k], h);
122
+ return h.equals(root);
123
+ }
124
+
125
+ /** Verifies a Rekor checkpoint (a signed note) against one pinned log; returns its origin, size and root. */
126
+ export function verifyCheckpoint(envelope, log) {
127
+ if (typeof envelope !== "string") return fail("the checkpoint is missing");
128
+ const split = envelope.indexOf("\n\n");
129
+ if (split < 0) return fail("the checkpoint is not a signed note");
130
+ const body = envelope.slice(0, split + 1);
131
+ const [origin, sizeLine, rootLine] = body.split("\n");
132
+ const keyId = fromB64(log.logId);
133
+ const key = createPublicKey({ key: fromB64(log.publicKey), format: "der", type: "spki" });
134
+ const host = new URL(log.baseUrl).host;
135
+ for (const line of envelope.slice(split + 2).split("\n")) {
136
+ const m = /^\u2014 (\S+) (\S+)$/.exec(line);
137
+ if (!m || m[1] !== host) continue;
138
+ const sig = Buffer.from(m[2], "base64");
139
+ if (sig.length < 5 || !sig.subarray(0, 4).equals(keyId.subarray(0, 4))) continue;
140
+ if (!verifySig("sha256", Buffer.from(body), key, sig.subarray(4))) continue;
141
+ const size = Number(sizeLine);
142
+ if (!origin?.startsWith(`${host} - `) || !Number.isSafeInteger(size) || !/^[A-Za-z0-9+/]+=*$/.test(rootLine ?? "")) return fail("the checkpoint is signed but malformed");
143
+ return { ok: true, origin, size, root: Buffer.from(rootLine, "base64") };
144
+ }
145
+ return fail(`the checkpoint carries no valid signature from ${host}`);
146
+ }
147
+
148
+ // ─── the bundle ──────────────────────────────────────────────────────────────────────────────────
149
+
150
+ function pae(type, payload) {
151
+ const t = Buffer.from(type, "utf8");
152
+ return Buffer.concat([Buffer.from(`DSSEv1 ${t.length} `), t, Buffer.from(` ${payload.length} `), payload]);
153
+ }
154
+
155
+ const pem = (der) => `-----BEGIN CERTIFICATE-----\n${der.toString("base64").match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----\n`;
156
+
157
+ function checkChain(leaf, trust, at) {
158
+ const chain = trust.fulcio.chain.map((c) => new X509Certificate(fromB64(c)));
159
+ const certs = [leaf, ...chain];
160
+ for (let i = 0; i < certs.length; i++) {
161
+ const issuer = certs[Math.min(i + 1, certs.length - 1)];
162
+ if (!certs[i].checkIssued(issuer) || !certs[i].verify(issuer.publicKey)) return `the certificate chain breaks at ${certs[i].subject || "the leaf"}`;
163
+ if (at < new Date(certs[i].validFrom) || at > new Date(certs[i].validTo)) return "a certificate in the chain was not valid when the entry was logged";
164
+ }
165
+ if (leaf.ca) return "the signing certificate is a CA, not a Fulcio leaf";
166
+ if (!(leaf.keyUsage ?? []).includes(EKU_CODE_SIGNING)) return "the signing certificate is not for code signing";
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Confirms `digest` ({ algorithm, hex }) is the subject of an in-toto statement that `signer`
172
+ * ({ issuer, san }) signed through Fulcio and that Rekor logged, from a Sigstore bundle (v0.3, DSSE).
173
+ * Pass `predicateType` to also require the statement's type (ENCLAVE_STATEMENT_TYPE for an image).
174
+ */
175
+ export function verifyLoggedMeasurement({ bundle, digest, signer, predicateType, trust = SIGSTORE_TRUST } = {}) {
176
+ try {
177
+ return check({ bundle, digest, signer, predicateType, trust });
178
+ } catch (err) {
179
+ return fail(`the bundle could not be read: ${err.message}`);
180
+ }
181
+ }
182
+
183
+ function check({ bundle, digest, signer, predicateType, trust }) {
184
+ if (!signer?.issuer || !signer?.san) return fail("no signer was named, so nothing says who must have logged it");
185
+ if (!digest?.algorithm || !/^[0-9a-f]+$/.test(digest?.hex ?? "")) return fail("no digest was named");
186
+ if (!bundle || typeof bundle !== "object") return fail("there is no bundle");
187
+
188
+ // 1. The statement.
189
+ const env = bundle.dsseEnvelope;
190
+ const payload = fromB64(env?.payload);
191
+ const sigs = env?.signatures;
192
+ if (!payload || !Array.isArray(sigs) || sigs.length !== 1) return fail("the bundle has no single-signature DSSE envelope");
193
+ if (env.payloadType !== "application/vnd.in-toto+json") return fail("the envelope does not hold an in-toto statement");
194
+ const statement = JSON.parse(payload.toString("utf8"));
195
+ if (!/^https:\/\/in-toto\.io\/Statement\/v(1|0\.1)$/.test(statement?._type ?? "")) return fail("the envelope does not hold an in-toto statement");
196
+ if (predicateType && statement.predicateType !== predicateType) return fail(`the statement type is ${statement.predicateType}, not ${predicateType}`);
197
+ const subjects = Array.isArray(statement.subject) ? statement.subject : [];
198
+ if (!subjects.some((s) => typeof s?.digest?.[digest.algorithm] === "string" && s.digest[digest.algorithm].toLowerCase() === digest.hex)) {
199
+ return fail(`the statement does not name ${digest.algorithm} digest ${digest.hex.slice(0, 16)}...`);
200
+ }
201
+
202
+ // 2 and 3. The signature, the certificate and who it was issued to.
203
+ const leafDer = fromB64(bundle.verificationMaterial?.certificate?.rawBytes);
204
+ if (!leafDer) return fail("the bundle carries no signing certificate");
205
+ const leaf = new X509Certificate(leafDer);
206
+ const sig = fromB64(sigs[0].sig);
207
+ if (!sig || !verifySig("sha256", pae(env.payloadType, payload), leaf.publicKey, sig)) return fail("the envelope's signature does not verify under its certificate");
208
+
209
+ const entries = bundle.verificationMaterial?.tlogEntries;
210
+ if (!Array.isArray(entries) || entries.length === 0) return fail("the bundle carries no log entry");
211
+ const entry = entries[0];
212
+ const integratedTime = Number(entry.integratedTime);
213
+ if (!Number.isSafeInteger(integratedTime)) return fail("the log entry has no integration time");
214
+ const chainProblem = checkChain(leaf, trust, new Date(integratedTime * 1000));
215
+ if (chainProblem) return fail(chainProblem);
216
+ const id = certificateIdentity(leafDer);
217
+ if (id.issuer !== signer.issuer) return fail(`the certificate's issuer is ${id.issuer}, not ${signer.issuer}`);
218
+ if (!id.sans.includes(signer.san)) return fail(`the certificate's signer is ${id.sans.join(", ") || "nobody"}, not ${signer.san}`);
219
+
220
+ // 4. The log entry names exactly this envelope.
221
+ const log = trust.tlogs.find((l) => l.logId === entry.logId?.keyId);
222
+ if (!log) return fail("the entry is from a log this phone does not trust");
223
+ if (integratedTime * 1000 < Date.parse(log.validFrom)) return fail("the entry predates the log's key");
224
+ const bodyBytes = fromB64(entry.canonicalizedBody);
225
+ if (!bodyBytes) return fail("the log entry has no body");
226
+ const body = JSON.parse(bodyBytes.toString("utf8"));
227
+ if (body.kind !== "dsse" || body.apiVersion !== "0.0.1" || entry.kindVersion?.kind !== "dsse" || entry.kindVersion?.version !== "0.0.1") {
228
+ return fail(`the log entry is ${body.kind} ${body.apiVersion}, and only dsse 0.0.1 is understood`);
229
+ }
230
+ if (body.spec?.payloadHash?.algorithm !== "sha256" || body.spec.payloadHash.value !== sha256(payload).toString("hex")) return fail("the log entry is for a different statement");
231
+ const logged = body.spec.signatures;
232
+ if (!Array.isArray(logged) || logged.length !== 1 || logged[0].signature !== sigs[0].sig) return fail("the log entry is for a different signature");
233
+ if (fromB64(logged[0].verifier)?.toString("utf8") !== pem(leafDer)) return fail("the log entry is for a different certificate");
234
+
235
+ // 5. The entry is in the tree whose root Rekor signed.
236
+ const proof = entry.inclusionProof;
237
+ if (!proof) return fail("the log entry has no inclusion proof");
238
+ const index = Number(proof.logIndex);
239
+ const size = Number(proof.treeSize);
240
+ const root = fromB64(proof.rootHash) ?? Buffer.alloc(0);
241
+ const hashes = Array.isArray(proof.hashes) ? proof.hashes.map((h) => Buffer.from(h, "base64")) : [];
242
+ const leafHash = sha256(Buffer.concat([Buffer.from([0]), bodyBytes]));
243
+ if (root.length !== 32 || !verifyInclusion({ index, size, leaf: leafHash, proof: hashes, root })) return fail("the inclusion proof does not reach the root");
244
+ const cp = verifyCheckpoint(proof.checkpoint?.envelope, log);
245
+ if (!cp.ok) return fail(cp.reason);
246
+ if (cp.size !== size || !cp.root.equals(root)) return fail("the checkpoint signs a different tree than the inclusion proof");
247
+
248
+ return { ok: true, logIndex: index, treeSize: size, integratedTime, statement };
249
+ }
package/lib/messages.mjs CHANGED
@@ -1,5 +1,8 @@
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.
1
+ // Dispatch through the record (HQ-1246): the owner's agent writes a message, the EVÐA phone app
2
+ // shows it as a card once it is paired and has synced, and the owner's reply comes back as a file
3
+ // the agent reads on its next run. This module only writes the file -- it has no way to see whether
4
+ // a phone ever reads it, so callers (lib/mcp.mjs, bin/evda.mjs) must not claim the card happened
5
+ // (HQ-2161: the tool told the model it had shown a card when the owner's phone app was not running).
3
6
  //
4
7
  // The format, which the phone app (iris-mobile, the EVÐA target) reads exactly:
5
8
  //
@@ -3,7 +3,7 @@
3
3
  // The six rules, each implemented once below:
4
4
  // 1. The file is copied to <record root>/.backup/people-migration-<YYYY-MM-DD>/ first.
5
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.
6
+ // name (the H1), observations.
7
7
  // 3. One observation per dated H2/H3 heading: text is the heading text, since is its date at the
8
8
  // precision written (day or month). A range uses its start. A year-only heading stays prose.
9
9
  // 4. supersededBy only when a heading says it replaces an earlier one AND exactly one earlier
@@ -14,7 +14,12 @@
14
14
  // The owner is provenance owner; the person is provenance person with by naming them.
15
15
  // 6. Undated sections stay prose.
16
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.
17
+ // A profile needs one observation, so a doc with no dated heading gets one dated the day it is typed,
18
+ // saying no section carries a date yet (HQ-2024: doctor fails an untyped file in a person folder, so
19
+ // every file there has to be typeable). A doc with no H1 takes its name from its file name.
20
+ // A file that already has frontmatter keeps every key it has; only the missing person-profile keys
21
+ // are added below them. One that already says a kind, or is sealed or just noted, is skipped.
22
+ // gather apply runs the same plan on a page it places in a person folder (lib/gather-apply.mjs).
18
23
 
19
24
  import { createHash } from "node:crypto";
20
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -23,6 +28,7 @@ import { findRoot, holderOf, readManifest } from "./manifest.mjs";
23
28
  import { readEntry } from "./kinds.mjs";
24
29
  import { parseYaml, toYaml } from "./yaml.mjs";
25
30
  import { carries, frontmatterOf, heldBy, sealedSections, NOTED, SEALED } from "./circles.mjs";
31
+ import { baseName } from "./person-folders.mjs";
26
32
 
27
33
  // The lines of a body with every line of a sealed section blanked, so nothing in one becomes an
28
34
  // observation, a name or a quote in frontmatter every reader shows (HQ-1414). Line numbers are kept.
@@ -105,18 +111,40 @@ export function speakerQuote(line) {
105
111
  return null;
106
112
  }
107
113
 
108
- // Plan the frontmatter for one doc. Pure: reads nothing from disk.
109
- export function planProfile({ fileName, text, owner }) {
114
+ const today = () => { const d = new Date(); return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; };
115
+
116
+ // A file's own frontmatter: its YAML lines and where its body starts, or null when it has none.
117
+ function ownFrontmatter(text) {
118
+ const lines = text.split("\n");
119
+ if (lines[0] !== "---") return null;
120
+ const end = lines.indexOf("---", 1);
121
+ if (end === -1) return null;
122
+ return { head: lines.slice(1, end), body: lines.slice(end + 1).join("\n") };
123
+ }
124
+
125
+ // Plan the frontmatter for one doc. Pure: reads nothing from disk. `date` dates the one observation
126
+ // a doc with no dated heading gets. Returns { entry, frontmatter, bodyAt, notes, problems } where
127
+ // the file becomes frontmatter + the original from byte bodyAt on, or { skip }.
128
+ export function planProfile({ fileName, text, owner, date = today() }) {
110
129
  const notes = [];
111
130
  // A sealed or just-noted file always has frontmatter, so it is skipped here and never rewritten.
112
131
  // EVDA_CARRY_FALSIFY=migrate-people:noted lets a noted one through, as a migration that read past
113
132
  // its frontmatter would, to show this skip is what keeps it noted.
114
133
  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);
134
+ let own = null;
135
+ if (/^---\r?\n/.test(text) && !falsifiedNoted) {
136
+ own = ownFrontmatter(text);
137
+ const front = frontmatterOf(text);
138
+ if (!own || !front) return { skip: "already has frontmatter that does not read as YAML" };
139
+ if (front.kind !== undefined) return { skip: `already has frontmatter saying kind ${front.kind}` };
140
+ if (heldBy(front, "migrate-people") || front[SEALED] === true || front[NOTED] === true) return { skip: "already has frontmatter that seals it or holds it noted; never rewritten" };
141
+ }
142
+ const body = own ? own.body : text;
143
+ const bodyAt = own ? Buffer.byteLength(text, "utf8") - Buffer.byteLength(body, "utf8") : 0;
144
+ const lines = openLines(body);
117
145
  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();
146
+ const name = h1 ? h1.slice(2).trim() : baseName(fileName.replace(/\.md$/i, "")).trim();
147
+ if (!name) return { skip: "has no H1 and no file name to take the name from" };
120
148
  const first = name.split(/\s+/)[0];
121
149
  const ownerFirst = String(owner).split(/\s+/)[0];
122
150
 
@@ -167,7 +195,11 @@ export function planProfile({ fileName, text, owner }) {
167
195
  }
168
196
  }
169
197
 
170
- if (observations.length === 0) return { skip: "has no dated H2 or H3 heading, so there is no observation; left as prose" };
198
+ // A profile holds at least one observation. With no dated section, the one it gets says so.
199
+ if (observations.length === 0) {
200
+ observations.push({ obs: { id: "typed", since: date, text: "Typed as a person profile; none of its sections carries a date yet." }, order: 0 });
201
+ notes.push(`no dated H2 or H3 heading, so its one observation is dated ${date}, the day it was typed`);
202
+ }
171
203
 
172
204
  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
205
  const entry = {
@@ -177,20 +209,22 @@ export function planProfile({ fileName, text, owner }) {
177
209
  name,
178
210
  observations: observations.map((o) => o.obs),
179
211
  };
180
- const frontmatter = `---\n${toYaml(entry)}---\n`;
212
+ // Keys the file already has stay as they are, above the ones added.
213
+ const front = own ? frontmatterOf(text) : {};
214
+ const missing = Object.fromEntries(Object.entries(entry).filter(([k]) => !(k in front)));
215
+ const kept = own && own.head.length ? `${own.head.join("\n")}\n` : "";
216
+ const frontmatter = `---\n${kept}${toYaml(missing)}---\n`;
181
217
  const problems = readEntry("person-profile", parseYaml(frontmatter.slice(4, -4)), { owner }).problems;
182
- return { entry, frontmatter, notes, problems };
218
+ return { entry, frontmatter, bodyAt, notes, problems };
183
219
  }
184
220
 
185
- // The body below a written frontmatter is the original, byte for byte.
186
- export function bodyMatches(original, written, frontmatter) {
221
+ // The body below a written frontmatter is the original's body (from byte bodyAt), byte for byte.
222
+ export function bodyMatches(original, written, frontmatter, bodyAt = 0) {
187
223
  const head = Buffer.from(frontmatter, "utf8");
188
224
  if (!written.subarray(0, head.length).equals(head)) return false;
189
- return sha256(written.subarray(head.length)) === sha256(original);
225
+ return sha256(written.subarray(head.length)) === sha256(original.subarray(bodyAt));
190
226
  }
191
227
 
192
- const today = () => { const d = new Date(); return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; };
193
-
194
228
  // Migrate files. Returns one result per file: { file, status: done|would|skipped|failed, ... }.
195
229
  export function migratePeople(files, { dry = false, date = today(), root = null } = {}) {
196
230
  const results = [];
@@ -203,7 +237,7 @@ export function migratePeople(files, { dry = false, date = today(), root = null
203
237
  const owner = (() => { const h = holderOf(readManifest(recordRoot)); return h.id ?? h.name; })();
204
238
  if (!owner) { Object.assign(r, { status: "failed", why: "evda.json names no owner" }); continue; }
205
239
  const original = readFileSync(file);
206
- const plan = planProfile({ fileName: basename(file), text: original.toString("utf8"), owner });
240
+ const plan = planProfile({ fileName: basename(file), text: original.toString("utf8"), owner, date });
207
241
  if (plan.skip) { Object.assign(r, { status: "skipped", why: plan.skip }); continue; }
208
242
  Object.assign(r, { frontmatter: plan.frontmatter, notes: plan.notes, observations: plan.entry.observations.length });
209
243
  if (plan.problems.length) { Object.assign(r, { status: "failed", why: `the frontmatter would not read as a person-profile: ${plan.problems.join("; ")}` }); continue; }
@@ -217,14 +251,14 @@ export function migratePeople(files, { dry = false, date = today(), root = null
217
251
  }
218
252
  copyFileSync(file, r.backup);
219
253
  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]));
254
+ writeFileSync(file, Buffer.concat([Buffer.from(plan.frontmatter, "utf8"), original.subarray(plan.bodyAt)]));
221
255
  const written = readFileSync(file);
222
- if (!bodyMatches(original, written, plan.frontmatter)) {
256
+ if (!bodyMatches(original, written, plan.frontmatter, plan.bodyAt)) {
223
257
  writeFileSync(file, original);
224
258
  Object.assign(r, { status: "failed", why: "the body below the frontmatter did not hash to the original; restored the original" });
225
259
  continue;
226
260
  }
227
- r.bodySha256 = sha256(original);
261
+ r.bodySha256 = sha256(original.subarray(plan.bodyAt));
228
262
  r.status = "done";
229
263
  }
230
264
  return results;