create-evda 0.1.5 → 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 +114 -0
- package/README.md +74 -7
- package/bin/evda.mjs +427 -9
- package/create-evda.mjs +64 -16
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +640 -0
- package/lib/gather.mjs +858 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +456 -36
- package/lib/messages.mjs +205 -0
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +176 -32
- package/lib/parity.mjs +184 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +106 -1
- package/lib/routines.mjs +142 -18
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +124 -4
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +93 -0
- package/lib/tools.mjs +133 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +67 -8
- package/templates/AGENTS.md +122 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +31 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
package/lib/organize.mjs
CHANGED
|
@@ -3,19 +3,25 @@
|
|
|
3
3
|
// The mechanical part is deterministic and done here:
|
|
4
4
|
// - each dated capture is copied, verbatim, into the daily file of its date, once. A marker comment
|
|
5
5
|
// in the daily file records the copy, so a second run files nothing twice and a rebuilt .evda/
|
|
6
|
-
// cannot make it forget.
|
|
6
|
+
// cannot make it forget. The copy carries the capture's flags (HQ-1414): a sealed capture is
|
|
7
|
+
// filed inside a sealed fence, and a just-noted one is not copied at all. Its day gets one
|
|
8
|
+
// pointer line naming the capture, so the words stay in one file under one flag that
|
|
9
|
+
// `evda_release_note` clears, and the first run after its owner releases it files the words.
|
|
7
10
|
// - the report lists what needs a person's judgment: people a recent note mentions whose file has
|
|
8
11
|
// nothing from that note, imports to check, undated notes, and notes that sit under no role.
|
|
9
12
|
// The report is appended to reports/organize/<date>.md. Nothing is moved, renamed or deleted, and
|
|
10
13
|
// nothing is written into circles/ or grants/.
|
|
11
14
|
//
|
|
12
15
|
// The judgment part runs through the owner's own AI (`--with-ai`): `claude -p` with the evda skill
|
|
13
|
-
// 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
|
|
14
19
|
// no key and calls no model; `claude` is the owner's install, signed in as the owner.
|
|
15
20
|
|
|
16
21
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
17
22
|
import { basename, dirname, join, relative } from "node:path";
|
|
18
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
19
25
|
import { resolveRole, resolveAll } from "./roles.mjs";
|
|
20
26
|
import { mdFiles, fileDate } from "./record.mjs";
|
|
21
27
|
import { secretShape } from "./references.mjs";
|
|
@@ -23,9 +29,13 @@ import { importsDir, localDate, localTime } from "./tools.mjs";
|
|
|
23
29
|
import { serverCommand, SKILL_SOURCE } from "./connect.mjs";
|
|
24
30
|
import { SERVER_NAME } from "./clients.mjs";
|
|
25
31
|
import { EvdaError } from "./errors.mjs";
|
|
32
|
+
import { frontmatterOf, heldBy, withoutSealed, SEALED, NOTED } from "./circles.mjs";
|
|
26
33
|
|
|
27
34
|
export const REPORTS = "reports";
|
|
28
35
|
const marker = (rel) => `<!-- evda:capture ${rel} -->`;
|
|
36
|
+
// The pointer a just-noted capture leaves in its day in place of its words. A different marker, so
|
|
37
|
+
// once the capture is released the next run still files its words.
|
|
38
|
+
const notedMarker = (rel) => `<!-- evda:noted-capture ${rel} -->`;
|
|
29
39
|
const posix = (p) => p.split("\\").join("/");
|
|
30
40
|
|
|
31
41
|
function stripFrontmatter(text) {
|
|
@@ -74,11 +84,21 @@ export function plan(evda, { now = new Date(), window = 7 } = {}) {
|
|
|
74
84
|
const text = readFileSync(abs, "utf8");
|
|
75
85
|
const date = fileDate(abs, text);
|
|
76
86
|
if (!date) { out.undated.push(rel(abs)); continue; }
|
|
87
|
+
const held = heldBy(frontmatterOf(text), "organize");
|
|
88
|
+
const target = join(daily.abs, `${date}.md`);
|
|
89
|
+
const dayText = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
90
|
+
// Just noted: its words are read for nothing, a secret included, and only a pointer is filed.
|
|
91
|
+
if (held === NOTED) {
|
|
92
|
+
if (!dayText.includes(marker(rel(abs))) && !dayText.includes(notedMarker(rel(abs)))) out.filings.push({ from: rel(abs), to: rel(target), date, noted: true });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
77
95
|
const secret = secretLine(text);
|
|
78
96
|
if (secret) { out.refused.push({ path: rel(abs), why: `holds ${secret}; not copied` }); continue; }
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
if (dayText.includes(marker(rel(abs)))) continue;
|
|
98
|
+
// A sealed capture (HQ-1220) is filed inside a sealed fence, heading and marker included, so the
|
|
99
|
+
// copy in the day is as sealed as the capture and names nothing outside the fence.
|
|
100
|
+
const sealed = held === SEALED;
|
|
101
|
+
out.filings.push({ from: rel(abs), to: rel(target), date, body: stripFrontmatter(text).replace(/\n+$/, ""), ...(sealed ? { sealed: true } : {}) });
|
|
82
102
|
}
|
|
83
103
|
}
|
|
84
104
|
for (const abs of mdFiles(daily.abs)) {
|
|
@@ -90,7 +110,11 @@ export function plan(evda, { now = new Date(), window = 7 } = {}) {
|
|
|
90
110
|
if (people.mapped && people.exists) {
|
|
91
111
|
const recent = [...mdFiles(daily.abs), ...(captures.mapped && captures.exists ? mdFiles(captures.abs) : [])]
|
|
92
112
|
.map((abs) => ({ abs, text: readFileSync(abs, "utf8") }))
|
|
93
|
-
.filter((f) => { const d = fileDate(f.abs, f.text); return d && d >= from; })
|
|
113
|
+
.filter((f) => { const d = fileDate(f.abs, f.text); return d && d >= from; })
|
|
114
|
+
// Who a sealed or just-noted note mentions is held back with it: it is not read for people, and
|
|
115
|
+
// a sealed section is cut before the rest is (HQ-1220, HQ-1414).
|
|
116
|
+
.filter((f) => !heldBy(frontmatterOf(f.text), "organize"))
|
|
117
|
+
.map((f) => ({ ...f, text: withoutSealed(f.text).text }));
|
|
94
118
|
for (const p of mdFiles(people.abs)) {
|
|
95
119
|
out.examined += 1;
|
|
96
120
|
const ptext = readFileSync(p, "utf8");
|
|
@@ -138,7 +162,7 @@ export function reportText(p, { now = new Date(), window = 7 } = {}) {
|
|
|
138
162
|
const lines = [`## Run ${localTime(now)}`, ""];
|
|
139
163
|
lines.push(`Examined ${p.examined} note(s). Moved nothing.`, "");
|
|
140
164
|
lines.push("### Filed");
|
|
141
|
-
if (p.filings.length) for (const f of p.filings) lines.push(`- ${f.from} copied verbatim into ${f.to}`);
|
|
165
|
+
if (p.filings.length) for (const f of p.filings) lines.push(f.sealed ? `- a sealed capture copied verbatim into ${f.to}, still sealed` : f.noted ? `- ${f.from} is just noted: ${f.to} points to it, and its words stay where they are until its owner releases it` : `- ${f.from} copied verbatim into ${f.to}`);
|
|
142
166
|
else lines.push("- Nothing new to file.");
|
|
143
167
|
lines.push("", `### People mentioned in the last ${window} days`);
|
|
144
168
|
if (p.people.length) for (const x of p.people) lines.push(`- ${x.note} mentions ${x.name}; ${x.person} has nothing from it yet.`);
|
|
@@ -163,21 +187,131 @@ function appendReport(evda, kind, date, block) {
|
|
|
163
187
|
}
|
|
164
188
|
|
|
165
189
|
// Run the owner's claude -p with the skill and read-only EVDA tools. Returns { ok, text, why }.
|
|
190
|
+
// With `evda` null (a gather, which has no record yet) it gets no EVDA tools and runs from the
|
|
191
|
+
// system's temporary directory, so it reads nothing but the prompt.
|
|
166
192
|
export function askOwnersAi(evda, prompt, { model = null, env = process.env, timeoutMs = 600_000 } = {}) {
|
|
167
193
|
const bin = env.EVDA_CLAUDE || "claude";
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
194
|
+
const argv = ["-p", prompt];
|
|
195
|
+
if (evda) {
|
|
196
|
+
const { command, args } = serverCommand(evda.dir);
|
|
197
|
+
const mcp = JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } });
|
|
198
|
+
const allowed = ["evda_operating_layer", "evda_recent", "evda_search", "evda_circles"].map((t) => `mcp__${SERVER_NAME}__${t}`);
|
|
199
|
+
argv.push("--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", allowed.join(","));
|
|
200
|
+
} else {
|
|
201
|
+
argv.push("--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: {} }), "--allowedTools", "");
|
|
202
|
+
}
|
|
203
|
+
argv.push("--output-format", "text");
|
|
171
204
|
const skill = existsSync(SKILL_SOURCE) ? readFileSync(SKILL_SOURCE, "utf8") : "";
|
|
172
|
-
const argv = ["-p", prompt, "--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", allowed.join(","), "--output-format", "text"];
|
|
173
205
|
if (skill) argv.push("--append-system-prompt", skill);
|
|
174
206
|
if (model) argv.push("--model", model);
|
|
175
|
-
const r = spawnSync(bin, argv, { cwd: evda.dir, env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
207
|
+
const r = spawnSync(bin, argv, { cwd: evda ? evda.dir : tmpdir(), env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
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)}`;
|
|
181
315
|
}
|
|
182
316
|
|
|
183
317
|
export async function organize(evda, { now = new Date(), window = 7, withAi = false, dryRun = false, model = null, env = process.env } = {}) {
|
|
@@ -190,23 +324,33 @@ export async function organize(evda, { now = new Date(), window = 7, withAi = fa
|
|
|
190
324
|
const abs = join(evda.dir, f.to);
|
|
191
325
|
const head = existsSync(abs) ? "" : `# ${f.date}\n`;
|
|
192
326
|
const lead = existsSync(abs) && !readFileSync(abs, "utf8").endsWith("\n") ? "\n" : "";
|
|
193
|
-
|
|
327
|
+
if (f.noted) {
|
|
328
|
+
appendFileSync(abs, `${lead}${head}\n## Capture: ${basename(f.from, ".md")}\n\n${notedMarker(f.from)}\n\nJust noted, so its words stay in ${f.from}, read by no AI. Once its owner releases it, the next organize files them here.\n`);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const block = `## Capture: ${basename(f.from, ".md")}\n\n${marker(f.from)}\n\n${f.body}\n`;
|
|
332
|
+
if (f.sealed) {
|
|
333
|
+
// A fence longer than any backtick run at the start of a line in the block, so nothing in it closes it.
|
|
334
|
+
const longest = Math.max(2, ...block.split("\n").map((l) => /^ {0,3}(`+)/.exec(l)?.[1].length ?? 0));
|
|
335
|
+
const fence = "`".repeat(longest + 1);
|
|
336
|
+
appendFileSync(abs, `${lead}${head}\n${fence}sealed\n${block}${fence}\n`);
|
|
337
|
+
} else appendFileSync(abs, `${lead}${head}\n${block}`);
|
|
194
338
|
}
|
|
195
339
|
let exit = 0;
|
|
196
340
|
if (withAi) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
354
|
}
|
|
211
355
|
}
|
|
212
356
|
const path = appendReport(evda, "organize", date, report);
|
|
@@ -218,7 +362,7 @@ export async function lookBack(evda, { now = new Date(), model = null, env = pro
|
|
|
218
362
|
const date = localDate(now);
|
|
219
363
|
const block = ai.ok
|
|
220
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`)
|
|
221
|
-
: `## Run ${localTime(now)}\n\
|
|
365
|
+
: `## Run ${localTime(now)}\n\nDid not run. ${ai.why}\n`;
|
|
222
366
|
const path = appendReport(evda, "look-back", date, block);
|
|
223
367
|
return { path, exit: ai.ok ? 0 : 1, summary: `${block}\nAppended to ${path}.` };
|
|
224
368
|
}
|
package/lib/parity.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Shadow parity (Spec 08, stage S1): the shadow a device folds received Changes into must equal the
|
|
2
|
+
// record it shadows, byte for byte, for exactly the circles that device holds, and must hold nothing
|
|
3
|
+
// of a circle it may never hold.
|
|
4
|
+
//
|
|
5
|
+
// The gate to S2 is this check holding every night for a week, so it is written to fail:
|
|
6
|
+
//
|
|
7
|
+
// MISSING a file of a held circle is in the record and not in the shadow
|
|
8
|
+
// EXTRA a file is in the shadow and not in the record (a rename is one MISSING and one EXTRA)
|
|
9
|
+
// DIFFERS the same path, different bytes (sha256)
|
|
10
|
+
// FORBIDDEN the shadow holds a file of a circle this device must never hold
|
|
11
|
+
// UNCOVERED the shadow holds a file no circle covers
|
|
12
|
+
// UNVERIFIABLE a file of a held circle is a symlink, an iCloud stub, or over the blob lane, so the
|
|
13
|
+
// record side has no bytes the shadow could be compared with
|
|
14
|
+
// CHECKED 0 nothing was compared, which is a broken discovery step and never a pass
|
|
15
|
+
//
|
|
16
|
+
// Two things are not differences, and each is counted rather than dropped:
|
|
17
|
+
//
|
|
18
|
+
// a name stored in another Unicode form paths are compared in NFC. iCloud delivered 23 of the
|
|
19
|
+
// vault's Greek and accented names to Hestia in the other
|
|
20
|
+
// form (measured 2026-09-22); the bytes are the same file,
|
|
21
|
+
// and APFS opens either spelling.
|
|
22
|
+
// a path iCloud never carries a component ending in .tmp or .nosync. During S1 the
|
|
23
|
+
// record on the shadow device arrived by iCloud, so the
|
|
24
|
+
// laptop's .tmp/ (65 files, never on Hestia) reaches the
|
|
25
|
+
// shadow by the relay and could never match. NOT_CARRIED n.
|
|
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
|
+
//
|
|
60
|
+
// It reads both trees and writes nothing.
|
|
61
|
+
|
|
62
|
+
import { createHash } from "node:crypto";
|
|
63
|
+
import { readFileSync, statSync } from "node:fs";
|
|
64
|
+
import { join } from "node:path";
|
|
65
|
+
import { circleForPath } from "./filechanges.mjs";
|
|
66
|
+
import { excluder, walk } from "./sync-rules.mjs";
|
|
67
|
+
|
|
68
|
+
const nfc = (rel) => rel.normalize("NFC");
|
|
69
|
+
// A path iCloud Drive does not sync, so it cannot be compared across devices while iCloud carries
|
|
70
|
+
// the record. Exported for the test.
|
|
71
|
+
export const notCarried = (rel) => rel.split("/").some((p) => p.endsWith(".tmp") || p.endsWith(".nosync"));
|
|
72
|
+
|
|
73
|
+
const sha = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
74
|
+
|
|
75
|
+
function circleOf(index, rel) {
|
|
76
|
+
try {
|
|
77
|
+
return circleForPath(index, rel);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `index` is circleIndex(evda). `holds` and `never` are circle names. `exclude` is the plan's
|
|
84
|
+
// excluder, the same one the scan used.
|
|
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
|
+
};
|
|
120
|
+
const held = new Set(holds);
|
|
121
|
+
const forbidden = new Set(never);
|
|
122
|
+
const problems = [];
|
|
123
|
+
const perCircle = Object.fromEntries(holds.map((n) => [n, 0]));
|
|
124
|
+
const expected = new Map();
|
|
125
|
+
let notHeld = 0;
|
|
126
|
+
let excluded = 0;
|
|
127
|
+
let notCarriedCount = 0;
|
|
128
|
+
|
|
129
|
+
for (const e of walk(source, { exclude })) {
|
|
130
|
+
if (e.kind === "excluded") { excluded += 1; continue; }
|
|
131
|
+
if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
|
|
132
|
+
const c = circleOf(index, e.rel);
|
|
133
|
+
if (!c || !held.has(c.name)) { notHeld += 1; continue; }
|
|
134
|
+
if (e.kind !== "file") {
|
|
135
|
+
problems.push({ code: "UNVERIFIABLE", rel: e.rel, circle: c.name, why: `a ${e.kind} on the record side` });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
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 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
let shadowFiles = 0;
|
|
144
|
+
for (const e of walk(shadow, { exclude: excluder() })) {
|
|
145
|
+
if (e.kind === "excluded") continue;
|
|
146
|
+
if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
|
|
147
|
+
shadowFiles += 1;
|
|
148
|
+
const c = circleOf(index, e.rel);
|
|
149
|
+
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; }
|
|
150
|
+
if (!c) { problems.push({ code: "UNCOVERED", rel: e.rel, why: "in the shadow and in no circle" }); continue; }
|
|
151
|
+
const want = expected.get(nfc(e.rel));
|
|
152
|
+
if (!want) { problems.push({ code: "EXTRA", rel: e.rel, circle: c.name, why: "in the shadow and not in the record" }); continue; }
|
|
153
|
+
seen.add(nfc(e.rel));
|
|
154
|
+
if (e.kind !== "file" || sha(join(shadow, e.rel)) !== want.sha) {
|
|
155
|
+
late({ ...want, rel: e.rel }, "DIFFERS", "the shadow's bytes are not the record's");
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
perCircle[want.circle] += 1;
|
|
159
|
+
}
|
|
160
|
+
for (const [key, want] of expected) {
|
|
161
|
+
if (!seen.has(key)) late(want, "MISSING", "in the record and not in the shadow");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const checked = Object.values(perCircle).reduce((a, b) => a + b, 0);
|
|
165
|
+
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 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The lines a person or a log reads. The first line always says what was examined.
|
|
170
|
+
export function formatParity(r, { limit = 20 } = {}) {
|
|
171
|
+
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()}` : ""}`);
|
|
173
|
+
const byCode = {};
|
|
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
|
+
}
|
|
180
|
+
for (const p of r.problems.slice(0, limit)) lines.push(`FAIL ${p.code} ${p.rel}${p.circle ? ` [${p.circle}]` : ""}: ${p.why}`);
|
|
181
|
+
if (r.problems.length > limit) lines.push(`... and ${r.problems.length - limit} more`);
|
|
182
|
+
lines.push(r.ok ? "PARITY OK" : `PARITY FAILED: ${Object.entries(byCode).map(([c, n]) => `${c} ${n}`).join(", ")}`);
|
|
183
|
+
return lines;
|
|
184
|
+
}
|
package/lib/presets.mjs
CHANGED
|
@@ -7,9 +7,17 @@
|
|
|
7
7
|
// "for": "One sentence: who it suits.",
|
|
8
8
|
// "folders": [{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." }],
|
|
9
9
|
// "circles": [{ "name": "family", "public": false, "description": "...", "members": { "people": { "path": "People" } } }],
|
|
10
|
-
// "routines": ["weekly"]
|
|
10
|
+
// "routines": ["weekly"],
|
|
11
|
+
// "mapping": { "journal": "Journal", "collections": "People" }
|
|
11
12
|
// }
|
|
12
13
|
//
|
|
14
|
+
// `mapping` is what the preset declares for the four roles (Spec 07 section 2, HQ-1371): each key
|
|
15
|
+
// a role or a `<role>/<facet>`, each value a folder the preset makes. Every folder the preset makes
|
|
16
|
+
// fills a role, by lying at or under one of these paths; a folder that fills none is refused, so
|
|
17
|
+
// no preset ships a folder an agent cannot say the purpose of. Applying writes the role paths the
|
|
18
|
+
// record does not have into its evda.json and never replaces a key it has: when the key is taken,
|
|
19
|
+
// the folder fills the same role under a facet key named for it (`collections/people`).
|
|
20
|
+
//
|
|
13
21
|
// `folders` are paths relative to the record root. A folder with a `type` gets folder.evda.json
|
|
14
22
|
// (type, connectors, views), checked by the same validator doctor uses; a folder with no type is a
|
|
15
23
|
// plain directory and nothing more. `circles` are starter circles, in the substrate's eco shape with
|
|
@@ -26,8 +34,8 @@
|
|
|
26
34
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
27
35
|
import { dirname, join, resolve, sep } from "node:path";
|
|
28
36
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { openEvda } from "./manifest.mjs";
|
|
30
|
-
import { resolveRole } from "./roles.mjs";
|
|
37
|
+
import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
|
|
38
|
+
import { resolveRole, fourRoleOf, FOUR_ROLES, LEGACY_FACETS } from "./roles.mjs";
|
|
31
39
|
import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders.mjs";
|
|
32
40
|
import { ROUTINE_KEYS } from "./routines.mjs";
|
|
33
41
|
import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
|
|
@@ -35,7 +43,7 @@ import { EvdaError } from "./errors.mjs";
|
|
|
35
43
|
|
|
36
44
|
export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
|
|
37
45
|
export const PRESET_FILE = "preset.json";
|
|
38
|
-
export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines"];
|
|
46
|
+
export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines", "mapping"];
|
|
39
47
|
export const PRESET_FOLDER_KEYS = ["path", "type", "connectors", "views", "note"];
|
|
40
48
|
export const PRESET_CIRCLE_KEYS = ["name", "public", "description", "members"];
|
|
41
49
|
export const NONE = "none";
|
|
@@ -98,6 +106,24 @@ export function validatePreset(p, dirName = null) {
|
|
|
98
106
|
}
|
|
99
107
|
});
|
|
100
108
|
}
|
|
109
|
+
// The four roles. Required: a preset is a way to fill them.
|
|
110
|
+
if (!isObject(p.mapping) || Object.keys(p.mapping).length === 0) fail(`"mapping" must name the folders that fill the four roles (${FOUR_ROLES.join(", ")}).`);
|
|
111
|
+
else {
|
|
112
|
+
const rolePaths = [];
|
|
113
|
+
for (const [key, rel] of Object.entries(p.mapping)) {
|
|
114
|
+
const role = fourRoleOf(key);
|
|
115
|
+
if (!role || LEGACY_FACETS[key]) { fail(`mapping key "${key}" is not one of the four roles or a <role>/<facet> of one.`); continue; }
|
|
116
|
+
if (!nonEmpty(rel) || !paths.has(rel)) { fail(`mapping "${key}" is ${JSON.stringify(rel)}, which is not a folder this preset makes.`); continue; }
|
|
117
|
+
rolePaths.push({ role, rel });
|
|
118
|
+
}
|
|
119
|
+
const missing = FOUR_ROLES.filter((r) => !rolePaths.some((x) => x.role === r));
|
|
120
|
+
if (missing.length) fail(`fills no folder for ${missing.join(", ")}. Every record has the four roles.`);
|
|
121
|
+
for (const fp of paths) {
|
|
122
|
+
if (!rolePaths.some((x) => fp === x.rel || fp.startsWith(`${x.rel}/`))) fail(`folder ${fp} fills no role: it is not at or under any path in "mapping".`);
|
|
123
|
+
}
|
|
124
|
+
const seen = new Map();
|
|
125
|
+
for (const x of rolePaths) { if (seen.has(x.rel) && seen.get(x.rel) !== x.role) fail(`folder ${x.rel} fills two roles.`); seen.set(x.rel, x.role); }
|
|
126
|
+
}
|
|
101
127
|
if (p.circles !== undefined) {
|
|
102
128
|
if (!Array.isArray(p.circles)) fail(`"circles" must be a list.`);
|
|
103
129
|
else {
|
|
@@ -205,7 +231,23 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
|
|
|
205
231
|
export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
|
|
206
232
|
const evda = openEvda(resolve(root));
|
|
207
233
|
const preset = loadPreset(id, { dir });
|
|
208
|
-
const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length };
|
|
234
|
+
const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
|
|
235
|
+
// The four roles the record does not declare yet. A key it has with another path is a conflict,
|
|
236
|
+
// left as it is; a path it already maps under a key of the same role is already there.
|
|
237
|
+
const has = evda.manifest.mapping;
|
|
238
|
+
for (const [key, rel] of Object.entries(preset.mapping ?? {})) {
|
|
239
|
+
if (has[key] === rel) continue;
|
|
240
|
+
if (Object.entries(has).some(([k, v]) => v === rel && fourRoleOf(k) === fourRoleOf(key))) continue;
|
|
241
|
+
// A key the record already uses for another path keeps it; the preset's folder fills the same
|
|
242
|
+
// role under a facet key named for the folder, so two presets compose and nothing is replaced.
|
|
243
|
+
let k = key;
|
|
244
|
+
if (has[k] !== undefined || plan.roles.some((r) => r.key === k)) {
|
|
245
|
+
const slug = rel.split("/").pop().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "folder";
|
|
246
|
+
k = `${fourRoleOf(key)}/${slug}`;
|
|
247
|
+
for (let n = 2; has[k] !== undefined || plan.roles.some((r) => r.key === k); n++) k = `${fourRoleOf(key)}/${slug}-${n}`;
|
|
248
|
+
}
|
|
249
|
+
plan.roles.push({ key: k, rel });
|
|
250
|
+
}
|
|
209
251
|
const blocked = new Set();
|
|
210
252
|
for (const f of preset.folders) {
|
|
211
253
|
const abs = resolve(evda.dir, f.path);
|
|
@@ -234,6 +276,8 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
|
|
|
234
276
|
if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
|
|
235
277
|
}
|
|
236
278
|
}
|
|
279
|
+
// A role is declared only over a folder this apply leaves in place; one a file blocks is not.
|
|
280
|
+
plan.roles = plan.roles.filter((r) => blockedBy(evda.dir, r.rel) === null);
|
|
237
281
|
const circlesRole = resolveRole(evda, "circles");
|
|
238
282
|
const proposals = join(evda.dir, PROPOSALS);
|
|
239
283
|
const proposalNames = existsSync(proposals) ? readdirSync(proposals) : [];
|
|
@@ -244,6 +288,11 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
|
|
|
244
288
|
}
|
|
245
289
|
if (dry) return plan;
|
|
246
290
|
|
|
291
|
+
if (plan.roles.length) {
|
|
292
|
+
const m = readManifest(evda.dir);
|
|
293
|
+
for (const r of plan.roles) m.mapping[r.key] ??= r.rel;
|
|
294
|
+
writeManifest(evda.dir, m);
|
|
295
|
+
}
|
|
247
296
|
for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
|
|
248
297
|
for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
|
|
249
298
|
for (const v of plan.views) {
|
|
@@ -268,17 +317,18 @@ export function diffPreset(root, id, opts = {}) {
|
|
|
268
317
|
return applyPreset(root, id, { ...opts, dry: true });
|
|
269
318
|
}
|
|
270
319
|
|
|
271
|
-
export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length;
|
|
320
|
+
export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
|
|
272
321
|
|
|
273
322
|
// The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
|
|
274
323
|
export function summary(plan, preset = loadPreset(plan.id)) {
|
|
275
324
|
const lines = [];
|
|
276
325
|
const adds = preset.folders.filter((f) => plan.create.includes(f.path) || plan.write.some((w) => w.folder.path === f.path));
|
|
277
|
-
if (!adds.length && !plan.circles.length && !plan.conflicts.length) {
|
|
326
|
+
if (!adds.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length) {
|
|
278
327
|
lines.push(`This record already has everything the ${plan.title} preset makes. Nothing would be written.`);
|
|
279
328
|
} else {
|
|
280
329
|
lines.push(`The ${plan.title} preset would add ${adds.length} folder(s) to this record:`);
|
|
281
330
|
for (const f of adds) lines.push(`- ${f.path}/${f.type ? ` (${f.type})` : ""}${f.note ? `: ${f.note}` : ""}`);
|
|
331
|
+
if (plan.roles?.length) lines.push(`It would declare ${plan.roles.length} role path(s) in evda.json: ${plan.roles.map((r) => `${r.key} -> ${r.rel}`).join(", ")}.`);
|
|
282
332
|
if (plan.circles.length) lines.push(`It would propose ${plan.circles.length} circle(s), as proposal files only: ${plan.circles.map((c) => c.name).join(", ")}.`);
|
|
283
333
|
if (plan.matched.length) lines.push(`${plan.matched.length} folder(s) are already set up the same way and would be left alone.`);
|
|
284
334
|
for (const c of plan.conflicts) lines.push(`Left as it is: ${c.path} ${c.why}.`);
|
|
@@ -294,6 +344,7 @@ export function planLines(plan, { diff = false } = {}) {
|
|
|
294
344
|
const verb = plan.dry || diff ? "would" : "did";
|
|
295
345
|
for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
|
|
296
346
|
for (const w of plan.write) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} type ${w.manifest.type}${w.manifest.connectors.length ? `, connectors ${w.manifest.connectors.join(", ")}` : ""}`);
|
|
347
|
+
for (const r of plan.roles ?? []) out.push(` ${verb === "did" ? "declared" : "declare "} ${r.key} -> ${r.rel} in evda.json`);
|
|
297
348
|
for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
|
|
298
349
|
if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
|
|
299
350
|
for (const c of plan.conflicts) out.push(` CONFLICT ${c.path} ${c.why}. Left as it is.`);
|