create-evda 0.1.6 → 0.1.8

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/lib/organize.mjs CHANGED
@@ -13,7 +13,9 @@
13
13
  // nothing is written into circles/ or grants/.
14
14
  //
15
15
  // The judgment part runs through the owner's own AI (`--with-ai`): `claude -p` with the evda skill
16
- // and read-only EVDA tools, given the report. Its answer is appended to the same report. EVDA holds
16
+ // and read-only EVDA tools, given the last two weeks of entries. It hands back one thread across
17
+ // days, quoted and dated, which leads the report; an answer whose quotes are not in the entries is
18
+ // dropped, and with nothing across two days the report says nothing (HQ-1246). EVDA holds
17
19
  // no key and calls no model; `claude` is the owner's install, signed in as the owner.
18
20
 
19
21
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
@@ -203,11 +205,113 @@ export function askOwnersAi(evda, prompt, { model = null, env = process.env, tim
203
205
  if (skill) argv.push("--append-system-prompt", skill);
204
206
  if (model) argv.push("--model", model);
205
207
  const r = spawnSync(bin, argv, { cwd: evda ? evda.dir : tmpdir(), env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
206
- if (r.error) return { ok: false, why: r.error.code === "ENOENT" ? `${bin} is not installed or not on PATH` : String(r.error.message) };
207
- if (r.status !== 0) return { ok: false, why: `${bin} -p exited ${r.status}: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" ")}` };
208
- const text = (r.stdout ?? "").trim();
209
- if (!text) return { ok: false, why: `${bin} -p answered with nothing` };
210
- return { ok: true, text };
208
+ const text = r.error || r.status !== 0 ? "" : (r.stdout ?? "").trim();
209
+ if (text) return { ok: true, text };
210
+ return { ok: false, why: aiFailure(r, { bin, env }) };
211
+ }
212
+
213
+ // Why the owner's AI did not answer, in words the owner can act on (HQ-1246). The report is read by
214
+ // a person, not a developer, so it never says "claude -p exited 1". Claude's own last line follows
215
+ // the plain sentence when it is the only clue. EVDA_ROUTINES_FALSIFY=raw-why gives the old text.
216
+ export function aiFailure(r, { bin = "claude", env = process.env } = {}) {
217
+ const said = `${r.stderr ?? ""}\n${r.stdout ?? ""}`.trim().split("\n").map((l) => l.trim()).filter(Boolean);
218
+ if ((env.EVDA_ROUTINES_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("raw-why")) {
219
+ if (r.error) return r.error.code === "ENOENT" ? `${bin} is not installed or not on PATH` : String(r.error.message);
220
+ return r.status !== 0 ? `${bin} -p exited ${r.status}: ${said.slice(-3).join(" ")}` : `${bin} -p answered with nothing`;
221
+ }
222
+ if (r.error?.code === "ENOENT") return "Claude Code is not on this computer. Install it from https://claude.com/claude-code, open it once and sign in, and the next run includes your AI's read.";
223
+ if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") return "Claude took too long and was stopped. The next run tries again.";
224
+ if (r.error) return `Claude could not be started on this computer. The next run tries again. What the computer said: ${r.error.message}`;
225
+ if (said.some((l) => /not logged in|please run \/login|invalid api key|oauth token (has )?expired|authentication[_ ]error|\b401\b/i.test(l))) return "Claude is not signed in on this computer: open Claude and sign in, and the next run includes your AI's read.";
226
+ if (r.status === 0) return "Claude answered with nothing. The next run tries again.";
227
+ return `Claude stopped with an error before answering. The next run tries again.${said.length ? ` What it said: ${said.at(-1)}` : ""}`;
228
+ }
229
+
230
+ // How far back the AI part reads for a thread.
231
+ export const PATTERN_DAYS = 14;
232
+ export const NO_PATTERN = "NOTHING";
233
+
234
+ // The owner's own words, one per dated daily entry in the last `days` days, as the AI part reads
235
+ // them: a sealed or just-noted day is left out, a sealed section is cut, and evda's markers and any
236
+ // secret-shaped line are dropped. Oldest first.
237
+ export function entriesFor(evda, { now = new Date(), days = PATTERN_DAYS } = {}) {
238
+ const daily = resolveRole(evda, "record/daily");
239
+ if (!daily.mapped || !daily.exists) return [];
240
+ const start = new Date(now);
241
+ start.setDate(start.getDate() - (days - 1));
242
+ const from = localDate(start);
243
+ const to = localDate(now);
244
+ const out = [];
245
+ for (const abs of mdFiles(daily.abs)) {
246
+ const raw = readFileSync(abs, "utf8");
247
+ const date = fileDate(abs, raw);
248
+ if (!date || date < from || date > to) continue;
249
+ if (heldBy(frontmatterOf(raw), "organize")) continue;
250
+ const text = stripFrontmatter(withoutSealed(raw).text)
251
+ .split("\n")
252
+ .filter((l) => !/^<!-- evda:/.test(l.trim()) && !/^#{1,6} /.test(l) && !secretShape(l))
253
+ .join("\n").replace(/\n{3,}/g, "\n\n").trim();
254
+ if (text) out.push({ date, text });
255
+ }
256
+ return out.sort((a, b) => a.date.localeCompare(b.date));
257
+ }
258
+
259
+ // The prompt for the AI part. It carries the mechanism the answer rests on, the exact shape the
260
+ // answer must take, and the entries themselves.
261
+ export function patternPrompt(entries) {
262
+ return [
263
+ "Below are the owner's own entries from the last two weeks, each under its date.",
264
+ "",
265
+ "Find one thing that comes back across different days: a person, a question, a worry, a want, a phrase they keep using.",
266
+ "Why it is worth their reading: a conclusion handed to someone from outside tends not to take, and one they recognize in their own words does. So hand back their words, not your reading of them.",
267
+ "",
268
+ "Answer in exactly this shape:",
269
+ "one or two plain sentences naming what comes back, in their vocabulary",
270
+ "- YYYY-MM-DD: \"words copied exactly from that day's entry\"",
271
+ "- YYYY-MM-DD: \"words copied exactly from another day's entry\"",
272
+ "",
273
+ "Quote at least two different days. Copy each quote character for character from the entry under that date; a quote that is not there is dropped, and so is the whole answer.",
274
+ "Say only what the entries say. No advice, no question back to them, no score, no count, no praise, and no words like journey, transformation or growth.",
275
+ "Do not mention filing, organizing, this report or yourself.",
276
+ `If nothing honestly comes back across two or more days, answer with the single word ${NO_PATTERN}.`,
277
+ "",
278
+ ...entries.flatMap((e) => [`=== ${e.date} ===`, e.text, ""]),
279
+ ].join("\n");
280
+ }
281
+
282
+ const squash = (t) => t.replace(/[\u2018\u2019]/g, "'").replace(/[\u201c\u201d]/g, '"').replace(/\s+/g, " ").trim().toLowerCase();
283
+
284
+ // The answer, kept only when it has the shape: prose first, then two or more quotes from two or more
285
+ // dates, each found in the entry of its date. Returns { prose, quotes } or null (say nothing).
286
+ // EVDA_ROUTINES_FALSIFY=no-quote-check keeps whatever came back.
287
+ export function patternFrom(text, entries, { env = process.env } = {}) {
288
+ const t = String(text ?? "").trim();
289
+ if ((env.EVDA_ROUTINES_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("no-quote-check")) return t ? { prose: t, quotes: [] } : null;
290
+ if (!t || t.replace(/[.\s]/g, "").toUpperCase() === NO_PATTERN) return null;
291
+ const lines = t.split("\n").map((l) => l.trim()).filter(Boolean);
292
+ const quoteRe = /^[-*] (\d{4}-\d{2}-\d{2}): ["\u201c](.+)["\u201d]$/;
293
+ const first = lines.findIndex((l) => quoteRe.test(l));
294
+ if (first < 1) return null;
295
+ const prose = lines.slice(0, first);
296
+ const rest = lines.slice(first);
297
+ if (prose.length > 3 || rest.some((l) => !quoteRe.test(l))) return null;
298
+ const byDate = new Map(entries.map((e) => [e.date, squash(e.text)]));
299
+ const quotes = [];
300
+ for (const l of rest) {
301
+ const [, date, words] = quoteRe.exec(l);
302
+ const q = squash(words);
303
+ if (q.length < 3 || !byDate.has(date) || !byDate.get(date).includes(q)) return null;
304
+ quotes.push({ date, words });
305
+ }
306
+ if (new Set(quotes.map((q) => q.date)).size < 2) return null;
307
+ return { prose: prose.join(" "), quotes };
308
+ }
309
+
310
+ // The pattern leads the run's section, before the filing list.
311
+ export function withPattern(report, { prose, quotes }) {
312
+ const block = ["### Across your entries", "", prose, "", ...quotes.map((q) => `- ${q.date}: "${q.words}"`), ""].join("\n");
313
+ const at = report.indexOf("\n", report.indexOf("## Run")) + 1;
314
+ return `${report.slice(0, at)}\n${block}${report.slice(at)}`;
211
315
  }
212
316
 
213
317
  export async function organize(evda, { now = new Date(), window = 7, withAi = false, dryRun = false, model = null, env = process.env } = {}) {
@@ -234,19 +338,19 @@ export async function organize(evda, { now = new Date(), window = 7, withAi = fa
234
338
  }
235
339
  let exit = 0;
236
340
  if (withAi) {
237
- const ai = askOwnersAi(evda, [
238
- "Use the evda skill's Organizing section. Here is today's organize report for this record.",
239
- "For each note that needs a place, answer in one line: the note's path, where it belongs, and the words from the note that say so, quoted.",
240
- "Check against the record with the evda tools first. Write nothing. If nothing needs a place, say so in one line.",
241
- "",
242
- report,
243
- ].join("\n"), { model, env });
244
- if (ai.ok) {
245
- const secret = secretLine(ai.text);
246
- report += secret ? `\n### Your AI's read\n\nNot kept: the answer held ${secret}.\n` : `\n### Your AI's read (claude -p)\n\n${ai.text}\n`;
247
- } else {
248
- report += `\n### Your AI's read\n\nSKIPPED: ${ai.why}. Nothing else in this run depended on it.\n`;
249
- exit = 1;
341
+ // The AI part hands back a thread across the owner's own entries, not a log of the filing above
342
+ // (HQ-1246). Its answer is kept only if every quote is in the entry of the date it names and the
343
+ // quotes span two days or more; otherwise the report says nothing about it.
344
+ const entries = entriesFor(evda, { now, days: PATTERN_DAYS });
345
+ if (entries.length) {
346
+ const ai = askOwnersAi(evda, patternPrompt(entries), { model, env });
347
+ if (ai.ok) {
348
+ const pattern = secretLine(ai.text) ? null : patternFrom(ai.text, entries, { env });
349
+ if (pattern) report = withPattern(report, pattern);
350
+ } else {
351
+ report += `\n### Your AI's read\n\nDid not run. ${ai.why}\n\nThe filing above ran without it.\n`;
352
+ exit = 1;
353
+ }
250
354
  }
251
355
  }
252
356
  const path = appendReport(evda, "organize", date, report);
@@ -258,7 +362,7 @@ export async function lookBack(evda, { now = new Date(), model = null, env = pro
258
362
  const date = localDate(now);
259
363
  const block = ai.ok
260
364
  ? (secretLine(ai.text) ? `## Run ${localTime(now)}\n\nNot kept: the answer held a secret-shaped line.\n` : `## Run ${localTime(now)}\n\n${ai.text}\n`)
261
- : `## Run ${localTime(now)}\n\nSKIPPED: ${ai.why}.\n`;
365
+ : `## Run ${localTime(now)}\n\nDid not run. ${ai.why}\n`;
262
366
  const path = appendReport(evda, "look-back", date, block);
263
367
  return { path, exit: ai.ok ? 0 : 1, summary: `${block}\nAppended to ${path}.` };
264
368
  }
@@ -0,0 +1,165 @@
1
+ // Device pairing for the evda CLI and the MCP (HQ-1217): pair a phone, list and remove devices, sync.
2
+ //
3
+ // The pairing code itself is atlas-local's (atlas/block/atlas-local/src/node/pairing.ts), shipped
4
+ // in this package as one bundled file, dist/pairing.mjs (scripts/build-pairing.mjs). It is loaded
5
+ // only when a pairing command runs, because it needs node:sqlite, which the rest of this package
6
+ // does not: an owner on an older Node still has every other command.
7
+ //
8
+ // Everything a person reads from here is plain words. The one credential shown is the pairing code,
9
+ // because typing or scanning it on the phone is the point; no id, key or hash is printed.
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { EvdaError } from "./errors.mjs";
16
+ import { openEvda } from "./manifest.mjs";
17
+
18
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ export const PAIRING_BUNDLE = join(PKG, "dist", "pairing.mjs");
20
+
21
+ // node:sqlite is unflagged from Node 22.13 (and 23.4); before that it needs --experimental-sqlite.
22
+ export const PAIRING_NODE = "22.13";
23
+
24
+ /** Why pairing cannot run on this Node, in plain words, or null when it can. */
25
+ export function pairingUnsupported(version = process.versions.node) {
26
+ const [major, minor] = String(version).split(".").map(Number);
27
+ const ok = major > 23 || (major === 23 && minor >= 4) || (major === 22 && minor >= 13);
28
+ return ok ? null : `Pairing a device needs Node ${PAIRING_NODE} or newer, and this is Node ${version}. Everything else in evda works on this Node; install a newer one to pair.`;
29
+ }
30
+
31
+ let loaded = null;
32
+
33
+ /** The bundled pairing module, pointed at this package's own lib/. Throws in plain words when it cannot load. */
34
+ export async function loadPairing({ bundle = PAIRING_BUNDLE, version = process.versions.node } = {}) {
35
+ const unsupported = pairingUnsupported(version);
36
+ if (unsupported) throw new EvdaError(unsupported, { code: "NODE_TOO_OLD", exit: 2 });
37
+ if (loaded?.bundle === bundle) return loaded.mod;
38
+ if (!existsSync(bundle)) {
39
+ throw new EvdaError("This copy of evda was installed without device pairing. Run it from the published package (npx -p create-evda evda pair), or build it here with `node scripts/build-pairing.mjs`.", { code: "NOT_BUILT", exit: 2 });
40
+ }
41
+ // Node 22 and 23 announce node:sqlite as experimental on stderr. That line is for developers, not
42
+ // for someone pairing a phone, so it is dropped while the bundle loads and nothing else is.
43
+ const emit = process.emitWarning;
44
+ process.emitWarning = function (warning, ...rest) {
45
+ if (/SQLite/i.test(String(warning?.message ?? warning))) return;
46
+ return emit.call(process, warning, ...rest);
47
+ };
48
+ try {
49
+ const mod = await import(pathToFileURL(bundle).href);
50
+ mod.usePersonRecord(PKG);
51
+ loaded = { bundle, mod };
52
+ return mod;
53
+ } catch (error) {
54
+ // A Node that has the version but not the module (started with --no-experimental-sqlite, or a
55
+ // build without it) fails here, and is told the same thing in the same words.
56
+ if (error?.code === "ERR_UNKNOWN_BUILTIN_MODULE") {
57
+ throw new EvdaError(`Pairing a device needs Node ${PAIRING_NODE} or newer with its built-in SQLite, and this Node cannot load it. Everything else in evda works on this Node.`, { code: "NODE_TOO_OLD", exit: 2 });
58
+ }
59
+ throw error;
60
+ } finally {
61
+ process.emitWarning = emit;
62
+ }
63
+ }
64
+
65
+ // A refusal wrote nothing and is exit 2; anything else (the relay unreachable) is exit 1.
66
+ const REFUSALS = /^(Your EVÐA has no private|This record has no private part|There is already a device|There is no paired device|Give the device a name|Name at least one part)/;
67
+ function asEvdaError(error) {
68
+ if (error instanceof EvdaError) return error;
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ return new EvdaError(message, { code: "PAIRING", exit: REFUSALS.test(message) ? 2 : 1 });
71
+ }
72
+
73
+ /** Where the computer keeps its device state: `<home>/.evda/<record>/`, never inside the record. */
74
+ export function ownerOptions(dir, { env = process.env, relay } = {}) {
75
+ return { record: dir, home: resolve(env.HOME || homedir()), relayUrl: relay || env.EVDA_RELAY || undefined };
76
+ }
77
+
78
+ const partsList = (value) => (Array.isArray(value) ? value : String(value ?? "").split(",")).map((s) => String(s).trim()).filter(Boolean);
79
+
80
+ export async function pair(dir, { name, parts, relay, env } = {}) {
81
+ const p = await loadPairing();
82
+ try {
83
+ return await p.pairDevice(ownerOptions(dir, { env, relay }), { name: name ?? "phone", parts: partsList(parts ?? "journal") });
84
+ } catch (error) {
85
+ throw asEvdaError(error);
86
+ }
87
+ }
88
+
89
+ export const NO_DEVICES = "No devices are paired with this EVÐA.";
90
+
91
+ // The computer's list of devices lives beside its device state (pairing.ts, paired-devices.json).
92
+ // A record with no id, or no list there, has paired nothing: that is answered without loading the
93
+ // bundle, so listing devices on a fresh record, or on a Node or a copy that cannot pair, is an
94
+ // answer and not an error.
95
+ function pairedList(dir, env) {
96
+ let id;
97
+ try { id = openEvda(dir).manifest.id; } catch { return null; }
98
+ if (typeof id !== "string" || !id || /[/\\]/.test(id)) return null;
99
+ return join(resolve(env.HOME || homedir()), ".evda", id, "paired-devices.json");
100
+ }
101
+
102
+ export async function devices(dir, { relay, env = process.env, bundle } = {}) {
103
+ const list = pairedList(dir, env);
104
+ if (!list || !existsSync(list)) return [NO_DEVICES];
105
+ const p = await loadPairing(bundle ? { bundle } : {});
106
+ try {
107
+ return await p.deviceLines(ownerOptions(dir, { env, relay }));
108
+ } catch (error) {
109
+ throw asEvdaError(error);
110
+ }
111
+ }
112
+
113
+ /** The devices as data, for an app (HQ-1925): `[{name, state, parts, since}]`, empty when nothing is paired. No id. */
114
+ export async function deviceData(dir, { relay, env = process.env, bundle } = {}) {
115
+ const list = pairedList(dir, env);
116
+ if (!list || !existsSync(list)) return [];
117
+ const p = await loadPairing(bundle ? { bundle } : {});
118
+ try {
119
+ return await p.deviceList(ownerOptions(dir, { env, relay }));
120
+ } catch (error) {
121
+ throw asEvdaError(error);
122
+ }
123
+ }
124
+
125
+ /** One sync as data, for an app (HQ-1925): `{paired: [names], received, sent, refused}`. No id. */
126
+ export async function syncData(dir, { relay, env } = {}) {
127
+ const p = await loadPairing();
128
+ try {
129
+ return await p.syncOnce(ownerOptions(dir, { env, relay }));
130
+ } catch (error) {
131
+ throw asEvdaError(error);
132
+ }
133
+ }
134
+
135
+ export async function remove(dir, name, { relay, env } = {}) {
136
+ if (!name || !String(name).trim()) throw new EvdaError("Say which device to remove: evda remove <device>. `evda devices` lists them.", { code: "USAGE", exit: 2 });
137
+ const p = await loadPairing();
138
+ try {
139
+ return await p.removeDeviceWords(ownerOptions(dir, { env, relay }), String(name).trim());
140
+ } catch (error) {
141
+ throw asEvdaError(error);
142
+ }
143
+ }
144
+
145
+ export async function sync(dir, { relay, env } = {}) {
146
+ const p = await loadPairing();
147
+ try {
148
+ return await p.syncWords(ownerOptions(dir, { env, relay }));
149
+ } catch (error) {
150
+ throw asEvdaError(error);
151
+ }
152
+ }
153
+
154
+ /** The MCP tool's answer: the code, the QR link, and what to do on the phone, step by step. */
155
+ export function pairText(r) {
156
+ return [
157
+ `Pairing code for ${r.name}: ${r.code}`,
158
+ `QR link (make it a QR code, or open it on the phone): ${r.qr}`,
159
+ "",
160
+ "On the phone:",
161
+ ...r.phoneSteps.map((s, i) => `${i + 1}. ${s}`),
162
+ "",
163
+ `${r.name} will be able to read and add to ${r.parts.join(", ")}, and nothing else. The code is a key until it is used: show it only to the owner.`,
164
+ ].join("\n");
165
+ }
package/lib/parity.mjs CHANGED
@@ -24,10 +24,43 @@
24
24
  // laptop's .tmp/ (65 files, never on Hestia) reaches the
25
25
  // shadow by the relay and could never match. NOT_CARRIED n.
26
26
  //
27
+ // And one thing is not yet a difference, while the source is being heard from:
28
+ //
29
+ // LAG the record's copy on this device changed after the newest Change this device has
30
+ // received from the source (`settled`). During S1 the shadow is fed only by the
31
+ // source (the laptop) and the record here arrives by iCloud, so bytes the shadow
32
+ // device's own passes write reach the shadow only after iCloud carries them to the
33
+ // source and its next scan mints them. The source cannot have sent what it had not
34
+ // seen. Applies to DIFFERS and MISSING, never to EXTRA or FORBIDDEN, and each LAG
35
+ // file is named. Measured 2026-09-25: the Hestia daily record and the journal,
36
+ // written on Hestia at 02:10 ET, differed at 03:35 because the laptop's last Change
37
+ // had arrived at 05:35Z and the laptop was off the network from 06:57Z.
38
+ // STALE a LAG file when the source's newest Change is older than `staleAfterMs` (24 h)
39
+ // at the run. A source that has not been heard from is not lagging, it is absent,
40
+ // and a week of LAG must not pass the gate. A failure.
41
+ //
42
+ // LAG, per path `settled` is the newest Change from the source for ANY file. A short wake of the
43
+ // source (a 21 s DarkWake on battery, 2026-09-27) can scan before iCloud has
44
+ // delivered a file's final bytes to it, mint a Change for some other file, and so
45
+ // move `settled` past a file whose bytes the source never saw. So when the source
46
+ // has sent a Change for this path (`pathSettled`, keyed by NFC path), the line is
47
+ // that path's newest Change, not the global one: a file written after the source
48
+ // last sent THIS path is LAG. Measured 2026-09-27 on Hestia: the two DIFFERS files'
49
+ // newest Changes were 06:20:41Z, their record mtime 06:48:41Z, the global newest
50
+ // Change 06:53:08Z. A path the source never sent keeps the global line.
51
+ // The per-path excuse is bounded: once the source's newest Change is more than
52
+ // `staleAfterMs` past the file's mtime, the source has been scanning (the laptop's
53
+ // daily full pass reads every file) and still has not sent it, so it fails as what
54
+ // it is, DIFFERS or MISSING.
55
+ //
56
+ // Without `settled` (the `evda parity` command) nothing is LAG: parity is byte for byte.
57
+ // A clock skew between the two machines moves the line by the skew; a stamp-preserving edit made
58
+ // before `settled` stays DIFFERS, which is the strict direction.
59
+ //
27
60
  // It reads both trees and writes nothing.
28
61
 
29
62
  import { createHash } from "node:crypto";
30
- import { readFileSync } from "node:fs";
63
+ import { readFileSync, statSync } from "node:fs";
31
64
  import { join } from "node:path";
32
65
  import { circleForPath } from "./filechanges.mjs";
33
66
  import { excluder, walk } from "./sync-rules.mjs";
@@ -49,7 +82,41 @@ function circleOf(index, rel) {
49
82
 
50
83
  // `index` is circleIndex(evda). `holds` and `never` are circle names. `exclude` is the plan's
51
84
  // excluder, the same one the scan used.
52
- export function parity({ source, shadow, index, holds, never = [], exclude = excluder() }) {
85
+ export const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
86
+
87
+ const finite = (n) => typeof n === "number" && Number.isFinite(n);
88
+
89
+ // The rule for one DIFFERS or MISSING record file, pure so it can be property-tested:
90
+ // "FAIL" it is the difference it looks like
91
+ // "LAG" the source had not seen these bytes when it last sent this path (or anything, when it
92
+ // never sent this path), and the source was heard from within `staleAfterMs`
93
+ // "STALE" it would be LAG, but the source has been silent for longer than `staleAfterMs`
94
+ // "BOUND" it would be LAG by its path's mark, but the source has sent other Changes for longer
95
+ // than `staleAfterMs` since the file was written, so it is a difference after all
96
+ export function lateVerdict({ mtimeMs, settled, pathSettled, now, staleAfterMs = STALE_AFTER_MS }) {
97
+ if (!finite(settled)) return "FAIL";
98
+ const line = finite(pathSettled) ? pathSettled : settled;
99
+ if (!(mtimeMs > line)) return "FAIL";
100
+ if (now - settled > staleAfterMs) return "STALE";
101
+ if (settled - mtimeMs > staleAfterMs) return "BOUND";
102
+ return "LAG";
103
+ }
104
+
105
+ export function parity({ source, shadow, index, holds, never = [], exclude = excluder(), settled, pathSettled, staleAfterMs = STALE_AFTER_MS, now = Date.now() }) {
106
+ const marked = finite(settled);
107
+ const lag = [];
108
+ const hours = Math.round(staleAfterMs / 3600000);
109
+ // Keyed by NFC here, whatever form the caller's change_log holds the names in.
110
+ const marks = new Map([...(pathSettled ?? [])].map(([rel, t]) => [nfc(rel), t]));
111
+ // A DIFFERS or MISSING on a record file written after the source last sent it: LAG, or STALE.
112
+ const late = (want, code, why) => {
113
+ const mark = marked ? marks.get(nfc(want.rel)) : undefined;
114
+ const v = lateVerdict({ mtimeMs: want.mtimeMs, settled, pathSettled: mark, now, staleAfterMs });
115
+ if (v === "LAG") { lag.push({ rel: want.rel, circle: want.circle, was: code }); return; }
116
+ if (v === "STALE") { problems.push({ code: "STALE", rel: want.rel, circle: want.circle, why: `${why}, written after the source's newest Change of ${new Date(settled).toISOString()}, which is older than ${hours} h` }); return; }
117
+ if (v === "BOUND") { problems.push({ code, rel: want.rel, circle: want.circle, why: `${why}; written ${new Date(want.mtimeMs).toISOString()}, after the source last sent this path (${new Date(mark).toISOString()}), but the source has sent Changes for more than ${hours} h since without it` }); return; }
118
+ problems.push({ code, rel: want.rel, circle: want.circle, why });
119
+ };
53
120
  const held = new Set(holds);
54
121
  const forbidden = new Set(never);
55
122
  const problems = [];
@@ -68,7 +135,8 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
68
135
  problems.push({ code: "UNVERIFIABLE", rel: e.rel, circle: c.name, why: `a ${e.kind} on the record side` });
69
136
  continue;
70
137
  }
71
- expected.set(nfc(e.rel), { rel: e.rel, circle: c.name, sha: sha(join(source, e.rel)) });
138
+ const abs = join(source, e.rel);
139
+ expected.set(nfc(e.rel), { rel: e.rel, circle: c.name, sha: sha(abs), mtimeMs: statSync(abs).mtimeMs });
72
140
  }
73
141
 
74
142
  const seen = new Set();
@@ -84,26 +152,31 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
84
152
  if (!want) { problems.push({ code: "EXTRA", rel: e.rel, circle: c.name, why: "in the shadow and not in the record" }); continue; }
85
153
  seen.add(nfc(e.rel));
86
154
  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" });
155
+ late({ ...want, rel: e.rel }, "DIFFERS", "the shadow's bytes are not the record's");
88
156
  continue;
89
157
  }
90
158
  perCircle[want.circle] += 1;
91
159
  }
92
160
  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" });
161
+ if (!seen.has(key)) late(want, "MISSING", "in the record and not in the shadow");
94
162
  }
95
163
 
96
164
  const checked = Object.values(perCircle).reduce((a, b) => a + b, 0);
97
165
  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 };
166
+ return { ok: problems.length === 0, checked, perCircle, expected: expected.size, shadowFiles, notHeld, excluded, notCarried: notCarriedCount, settled: marked ? settled : null, lag, problems };
99
167
  }
100
168
 
101
169
  // The lines a person or a log reads. The first line always says what was examined.
102
170
  export function formatParity(r, { limit = 20 } = {}) {
103
171
  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`);
172
+ 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${r.settled ? `; source's newest Change ${new Date(r.settled).toISOString()}` : ""}`);
105
173
  const byCode = {};
106
174
  for (const p of r.problems) byCode[p.code] = (byCode[p.code] ?? 0) + 1;
175
+ const lag = r.lag ?? [];
176
+ if (lag.length) {
177
+ lines.push(` LAG ${lag.length} (written on this device after the source last sent that path, or after its newest Change, ${new Date(r.settled).toISOString()}, for a path it never sent; the source has not seen them yet)`);
178
+ for (const l of lag.slice(0, limit)) lines.push(` LAG ${l.rel} [${l.circle}] (${l.was})`);
179
+ }
107
180
  for (const p of r.problems.slice(0, limit)) lines.push(`FAIL ${p.code} ${p.rel}${p.circle ? ` [${p.circle}]` : ""}: ${p.why}`);
108
181
  if (r.problems.length > limit) lines.push(`... and ${r.problems.length - limit} more`);
109
182
  lines.push(r.ok ? "PARITY OK" : `PARITY FAILED: ${Object.entries(byCode).map(([c, n]) => `${c} ${n}`).join(", ")}`);
package/lib/roles.mjs CHANGED
@@ -26,6 +26,10 @@ export const ROLES = {
26
26
  // directory per owner and one subfolder per circle, so several circles from one owner are held
27
27
  // side by side. Unmapped, a record holds one circle per owner at `mounts/<owner record id>`.
28
28
  mounts: { layer: "circles", kind: "dir" },
29
+ // Where the owner's agent leaves messages for them and their replies come back (HQ-1246,
30
+ // lib/messages.mjs). Not part of any layer, like agents: a record without the mapping still has
31
+ // one, at `messages/`, which is what the phone app reads too.
32
+ messages: { layer: null, kind: "dir" },
29
33
  // The four roles every record has at any scale (Spec 07 section 2, "Four roles, at every scale",
30
34
  // HQ-1371). Each maps to one or more paths: the role's own key and any `<role>/<facet>` key, so a
31
35
  // record with three journal folders writes `journal`, `journal/captures`, `journal/dreams`. Every