create-evda 0.1.8 → 0.1.9

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/parity.mjs CHANGED
@@ -47,7 +47,17 @@
47
47
  // that path's newest Change, not the global one: a file written after the source
48
48
  // last sent THIS path is LAG. Measured 2026-09-27 on Hestia: the two DIFFERS files'
49
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.
50
+ // Change 06:53:08Z.
51
+ // LAG, first a path the source has never sent has no mark of its own, and the global one says
52
+ // appearance nothing about it: a file written on this device reaches the source as an iCloud
53
+ // placeholder, which the source skips, while it mints other paths and moves
54
+ // `settled` past it. Measured 2026-10-04 on Hestia (HQ-2169): Overnight's daily record
55
+ // written here 06:02:12Z, the laptop's 07:09:56Z tick logged it "in iCloud only" and
56
+ // minted four other paths, the newest 07:10:27.816Z, and the file read MISSING. So a
57
+ // MISSING path with no mark, when the marks were given, is judged against its own
58
+ // write time: LAG while the source's newest Change is within `staleAfterMs` of it,
59
+ // MISSING past that. An unmarked DIFFERS keeps the global line: the shadow holds the
60
+ // path, so its bytes came from somewhere the marks do not explain.
51
61
  // The per-path excuse is bounded: once the source's newest Change is more than
52
62
  // `staleAfterMs` past the file's mtime, the source has been scanning (the laptop's
53
63
  // daily full pass reads every file) and still has not sent it, so it fails as what
@@ -63,7 +73,7 @@ import { createHash } from "node:crypto";
63
73
  import { readFileSync, statSync } from "node:fs";
64
74
  import { join } from "node:path";
65
75
  import { circleForPath } from "./filechanges.mjs";
66
- import { excluder, walk } from "./sync-rules.mjs";
76
+ import { excluder, fileClass, walk } from "./sync-rules.mjs";
67
77
 
68
78
  const nfc = (rel) => rel.normalize("NFC");
69
79
  // A path iCloud Drive does not sync, so it cannot be compared across devices while iCloud carries
@@ -88,21 +98,36 @@ const finite = (n) => typeof n === "number" && Number.isFinite(n);
88
98
 
89
99
  // The rule for one DIFFERS or MISSING record file, pure so it can be property-tested:
90
100
  // "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`
101
+ // "LAG" the source had not seen these bytes when it last sent this path, or it never sent this
102
+ // path and its newest Change is within `staleAfterMs` of the write; and the source was
103
+ // heard from within `staleAfterMs`
93
104
  // "STALE" it would be LAG, but the source has been silent for longer than `staleAfterMs`
94
105
  // "BOUND" it would be LAG by its path's mark, but the source has sent other Changes for longer
95
106
  // 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 }) {
107
+ //
108
+ // `neverSent` is true only when the caller holds the source's per-path marks and none is for this
109
+ // path, and the file is MISSING from the shadow. Without it, an unmarked path keeps the global line.
110
+ export function lateVerdict({ mtimeMs, settled, pathSettled, neverSent = false, now, staleAfterMs = STALE_AFTER_MS }) {
97
111
  if (!finite(settled)) return "FAIL";
98
- const line = finite(pathSettled) ? pathSettled : settled;
99
- if (!(mtimeMs > line)) return "FAIL";
112
+ if (finite(pathSettled)) {
113
+ if (!(mtimeMs > pathSettled)) return "FAIL";
114
+ } else if (neverSent) {
115
+ // The source's newest Change has been more than the window past the write without this path.
116
+ if (settled - mtimeMs > staleAfterMs) return "FAIL";
117
+ } else if (!(mtimeMs > settled)) {
118
+ return "FAIL";
119
+ }
100
120
  if (now - settled > staleAfterMs) return "STALE";
101
121
  if (settled - mtimeMs > staleAfterMs) return "BOUND";
102
122
  return "LAG";
103
123
  }
104
124
 
105
- export function parity({ source, shadow, index, holds, never = [], exclude = excluder(), settled, pathSettled, staleAfterMs = STALE_AFTER_MS, now = Date.now() }) {
125
+ // `carry` is the manifest's `sync.carry` (sync-rules `carryList`). A file whose bytes do not travel
126
+ // (a reference, HQ-2017) is compared on neither side and never read: counted as REFERENCED.
127
+ export function parity({ source, shadow, index, holds, never = [], exclude = excluder(), settled, pathSettled, staleAfterMs = STALE_AFTER_MS, now = Date.now(), carry = [] }) {
128
+ const classOf = fileClass(carry);
129
+ let referenced = 0;
130
+ const references = new Set();
106
131
  const marked = finite(settled);
107
132
  const lag = [];
108
133
  const hours = Math.round(staleAfterMs / 3600000);
@@ -111,9 +136,12 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
111
136
  // A DIFFERS or MISSING on a record file written after the source last sent it: LAG, or STALE.
112
137
  const late = (want, code, why) => {
113
138
  const mark = marked ? marks.get(nfc(want.rel)) : undefined;
114
- const v = lateVerdict({ mtimeMs: want.mtimeMs, settled, pathSettled: mark, now, staleAfterMs });
139
+ // HQ-2169: a MISSING path the source has never sent, when its marks were given, is judged against
140
+ // its own write time rather than another path's Change.
141
+ const neverSent = marked && pathSettled != null && code === "MISSING" && !finite(mark);
142
+ const v = lateVerdict({ mtimeMs: want.mtimeMs, settled, pathSettled: mark, neverSent, now, staleAfterMs });
115
143
  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; }
144
+ if (v === "STALE") { problems.push({ code: "STALE", rel: want.rel, circle: want.circle, why: `${why}, written ${new Date(want.mtimeMs).toISOString()}, and the source's newest Change, ${new Date(settled).toISOString()}, is older than ${hours} h` }); return; }
117
145
  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
146
  problems.push({ code, rel: want.rel, circle: want.circle, why });
119
147
  };
@@ -129,6 +157,9 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
129
157
  for (const e of walk(source, { exclude })) {
130
158
  if (e.kind === "excluded") { excluded += 1; continue; }
131
159
  if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
160
+ // A placeholder is judged by the file it stands for: `.x.m4a.icloud` is media, `.x.md.icloud` is not.
161
+ const named = e.kind === "placeholder" ? e.rel.replace(/(^|\/)\.([^/]+)\.icloud$/, "$1$2") : e.rel;
162
+ if (e.kind !== "symlink" && classOf(named) === "reference") { referenced += 1; references.add(nfc(e.rel)); continue; }
132
163
  const c = circleOf(index, e.rel);
133
164
  if (!c || !held.has(c.name)) { notHeld += 1; continue; }
134
165
  if (e.kind !== "file") {
@@ -144,6 +175,9 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
144
175
  for (const e of walk(shadow, { exclude: excluder() })) {
145
176
  if (e.kind === "excluded") continue;
146
177
  if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
178
+ // A reference the record holds may have bytes in a shadow from before HQ-2017; they are not
179
+ // compared. A reference-class file the record does not hold is still EXTRA.
180
+ if (references.has(nfc(e.rel))) continue;
147
181
  shadowFiles += 1;
148
182
  const c = circleOf(index, e.rel);
149
183
  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; }
@@ -163,18 +197,18 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
163
197
 
164
198
  const checked = Object.values(perCircle).reduce((a, b) => a + b, 0);
165
199
  if (checked === 0) problems.push({ code: "CHECKED_0", rel: "", why: "nothing was compared; zero is a broken discovery step, not a pass" });
166
- return { ok: problems.length === 0, checked, perCircle, expected: expected.size, shadowFiles, notHeld, excluded, notCarried: notCarriedCount, settled: marked ? settled : null, lag, problems };
200
+ return { ok: problems.length === 0, checked, perCircle, expected: expected.size, shadowFiles, notHeld, excluded, notCarried: notCarriedCount, referenced, settled: marked ? settled : null, lag, problems };
167
201
  }
168
202
 
169
203
  // The lines a person or a log reads. The first line always says what was examined.
170
204
  export function formatParity(r, { limit = 20 } = {}) {
171
205
  const lines = [`CHECKED ${r.checked} files: ${Object.entries(r.perCircle).map(([n, k]) => `${n} ${k}`).join(", ")}`];
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()}` : ""}`);
206
+ lines.push(` record side: ${r.expected} expected, ${r.notHeld} in circles this device does not hold, ${r.excluded} excluded${r.referenced ? `, REFERENCED ${r.referenced} (named, not carried)` : ""}${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()}` : ""}`);
173
207
  const byCode = {};
174
208
  for (const p of r.problems) byCode[p.code] = (byCode[p.code] ?? 0) + 1;
175
209
  const lag = r.lag ?? [];
176
210
  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)`);
211
+ lines.push(` LAG ${lag.length} (written on this device after the source last sent that path, or, for a path it never sent, within ${Math.round(STALE_AFTER_MS / 3600000)} h of its newest Change, ${new Date(r.settled).toISOString()}; the source has not seen them yet)`);
178
212
  for (const l of lag.slice(0, limit)) lines.push(` LAG ${l.rel} [${l.circle}] (${l.was})`);
179
213
  }
180
214
  for (const p of r.problems.slice(0, limit)) lines.push(`FAIL ${p.code} ${p.rel}${p.circle ? ` [${p.circle}]` : ""}: ${p.why}`);
package/lib/qr.mjs ADDED
@@ -0,0 +1,317 @@
1
+ // A QR code for the pairing link, drawn here (HQ-2040), so asking Claude to pair a phone can hand
2
+ // the person something to scan. No network QR service sees the link (the code in it is a key until
3
+ // it is used), and the package keeps its zero runtime dependencies (test/deps.test.mjs).
4
+ //
5
+ // Byte mode, versions 1 to 40, error correction L, M, Q or H, following ISO/IEC 18004 the way
6
+ // Project Nayuki's reference encoder lays it out. test/qr.test.mjs holds the matrices against a
7
+ // second, independent encoder's output, module for module.
8
+ //
9
+ // Out: the module matrix, a PNG (8-bit grey, deflated by node:zlib), and the same code as text in
10
+ // half blocks, which a chat can show in a code block when it cannot show an image.
11
+
12
+ import { deflateSync } from "node:zlib";
13
+
14
+ const ECL = {
15
+ L: { bits: 1, i: 0 },
16
+ M: { bits: 0, i: 1 },
17
+ Q: { bits: 3, i: 2 },
18
+ H: { bits: 2, i: 3 },
19
+ };
20
+
21
+ // Error correction codewords per block, and the number of blocks, by level then version (index 0 unused).
22
+ const ECC_PER_BLOCK = [
23
+ [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
24
+ [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
25
+ [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
26
+ [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
27
+ ];
28
+ const NUM_BLOCKS = [
29
+ [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
30
+ [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
31
+ [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
32
+ [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
33
+ ];
34
+
35
+ const bit = (x, i) => ((x >>> i) & 1) !== 0;
36
+
37
+ function rawDataModules(ver) {
38
+ let n = (16 * ver + 128) * ver + 64;
39
+ if (ver >= 2) {
40
+ const align = Math.floor(ver / 7) + 2;
41
+ n -= (25 * align - 10) * align - 55;
42
+ if (ver >= 7) n -= 36;
43
+ }
44
+ return n;
45
+ }
46
+
47
+ const dataCodewords = (ver, e) => Math.floor(rawDataModules(ver) / 8) - ECC_PER_BLOCK[e.i][ver] * NUM_BLOCKS[e.i][ver];
48
+
49
+ function gfMul(x, y) {
50
+ let z = 0;
51
+ for (let i = 7; i >= 0; i--) {
52
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
53
+ z ^= ((y >>> i) & 1) * x;
54
+ }
55
+ return z;
56
+ }
57
+
58
+ function rsDivisor(degree) {
59
+ const r = new Array(degree).fill(0);
60
+ r[degree - 1] = 1;
61
+ let root = 1;
62
+ for (let i = 0; i < degree; i++) {
63
+ for (let j = 0; j < r.length; j++) {
64
+ r[j] = gfMul(r[j], root);
65
+ if (j + 1 < r.length) r[j] ^= r[j + 1];
66
+ }
67
+ root = gfMul(root, 0x02);
68
+ }
69
+ return r;
70
+ }
71
+
72
+ function rsRemainder(data, divisor) {
73
+ const r = divisor.map(() => 0);
74
+ for (const b of data) {
75
+ const factor = b ^ r.shift();
76
+ r.push(0);
77
+ divisor.forEach((coef, i) => { r[i] ^= gfMul(coef, factor); });
78
+ }
79
+ return r;
80
+ }
81
+
82
+ function alignmentPositions(ver) {
83
+ if (ver === 1) return [];
84
+ const size = ver * 4 + 17;
85
+ const n = Math.floor(ver / 7) + 2;
86
+ const step = Math.floor((ver * 8 + n * 3 + 5) / (n * 4 - 4)) * 2;
87
+ const out = [6];
88
+ for (let pos = size - 7; out.length < n; pos -= step) out.splice(1, 0, pos);
89
+ return out;
90
+ }
91
+
92
+ const MASKS = [
93
+ (x, y) => (x + y) % 2 === 0,
94
+ (x, y) => y % 2 === 0,
95
+ (x) => x % 3 === 0,
96
+ (x, y) => (x + y) % 3 === 0,
97
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
98
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
99
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
100
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
101
+ ];
102
+
103
+ /**
104
+ * Encode `text` (UTF-8, byte mode) as a QR code. Returns `{ version, ecl, mask, size, modules }`,
105
+ * with `modules[y][x]` true for a dark module. Picks the smallest version that fits; picks the mask
106
+ * by the standard's penalty unless `mask` (0 to 7) is given. Throws when the text does not fit.
107
+ */
108
+ export function encodeQr(text, { ecl = "M", mask = null, minVersion = 1 } = {}) {
109
+ const e = ECL[ecl];
110
+ if (!e) throw new Error(`QR error correction is L, M, Q or H, not ${ecl}.`);
111
+ if (mask !== null && !(Number.isInteger(mask) && mask >= 0 && mask <= 7)) throw new Error("A QR mask is 0 to 7.");
112
+ const bytes = [...Buffer.from(String(text), "utf8")];
113
+ let ver = Math.max(1, minVersion);
114
+ for (; ver <= 40; ver++) {
115
+ const countBits = ver <= 9 ? 8 : 16;
116
+ if (bytes.length < 2 ** countBits && 4 + countBits + bytes.length * 8 <= dataCodewords(ver, e) * 8) break;
117
+ }
118
+ if (ver > 40) throw new Error("That is too long for a QR code.");
119
+
120
+ // The data bits: mode, length, bytes, terminator, then pad to the capacity.
121
+ const bits = [];
122
+ const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
123
+ put(0b0100, 4);
124
+ put(bytes.length, ver <= 9 ? 8 : 16);
125
+ for (const b of bytes) put(b, 8);
126
+ const capacity = dataCodewords(ver, e) * 8;
127
+ put(0, Math.min(4, capacity - bits.length));
128
+ put(0, (8 - (bits.length % 8)) % 8);
129
+ for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8);
130
+ const data = [];
131
+ for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
132
+
133
+ // Split into blocks, add error correction to each, interleave.
134
+ const numBlocks = NUM_BLOCKS[e.i][ver];
135
+ const eccLen = ECC_PER_BLOCK[e.i][ver];
136
+ const rawCodewords = Math.floor(rawDataModules(ver) / 8);
137
+ const numShort = numBlocks - (rawCodewords % numBlocks);
138
+ const shortLen = Math.floor(rawCodewords / numBlocks);
139
+ const divisor = rsDivisor(eccLen);
140
+ const blocks = [];
141
+ for (let i = 0, k = 0; i < numBlocks; i++) {
142
+ const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
143
+ k += dat.length;
144
+ const ecc = rsRemainder(dat, divisor);
145
+ if (i < numShort) dat.push(0);
146
+ blocks.push(dat.concat(ecc));
147
+ }
148
+ const codewords = [];
149
+ for (let i = 0; i < blocks[0].length; i++) {
150
+ blocks.forEach((b, j) => {
151
+ if (i !== shortLen - eccLen || j >= numShort) codewords.push(b[i]);
152
+ });
153
+ }
154
+
155
+ const size = ver * 4 + 17;
156
+ const modules = Array.from({ length: size }, () => new Array(size).fill(false));
157
+ const fn = Array.from({ length: size }, () => new Array(size).fill(false));
158
+ const set = (x, y, dark) => { modules[y][x] = dark; fn[y][x] = true; };
159
+
160
+ for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
161
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
162
+ for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
163
+ const d = Math.max(Math.abs(dx), Math.abs(dy));
164
+ const x = cx + dx;
165
+ const y = cy + dy;
166
+ if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
167
+ }
168
+ }
169
+ const al = alignmentPositions(ver);
170
+ for (let i = 0; i < al.length; i++) for (let j = 0; j < al.length; j++) {
171
+ if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) continue;
172
+ for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(al[i] + dx, al[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
173
+ }
174
+ const drawFormat = (m) => {
175
+ const d = (e.bits << 3) | m;
176
+ let rem = d;
177
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
178
+ const b = ((d << 10) | rem) ^ 0x5412;
179
+ for (let i = 0; i <= 5; i++) set(8, i, bit(b, i));
180
+ set(8, 7, bit(b, 6));
181
+ set(8, 8, bit(b, 7));
182
+ set(7, 8, bit(b, 8));
183
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(b, i));
184
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(b, i));
185
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(b, i));
186
+ set(8, size - 8, true);
187
+ };
188
+ drawFormat(0);
189
+ if (ver >= 7) {
190
+ let rem = ver;
191
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
192
+ const b = (ver << 12) | rem;
193
+ for (let i = 0; i < 18; i++) {
194
+ const a = size - 11 + (i % 3);
195
+ const c = Math.floor(i / 3);
196
+ set(a, c, bit(b, i));
197
+ set(c, a, bit(b, i));
198
+ }
199
+ }
200
+
201
+ // The codewords, in the zigzag.
202
+ let n = 0;
203
+ for (let right = size - 1; right >= 1; right -= 2) {
204
+ if (right === 6) right = 5;
205
+ for (let v = 0; v < size; v++) for (let j = 0; j < 2; j++) {
206
+ const x = right - j;
207
+ const y = ((right + 1) & 2) === 0 ? size - 1 - v : v;
208
+ if (!fn[y][x] && n < codewords.length * 8) {
209
+ modules[y][x] = bit(codewords[n >>> 3], 7 - (n & 7));
210
+ n++;
211
+ }
212
+ }
213
+ }
214
+
215
+ const applyMask = (m) => {
216
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && MASKS[m](x, y)) modules[y][x] = !modules[y][x];
217
+ };
218
+ let chosen = mask;
219
+ if (chosen === null) {
220
+ let best = Infinity;
221
+ for (let m = 0; m < 8; m++) {
222
+ applyMask(m);
223
+ drawFormat(m);
224
+ const p = penalty(modules);
225
+ if (p < best) { best = p; chosen = m; }
226
+ applyMask(m);
227
+ }
228
+ }
229
+ applyMask(chosen);
230
+ drawFormat(chosen);
231
+ return { version: ver, ecl, mask: chosen, size, modules };
232
+ }
233
+
234
+ // The standard's four penalty rules; the lowest total picks the mask.
235
+ function penalty(m) {
236
+ const size = m.length;
237
+ let score = 0;
238
+ const lines = [];
239
+ for (let y = 0; y < size; y++) lines.push(m[y]);
240
+ for (let x = 0; x < size; x++) lines.push(m.map((row) => row[x]));
241
+ const finder = [true, false, true, true, true, false, true];
242
+ for (const line of lines) {
243
+ for (let i = 0, run = 1; i < size; i++) {
244
+ if (i + 1 < size && line[i + 1] === line[i]) { run++; continue; }
245
+ if (run >= 5) score += 3 + (run - 5);
246
+ run = 1;
247
+ }
248
+ for (let i = 0; i + 7 <= size; i++) {
249
+ if (!finder.every((v, k) => line[i + k] === v)) continue;
250
+ const lightBefore = i >= 4 && [1, 2, 3, 4].every((k) => !line[i - k]);
251
+ const lightAfter = i + 11 <= size && [0, 1, 2, 3].every((k) => !line[i + 7 + k]);
252
+ if (lightBefore || lightAfter) score += 40;
253
+ }
254
+ }
255
+ for (let y = 0; y + 1 < size; y++) for (let x = 0; x + 1 < size; x++) {
256
+ const c = m[y][x];
257
+ if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) score += 3;
258
+ }
259
+ const dark = m.reduce((a, row) => a + row.filter(Boolean).length, 0);
260
+ score += Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10;
261
+ return score;
262
+ }
263
+
264
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
265
+ let c = n;
266
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
267
+ return c >>> 0;
268
+ });
269
+ function crc32(buf) {
270
+ let c = 0xffffffff;
271
+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
272
+ return (c ^ 0xffffffff) >>> 0;
273
+ }
274
+ function chunk(type, data) {
275
+ const len = Buffer.alloc(4);
276
+ len.writeUInt32BE(data.length);
277
+ const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
278
+ const crc = Buffer.alloc(4);
279
+ crc.writeUInt32BE(crc32(td));
280
+ return Buffer.concat([len, td, crc]);
281
+ }
282
+
283
+ /** The code as a PNG: black on white, `scale` pixels a module, a quiet zone of `border` modules. */
284
+ export function qrPng(qr, { scale = 8, border = 4 } = {}) {
285
+ const px = (qr.size + border * 2) * scale;
286
+ const raw = Buffer.alloc((px + 1) * px, 0xff);
287
+ for (let y = 0; y < px; y++) {
288
+ raw[y * (px + 1)] = 0; // filter: none
289
+ const my = Math.floor(y / scale) - border;
290
+ for (let x = 0; x < px; x++) {
291
+ const mx = Math.floor(x / scale) - border;
292
+ if (my >= 0 && my < qr.size && mx >= 0 && mx < qr.size && qr.modules[my][mx]) raw[y * (px + 1) + 1 + x] = 0;
293
+ }
294
+ }
295
+ const ihdr = Buffer.alloc(13);
296
+ ihdr.writeUInt32BE(px, 0);
297
+ ihdr.writeUInt32BE(px, 4);
298
+ ihdr[8] = 8; // bit depth
299
+ ihdr[9] = 0; // greyscale
300
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
301
+ }
302
+
303
+ /** The code as text, two rows a line in half blocks, dark modules drawn, with a quiet zone of `border`. */
304
+ export function qrText(qr, { border = 2 } = {}) {
305
+ const at = (x, y) => x >= 0 && y >= 0 && x < qr.size && y < qr.size && qr.modules[y][x];
306
+ const lines = [];
307
+ for (let y = -border; y < qr.size + border; y += 2) {
308
+ let line = "";
309
+ for (let x = -border; x < qr.size + border; x++) {
310
+ const top = at(x, y);
311
+ const bottom = at(x, y + 1);
312
+ line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
313
+ }
314
+ lines.push(line);
315
+ }
316
+ return lines.join("\n");
317
+ }
package/lib/record.mjs CHANGED
@@ -47,6 +47,26 @@ export function headings(text) {
47
47
  return text.split("\n").filter((l) => /^#{1,6} /.test(l)).map((l) => l.replace(/^#+ /, "").trim());
48
48
  }
49
49
 
50
+ // The first line of the file's most recent section: past any frontmatter block, past heading
51
+ // lines (those are already in `headings()`), and past every heading but the last. A day file
52
+ // accumulates one heading per entry through the day, so the last heading is the newest entry --
53
+ // a "recent" preview wants that one, not the file's opening paragraph, which for a fresh day file
54
+ // is create-evda's own scaffold text and was never the owner's. A file with no heading at all uses
55
+ // its first non-blank line, same as before.
56
+ export function firstLine(text) {
57
+ let body = text;
58
+ if (body.startsWith("---")) {
59
+ const end = body.indexOf("\n---", 3);
60
+ if (end !== -1) body = body.slice(end + 4);
61
+ }
62
+ const lines = body.split("\n");
63
+ let lastHeadingAt = -1;
64
+ for (let i = 0; i < lines.length; i++) if (/^#{1,6} /.test(lines[i])) lastHeadingAt = i;
65
+ const after = lastHeadingAt === -1 ? lines : lines.slice(lastHeadingAt + 1);
66
+ const line = after.find((l) => l.trim() !== "" && !/^#{1,6} /.test(l));
67
+ return line ? line.trim() : "";
68
+ }
69
+
50
70
  export function moment(evda, { from, to = from } = {}) {
51
71
  if (!DATE.test(from ?? "") || !DATE.test(to ?? "")) {
52
72
  throw new EvdaError(`moment needs YYYY-MM-DD dates, got ${from}..${to}`);
package/lib/routines.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { mkdirSync, writeFileSync, existsSync, realpathSync, rmSync, statSync } from "node:fs";
15
15
  import { delimiter, join, resolve, sep } from "node:path";
16
- import { homedir } from "node:os";
16
+ import { homedir, tmpdir, userInfo } from "node:os";
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { openEvda, holderOf } from "./manifest.mjs";
19
19
  import { serverCommand } from "./connect.mjs";
@@ -165,10 +165,42 @@ function bootout(target, env, launchctl) {
165
165
  // loaded job (113 when there is none).
166
166
  const isLoaded = (target, launchctl) => launchctl(["print", target]).status === 0;
167
167
 
168
+ // Is this record under a temp folder? Such a folder goes away, and a job for it runs daily on
169
+ // nothing (HQ-2006: the Mac app's Rust tests left two loaded in his launchd). Both the path as given
170
+ // and its realpath are checked, since /var and /tmp are symlinks into /private on a Mac.
171
+ const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
172
+ export function underTempDir(dir) {
173
+ const paths = [resolve(dir)];
174
+ try { paths.push(realpathSync(dir)); } catch {}
175
+ const roots = [...TEMP_ROOTS, resolve(tmpdir())];
176
+ try { roots.push(realpathSync(tmpdir())); } catch {}
177
+ return paths.some((p) => roots.some((r) => p === r || p.startsWith(r.endsWith(sep) ? r : r + sep)));
178
+ }
179
+
180
+ // The owner's real home, from the account database rather than $HOME, which a test or a spawned
181
+ // process can point anywhere.
182
+ function accountHome() {
183
+ try { return userInfo().homedir; } catch { return homedir(); }
184
+ }
185
+
186
+ const samePath = (a, b) => {
187
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
188
+ return resolve(a) === resolve(b) || real(a) === real(b);
189
+ };
190
+
168
191
  // Write the launchd job and load it. `agentsDir`, `load` and `launchctl` exist so tests never touch
169
192
  // the real ~/Library/LaunchAgents or launchctl. A job already loaded under this label is unloaded
170
193
  // first, so a second install replaces it rather than failing to load a duplicate.
171
- export function installLaunchd(key, dir, { env = process.env, home = env.HOME ?? homedir(), agentsDir = env.EVDA_LAUNCH_AGENTS ?? process.env.EVDA_LAUNCH_AGENTS ?? join(home, "Library", "LaunchAgents"), load = !noLaunchctl(env), claude = findClaude(env), launchctl = (a) => runLaunchctl(a, env) } = {}) {
194
+ //
195
+ // A record under a temp folder is refused before anything is written when the job would land in the
196
+ // real ~/Library/LaunchAgents or be loaded by the real launchctl, whoever the caller is. `realHome`
197
+ // is the seam that lets a test name a stand-in for the real home.
198
+ export function installLaunchd(key, dir, { env = process.env, home = env.HOME ?? homedir(), realHome = accountHome(), agentsDir = env.EVDA_LAUNCH_AGENTS ?? process.env.EVDA_LAUNCH_AGENTS ?? join(home, "Library", "LaunchAgents"), load = !noLaunchctl(env), claude = findClaude(env), launchctl } = {}) {
199
+ const realLaunchctl = !launchctl && !env.EVDA_LAUNCHCTL;
200
+ if (underTempDir(dir) && (samePath(agentsDir, join(realHome, "Library", "LaunchAgents")) || (load && realLaunchctl))) {
201
+ throw new EvdaError(`REFUSED: ${resolve(dir)} is in a temp folder, so no launchd job is scheduled for it (it would run daily on a folder that goes away). Nothing was written.`, { code: "TEMP_RECORD", exit: 2 });
202
+ }
203
+ launchctl ??= (a) => runLaunchctl(a, env);
172
204
  if (process.platform !== "darwin" && load && !env.EVDA_LAUNCHCTL) throw new EvdaError("REFUSED: launchd is macOS only. Use the Claude desktop routine instead. Nothing was written.", { exit: 2 });
173
205
  const evda = openEvda(dir);
174
206
  const plist = launchdPlist(key, dir, { home, claude });
@@ -217,7 +249,14 @@ export function installAtSetup(keys, dir, { env = process.env, platform = proces
217
249
  const r = ROUTINES[key];
218
250
  lines.push("");
219
251
  if (platform === "darwin") {
220
- const done = installLaunchd(key, dir, { env });
252
+ let done;
253
+ try {
254
+ done = installLaunchd(key, dir, { env });
255
+ } catch (e) {
256
+ if (e.code !== "TEMP_RECORD") throw e;
257
+ lines.push(e.message);
258
+ continue;
259
+ }
221
260
  const lead = key === "morning" ? "Organizing is on" : `${r.title} is on`;
222
261
  lines.push(`${lead}: ${r.when.toLowerCase()} your own Claude ${key === "morning" ? "files your notes into their day and writes what belongs where" : "reads your week back to you"}, in reports/. Turn it off: evda routine ${key} ${resolve(dir)} --uninstall`);
223
262
  lines.push(` launchd job ${done.path}: ${done.loaded}. It runs while this computer is on; asleep at that hour, it runs when it wakes.`);
@@ -0,0 +1,20 @@
1
+ {
2
+ "source": "https://raw.githubusercontent.com/sigstore/root-signing/main/targets/trusted_root.json (Sigstore public-good instance), fetched 2026-10-04",
3
+ "tlogs": [
4
+ {
5
+ "baseUrl": "https://rekor.sigstore.dev",
6
+ "logId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=",
7
+ "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==",
8
+ "keyDetails": "PKIX_ECDSA_P256_SHA_256",
9
+ "validFrom": "2021-01-12T11:53:27Z"
10
+ }
11
+ ],
12
+ "fulcio": {
13
+ "baseUrl": "https://fulcio.sigstore.dev",
14
+ "validFrom": "2022-04-13T20:06:15Z",
15
+ "chain": [
16
+ "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=",
17
+ "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"
18
+ ]
19
+ }
20
+ }
package/lib/skills.mjs CHANGED
@@ -226,7 +226,7 @@ export function writeSkill(evda, args = {}, { now = new Date(), env = process.en
226
226
  // Tools that are how a session starts or keeps itself, never a step of a process the owner asked
227
227
  // for. And the plain read-and-file loop: search, read, write is the record's ordinary use, not a
228
228
  // process worth a skill, so a run made only of those is never pointed at.
229
- const META = new Set(["evda_operating_layer", "evda_skills", "evda_write_skill", "evda_check_update"]);
229
+ const META = new Set(["evda_operating_layer", "evda_skills", "evda_write_skill", "evda_check_update", "evda_acknowledge", "evda_acknowledgements"]);
230
230
  const ORDINARY = new Set(["evda_recent", "evda_search", "evda_read", "evda_write", "evda_note", "evda_circles", "evda_presets"]);
231
231
  export const MIN_STEPS = 3;
232
232
  export const MAX_STEPS = 8;