create-evda 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/evda.mjs +37 -4
- package/create-evda.mjs +10 -6
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +6 -1
- package/lib/gather.mjs +60 -10
- package/lib/mcp.mjs +33 -2
- package/lib/messages.mjs +205 -0
- package/lib/organize.mjs +124 -20
- package/lib/parity.mjs +80 -7
- package/lib/roles.mjs +4 -0
- package/lib/routines.mjs +142 -18
- package/lib/setup.mjs +6 -4
- package/lib/sync-rules.mjs +10 -4
- package/lib/tools.mjs +15 -0
- package/package.json +3 -2
- package/skills/evda/SKILL.md +10 -9
- package/templates/AGENTS.md +4 -0
- package/templates/evda-digest.md +1 -0
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
|
|
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
|
-
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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\
|
|
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
|
}
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
package/lib/routines.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Starter routines: a morning organize and a weekly look back
|
|
2
|
-
//
|
|
1
|
+
// Starter routines: a morning organize and a weekly look back. The morning organize is on by default
|
|
2
|
+
// (HQ-1246, his call 2026-09-27: "We know that it needs to be organizing and stuff 100%"); setup says
|
|
3
|
+
// so in one line and offers off. The weekly look back stays offered, never on without a yes.
|
|
3
4
|
//
|
|
4
5
|
// The first choice is Claude's own scheduler. A local scheduled task in the Claude desktop app runs
|
|
5
6
|
// on this machine with access to this folder (https://code.claude.com/docs/en/desktop-scheduled-tasks).
|
|
@@ -10,8 +11,8 @@
|
|
|
10
11
|
// calendar, through a login shell so the owner's `claude` is on PATH. `--install` writes it and
|
|
11
12
|
// loads it; nothing else here schedules anything.
|
|
12
13
|
|
|
13
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
-
import { join, resolve } from "node:path";
|
|
14
|
+
import { mkdirSync, writeFileSync, existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
15
|
+
import { delimiter, join, resolve, sep } from "node:path";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
17
|
import { spawnSync } from "node:child_process";
|
|
17
18
|
import { openEvda, holderOf } from "./manifest.mjs";
|
|
@@ -38,6 +39,43 @@ export const ROUTINES = {
|
|
|
38
39
|
};
|
|
39
40
|
export const ROUTINE_KEYS = Object.keys(ROUTINES);
|
|
40
41
|
|
|
42
|
+
// EVDA_ROUTINES_FALSIFY=opt-in makes the default nothing, the state before HQ-1246, for the test
|
|
43
|
+
// that shows the default is what installs it.
|
|
44
|
+
export function defaultRoutines(env = process.env) {
|
|
45
|
+
return (env.EVDA_ROUTINES_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("opt-in") ? [] : ["morning"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The owner's claude, found once at setup: EVDA_CLAUDE, then PATH, then where the installers put it.
|
|
49
|
+
// A launchd job runs a login shell, which does not read .zshrc, so a claude on PATH only through
|
|
50
|
+
// .zshrc is invisible to it; the job carries the path found here instead.
|
|
51
|
+
export function findClaude(env = process.env) {
|
|
52
|
+
if (env.EVDA_CLAUDE) return env.EVDA_CLAUDE;
|
|
53
|
+
const home = env.HOME ?? homedir();
|
|
54
|
+
const dirs = [...(env.PATH ?? "").split(delimiter), join(home, ".local", "bin"), join(home, ".claude", "local"), "/opt/homebrew/bin", "/usr/local/bin"];
|
|
55
|
+
for (const d of dirs) {
|
|
56
|
+
if (!d) continue;
|
|
57
|
+
const p = join(d, "claude");
|
|
58
|
+
try { if (statSync(p).isFile()) return p; } catch {}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// macOS keeps a scheduled job out of these folders unless the shell running it has Full Disk Access.
|
|
64
|
+
export function protectedLocation(dir, env = process.env) {
|
|
65
|
+
const home = env.HOME ?? homedir();
|
|
66
|
+
let real = resolve(dir);
|
|
67
|
+
try { real = realpathSync(real); } catch {}
|
|
68
|
+
let realHome = home;
|
|
69
|
+
try { realHome = realpathSync(home); } catch {}
|
|
70
|
+
for (const h of new Set([home, realHome])) {
|
|
71
|
+
for (const f of ["Documents", "Desktop", "Downloads", join("Library", "Mobile Documents")]) {
|
|
72
|
+
const base = join(h, f);
|
|
73
|
+
if (real === base || real.startsWith(base + sep) || resolve(dir).startsWith(base + sep)) return f === join("Library", "Mobile Documents") ? "iCloud Drive" : f;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
41
79
|
const q = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
|
|
42
80
|
|
|
43
81
|
// The shell command a routine runs, with this machine's node and this package's bin.
|
|
@@ -58,7 +96,7 @@ export function desktopInstructions(key, dir) {
|
|
|
58
96
|
` Name: ${r.id}`,
|
|
59
97
|
` Schedule: ${r.when}`,
|
|
60
98
|
` Folder: ${resolve(dir)}`,
|
|
61
|
-
" Instructions: Run this command in the folder, then tell me the report path it prints and anything
|
|
99
|
+
" Instructions: Run this command in the folder, then tell me the report path it prints and anything that did not run:",
|
|
62
100
|
` ${routineCommand(key, dir)}`,
|
|
63
101
|
"",
|
|
64
102
|
"A local routine runs while the app is open and the computer is awake.",
|
|
@@ -72,7 +110,7 @@ export function label(key, evda) {
|
|
|
72
110
|
|
|
73
111
|
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
74
112
|
|
|
75
|
-
export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
113
|
+
export function launchdPlist(key, dir, { home = homedir(), claude = null } = {}) {
|
|
76
114
|
const evda = openEvda(dir);
|
|
77
115
|
const r = ROUTINES[key];
|
|
78
116
|
const log = join(home, "Library", "Logs", "evda", `${r.id}.log`);
|
|
@@ -93,6 +131,7 @@ export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
|
93
131
|
" <dict>",
|
|
94
132
|
cal,
|
|
95
133
|
" </dict>",
|
|
134
|
+
...(claude ? [" <key>EnvironmentVariables</key>", " <dict>", ` <key>EVDA_CLAUDE</key><string>${xml(claude)}</string>`, " </dict>"] : []),
|
|
96
135
|
` <key>StandardOutPath</key><string>${xml(log)}</string>`,
|
|
97
136
|
` <key>StandardErrorPath</key><string>${xml(log)}</string>`,
|
|
98
137
|
"</dict>",
|
|
@@ -101,31 +140,116 @@ export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
|
101
140
|
].join("\n");
|
|
102
141
|
}
|
|
103
142
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
143
|
+
// launchctl is off when the env handed in says so OR this process's own env does. A caller that
|
|
144
|
+
// hands in a partial env (a test's `{ HOME }`) no longer drops the guard and loads a real job, which
|
|
145
|
+
// is how io.evda.evda-morning-organize.*.atlas jobs stayed loaded after test runs.
|
|
146
|
+
export const noLaunchctl = (env = process.env) => env.EVDA_NO_LAUNCHCTL === "1" || process.env.EVDA_NO_LAUNCHCTL === "1";
|
|
147
|
+
|
|
148
|
+
// How launchctl is run. EVDA_LAUNCHCTL names a stand-in binary so the tests can drive the whole CLI
|
|
149
|
+
// without the real launchctl; `launchctl` in the options is the same seam for in-process callers.
|
|
150
|
+
export function runLaunchctl(args, env = process.env) {
|
|
151
|
+
const r = spawnSync(env.EVDA_LAUNCHCTL || "launchctl", args, { encoding: "utf8" });
|
|
152
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const falsified = (env, mode) => (env.EVDA_ROUTINES_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
|
|
156
|
+
|
|
157
|
+
// Unload a job by its label, not by its file: a job whose file is already gone, or was loaded from
|
|
158
|
+
// another path, is still unloaded. EVDA_ROUTINES_FALSIFY=skip-bootout skips it (the HQ-1246 bug:
|
|
159
|
+
// the file went, the job stayed loaded), for the tests.
|
|
160
|
+
function bootout(target, env, launchctl) {
|
|
161
|
+
if (!falsified(env, "skip-bootout")) launchctl(["bootout", target]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Loaded or not, asked of launchd itself: `launchctl print gui/<uid>/<label>` exits 0 only for a
|
|
165
|
+
// loaded job (113 when there is none).
|
|
166
|
+
const isLoaded = (target, launchctl) => launchctl(["print", target]).status === 0;
|
|
167
|
+
|
|
168
|
+
// Write the launchd job and load it. `agentsDir`, `load` and `launchctl` exist so tests never touch
|
|
169
|
+
// the real ~/Library/LaunchAgents or launchctl. A job already loaded under this label is unloaded
|
|
170
|
+
// 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) } = {}) {
|
|
172
|
+
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 });
|
|
108
173
|
const evda = openEvda(dir);
|
|
109
|
-
const plist = launchdPlist(key, dir, { home });
|
|
174
|
+
const plist = launchdPlist(key, dir, { home, claude });
|
|
110
175
|
mkdirSync(agentsDir, { recursive: true });
|
|
111
|
-
|
|
112
|
-
const path = join(agentsDir, `${
|
|
176
|
+
const name = label(key, evda);
|
|
177
|
+
const path = join(agentsDir, `${name}.plist`);
|
|
113
178
|
const existed = existsSync(path);
|
|
114
179
|
writeFileSync(path, plist);
|
|
115
180
|
let loaded = "not loaded (EVDA_NO_LAUNCHCTL=1)";
|
|
116
181
|
if (load) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
182
|
+
// The log folder is made only for a job that is loaded, so an unloaded install writes nothing
|
|
183
|
+
// into the owner's home but the job file.
|
|
184
|
+
mkdirSync(join(home, "Library", "Logs", "evda"), { recursive: true });
|
|
185
|
+
const domain = `gui/${process.getuid?.() ?? ""}`;
|
|
186
|
+
bootout(`${domain}/${name}`, env, launchctl);
|
|
187
|
+
const r = launchctl(["bootstrap", domain, path]);
|
|
120
188
|
loaded = r.status === 0 ? "loaded" : `launchctl bootstrap exited ${r.status}: ${(r.stderr ?? "").trim()}`;
|
|
121
189
|
}
|
|
122
|
-
return { path, existed, loaded };
|
|
190
|
+
return { path, label: name, existed, loaded, claude };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Turn a routine off: unload the job, remove its file, then look again. `off` is true only when
|
|
194
|
+
// launchd has no job under the label and the file is gone; anything left is named in `left`.
|
|
195
|
+
// Nothing there at all is not an error.
|
|
196
|
+
export function uninstallLaunchd(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), launchctl = (a) => runLaunchctl(a, env) } = {}) {
|
|
197
|
+
const name = label(key, openEvda(dir));
|
|
198
|
+
const path = join(agentsDir, `${name}.plist`);
|
|
199
|
+
const target = `gui/${process.getuid?.() ?? ""}/${name}`;
|
|
200
|
+
const hadFile = existsSync(path);
|
|
201
|
+
const wasLoaded = load && isLoaded(target, launchctl);
|
|
202
|
+
if (!hadFile && !wasLoaded) return { path, label: name, removed: false, off: true, left: [] };
|
|
203
|
+
if (wasLoaded) bootout(target, env, launchctl);
|
|
204
|
+
rmSync(path, { force: true });
|
|
205
|
+
const left = [];
|
|
206
|
+
if (load && isLoaded(target, launchctl)) left.push(`launchd still has ${name} loaded (remove it: launchctl bootout ${target})`);
|
|
207
|
+
if (existsSync(path)) left.push(`the job file is still at ${path}`);
|
|
208
|
+
return { path, label: name, removed: true, off: left.length === 0, left };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// What setup does with the routines it was given (HQ-1246). On a Mac each is installed as a launchd
|
|
212
|
+
// job; elsewhere nothing can be scheduled from here, so it prints the Claude desktop steps and says
|
|
213
|
+
// so. Returns the lines to print.
|
|
214
|
+
export function installAtSetup(keys, dir, { env = process.env, platform = process.platform } = {}) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
for (const key of keys) {
|
|
217
|
+
const r = ROUTINES[key];
|
|
218
|
+
lines.push("");
|
|
219
|
+
if (platform === "darwin") {
|
|
220
|
+
const done = installLaunchd(key, dir, { env });
|
|
221
|
+
const lead = key === "morning" ? "Organizing is on" : `${r.title} is on`;
|
|
222
|
+
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
|
+
lines.push(` launchd job ${done.path}: ${done.loaded}. It runs while this computer is on; asleep at that hour, it runs when it wakes.`);
|
|
224
|
+
if (!done.claude) lines.push(" Claude Code was not found on this computer, so the filing runs and the report says the AI part did not. Install Claude Code and sign in to add it.");
|
|
225
|
+
} else {
|
|
226
|
+
for (const l of desktopInstructions(key, dir).split("\n")) lines.push(l);
|
|
227
|
+
lines.push("Nothing was scheduled on this computer: add the routine above in the Claude desktop app.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const where = keys.length && platform === "darwin" ? protectedLocation(dir, env) : null;
|
|
231
|
+
if (where) lines.push(`This record is in ${where}. A scheduled run cannot read it there unless the shell has Full Disk Access (System Settings, Privacy & Security, Full Disk Access), or move the record to a folder of its own.`);
|
|
232
|
+
return lines;
|
|
123
233
|
}
|
|
124
234
|
|
|
125
235
|
export function routineMain(args, { pickDir }) {
|
|
126
236
|
const key = args[0];
|
|
127
|
-
if (!ROUTINE_KEYS.includes(key)) throw new EvdaError(`usage: evda routine <${ROUTINE_KEYS.join("|")}> [dir] [--launchd] [--install]`, { exit: 2 });
|
|
237
|
+
if (!ROUTINE_KEYS.includes(key)) throw new EvdaError(`usage: evda routine <${ROUTINE_KEYS.join("|")}> [dir] [--launchd] [--install] [--uninstall]`, { exit: 2 });
|
|
128
238
|
const { dir, flags } = pickDir(args.slice(1));
|
|
239
|
+
if (flags.includes("--uninstall")) {
|
|
240
|
+
const r = uninstallLaunchd(key, dir);
|
|
241
|
+
if (!r.removed) {
|
|
242
|
+
console.log(`nothing to remove: no ${ROUTINES[key].id} job at ${r.path}`);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
const what = key === "morning" ? "organizing" : ROUTINES[key].title;
|
|
246
|
+
if (!r.off) {
|
|
247
|
+
console.log(`${what} is not off: ${r.left.join("; ")}.`);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
console.log(`removed ${r.path}: ${what} is off. Turn it back on: evda routine ${key} ${resolve(dir)} --install`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
129
253
|
if (flags.includes("--install")) {
|
|
130
254
|
const r = installLaunchd(key, dir);
|
|
131
255
|
console.log(`${r.existed ? "replaced" : "wrote"} ${r.path}: ${r.loaded}. ${ROUTINES[key].when}, it runs: ${routineCommand(key, dir)}`);
|
package/lib/setup.mjs
CHANGED
|
@@ -283,7 +283,7 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
283
283
|
{ key: "filing", text: `Should ${agent} file everything you say automatically, or only when you ask? (everything)` },
|
|
284
284
|
{ key: "import", text: "Bring in what your AI already knows about you?" },
|
|
285
285
|
{ key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
|
|
286
|
-
{ key: "morning", text: "
|
|
286
|
+
{ key: "morning", text: "Organizing is on: each morning your own Claude files your notes into their day and writes what belongs where. Keep it on?" },
|
|
287
287
|
{ key: "weekly", text: "Add a weekly look back routine, which reads your week back to you?" },
|
|
288
288
|
];
|
|
289
289
|
}
|
|
@@ -397,9 +397,11 @@ export async function interview(opts, io, { presets = [] } = {}) {
|
|
|
397
397
|
}
|
|
398
398
|
if (a.routines == null) {
|
|
399
399
|
out("");
|
|
400
|
-
out("Routines
|
|
401
|
-
|
|
402
|
-
|
|
400
|
+
out("Routines run through your own AI, never an API key in EVÐA.");
|
|
401
|
+
// Organizing is on by default (HQ-1246): only an explicit no turns it off.
|
|
402
|
+
const keep = String(await ask(`${q("morning", agent)} (yes/no) `)).trim().toLowerCase();
|
|
403
|
+
a.routines = ["no", "n", "off"].includes(keep) ? [] : ["morning"];
|
|
404
|
+
if (await yes("weekly")) a.routines.push("weekly");
|
|
403
405
|
}
|
|
404
406
|
return a;
|
|
405
407
|
}
|
package/lib/sync-rules.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Three kinds of thing never sync, and each is counted rather than dropped:
|
|
9
9
|
//
|
|
10
|
-
// excluded by name anywhere (.DS_Store, node_modules, .git), by name
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// excluded by name anywhere (.DS_Store, node_modules, .git), by name prefix anywhere (~$, the
|
|
11
|
+
// owner file Word, Excel and PowerPoint keep beside a document while it is open), by
|
|
12
|
+
// name at the root (.evda, the derived index every device rebuilds), or by a path the
|
|
13
|
+
// plan names. Reported as EXCLUDED n.
|
|
13
14
|
// symlink never followed and never carried: sync moves bytes, and a link's target may be
|
|
14
15
|
// outside the record, missing, or itself. A symlink inside a circle fails the
|
|
15
16
|
// coverage check until the plan excludes it.
|
|
@@ -24,6 +25,11 @@ import { lstatSync, readdirSync } from "node:fs";
|
|
|
24
25
|
import { join } from "node:path";
|
|
25
26
|
|
|
26
27
|
export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
|
|
28
|
+
// A name starting with one of these is one machine's state about a file, not the record. `~$` is
|
|
29
|
+
// the Office owner file: it exists only while a document is open on that machine, and iCloud never
|
|
30
|
+
// carries one, so a shadow fed by the laptop held one Hestia's record could not (HQ-1219,
|
|
31
|
+
// 2026-09-25, `~$e Lantern Version 3.docx`, EXTRA).
|
|
32
|
+
export const EXCLUDE_PREFIXES = ["~$"];
|
|
27
33
|
export const EXCLUDE_ROOT_NAMES = [".evda"];
|
|
28
34
|
// The relay refuses a blob over 64 MiB (`xania-relay/src/relay.mjs`, MAX_BLOB_BYTES); the seal adds
|
|
29
35
|
// a nonce, a tag and an ephemeral key, so leave a margin.
|
|
@@ -40,7 +46,7 @@ export function excluder(extra = []) {
|
|
|
40
46
|
const paths = extra.map(norm).filter((p) => p !== "");
|
|
41
47
|
return (rel) => {
|
|
42
48
|
const parts = norm(rel).split("/");
|
|
43
|
-
if (parts.some((p) => EXCLUDE_NAMES.includes(p))) return true;
|
|
49
|
+
if (parts.some((p) => EXCLUDE_NAMES.includes(p) || EXCLUDE_PREFIXES.some((x) => p.startsWith(x)))) return true;
|
|
44
50
|
if (EXCLUDE_ROOT_NAMES.includes(parts[0])) return true;
|
|
45
51
|
const r = parts.join("/");
|
|
46
52
|
return paths.some((p) => r === p || r.startsWith(`${p}/`));
|
package/lib/tools.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { doctor } from "./doctor.mjs";
|
|
|
35
35
|
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
|
|
36
36
|
import { EvdaError } from "./errors.mjs";
|
|
37
37
|
import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
38
|
+
import { writeMessage, unseenReplies } from "./messages.mjs";
|
|
38
39
|
|
|
39
40
|
export { localDate, localTime, CONTEXT_LABEL };
|
|
40
41
|
|
|
@@ -296,6 +297,20 @@ export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
|
|
|
296
297
|
return releaseNote(evda, args.path, { now });
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
// 5d. Tell the owner something (HQ-1246): one new message file under the messages role, which the
|
|
301
|
+
// phone app shows as a card. Never edits one; refuses anything secret-shaped, like every write.
|
|
302
|
+
export function message(evda, args = {}, { now = new Date() } = {}) {
|
|
303
|
+
onlyKeys("evda_message", args, ["title", "body", "from"], "It writes one new message for the owner and nothing else: it takes no path, and it never edits a message once written.");
|
|
304
|
+
return writeMessage(evda, args, { now, refuseSecrets });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5e. The owner's replies to messages, the ones not seen yet, marked seen unless peek is true.
|
|
308
|
+
export function replies(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
309
|
+
onlyKeys("evda_replies", args, ["peek"]);
|
|
310
|
+
if (args.peek !== undefined && typeof args.peek !== "boolean") throw new EvdaError("evda_replies peek is true or false.", { code: "BAD_ARGUMENT", exit: 2 });
|
|
311
|
+
return unseenReplies(evda, { peek: args.peek === true }, { caller });
|
|
312
|
+
}
|
|
313
|
+
|
|
299
314
|
// 6. Circles and their policies, as the record declares them. Read only.
|
|
300
315
|
export function circles(evda, args = {}) {
|
|
301
316
|
onlyKeys("evda_circles", args, []);
|