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
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}`);
@@ -78,3 +98,57 @@ export function moment(evda, { from, to = from } = {}) {
78
98
  out.entries.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : a.date.localeCompare(b.date)));
79
99
  return out;
80
100
  }
101
+
102
+ // What a reader needs from each section of a file to know what it was about without opening it
103
+ // (HQ-2324): its heading, its context line (the one line the filing agent wrote above the owner's
104
+ // words, `Context, not their words:` or `*Context (Iris, ...): ...*`), and the opening of the
105
+ // owner's words, cut at a word. A day file's headings are mostly times (`## 20:13`), so a heading
106
+ // alone told a chat app nothing about his last days. Text before the first heading is a section
107
+ // with no heading. Frontmatter, fences and the one-line marker a sealed part leaves are skipped.
108
+ export const OPENING_MAX = 280;
109
+ const CONTEXT_MAX = 240;
110
+ const CONTEXT_LINE = /^\*{0,2}_?\s*Context\b[^:]*:/i;
111
+
112
+ function cutAt(text, max) {
113
+ if (text.length <= max) return text;
114
+ const cut = text.slice(0, max);
115
+ const at = cut.lastIndexOf(" ");
116
+ return `${at > max / 2 ? cut.slice(0, at) : cut}...`;
117
+ }
118
+
119
+ export function sectionDigest(text, { openingMax = OPENING_MAX } = {}) {
120
+ let body = text;
121
+ if (body.startsWith("---")) {
122
+ const end = body.indexOf("\n---", 3);
123
+ if (end !== -1) body = body.slice(end + 4);
124
+ }
125
+ const out = [];
126
+ let cur = { heading: null, lines: [] };
127
+ let fence = false;
128
+ const flush = () => {
129
+ const lines = cur.lines.map((l) => l.trim()).filter((l) => l && !/^\[sealed[:\]]/i.test(l));
130
+ const ctxAt = lines.findIndex((l) => CONTEXT_LINE.test(l));
131
+ const context = ctxAt === -1 ? null : cutAt(lines[ctxAt].replace(/^[*_\s]+|[*_\s]+$/g, ""), CONTEXT_MAX);
132
+ const words = lines.filter((_, i) => i !== ctxAt)
133
+ .map((l) => l.replace(/^>\s?/, "").replace(/^>\s?/, "").trim())
134
+ .filter(Boolean)
135
+ .join(" ")
136
+ .replace(/\s+/g, " ")
137
+ .trim();
138
+ if (cur.heading !== null || context || words) out.push({ heading: cur.heading, context, opening: words ? cutAt(words, openingMax) : null });
139
+ };
140
+ for (const line of body.split("\n")) {
141
+ if (/^\s*(`{3,}|~{3,})/.test(line)) { fence = !fence; continue; }
142
+ if (fence) continue;
143
+ const h = /^#{1,6} (.*)$/.exec(line);
144
+ if (h) { flush(); cur = { heading: h[1].trim(), lines: [] }; continue; }
145
+ cur.lines.push(line);
146
+ }
147
+ flush();
148
+ return out;
149
+ }
150
+
151
+ // The date a heading names (`## 2026-10-02, morning: ...`, `## October 4 (added 2026-10-04)`), or null.
152
+ export function headingDate(heading) {
153
+ return /\b(\d{4}-\d{2}-\d{2})\b/.exec(heading ?? "")?.[1] ?? null;
154
+ }
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 });
@@ -211,13 +243,31 @@ export function uninstallLaunchd(key, dir, { env = process.env, home = env.HOME
211
243
  // What setup does with the routines it was given (HQ-1246). On a Mac each is installed as a launchd
212
244
  // job; elsewhere nothing can be scheduled from here, so it prints the Claude desktop steps and says
213
245
  // so. Returns the lines to print.
214
- export function installAtSetup(keys, dir, { env = process.env, platform = process.platform } = {}) {
246
+ //
247
+ // `say` prints a line at once, before anything is installed (HQ-1368): a run with --yes asks nothing,
248
+ // so the line that a launchd job is about to be loaded into this Mac is said before launchctl runs,
249
+ // not only in the summary after it. `launchctl` is the tests' stand-in, handed through to
250
+ // installLaunchd with loading on, so a test can watch the order without the real launchctl.
251
+ export function setupNotice(key, dir) {
252
+ const r = ROUTINES[key];
253
+ return `Installing a launchd job on this Mac: ${r.title}, ${r.when.toLowerCase()}, runs ${r.command.join(" ")} on ${resolve(dir)} with your own Claude. Not wanted: create-evda --no-routines, or afterwards evda routine ${key} ${resolve(dir)} --uninstall`;
254
+ }
255
+
256
+ export function installAtSetup(keys, dir, { env = process.env, platform = process.platform, say = null, launchctl = null } = {}) {
215
257
  const lines = [];
216
258
  for (const key of keys) {
217
259
  const r = ROUTINES[key];
218
260
  lines.push("");
219
261
  if (platform === "darwin") {
220
- const done = installLaunchd(key, dir, { env });
262
+ let done;
263
+ try {
264
+ if (say && !falsified(env, "silent-install")) say(setupNotice(key, dir));
265
+ done = installLaunchd(key, dir, launchctl ? { env, launchctl, load: true } : { env });
266
+ } catch (e) {
267
+ if (e.code !== "TEMP_RECORD") throw e;
268
+ lines.push(e.message);
269
+ continue;
270
+ }
221
271
  const lead = key === "morning" ? "Organizing is on" : `${r.title} is on`;
222
272
  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
273
  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;
@@ -0,0 +1,143 @@
1
+ // One sync at a time per record, across processes (HQ-1925).
2
+ //
3
+ // The Mac app syncs on its own, a launchd job or a person at a terminal can run `evda sync`, and the
4
+ // MCP's evda_sync_devices syncs too. Every one of them reaches the relay through lib/pairing.mjs, so
5
+ // the lock is taken there and nowhere else.
6
+ //
7
+ // The lock is `<record>/.evda/sync.lock`: the record's derived state, which sync-rules never sends
8
+ // to a device, never under record/. It holds the holder's pid and that process's start time. A lock
9
+ // is stale only when its holder is gone: the pid runs no process, or the process running under that
10
+ // pid started at a different time (the pid was reused). Its age never counts, because a sync over a
11
+ // slow network is still a live sync.
12
+ //
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { randomBytes } from "node:crypto";
17
+ import { join } from "node:path";
18
+ import { EvdaError } from "./errors.mjs";
19
+
20
+ export const SYNC_LOCK = join(".evda", "sync.lock");
21
+ export const SYNC_BUSY = "A sync of this EVÐA is already running. Try again in a moment.";
22
+ export const SYNC_WAIT_MS = 20_000;
23
+
24
+ /** When the process with this pid started, as the system reports it, or null when no process has that pid. */
25
+ export function processStart(pid) {
26
+ if (!Number.isInteger(pid) || pid <= 0) return null;
27
+ try {
28
+ process.kill(pid, 0);
29
+ } catch (error) {
30
+ if (error?.code !== "EPERM") return null; // EPERM: it runs, as someone else
31
+ }
32
+ if (process.platform === "win32") return ""; // no start time to read; the pid alone decides
33
+ try {
34
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
35
+ env: { PATH: "/bin:/usr/bin", LC_ALL: "C" },
36
+ encoding: "utf8",
37
+ stdio: ["ignore", "pipe", "ignore"],
38
+ }).trim();
39
+ return out || null;
40
+ } catch {
41
+ return null; // ps exits 1 when the pid is gone between the two checks
42
+ }
43
+ }
44
+
45
+ /** Is the holder named in a lock file still the process that wrote it? */
46
+ export function holderAlive(holder, { startOf = processStart } = {}) {
47
+ if (!holder || !Number.isInteger(holder.pid)) return false;
48
+ const now = startOf(holder.pid);
49
+ if (now === null) return false;
50
+ if (now === "" || !holder.start) return true; // no start time on this system: the pid decides
51
+ return now === holder.start;
52
+ }
53
+
54
+ function readHolder(raw) {
55
+ try {
56
+ const h = JSON.parse(raw);
57
+ return h && typeof h === "object" ? h : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function readRaw(path) {
64
+ try {
65
+ return readFileSync(path, "utf8");
66
+ } catch (error) {
67
+ if (error?.code === "ENOENT") return null;
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ export const SYNC_GUARD = join(".evda", "sync.guard");
73
+
74
+ // Judging a lock stale and taking it must be one step, or two processes that both read the same
75
+ // stale lock can each take it. That step runs inside an exclusive SQLite transaction on
76
+ // .evda/sync.guard: SQLite holds it with an OS file lock, which the OS drops when a process dies, so
77
+ // the guard itself can never be left stale. It is held only for the few file operations below,
78
+ // never for a sync. node:sqlite is loaded here and not at the top, because pairing (the only caller)
79
+ // already needs it and every other evda command must still run on a Node without it.
80
+ async function guarded(dir, fn) {
81
+ const { DatabaseSync } = await import("node:sqlite");
82
+ const db = new DatabaseSync(join(dir, SYNC_GUARD));
83
+ try {
84
+ db.exec("PRAGMA busy_timeout = 10000");
85
+ db.exec("BEGIN EXCLUSIVE");
86
+ try {
87
+ return fn();
88
+ } finally {
89
+ db.exec("COMMIT");
90
+ }
91
+ } finally {
92
+ db.close();
93
+ }
94
+ }
95
+
96
+ const pause = (ms) => new Promise((ok) => setTimeout(ok, ms));
97
+
98
+ /**
99
+ * Take the record's sync lock, waiting up to `waitMs` for a running sync to finish. Returns a
100
+ * release function. When the wait runs out, throws SYNC_BUSY (exit 2: nothing was written).
101
+ * `beforeReclaim` is for tests: it runs, synchronously, between judging a lock stale and taking it.
102
+ */
103
+ export async function acquireSyncLock(dir, { waitMs, pollMs = 100, startOf = processStart, env = process.env, beforeReclaim } = {}) {
104
+ const fromEnv = env.EVDA_SYNC_WAIT_MS ? Number(env.EVDA_SYNC_WAIT_MS) : NaN;
105
+ const wait = waitMs ?? (Number.isFinite(fromEnv) && fromEnv >= 0 ? fromEnv : SYNC_WAIT_MS);
106
+ const path = join(dir, SYNC_LOCK);
107
+ mkdirSync(join(dir, ".evda"), { recursive: true });
108
+ const mine = JSON.stringify({ pid: process.pid, start: startOf(process.pid) || null, n: randomBytes(4).toString("hex") });
109
+ const deadline = Date.now() + wait;
110
+ for (;;) {
111
+ const took = await guarded(dir, () => {
112
+ const raw = readRaw(path);
113
+ if (raw !== null) {
114
+ if (holderAlive(readHolder(raw), { startOf })) return false;
115
+ if (beforeReclaim) beforeReclaim();
116
+ unlinkSync(path);
117
+ }
118
+ writeFileSync(path, mine, { flag: "wx" });
119
+ return true;
120
+ });
121
+ if (took) break;
122
+ if (Date.now() >= deadline) throw new EvdaError(SYNC_BUSY, { code: "SYNC_BUSY", exit: 2 });
123
+ await pause(pollMs);
124
+ }
125
+ let released = false;
126
+ return async function release() {
127
+ if (released) return;
128
+ released = true;
129
+ await guarded(dir, () => {
130
+ if (readRaw(path) === mine) unlinkSync(path);
131
+ });
132
+ };
133
+ }
134
+
135
+ /** Run `fn` holding the record's sync lock; the lock is released however `fn` ends. */
136
+ export async function withSyncLock(dir, fn, opts = {}) {
137
+ const release = await acquireSyncLock(dir, opts);
138
+ try {
139
+ return await fn();
140
+ } finally {
141
+ await release();
142
+ }
143
+ }