create-evda 0.1.10 → 0.1.11
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 +23 -0
- package/README.md +1 -1
- package/bin/evda.mjs +7 -1
- package/create-evda.mjs +3 -8
- package/lib/adopt.mjs +43 -15
- package/lib/attested-host.mjs +25 -7
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/day-writer.mjs +31 -4
- package/lib/doctor.mjs +28 -2
- package/lib/folders.mjs +2 -1
- package/lib/gather.mjs +4 -3
- package/lib/keep-hook.mjs +23 -4
- package/lib/person-folders.mjs +2 -1
- package/lib/presets.mjs +2 -1
- package/lib/prompt-hook.mjs +115 -4
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/tdx-attestation.mjs +28 -8
- package/package.json +1 -1
package/lib/day-writer.mjs
CHANGED
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
//
|
|
18
18
|
// It refuses Write, Edit, MultiEdit and NotebookEdit of a file directly in the daily folder, and a
|
|
19
19
|
// shell command that names such a file next to a write-shaped operator (>, >>, tee, sed -i, cp, mv,
|
|
20
|
-
// rm and the rest)
|
|
20
|
+
// rm and the rest), after expanding the variables the command itself sets, $HOME and ~ (HQ-2817).
|
|
21
|
+
// Like the read guard, it stops a cooperating agent on the ordinary paths; a
|
|
21
22
|
// command built to hide its target gets past it. Anything it cannot parse is allowed.
|
|
22
23
|
//
|
|
23
24
|
// EVDA_DAY_WRITER_FALSIFY=allow-all every decision is allow, so the tests can be watched failing
|
|
24
25
|
|
|
25
26
|
import { realpathSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
26
28
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
27
29
|
import { openEvda } from "./manifest.mjs";
|
|
28
30
|
import { dayRole, findRecord } from "./day-file.mjs";
|
|
@@ -35,9 +37,34 @@ export const DAY_WRITER_ENV = "EVDA_DAY_WRITER";
|
|
|
35
37
|
const WRITERS = /((^|[\s;&|(`])(tee|cp|mv|rm|truncate|touch|install|ln|dd|ed|ex)(\s|$)|\b(sed|perl|ruby)\s+(-[A-Za-z]*\s+)*-[A-Za-z]*i)/;
|
|
36
38
|
const REDIRECT = />>?\|?\s*("[^"]*"|'[^']*'|[^\s;&|<>()`]+)/g;
|
|
37
39
|
|
|
40
|
+
// HQ-2817: a command that names the day file through a variable it set itself got past the guard
|
|
41
|
+
// (2026-10-22: `V="<vault>"; ... cat >> "$V/06_Metadata/Daily Journal/<date>.md"`). This expands,
|
|
42
|
+
// in order, every `NAME=value` the command assigns (bare, `export`, quoted or not) and then every
|
|
43
|
+
// `$NAME` / `${NAME}` it names, plus `$HOME` and a leading `~`. A variable the command neither sets
|
|
44
|
+
// nor HOME supplies is left as written. It is not a shell: a value built by `$(...)` stays unexpanded.
|
|
45
|
+
const ASSIGN = /(^|[\s;&|(`]|\bexport\s+)([A-Za-z_][A-Za-z0-9_]*)=("([^"]*)"|'([^']*)'|[^\s;&|<>()`]*)/g;
|
|
46
|
+
export function expandShellVars(command, { env = process.env } = {}) {
|
|
47
|
+
const vars = { HOME: env.HOME || homedir() };
|
|
48
|
+
const sub = (s) => s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, a, b) => (vars[a ?? b] ?? m));
|
|
49
|
+
const tilde = (s) => s.replace(/(^|[\s;&|(`=>"'])~(?=\/|[\s"';&|)]|$)/g, (m, lead) => `${lead}${vars.HOME}`);
|
|
50
|
+
let out = "";
|
|
51
|
+
let last = 0;
|
|
52
|
+
for (const m of String(command).matchAll(ASSIGN)) {
|
|
53
|
+
out += sub(tilde(String(command).slice(last, m.index)));
|
|
54
|
+
const raw = m[4] ?? m[5] ?? m[3];
|
|
55
|
+
const next = String(command)[m.index + m[0].length] ?? "";
|
|
56
|
+
// A value the shell computes (`$(...)`, backticks) is not known here: the name stays unexpanded.
|
|
57
|
+
if (/[$`]/.test(raw.replace(/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/g, "")) || next === "(" || next === "`") delete vars[m[2]];
|
|
58
|
+
else vars[m[2]] = m[5] !== undefined ? raw : sub(tilde(raw));
|
|
59
|
+
out += m[0].slice(0, m[0].length - m[3].length) + (m[5] !== undefined ? m[3] : sub(tilde(m[3])));
|
|
60
|
+
last = m.index + m[0].length;
|
|
61
|
+
}
|
|
62
|
+
return out + sub(tilde(String(command).slice(last)));
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
// The paths a shell command may write: every path when a writing command runs, else redirect targets.
|
|
39
|
-
export function shellWriteTargets(command) {
|
|
40
|
-
const c =
|
|
66
|
+
export function shellWriteTargets(command, { env = process.env } = {}) {
|
|
67
|
+
const c = expandShellVars(command, { env });
|
|
41
68
|
if (WRITERS.test(c)) return shellPaths(c);
|
|
42
69
|
return [...c.matchAll(REDIRECT)].map((m) => m[1].replace(/^["']|["']$/g, "")).filter((w) => w && !w.startsWith("&"));
|
|
43
70
|
}
|
|
@@ -89,7 +116,7 @@ export function decide(input, { env = process.env } = {}) {
|
|
|
89
116
|
let paths = [];
|
|
90
117
|
if (FILE_TOOLS.has(tool)) paths = [args.file_path ?? args.notebook_path].filter((p) => typeof p === "string" && p);
|
|
91
118
|
else if (tool === "Bash") {
|
|
92
|
-
paths = shellWriteTargets(args.command ?? "");
|
|
119
|
+
paths = shellWriteTargets(args.command ?? "", { env });
|
|
93
120
|
} else return { decision: "allow" };
|
|
94
121
|
const hit = paths.map(at).find(isDayFile);
|
|
95
122
|
if (!hit) return { decision: "allow" };
|
package/lib/doctor.mjs
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
// the same records print "FAIL mounts 1 file(s) under the mount root mounts that no held circle
|
|
47
47
|
// covers: mounts/ava.atlas/stray.md" and exit 1.
|
|
48
48
|
|
|
49
|
+
import { checkoutFreshness } from "./checkout-freshness.mjs";
|
|
49
50
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
50
51
|
import { join, relative, resolve, sep } from "node:path";
|
|
51
52
|
import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
|
|
@@ -77,6 +78,7 @@ import { correctionsTarget } from "./corrections.mjs";
|
|
|
77
78
|
import { EVERY_CHAT_KEY } from "./every-chat.mjs";
|
|
78
79
|
import { listSkills, SKILLS_REL } from "./skills.mjs";
|
|
79
80
|
import { guardStatus } from "./connect.mjs";
|
|
81
|
+
import { readmeCitations, missingHeadings, README_REL } from "./readme.mjs";
|
|
80
82
|
|
|
81
83
|
// The dated sections of a person-profile that no observation covers (HQ-1397). An observation
|
|
82
84
|
// covers a section when its text is the heading's exactly, or else when it carries the heading's
|
|
@@ -99,7 +101,10 @@ export function uncoveredSections(headings, observations) {
|
|
|
99
101
|
// A suffix after the date is allowed (`2026-01-19 - The Scale Question.md`); no date is not.
|
|
100
102
|
const DAILY_NAME = /^\d{4}-\d{2}-\d{2}(?:[ .-]|$)/;
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
// `checkout` (HQ-2736): the directory this doctor's code runs from. When given, the doctor says,
|
|
105
|
+
// just before its verdict, whether that checkout is behind origin/main or not on main (see
|
|
106
|
+
// lib/checkout-freshness.mjs). Only the CLI passes it; programmatic callers judge records, not tools.
|
|
107
|
+
export function doctor(dir, { out = console.log, via = "argument", depth = 0, checkout = null } = {}) {
|
|
103
108
|
const failures = [];
|
|
104
109
|
const warnings = [];
|
|
105
110
|
const checked = [];
|
|
@@ -254,6 +259,20 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
254
259
|
if (!imports) warn("CLAUDE.md", `does not import ${rel}. Claude Code loads CLAUDE.md and not ${rel}, so a Claude Code session here would start with no operating layer.`);
|
|
255
260
|
}
|
|
256
261
|
ok(1, "operating layer", rel);
|
|
262
|
+
|
|
263
|
+
// 5a. README.md sections the operating layer cites (HQ-2812). AGENTS.md gives the training-off
|
|
264
|
+
// steps, each AI company's terms and the local-model walk-through only from README.md, never from
|
|
265
|
+
// memory, so a cited section that is not there is the privacy step pointing at nothing: a failure.
|
|
266
|
+
const cites = readmeCitations(agents);
|
|
267
|
+
if (!cites.length) skip("README.md sections", `${rel} cites no section of README.md`);
|
|
268
|
+
else {
|
|
269
|
+
const readmeAbs = join(dir, README_REL);
|
|
270
|
+
const readmeText = existsSync(readmeAbs) ? readFileSync(readmeAbs, "utf8") : null;
|
|
271
|
+
const missing = missingHeadings(readmeText, cites);
|
|
272
|
+
if (readmeText === null) fail(README_REL, `is missing, and ${rel} sends the agent there for ${cites.map((c) => `"${c}"`).join(", ")}, including the steps for turning training off. \`evda upgrade\` writes it.`);
|
|
273
|
+
else if (missing.length) fail(README_REL, `has no ${missing.map((c) => `"${c}"`).join(", ")} section, and ${rel} sends the agent there${missing.includes("Turning training off") ? " for the steps that turn training off before anything personal is written" : ""}. \`evda upgrade\` adds EVÐA's block with ${missing.length === 1 ? "it" : "them"} after your text, which it does not change.`);
|
|
274
|
+
ok(cites.length, "README.md sections", `cited by ${rel}, ${cites.length - (readmeText === null ? cites.length : missing.length)} present`);
|
|
275
|
+
}
|
|
257
276
|
}
|
|
258
277
|
|
|
259
278
|
// 5b. the keep-record Stop hook (HQ-1376): whether a Claude Code session opened here is asked to
|
|
@@ -295,7 +314,11 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
295
314
|
else if (prompt.registered && !prompt.current) warn(PROMPT_HOOK_REL, "or a file copied beside it differs from what this tooling ships. `evda hook install` updates them.");
|
|
296
315
|
else if (prompt.registered && !everything) warn(SETTINGS_REL, `registers the prompt hook, but evda.json does not say "filing": "everything", so it files nothing.`);
|
|
297
316
|
else if (prompt.registered) ok(1, "prompt hook", `${SETTINGS_REL} runs ${PROMPT_HOOK_REL}, current: each message the owner sends is filed verbatim`);
|
|
298
|
-
|
|
317
|
+
// HQ-2817: "everything" with no prompt hook is a promise nothing keeps in Claude Code (his own vault
|
|
318
|
+
// filed nothing across four messages on 2026-10-22), so it warns. The keep-record Stop hook stays a
|
|
319
|
+
// skip: it asks once before a quiet stop, and evda.json promises nothing that rests on it alone.
|
|
320
|
+
else if (everything) warn("evda.json", `says "filing": "everything", and no prompt hook is installed, so a Claude Code session here files the owner's messages only if the model remembers to. \`evda hook install\` adds it to ${SETTINGS_REL}; on a machine that does not write the day it sends each message to the writer (EVDA_DAY_WRITER, EVDA_DAY_WRITER_SEND).`);
|
|
321
|
+
else skip("prompt hook", `installed only for an owner whose evda.json says "filing": "everything".`);
|
|
299
322
|
|
|
300
323
|
// 5c. the read guard (HQ-1374), the other hook create-evda installs for a Claude owner (HQ-1377):
|
|
301
324
|
// a PreToolUse hook in the record's own settings that stops a Claude Code session reading a noted
|
|
@@ -799,6 +822,9 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
799
822
|
return finish(0);
|
|
800
823
|
|
|
801
824
|
function finish(refusedExit) {
|
|
825
|
+
// The tool's own freshness, not the record's: printed, never counted as a failure or warning,
|
|
826
|
+
// so it cannot change the exit, which stays the record's verdict.
|
|
827
|
+
if (checkout && depth === 0) for (const line of checkoutFreshness(checkout).lines) out(` CHECKOUT ${line}`);
|
|
802
828
|
if (refusedExit === 2) {
|
|
803
829
|
out(`REFUSED nothing was written`);
|
|
804
830
|
return { failures, warnings, checked, exit: 2 };
|
package/lib/folders.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { join, relative, resolve, sep } from "node:path";
|
|
|
25
25
|
import { resolveAll } from "./roles.mjs";
|
|
26
26
|
import { SCHEMES } from "./references.mjs";
|
|
27
27
|
import { EvdaError } from "./errors.mjs";
|
|
28
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
28
29
|
|
|
29
30
|
export const FOLDER_MANIFEST = "folder.evda.json";
|
|
30
31
|
export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
|
|
@@ -138,7 +139,7 @@ export function folderManifests(evda) {
|
|
|
138
139
|
}
|
|
139
140
|
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
140
141
|
for (const e of entries) {
|
|
141
|
-
if (e.name.startsWith(".") || e.name
|
|
142
|
+
if (e.name.startsWith(".") || outsideRecord(e.name)) continue;
|
|
142
143
|
if (e.isDirectory()) walk(join(dir, e.name));
|
|
143
144
|
}
|
|
144
145
|
};
|
package/lib/gather.mjs
CHANGED
|
@@ -66,6 +66,7 @@ import { EvdaError } from "./errors.mjs";
|
|
|
66
66
|
import { noteShape, htmlNoteToMarkdown } from "./html-notes.mjs";
|
|
67
67
|
import { pairSidecars } from "./takeout.mjs";
|
|
68
68
|
import { frontmatterOf, heldBy, withoutSealed, carries, NOTED } from "./circles.mjs";
|
|
69
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
69
70
|
|
|
70
71
|
export const GATHER_VERSION = 1;
|
|
71
72
|
// The person-layer kinds an item is typed with, and the folder type each lives under.
|
|
@@ -219,7 +220,7 @@ const slugId = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-
|
|
|
219
220
|
// added to its frontmatter, a finished status) no longer hashes like its source; this is how a
|
|
220
221
|
// second gather still knows it.
|
|
221
222
|
export const GATHERED = ".evda/gathered.jsonl";
|
|
222
|
-
const WALK_SKIP = new Set([".git", "
|
|
223
|
+
const WALK_SKIP = new Set([".git", ".evda", ".obsidian", ".trash", ".DS_Store"]); // plus outsideRecord
|
|
223
224
|
|
|
224
225
|
// Every file in the record by the hash of its bytes, and the source hashes it has gathered.
|
|
225
226
|
// Returns { byHash: Map<sha256, rel>, gathered: Map<sha256, rel> }.
|
|
@@ -229,7 +230,7 @@ export function presentIndex(dir) {
|
|
|
229
230
|
if (falsified("reapply-resorts")) return { byHash, gathered };
|
|
230
231
|
const go = (d) => {
|
|
231
232
|
for (const name of readdirSync(d).sort()) {
|
|
232
|
-
if (WALK_SKIP.has(name)) continue;
|
|
233
|
+
if (WALK_SKIP.has(name) || outsideRecord(name)) continue;
|
|
233
234
|
const abs = join(d, name);
|
|
234
235
|
const st = lstatSync(abs);
|
|
235
236
|
if (st.isDirectory()) go(abs);
|
|
@@ -277,7 +278,7 @@ function walk(root) {
|
|
|
277
278
|
const abs = join(dir, name);
|
|
278
279
|
const rel = posix(relative(root, abs));
|
|
279
280
|
if (APP_DIRS[name]) apps.add(APP_DIRS[name]);
|
|
280
|
-
if (SKIP_NAMES.has(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
|
|
281
|
+
if (SKIP_NAMES.has(name) || outsideRecord(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
|
|
281
282
|
const s = lstatSync(abs);
|
|
282
283
|
if (s.isSymbolicLink()) { skipped.push({ rel, why: "a symlink; not followed" }); continue; }
|
|
283
284
|
const junk = s.isDirectory() || s.isFile() ? junkWhy(name, s.size, s.isDirectory()) : null;
|
package/lib/keep-hook.mjs
CHANGED
|
@@ -127,7 +127,9 @@ export function classify(e, facts) {
|
|
|
127
127
|
|
|
128
128
|
// The decision, pure. `lastBlock` is the event index this hook last blocked at (0 for never).
|
|
129
129
|
// `fileMtime` is the day file's modification time in ms, or null when it does not exist.
|
|
130
|
-
|
|
130
|
+
// `nonWriter` (HQ-2817) is the EVDA_DAY_WRITER value on a machine that does not write the day: the
|
|
131
|
+
// block then says evda_write, because a direct write here is the iCloud race and the guard refuses it.
|
|
132
|
+
export function decide(evs, facts, { lastBlock = 0, fileMtime = null, nonWriter = "" } = {}) {
|
|
131
133
|
let lastWrite = 0;
|
|
132
134
|
evs.forEach((e, i) => { if (classify(e, facts) === "write") lastWrite = i + 1; });
|
|
133
135
|
const start = Math.max(lastWrite, lastBlock);
|
|
@@ -145,7 +147,9 @@ export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
|
|
|
145
147
|
const why = reads && words >= OWNER_WORDS ? "read the record and the owner talked" : reads ? "read the record" : "the owner talked";
|
|
146
148
|
const reason = [
|
|
147
149
|
`evda: this conversation ${why}, and nothing from it is in today's record (${tools} tool call(s), ${writes} write(s), none to ${facts.fileRel}).`,
|
|
148
|
-
|
|
150
|
+
nonWriter
|
|
151
|
+
? `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, through evda_write. This machine does not write ${facts.fileRel} (EVDA_DAY_WRITER names the one that does, ${nonWriter}), so never write that file directly here.`
|
|
152
|
+
: `Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, appended to ${facts.fileRel} under a "## HH:MM" heading or through evda_write.`,
|
|
149
153
|
"Then tell them the path in the past tense. If a person, project or place with an entry came up, add a dated section to that entry too (never to a noted one); one with no entry, offer to start it and wait for their yes.",
|
|
150
154
|
"If nothing they said is worth keeping, stop. This asks once per stretch.",
|
|
151
155
|
].join(" ");
|
|
@@ -189,6 +193,16 @@ function stateFile(sid) {
|
|
|
189
193
|
return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
190
194
|
}
|
|
191
195
|
|
|
196
|
+
// When the prompt hook (lib/prompt-hook.mjs) last filed or queued a message of this session, in ms,
|
|
197
|
+
// or null. Its state file is the one place both hooks can see without the record changing.
|
|
198
|
+
function promptFiledAt(sid) {
|
|
199
|
+
const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
|
|
200
|
+
try {
|
|
201
|
+
const t = Date.parse(JSON.parse(readFileSync(join(dir, `${String(sid ?? "unknown").replace(/[^A-Za-z0-9_-]/g, "_")}.json`), "utf8")).filedAt);
|
|
202
|
+
return Number.isFinite(t) ? t : null;
|
|
203
|
+
} catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
192
206
|
// One Stop event in, the hook's stdout out ("" for let it stop). Never throws.
|
|
193
207
|
export function run(payload, { now = new Date() } = {}) {
|
|
194
208
|
try {
|
|
@@ -207,8 +221,13 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
207
221
|
try { state = JSON.parse(readFileSync(sf, "utf8")) ?? {}; } catch { /* first stop of the session */ }
|
|
208
222
|
let lastBlock = Number(state.at) || 0;
|
|
209
223
|
if (lastBlock > evs.length) { lastBlock = 0; state = {}; } // a different transcript under the same id
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
let fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
|
|
225
|
+
// HQ-2817: the prompt hook's last hand-off counts as a write to the day. On a machine that does
|
|
226
|
+
// not write the day the file here does not change when a message is filed through the writer.
|
|
227
|
+
const filedAt = promptFiledAt(payload.session_id);
|
|
228
|
+
if (filedAt !== null && (fileMtime === null || filedAt > fileMtime)) fileMtime = filedAt;
|
|
229
|
+
const nonWriter = (process.env.EVDA_DAY_WRITER ?? "").trim();
|
|
230
|
+
const d = decide(evs, facts, { lastBlock, fileMtime, nonWriter });
|
|
212
231
|
const mtimeOf = (rel) => { try { return statSync(join(root, rel)).mtimeMs; } catch { return null; } };
|
|
213
232
|
const e = d.block ? null : decideEntries(evs, facts, { asked: Boolean(state.entries), mtimeOf });
|
|
214
233
|
if (!d.block && !e.block) return "";
|
package/lib/person-folders.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
22
23
|
|
|
23
24
|
export const PROCESSING_DIR = "Processing";
|
|
24
25
|
export const README_NAME = "README.md";
|
|
@@ -64,7 +65,7 @@ export function personFolderProblems(dirs, { skip = new Set() } = {}) {
|
|
|
64
65
|
let names;
|
|
65
66
|
try { names = readdirSync(d).sort(); } catch { continue; }
|
|
66
67
|
for (const name of names) {
|
|
67
|
-
if (name.startsWith(".") || name
|
|
68
|
+
if (name.startsWith(".") || outsideRecord(name)) continue;
|
|
68
69
|
const p = join(d, name);
|
|
69
70
|
let st;
|
|
70
71
|
try { st = statSync(p); } catch { continue; }
|
package/lib/presets.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { ROUTINE_KEYS } from "./routines.mjs";
|
|
|
44
44
|
import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
|
|
45
45
|
import { EvdaError } from "./errors.mjs";
|
|
46
46
|
import { PERSON_FOLDER_README, PROCESSING_DIR, README_NAME } from "./person-folders.mjs";
|
|
47
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
47
48
|
|
|
48
49
|
export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
|
|
49
50
|
export const PRESET_FILE = "preset.json";
|
|
@@ -66,7 +67,7 @@ function pathProblem(p) {
|
|
|
66
67
|
if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
|
|
67
68
|
for (const seg of p.split("/")) {
|
|
68
69
|
if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
|
|
69
|
-
if (seg.startsWith(".") || seg
|
|
70
|
+
if (seg.startsWith(".") || outsideRecord(seg)) return `has the segment "${seg}", which doctor does not read`;
|
|
70
71
|
}
|
|
71
72
|
return null;
|
|
72
73
|
}
|
package/lib/prompt-hook.mjs
CHANGED
|
@@ -23,10 +23,30 @@
|
|
|
23
23
|
//
|
|
24
24
|
// EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
|
|
25
25
|
// that shows the hook reads the owner's answer.
|
|
26
|
+
//
|
|
27
|
+
// ON A MACHINE THAT DOES NOT WRITE THE DAY (HQ-2817). EVDA_DAY_WRITER set means another machine
|
|
28
|
+
// writes the daily file, and a local append is the iCloud race HQ-2165 exists to stop (lib/
|
|
29
|
+
// day-writer.mjs). There the hook never touches the day file. It hands the words to the command in
|
|
30
|
+
// EVDA_DAY_WRITER_SEND, which receives evda_write's arguments as JSON on stdin and exits 0 once the
|
|
31
|
+
// writer filed them (Mike's: ssh to Hestia and its evda-tool.mjs evda_write). Each message is first
|
|
32
|
+
// saved as one JSON file in this machine's own outbox, outside the record and outside any synced
|
|
33
|
+
// folder (EVDA_PROMPT_OUTBOX, default ~/.evda/outbox), then the outbox is sent oldest first and a
|
|
34
|
+
// file is deleted only after its send exits 0. So a message said while the writer is unreachable
|
|
35
|
+
// waits there and goes on the next message that reaches it, filed once:
|
|
36
|
+
// - one drainer: only this machine's hook reads its outbox, so no other machine can file it too;
|
|
37
|
+
// - one sender per file: a send first renames the file to `.sending`, an atomic claim, so two
|
|
38
|
+
// sessions submitting at once never send the same file; a claim older than SENDING_STALE_MS
|
|
39
|
+
// (a hook that was killed mid-send) is put back for the next run.
|
|
40
|
+
// The whole send is bounded by EVDA_DAY_WRITER_SEND_MS (default 6000) because Claude Code holds the
|
|
41
|
+
// owner's message until this hook returns. One case can still file twice: a send that reached the
|
|
42
|
+
// writer and timed out before it answered. A connect timeout on the sender's side keeps that rare.
|
|
43
|
+
// With EVDA_DAY_WRITER set and no EVDA_DAY_WRITER_SEND, nothing on this machine can reach the
|
|
44
|
+
// writer, so nothing is queued and the hook tells the model to file the message with evda_write.
|
|
26
45
|
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
47
|
import { dirname, join, relative } from "node:path";
|
|
29
|
-
import { tmpdir } from "node:os";
|
|
48
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
49
|
+
import { spawnSync } from "node:child_process";
|
|
30
50
|
import { fileURLToPath } from "node:url";
|
|
31
51
|
import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
|
|
32
52
|
import { secretShape } from "./secret-shape.mjs";
|
|
@@ -35,14 +55,100 @@ export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
|
|
|
35
55
|
|
|
36
56
|
const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
37
57
|
|
|
58
|
+
export const DAY_WRITER_SEND_ENV = "EVDA_DAY_WRITER_SEND";
|
|
59
|
+
export const PROMPT_OUTBOX_ENV = "EVDA_PROMPT_OUTBOX";
|
|
60
|
+
export const SEND_BUDGET_MS = 6000;
|
|
61
|
+
export const SENDING_STALE_MS = 2 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
export function promptOutboxDir(env = process.env) {
|
|
64
|
+
return env[PROMPT_OUTBOX_ENV] || join(homedir(), ".evda", "outbox");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
68
|
+
|
|
69
|
+
// Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
|
|
70
|
+
export function queuePrompt(root, { words, context, now = new Date(), env = process.env }) {
|
|
71
|
+
const dir = promptOutboxDir(env);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
const at = now.toISOString();
|
|
74
|
+
const stamp = at.replace(/[-:.]/g, "");
|
|
75
|
+
let file = join(dir, `${stamp}-${process.pid}.json`);
|
|
76
|
+
for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
|
|
77
|
+
writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
|
|
78
|
+
return file;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Send this record's queued messages to the writer, oldest first, within `budgetMs`. Returns
|
|
82
|
+
// { sent: [file...], left, error }. Stops at the first failure, so the order of the day is kept.
|
|
83
|
+
// A claim's age is measured against the file system's own clock (the mtime of a file touched now),
|
|
84
|
+
// never Date.now(): a claim's mtime is stamped by the file system, and the two clocks can disagree.
|
|
85
|
+
export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(env.EVDA_DAY_WRITER_SEND_MS) || SEND_BUDGET_MS, nowMs = null } = {}) {
|
|
86
|
+
const cmd = (env[DAY_WRITER_SEND_ENV] ?? "").trim();
|
|
87
|
+
const dir = promptOutboxDir(env);
|
|
88
|
+
const out = { sent: [], left: 0, error: null };
|
|
89
|
+
if (!cmd || !existsSync(dir)) return out;
|
|
90
|
+
if (nowMs === null) { const probe = join(dir, ".now"); writeFileSync(probe, ""); nowMs = statSync(probe).mtimeMs; }
|
|
91
|
+
const rec = realOr(root);
|
|
92
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".json.sending"))) {
|
|
93
|
+
const abs = join(dir, f);
|
|
94
|
+
try { if (nowMs - statSync(abs).mtimeMs > SENDING_STALE_MS) renameSync(abs, abs.replace(/\.sending$/, "")); } catch { /* another run took it back */ }
|
|
95
|
+
}
|
|
96
|
+
const mine = readdirSync(dir).filter((n) => n.endsWith(".json")).sort().filter((n) => {
|
|
97
|
+
try { return JSON.parse(readFileSync(join(dir, n), "utf8")).record === rec; } catch { return false; }
|
|
98
|
+
});
|
|
99
|
+
const deadline = Date.now() + budgetMs;
|
|
100
|
+
for (let i = 0; i < mine.length; i++) {
|
|
101
|
+
const abs = join(dir, mine[i]);
|
|
102
|
+
const claim = `${abs}.sending`;
|
|
103
|
+
try { renameSync(abs, claim); } catch { continue; } // another session claimed it
|
|
104
|
+
let entry;
|
|
105
|
+
try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
|
|
106
|
+
const left = deadline - Date.now();
|
|
107
|
+
const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}) };
|
|
108
|
+
const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
|
|
109
|
+
if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
|
|
110
|
+
try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
|
|
111
|
+
out.error = r.error ? (r.error.code === "ETIMEDOUT" ? `no answer in ${budgetMs}ms` : r.error.code ?? r.error.message) : `exited ${r.status ?? r.signal}: ${String(r.stderr ?? "").trim().split("\n").pop() ?? ""}`.trim();
|
|
112
|
+
out.left = mine.length - i;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
119
|
+
|
|
120
|
+
// The non-writer path: queue, send, and say what happened. Never touches the day file.
|
|
121
|
+
function viaWriter(root, prompt, now, env) {
|
|
122
|
+
const writer = env.EVDA_DAY_WRITER.trim();
|
|
123
|
+
if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
|
|
124
|
+
return `evda: this machine does not write the daily file (EVDA_DAY_WRITER is ${writer}) and has no ${DAY_WRITER_SEND_ENV} to reach it, so the owner's message above is NOT filed yet. File it verbatim with evda_write this turn.`;
|
|
125
|
+
}
|
|
126
|
+
const file = queuePrompt(root, { words: prompt, context: `Claude Code on ${hostname().split(".")[0]}, said ${timeOf(now)}`, now, env });
|
|
127
|
+
const r = drainPromptOutbox(root, { env });
|
|
128
|
+
if (r.sent.includes(file)) {
|
|
129
|
+
const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
|
|
130
|
+
return `evda: the owner's message above is already filed verbatim through the day writer (${writer})${more}. Do not file it again with evda_write.`;
|
|
131
|
+
}
|
|
132
|
+
return `evda: the day writer (${writer}) could not be reached (${r.error ?? "not sent"}), so the owner's message above is queued in ${dirname(file)} on this machine and goes to the writer with their next message. Do not file it again with evda_write; if you tell them, say it is queued, not filed.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
38
135
|
function stateFile(sid) {
|
|
39
136
|
const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
|
|
40
137
|
mkdirSync(dir, { recursive: true });
|
|
41
138
|
return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
42
139
|
}
|
|
43
140
|
|
|
141
|
+
// The keep hook reads this (HQ-2817): on a machine that does not write the day, the day file does
|
|
142
|
+
// not change here when a message is filed, so the time it was handed over is the evidence.
|
|
143
|
+
function markFiled(sid, now) {
|
|
144
|
+
const sf = stateFile(sid ?? "unknown");
|
|
145
|
+
let session = {};
|
|
146
|
+
try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* none yet */ }
|
|
147
|
+
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
44
150
|
// One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
|
|
45
|
-
export function run(payload, { now = new Date() } = {}) {
|
|
151
|
+
export function run(payload, { now = new Date(), env = process.env } = {}) {
|
|
46
152
|
try {
|
|
47
153
|
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
48
154
|
if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
|
|
@@ -55,6 +161,11 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
55
161
|
if (!role) return "";
|
|
56
162
|
const shape = prompt.split("\n").map(secretShape).find(Boolean);
|
|
57
163
|
if (shape) return `evda: this message was not filed, because it holds ${shape}. Keep that in a password manager; file the rest of what they said with evda_write if it is worth keeping.`;
|
|
164
|
+
if ((env.EVDA_DAY_WRITER ?? "").trim()) {
|
|
165
|
+
const said = viaWriter(root, prompt, now, env);
|
|
166
|
+
if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
|
|
167
|
+
return said;
|
|
168
|
+
}
|
|
58
169
|
const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
|
|
59
170
|
mkdirSync(dayAbs, { recursive: true });
|
|
60
171
|
const abs = join(dayAbs, `${localDate(now)}.md`);
|
|
@@ -62,7 +173,7 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
62
173
|
let session = {};
|
|
63
174
|
try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
|
|
64
175
|
appendToDay(abs, { now, words: prompt, session });
|
|
65
|
-
writeFileSync(sf, JSON.stringify(session) + "\n");
|
|
176
|
+
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
66
177
|
const rel = relative(root, abs).split("\\").join("/");
|
|
67
178
|
return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
|
|
68
179
|
} catch {
|
package/lib/readme.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// README.md, the owner-facing half of the operating layer (HQ-2812).
|
|
2
|
+
//
|
|
3
|
+
// AGENTS.md sends the agent to sections of README.md for the facts it must never give from memory:
|
|
4
|
+
// the steps for turning training off, what each AI company says, and running a model locally. A
|
|
5
|
+
// record create-evda makes has them, because it writes README.md from templates/README.md. A record
|
|
6
|
+
// `evda adopt` makes keeps the owner's own README.md, which has none of them, so the privacy step
|
|
7
|
+
// pointed at nothing. This module renders the README once, for both, and keeps those sections in an
|
|
8
|
+
// adopted README as one marked block the owner's text never shares:
|
|
9
|
+
//
|
|
10
|
+
// <the owner's README, byte for byte>
|
|
11
|
+
// <!-- evda:readme -->
|
|
12
|
+
// _lead line_
|
|
13
|
+
// ## Turning training off ... ## Running a model on your own computer ...
|
|
14
|
+
// <!-- /evda:readme -->
|
|
15
|
+
//
|
|
16
|
+
// `ensureReadme` writes the file when it is absent, appends the block when a cited section is
|
|
17
|
+
// missing, and refreshes the block's content when it is there (`evda upgrade`). A README that already
|
|
18
|
+
// holds every cited heading outside a block (create-evda's own) is left alone.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { trainingSection, termsSection, INTRO } from "./setup.mjs";
|
|
24
|
+
|
|
25
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
export const README_REL = "README.md";
|
|
27
|
+
export const README_OPEN = "<!-- evda:readme -->";
|
|
28
|
+
export const README_CLOSE = "<!-- /evda:readme -->";
|
|
29
|
+
export const README_LEAD = "_EVÐA wrote this block and `evda upgrade` keeps it current. Everything above it is yours, and EVÐA never changes it._";
|
|
30
|
+
// The first and the last heading of templates/README.md the block carries, in that file's order.
|
|
31
|
+
const FIRST = "## Turning training off";
|
|
32
|
+
const AFTER = "## Getting started";
|
|
33
|
+
|
|
34
|
+
// The local-model doc as a README section: its title dropped and its headings one level down.
|
|
35
|
+
export function localModelSection() {
|
|
36
|
+
const text = readFileSync(join(PKG, "docs", "local-model.md"), "utf8");
|
|
37
|
+
return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The README create-evda writes into a new record.
|
|
41
|
+
export function renderReadme() {
|
|
42
|
+
let text = readFileSync(join(PKG, "templates", "README.md"), "utf8");
|
|
43
|
+
const vars = { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() };
|
|
44
|
+
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The sections AGENTS.md cites, cut from the rendered README rather than written a second time.
|
|
49
|
+
export function readmeSections() {
|
|
50
|
+
const text = renderReadme();
|
|
51
|
+
const from = text.indexOf(FIRST);
|
|
52
|
+
const to = text.indexOf(AFTER);
|
|
53
|
+
if (from === -1 || to === -1 || to < from) throw new Error(`templates/README.md no longer has "${FIRST}" before "${AFTER}", so the adopted README block cannot be cut from it.`);
|
|
54
|
+
return text.slice(from, to).trimEnd();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readmeBlock() {
|
|
58
|
+
return `${README_OPEN}\n${README_LEAD}\n\n${readmeSections()}\n${README_CLOSE}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every README.md section an operating layer sends the agent to: a quoted heading followed by
|
|
62
|
+
// `section of` or `in`, then `README.md`, line breaks inside the quote allowed.
|
|
63
|
+
export function readmeCitations(agentsText) {
|
|
64
|
+
const flat = String(agentsText).replace(/\s+/g, " ");
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const m of flat.matchAll(/"([^"]{2,120})"(?: section)? (?:of|in) `?README\.md`?/g)) if (!out.includes(m[1])) out.push(m[1]);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
71
|
+
export const hasHeading = (text, heading) => new RegExp(`^#{1,6}[ \\t]+${escape(heading)}[ \\t]*#*[ \\t]*$`, "m").test(text);
|
|
72
|
+
|
|
73
|
+
// The cited headings README text lacks.
|
|
74
|
+
export function missingHeadings(readmeText, citations) {
|
|
75
|
+
return citations.filter((h) => !hasHeading(readmeText ?? "", h));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The block's place in README text: { start, end } around the markers (end past the closing line
|
|
79
|
+
// break), or null when there is none. Two blocks, or an open with no close, is a problem, not a block.
|
|
80
|
+
export function findBlock(text) {
|
|
81
|
+
const open = text.indexOf(README_OPEN);
|
|
82
|
+
const close = text.indexOf(README_CLOSE);
|
|
83
|
+
if (open === -1 && close === -1) return null;
|
|
84
|
+
if (open === -1 || close === -1 || close < open || text.indexOf(README_OPEN, open + 1) !== -1 || text.indexOf(README_CLOSE, close + 1) !== -1) {
|
|
85
|
+
throw new Error(`${README_REL} has a broken ${README_OPEN} block (one marker without the other, or two of them). Fix the markers by hand; nothing was changed.`);
|
|
86
|
+
}
|
|
87
|
+
let end = close + README_CLOSE.length;
|
|
88
|
+
if (text[end] === "\n") end += 1;
|
|
89
|
+
return { start: open, end };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What the README should become. Returns { action, text, added }:
|
|
93
|
+
// "written" absent: the whole README create-evda writes
|
|
94
|
+
// "appended" a cited section was missing and there was no block: the block is added at the end
|
|
95
|
+
// "refreshed" the block was there with other content: its content is replaced
|
|
96
|
+
// "current" the block is there and matches
|
|
97
|
+
// "present" no block, and every cited heading is already in the owner's text
|
|
98
|
+
// `added` is exactly what "appended" put after the owner's bytes, so a revert can take it back out.
|
|
99
|
+
export function planReadme(text, citations) {
|
|
100
|
+
if (text === null) return { action: "written", text: renderReadme(), added: null };
|
|
101
|
+
const block = readmeBlock();
|
|
102
|
+
const at = findBlock(text);
|
|
103
|
+
if (at) {
|
|
104
|
+
const next = text.slice(0, at.start) + block + text.slice(at.end);
|
|
105
|
+
return next === text ? { action: "current", text, added: null } : { action: "refreshed", text: next, added: null };
|
|
106
|
+
}
|
|
107
|
+
if (!missingHeadings(text, citations).length) return { action: "present", text, added: null };
|
|
108
|
+
const added = `${text === "" || text.endsWith("\n") ? "" : "\n"}\n${block}`;
|
|
109
|
+
return { action: "appended", text: text + added, added };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply planReadme to <dir>/README.md. `citations` defaults to the record's AGENTS.md, or the shipped
|
|
113
|
+
// template's when there is none.
|
|
114
|
+
export function ensureReadme(dir, { citations = null, dry = false, agents = null } = {}) {
|
|
115
|
+
const abs = join(dir, README_REL);
|
|
116
|
+
const cites = citations ?? readmeCitations(agents ?? readFileSync(join(PKG, "templates", "AGENTS.md"), "utf8"));
|
|
117
|
+
const before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
118
|
+
const plan = planReadme(before, cites);
|
|
119
|
+
if (!dry && plan.text !== before) writeFileSync(abs, plan.text);
|
|
120
|
+
return { ...plan, abs, rel: README_REL, citations: cites };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// README text with the block taken back out: exactly what was there before an "appended", when the
|
|
124
|
+
// owner did not edit around the block. Null when there is no block to take.
|
|
125
|
+
export function withoutBlock(text, added) {
|
|
126
|
+
if (added && text.endsWith(added)) return text.slice(0, -added.length);
|
|
127
|
+
let at;
|
|
128
|
+
try { at = findBlock(text); } catch { return null; }
|
|
129
|
+
if (!at) return null;
|
|
130
|
+
// The line breaks the adopt put in front of the block go with it.
|
|
131
|
+
const lead = added ? added.slice(0, added.indexOf(README_OPEN)) : "";
|
|
132
|
+
const start = lead && text.slice(at.start - lead.length, at.start) === lead ? at.start - lead.length : at.start;
|
|
133
|
+
return text.slice(0, start) + text.slice(at.end);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readmeLine(r) {
|
|
137
|
+
const names = r.citations.map((c) => `"${c}"`).join(", ");
|
|
138
|
+
switch (r.action) {
|
|
139
|
+
case "written": return `README.md: written, with ${names}`;
|
|
140
|
+
case "appended": return `README.md: EVÐA's block added at the end, with ${names}; your text above it is unchanged`;
|
|
141
|
+
case "refreshed": return "README.md: EVÐA's block refreshed to what this tool ships; your text around it is unchanged";
|
|
142
|
+
case "current": return "README.md: EVÐA's block already matches what this tool ships";
|
|
143
|
+
default: return `README.md: already has ${names}; left alone`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The one rule every walker over the record uses for what is outside it (HQ-2811): a folder named
|
|
2
|
+
// node_modules (packages, not notes), and any name ending in .nosync, the iCloud convention for
|
|
3
|
+
// "do not sync this". A .nosync folder is one the owner chose to keep out of sync, which is the same
|
|
4
|
+
// as keeping it out of the record; Mike's vault keeps its packages in node_modules.nosync, and the
|
|
5
|
+
// bears-on notice once named a vendored eslint doc in it as an existing entry. Dot-names and app
|
|
6
|
+
// folders stay each walker's own choice, since they differ by what the walk is for.
|
|
7
|
+
export function outsideRecord(name) {
|
|
8
|
+
return name === "node_modules" || name.endsWith(".nosync");
|
|
9
|
+
}
|