create-evda 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. package/presets/student/preset.json +0 -13
@@ -0,0 +1,121 @@
1
+ // Making a record inside a record from a conversation (HQ-1377, the HQ-1371 shape): the owner asks
2
+ // for a sub-EVÐA for a project, a company or an agent, and their AI makes it through the MCP server.
3
+ //
4
+ // What it makes, at a path inside the record: an evda.json with the four roles (journal from
5
+ // record/daily and record/captures, work from work/, collections from record/people, knowledge from
6
+ // the ledger), an AGENTS.md whose owner section carries what it is for, and everything else a new
7
+ // record gets from create-evda. It is made by the same scaffold, so a sub-record is never a second
8
+ // shape of record.
9
+ //
10
+ // The parent stays as it is. Its evda.json changes only when the owner opts into "subrecords":
11
+ // "bounded" (the parent's circles stop at the sub-record), which changes what the parent syncs and
12
+ // so waits for confirmed: true. Doctor runs on the parent before and after, walking the new
13
+ // sub-record as its own record; when the parent was clean before and is not after, everything this
14
+ // call made is removed again and the report says why.
15
+
16
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
17
+ import { isAbsolute, join, normalize, resolve, sep } from "node:path";
18
+ import { parseArgs, scaffold, slug } from "../create-evda.mjs";
19
+ import { readManifest, isBounded } from "./manifest.mjs";
20
+ import { subRecords } from "./records.mjs";
21
+ import { doctor } from "./doctor.mjs";
22
+ import { EvdaError } from "./errors.mjs";
23
+
24
+ export const WORK_DIR = "work";
25
+
26
+ // EVDA_SUBRECORD_FALSIFY=<mode,...>, for the tests: no-doctor returns without running doctor after,
27
+ // keep-broken leaves what it made in place when doctor fails on it.
28
+ export const SUBRECORD_FALSIFY_MODES = ["no-doctor", "keep-broken"];
29
+ const falsified = (mode) => (process.env.EVDA_SUBRECORD_FALSIFY ?? "").split(",").map((x) => x.trim()).includes(mode);
30
+
31
+ function refuse(message, code) {
32
+ return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
33
+ }
34
+
35
+ function runDoctor(dir) {
36
+ const lines = [];
37
+ const r = doctor(dir, { out: (l) => lines.push(l) });
38
+ return { exit: r.exit, text: lines.join("\n") };
39
+ }
40
+
41
+ // Where the new record may go, as a relative path, or a refusal naming why not.
42
+ export function placeFor(evda, path) {
43
+ if (typeof path !== "string" || path.trim() === "") throw refuse(`evda_create_subrecord needs "path": where in the record the new EVÐA goes, such as Projects/Garden.`, "NO_PATH");
44
+ if (isAbsolute(path)) throw refuse(`"path" ${JSON.stringify(path)} is absolute; it is relative to the record.`, "BAD_PATH");
45
+ const rel = normalize(path).split(sep).join("/").replace(/\/+$/, "");
46
+ const abs = resolve(evda.dir, rel);
47
+ if (abs === evda.dir || !abs.startsWith(evda.dir + sep)) throw refuse(`"path" ${JSON.stringify(path)} is not inside the record.`, "BAD_PATH");
48
+ if (rel.split("/").some((part) => part.startsWith("."))) throw refuse(`"path" ${JSON.stringify(path)} goes through a dot folder, which is the tooling's, not the record's.`, "BAD_PATH");
49
+ const inside = subRecords(evda.dir).find((s) => rel === s || rel.startsWith(`${s}/`));
50
+ if (inside !== undefined) throw refuse(`${rel} is inside the sub-record at ${inside}, which is a record of its own. Make it from that record's server.`, "INSIDE_SUBRECORD");
51
+ for (const [key, value] of Object.entries(evda.manifest.mapping)) {
52
+ if (typeof value !== "string" || /^[a-z][a-z0-9-]*:/.test(value)) continue;
53
+ const mapped = value.replace(/\/+$/, "");
54
+ if (mapped === rel || mapped.startsWith(`${rel}/`)) throw refuse(`${rel} would hold ${mapped}, which this record maps as "${key}". A parent's own folders never go inside a sub-record.`, "HOLDS_ROLE");
55
+ if (key === "agents" && rel.startsWith(`${mapped}/`)) throw refuse(`${rel} is under ${mapped}, a file.`, "BAD_PATH");
56
+ }
57
+ if (existsSync(abs) && readdirSync(abs).length) throw refuse(`${rel} already exists and is not empty. A sub-EVÐA starts in a new or empty folder; to make an existing folder a record of its own, ask the owner and run create-evda there with --force.`, "NOT_EMPTY");
58
+ return { rel, abs };
59
+ }
60
+
61
+ export function createSubrecord(evda, args = {}) {
62
+ const allowed = ["path", "name", "purpose", "kind", "bounded", "confirmed"];
63
+ const extra = Object.keys(args).filter((k) => !allowed.includes(k));
64
+ if (extra.length) throw refuse(`evda_create_subrecord does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}.`, "UNKNOWN_ARGUMENT");
65
+ if (args.bounded === true && args.confirmed !== true) {
66
+ throw refuse(`bounded: true changes this record's evda.json so its circles stop at the new sub-record, which changes what it syncs. Pass confirmed: true only when the owner said yes to that in this conversation. Without bounded, the sub-EVÐA is made and the parent is left as it is.`, "NOT_CONFIRMED");
67
+ }
68
+ const { rel, abs } = placeFor(evda, args.path);
69
+ const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : rel.split("/").pop();
70
+ const kind = args.kind ?? "person";
71
+ const parentId = typeof evda.manifest.id === "string" && evda.manifest.id ? evda.manifest.id : "atlas";
72
+ const id = `${slug(name)}.${parentId}`;
73
+ const owner = evda.manifest.owner?.name ?? "";
74
+
75
+ const before = runDoctor(evda.dir);
76
+ const manifestFile = join(evda.dir, "evda.json");
77
+ const parentBefore = readFileSync(manifestFile, "utf8");
78
+ const existed = existsSync(abs);
79
+ // The outermost folder this call creates, so an undo leaves no empty parent behind.
80
+ const parts = rel.split("/");
81
+ let top = abs;
82
+ for (let i = 1; i <= parts.length; i++) {
83
+ const p = join(evda.dir, ...parts.slice(0, i));
84
+ if (!existsSync(p)) { top = p; break; }
85
+ }
86
+ let madeParentBounded = false;
87
+ try {
88
+ const argv = [abs, "--yes", "--no-pointer", "--no-guard", "--name", name, "--kind", kind, "--id", id, "--layers", "record,ledger"];
89
+ if (owner) argv.push("--owner", owner);
90
+ if (typeof args.purpose === "string" && args.purpose.trim()) argv.push("--purpose", args.purpose.trim());
91
+ scaffold({ ...parseArgs(argv), force: existed });
92
+ // The fourth role. record/daily and record/captures fill journal, record/people collections and
93
+ // the ledger knowledge; work is a folder of effort files until the owner points it at a board.
94
+ const m = readManifest(abs);
95
+ m.mapping.work = WORK_DIR;
96
+ if (evda.manifest.read_by !== undefined && m.read_by === undefined) m.read_by = evda.manifest.read_by;
97
+ mkdirSync(join(abs, WORK_DIR), { recursive: true });
98
+ writeFileSync(join(abs, "evda.json"), `${JSON.stringify(m, null, 2)}\n`);
99
+ if (args.bounded === true && !isBounded(evda.manifest)) {
100
+ const pm = JSON.parse(parentBefore);
101
+ pm.subrecords = "bounded";
102
+ writeFileSync(manifestFile, `${JSON.stringify(pm, null, 2)}\n`);
103
+ madeParentBounded = true;
104
+ }
105
+ } catch (e) {
106
+ undo();
107
+ throw e;
108
+ }
109
+ const after = falsified("no-doctor") ? { exit: 0, text: "" } : runDoctor(evda.dir);
110
+ if (before.exit === 0 && after.exit !== 0 && !falsified("keep-broken")) {
111
+ undo();
112
+ throw new EvdaError(`REFUSED: doctor was clean on this record before and failed with the sub-EVÐA at ${rel}, so what this call made was removed and the record is as it was.\n\n${after.text}`, { code: "DOCTOR_FAILED", exit: 2 });
113
+ }
114
+ return { path: rel, id, name, parentBounded: isBounded(readManifest(evda.dir)), madeParentBounded, doctor: after, doctorBefore: before.exit };
115
+
116
+ function undo() {
117
+ if (existed) for (const n of readdirSync(abs)) rmSync(join(abs, n), { recursive: true, force: true });
118
+ else rmSync(top, { recursive: true, force: true });
119
+ writeFileSync(manifestFile, parentBefore);
120
+ }
121
+ }
@@ -0,0 +1,87 @@
1
+ // Which files in a record sync, and which are left where they are (Spec 08, stages S0 and S1).
2
+ //
3
+ // One definition, read by three things that must agree: the circle plan (what a circle's members
4
+ // are), the coverage check (every file in exactly one circle), and the shadow scan and parity check
5
+ // (what is sent, and what the shadow must equal). Two copies of this list would be two answers to
6
+ // "does this file sync", and parity would pass on a file both sides agreed to skip.
7
+ //
8
+ // Three kinds of thing never sync, and each is counted rather than dropped:
9
+ //
10
+ // excluded by name anywhere (.DS_Store, node_modules, .git), by name at the root (.evda, the
11
+ // derived index every device rebuilds), or by a path the plan names. Reported as
12
+ // EXCLUDED n.
13
+ // symlink never followed and never carried: sync moves bytes, and a link's target may be
14
+ // outside the record, missing, or itself. A symlink inside a circle fails the
15
+ // coverage check until the plan excludes it.
16
+ // placeholder an iCloud stub (`.name.icloud`) for a file whose bytes are not on this disk.
17
+ // Syncing the stub would make parity pass while the file never moved, so a stub
18
+ // inside a circle fails until it is downloaded.
19
+ //
20
+ // And one kind cannot sync yet: a file larger than the relay's blob lane takes (64 MB, less the
21
+ // seal), which fails the coverage check until the plan excludes it.
22
+
23
+ import { lstatSync, readdirSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
27
+ export const EXCLUDE_ROOT_NAMES = [".evda"];
28
+ // The relay refuses a blob over 64 MiB (`xania-relay/src/relay.mjs`, MAX_BLOB_BYTES); the seal adds
29
+ // a nonce, a tag and an ephemeral key, so leave a margin.
30
+ export const MAX_SYNC_BYTES = 64 * 1024 * 1024 - 64 * 1024;
31
+
32
+ const PLACEHOLDER = /^\..+\.icloud$/;
33
+ export const isPlaceholder = (name) => PLACEHOLDER.test(name);
34
+
35
+ const norm = (rel) => rel.replace(/^\/+|\/+$/g, "");
36
+
37
+ // A predicate over a relative path: is it (or something above it) excluded? `extra` is the plan's
38
+ // own list of excluded paths, each covering itself and everything under it.
39
+ export function excluder(extra = []) {
40
+ const paths = extra.map(norm).filter((p) => p !== "");
41
+ return (rel) => {
42
+ const parts = norm(rel).split("/");
43
+ if (parts.some((p) => EXCLUDE_NAMES.includes(p))) return true;
44
+ if (EXCLUDE_ROOT_NAMES.includes(parts[0])) return true;
45
+ const r = parts.join("/");
46
+ return paths.some((p) => r === p || r.startsWith(`${p}/`));
47
+ };
48
+ }
49
+
50
+ // Walk a directory without following links. Yields { rel, kind, size } for every entry that is not
51
+ // a directory, where kind is file | symlink | placeholder | excluded | oversize. An excluded
52
+ // directory is yielded once, as itself, and not entered.
53
+ export function* walk(root, { exclude = excluder(), from = "" } = {}) {
54
+ const dir = from === "" ? root : join(root, from);
55
+ let names;
56
+ try {
57
+ names = readdirSync(dir).sort();
58
+ } catch (e) {
59
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return;
60
+ throw e;
61
+ }
62
+ for (const name of names) {
63
+ const rel = from === "" ? name : `${from}/${name}`;
64
+ const st = lstatSync(join(root, rel));
65
+ if (exclude(rel)) {
66
+ yield { rel, kind: "excluded", size: st.isDirectory() ? 0 : st.size, dir: st.isDirectory() };
67
+ continue;
68
+ }
69
+ if (st.isSymbolicLink()) { yield { rel, kind: "symlink", size: 0 }; continue; }
70
+ if (st.isDirectory()) { yield* walk(root, { exclude, from: rel }); continue; }
71
+ if (isPlaceholder(name)) { yield { rel, kind: "placeholder", size: st.size }; continue; }
72
+ yield { rel, kind: st.size > MAX_SYNC_BYTES ? "oversize" : "file", size: st.size };
73
+ }
74
+ }
75
+
76
+ // The same rules as a filter a scanner applies while it walks: true means do not list it and do not
77
+ // enter it. Everything that is not a plain, syncable file is skipped, and `walk` is what counts them.
78
+ export function scanSkip(extra = []) {
79
+ const exclude = excluder(extra);
80
+ return (rel, st) => {
81
+ if (exclude(rel)) return true;
82
+ if (st.isSymbolicLink()) return true;
83
+ if (st.isDirectory()) return false;
84
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
85
+ return isPlaceholder(name) || st.size > MAX_SYNC_BYTES;
86
+ };
87
+ }
package/lib/tools.mjs CHANGED
@@ -11,22 +11,32 @@
11
11
  // reference (lib/references.mjs secretShape) and a hit refuses the whole write.
12
12
  // 3. Every write says where it landed, as a path relative to the record.
13
13
  //
14
+ // Who may read (HQ-1374). Every tool that returns the record's words asks lib/readers.mjs first, for
15
+ // each file: a just-noted entry is returned to no AI, and a file whose circle (or evda.json) names a
16
+ // reader that is not the caller is held back. What was held back is said in one plain sentence at
17
+ // the end of the answer, so the agent declines out loud instead of going quiet. The caller is who
18
+ // the MCP client said it was on initialize; $EVDA_READER overrides it.
19
+ //
20
+ // Sealed (HQ-1220) is stronger and holds for every caller: a sealed file is in no answer, and no
21
+ // answer names it or says whether it matched; a sealed section is cut out of every file shown
22
+ // (lib/readers.mjs aiView). Every tool below that returns words goes through both.
23
+ //
14
24
  // A circle is never created here. `propose_circle` writes a proposal file for the owner to read;
15
25
  // making the circle, and granting anyone anything, stays the owner's explicit yes through
16
26
  // `evda grant` or their own hands.
17
27
 
18
28
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
- import { dirname, join, relative } from "node:path";
29
+ import { dirname, join, relative, resolve, sep } from "node:path";
20
30
  import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
21
31
  import { mdFiles, fileDate } from "./record.mjs";
22
- import { readCircles } from "./circles.mjs";
32
+ import { readCircles, isSealed } from "./circles.mjs";
23
33
  import { secretShape } from "./references.mjs";
24
34
  import { doctor } from "./doctor.mjs";
35
+ import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
25
36
  import { EvdaError } from "./errors.mjs";
37
+ import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
26
38
 
27
- const pad = (n) => String(n).padStart(2, "0");
28
- export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
29
- export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
39
+ export { localDate, localTime, CONTEXT_LABEL };
30
40
 
31
41
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
32
42
  const MAX_WORDS = 200_000;
@@ -63,10 +73,12 @@ function rel(evda, abs) {
63
73
  return relative(evda.dir, abs).split("\\").join("/");
64
74
  }
65
75
 
76
+ // The day's record: `record/daily`, else the `journal` role (HQ-1371). keep-hook.mjs dayRole is the
77
+ // one rule, so the Stop hook watches the same file this appends to.
66
78
  function dailyDir(evda) {
67
- const r = resolveRole(evda, "record/daily");
68
- if (!r.mapped) throw refuse('this record maps no "record/daily" role, so there is no daily file to append to.', "NO_ROLE");
69
- return r;
79
+ const role = dayRole(evda.manifest.mapping);
80
+ if (!role) throw refuse('this record maps no "record/daily" or "journal" role, so there is no daily file to append to.', "NO_ROLE");
81
+ return resolveRole(evda, role);
70
82
  }
71
83
 
72
84
  // Where imports go: `imports/` beside the daily role, so `record/daily` gives `record/imports`, and
@@ -85,12 +97,13 @@ export function operatingLayer(evda, args = {}) {
85
97
  onlyKeys("evda_operating_layer", args, []);
86
98
  const a = agentsFile(evda);
87
99
  if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
88
- return { path: a.rel, text: readFileSync(a.abs, "utf8") };
100
+ if (isSealed(a.abs)) throw new EvdaError(`REFUSED: ${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, { code: "SEALED", exit: 2 });
101
+ return { path: a.rel, text: aiView(readFileSync(a.abs, "utf8")).text };
89
102
  }
90
103
 
91
104
  // 2. The last `days` days of the record, newest first, whole files, capped in size. A file is in the
92
105
  // window by its date (filename, then a date or created key), the same rule `evda moment` uses.
93
- export function recent(evda, args = {}, { now = new Date() } = {}) {
106
+ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
94
107
  onlyKeys("evda_recent", args, ["days"]);
95
108
  const days = args.days === undefined ? 3 : Number(args.days);
96
109
  if (!Number.isInteger(days) || days < 1 || days > 90) throw new EvdaError("evda_recent days must be a whole number from 1 to 90.", { code: "BAD_ARGUMENT", exit: 2 });
@@ -99,11 +112,18 @@ export function recent(evda, args = {}, { now = new Date() } = {}) {
99
112
  const from = localDate(start);
100
113
  const to = localDate(now);
101
114
  const files = [];
115
+ const ctx = readContext(evda);
116
+ const refused = [];
117
+ let sealed = false;
102
118
  for (const abs of recordFiles(evda)) {
103
- const body = readFileSync(abs, "utf8");
104
- const date = fileDate(abs, body);
119
+ const raw = readFileSync(abs, "utf8");
120
+ const date = fileDate(abs, raw);
105
121
  if (!date || date < from || date > to) continue;
106
- files.push({ date, path: rel(evda, abs), text: body });
122
+ const may = mayRead(ctx, abs, caller);
123
+ if (!may.ok) { refused.push(may); continue; }
124
+ const shown = aiView(raw);
125
+ if (shown.sections) sealed = true;
126
+ files.push({ date, path: rel(evda, abs), text: shown.text });
107
127
  }
108
128
  files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
109
129
  let used = 0;
@@ -114,7 +134,7 @@ export function recent(evda, args = {}, { now = new Date() } = {}) {
114
134
  used += f.text.length;
115
135
  out.push(f);
116
136
  }
117
- return { from, to, files: out, omitted: truncated };
137
+ return { from, to, files: out, omitted: truncated, refused: refused.filter((r) => r.kind !== "sealed"), heldBack: heldBack(refused, { sealed }) };
118
138
  }
119
139
 
120
140
  // Every markdown file the record holds: each record-layer role, and the imports folder. Not the
@@ -134,20 +154,37 @@ function recordFiles(evda) {
134
154
  return out;
135
155
  }
136
156
 
137
- // 3. Append the owner's words, verbatim, to today's daily file, under the local time. Creates the
138
- // file with a date heading when it is the first write of the day. Returns the path.
139
- export function write(evda, args = {}, { now = new Date() } = {}) {
140
- onlyKeys("evda_write", args, ["words"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
157
+ // 3. Append the owner's words, verbatim, to today's daily file. Creates the file with a date heading
158
+ // when it is the first write of the day. Returns the path.
159
+ // `context` (HQ-1377) is one line on what the conversation was about, in the agent's words, written
160
+ // above theirs and labeled as not theirs, so a later reader never mistakes it for something they said.
161
+ // `session` (HQ-1385) is the conversation: its first write opens `## HH:MM` with the context line,
162
+ // and later writes append under it (lib/day-file.mjs, the one append path).
163
+ export const CONTEXT_MAX = 240;
164
+ export function write(evda, args = {}, { now = new Date(), session = null } = {}) {
165
+ onlyKeys("evda_write", args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
141
166
  const words = text(args.words, "words", "evda_write");
142
167
  refuseSecrets(words, "evda_write");
168
+ let context = null;
169
+ if (args.context !== undefined) {
170
+ if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(`evda_write "context" is one line on what the conversation was about; leave it out rather than send it empty.`, "EMPTY");
171
+ if (/\n/.test(args.context.trim())) throw refuse(`evda_write "context" is one line. Their words go in "words"; the context is a single line on what the conversation was about.`, "CONTEXT_LINES");
172
+ if (args.context.trim().length > CONTEXT_MAX) throw refuse(`evda_write "context" is ${args.context.trim().length} characters; one line of context is at most ${CONTEXT_MAX}.`, "TOO_LONG");
173
+ refuseSecrets(args.context, "evda_write");
174
+ context = args.context.trim();
175
+ }
143
176
  const r = dailyDir(evda);
144
177
  mkdirSync(r.abs, { recursive: true });
145
- const date = localDate(now);
146
- const abs = join(r.abs, `${date}.md`);
147
- const head = existsSync(abs) ? "" : `# ${date}\n`;
148
- const lead = existsSync(abs) && !readFileSync(abs, "utf8").endsWith("\n") ? "\n" : "";
149
- appendFileSync(abs, `${lead}${head}\n## ${localTime(now)}\n\n${words.replace(/\n+$/, "")}\n`);
150
- return { path: rel(evda, abs), bytes: Buffer.byteLength(words) };
178
+ const abs = join(r.abs, `${localDate(now)}.md`);
179
+ const w = appendToDay(abs, { now, words, context, session });
180
+ return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
181
+ }
182
+
183
+ // The day file for a date, as { abs, rel }, for writers that are not evda_write (the chat-export
184
+ // backstop in `evda gather`). Throws when the record maps no day role.
185
+ export function dayFile(evda, date) {
186
+ const r = dailyDir(evda);
187
+ return { abs: join(r.abs, `${date}.md`), rel: rel(evda, join(r.abs, `${date}.md`)), dir: r.abs };
151
188
  }
152
189
 
153
190
  // 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
@@ -184,7 +221,7 @@ export function importText(evda, args = {}, { now = new Date() } = {}) {
184
221
 
185
222
  // 5. Plain text search inside the record, on this machine. Case-insensitive substring over every
186
223
  // record file, the imports, and the ledger. Returns each matching line with its path and number.
187
- export function search(evda, args = {}) {
224
+ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
188
225
  onlyKeys("evda_search", args, ["query", "limit"]);
189
226
  if (typeof args.query !== "string" || args.query.trim() === "") throw new EvdaError('evda_search needs "query".', { code: "BAD_ARGUMENT", exit: 2 });
190
227
  const limit = args.limit === undefined ? 50 : Number(args.limit);
@@ -195,14 +232,68 @@ export function search(evda, args = {}) {
195
232
  if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
196
233
  const matches = [];
197
234
  let more = 0;
235
+ const ctx = readContext(evda);
236
+ const refused = [];
237
+ // Whether any searched file is sealed or holds a sealed section, decided without the query, so
238
+ // the answer to a search whose only hit is sealed is the answer to one that hit nothing.
239
+ let sealed = false;
198
240
  for (const abs of files) {
199
- readFileSync(abs, "utf8").split("\n").forEach((line, i) => {
241
+ const raw = readFileSync(abs, "utf8");
242
+ const may = mayRead(ctx, abs, caller);
243
+ if (!may.ok && may.kind === "sealed") { sealed = true; continue; }
244
+ if (!may.ok) {
245
+ // Only a held-back file that would have matched is named, and never the line it matched on.
246
+ // Matched on what the AI could be shown, so a hit inside a sealed section names nothing.
247
+ if (aiView(raw).text.toLowerCase().includes(needle)) refused.push(may);
248
+ continue;
249
+ }
250
+ const shown = aiView(raw);
251
+ if (shown.sections) sealed = true;
252
+ const body = shown.text;
253
+ body.split("\n").forEach((line, i) => {
200
254
  if (!line.toLowerCase().includes(needle)) return;
201
255
  if (matches.length >= limit) { more += 1; return; }
202
256
  matches.push({ path: rel(evda, abs), line: i + 1, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
203
257
  });
204
258
  }
205
- return { query: args.query, searched: files.length, matches, more };
259
+ return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
260
+ }
261
+
262
+ // 5a. One file of the record, whole, when the caller may read it; a refusal that says why when not.
263
+ export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
264
+ onlyKeys("evda_read", args, ["path"]);
265
+ if (typeof args.path !== "string" || args.path.trim() === "") throw new EvdaError('evda_read needs "path", a file in the record.', { code: "BAD_ARGUMENT", exit: 2 });
266
+ const abs = resolve(evda.dir, args.path.trim());
267
+ if (!abs.startsWith(evda.dir + sep)) throw new EvdaError(`REFUSED: ${args.path} is outside the record.`, { code: "OUTSIDE", exit: 2 });
268
+ if (!existsSync(abs) || !statSync(abs).isFile()) throw new EvdaError(`Nothing is at ${args.path} in the record.`, { code: "MISSING", exit: 2 });
269
+ const may = mayRead(readContext(evda), abs, caller);
270
+ if (!may.ok) throw new EvdaError(`REFUSED: ${may.why}`, { code: may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER", exit: 2 });
271
+ const text = aiView(readFileSync(abs, "utf8")).text;
272
+ if (text.length > MAX_READ) return { path: rel(evda, abs), text: text.slice(0, MAX_READ), truncated: true };
273
+ return { path: rel(evda, abs), text, truncated: false };
274
+ }
275
+
276
+ // 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
277
+ // anything secret-shaped, like every other write.
278
+ export function noteTool(evda, args = {}, { now = new Date() } = {}) {
279
+ onlyKeys("evda_note", args, ["words"], "It writes one new noted capture and nothing else.");
280
+ const words = text(args.words, "words", "evda_note");
281
+ refuseSecrets(words, "evda_note");
282
+ return writeNote(evda, words, { now });
283
+ }
284
+
285
+ // 5c. "You can read that now": clear the flag on one noted entry. Only on the owner's yes in this
286
+ // conversation, and only the flag: the words are not changed. The one tool that edits a file, and
287
+ // it edits one frontmatter line.
288
+ export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
289
+ onlyKeys("evda_release_note", args, ["path", "confirmed"]);
290
+ // Sealed is not noted: no tool releases it, whatever was said. Unsealing is the owner's own edit.
291
+ if (typeof args.path === "string" && args.path.trim() !== "") {
292
+ const abs = resolve(evda.dir, args.path.trim());
293
+ if (existsSync(abs) && isSealed(abs)) throw new EvdaError(`REFUSED: ${args.path} is sealed: only its owner opens it, on their own devices, and no tool releases it. Unsealing is the owner editing the file themselves. Nothing was changed.`, { code: "SEALED", exit: 2 });
294
+ }
295
+ if (args.confirmed !== true) throw new EvdaError("REFUSED: evda_release_note needs confirmed: true, which means the owner said in this conversation that this entry can be read now. Ask them which one, and wait. Nothing was changed.", { code: "UNCONFIRMED", exit: 2 });
296
+ return releaseNote(evda, args.path, { now });
206
297
  }
207
298
 
208
299
  // 6. Circles and their policies, as the record declares them. Read only.
@@ -0,0 +1,146 @@
1
+ // Update-check hygiene (HQ-1242), from the three gstack rules the plan doc names for this
2
+ // package (01_Projects/Evda/Evda/2026-09-15 - What EVDA Takes From gstack.md, section
3
+ // "5. Update-check hygiene"):
4
+ //
5
+ // 1. A crash must not read as up to date. Any network or parse failure returns
6
+ // status: "CHECK_FAILED" with the reason attached, never a silent "you're current."
7
+ // 2. The check is throttled and snoozable: at most once per THROTTLE_MS (one hour, per the
8
+ // plan doc's "throttled to once an hour," not gstack's escalating 24h/48h/7d scheme).
9
+ // 3. Nothing destructive runs from here. This module only reads the registry and this
10
+ // record's own state file; it never writes AGENTS.md, never runs a migration.
11
+ //
12
+ // `.evda/package-version` (not `.evda/version`, which is the unrelated SCHEMA_VERSION marker
13
+ // lib/manifest.mjs owns) records which npm version last replaced the AGENTS.md fence via
14
+ // `evda upgrade`. `.evda/update-check.json` records when this module last asked the registry,
15
+ // so re-running `evda check-update` inside the same hour is a local read, not a network call.
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+
20
+ export const THROTTLE_MS = 60 * 60 * 1000; // one hour, per the plan doc
21
+ const REGISTRY_URL = "https://registry.npmjs.org/create-evda/latest";
22
+ const FETCH_TIMEOUT_MS = 5000;
23
+
24
+ function stateFile(dir) {
25
+ return join(dir, ".evda", "update-check.json");
26
+ }
27
+
28
+ function packageVersionFile(dir) {
29
+ return join(dir, ".evda", "package-version");
30
+ }
31
+
32
+ function readJsonSafe(path) {
33
+ try {
34
+ return JSON.parse(readFileSync(path, "utf8"));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // What this record's own AGENTS.md fence was last generated from. Absent means `evda upgrade`
41
+ // has never stamped it (an install predating this feature, or a record adopted by hand); that
42
+ // is reported as its own status rather than guessed at.
43
+ export function installedVersion(dir) {
44
+ try {
45
+ return readFileSync(packageVersionFile(dir), "utf8").trim();
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function stampInstalledVersion(dir, version) {
52
+ const f = packageVersionFile(dir);
53
+ mkdirSync(dirname(f), { recursive: true });
54
+ writeFileSync(f, version + "\n");
55
+ }
56
+
57
+ // Fetches the registry's `latest` dist-tag. Never throws: every failure mode (network, timeout,
58
+ // a non-200, a body with no `version` field) becomes a returned CHECK_FAILED result, because a
59
+ // thrown error from inside a session preamble is worse than a stale-looking check.
60
+ async function fetchLatest() {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
63
+ try {
64
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
65
+ if (!res.ok) {
66
+ return { ok: false, reason: `registry returned HTTP ${res.status}` };
67
+ }
68
+ const body = await res.json();
69
+ if (typeof body?.version !== "string" || !body.version) {
70
+ return { ok: false, reason: "registry response had no version field" };
71
+ }
72
+ return { ok: true, version: body.version };
73
+ } catch (err) {
74
+ return { ok: false, reason: err?.name === "AbortError" ? `timed out after ${FETCH_TIMEOUT_MS}ms` : String(err?.message ?? err) };
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ function higherVersion(a, b) {
81
+ const pa = a.split(".").map(Number);
82
+ const pb = b.split(".").map(Number);
83
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
84
+ const da = pa[i] ?? 0, db = pb[i] ?? 0;
85
+ if (da !== db) return da > db ? a : b;
86
+ }
87
+ return a;
88
+ }
89
+
90
+ // The one entry point. `now` is injected so a caller (or a test) can control throttling without
91
+ // waiting an hour; callers outside a test pass Date.now().
92
+ //
93
+ // Returns one of:
94
+ // { status: "SKIPPED_THROTTLED", checkedAt: <ms of the last real check> }
95
+ // { status: "CHECK_FAILED", reason: "..." }
96
+ // { status: "UP_TO_DATE", installed, latest }
97
+ // { status: "UPDATE_AVAILABLE", installed, latest }
98
+ // { status: "UNSTAMPED", latest } -- this record predates package-version stamping
99
+ export async function checkForUpdate(dir, { now = Date.now(), force = false } = {}) {
100
+ const sf = stateFile(dir);
101
+ const state = readJsonSafe(sf);
102
+
103
+ if (!force && state?.lastCheckedAt && now - state.lastCheckedAt < THROTTLE_MS) {
104
+ return { status: "SKIPPED_THROTTLED", checkedAt: state.lastCheckedAt };
105
+ }
106
+
107
+ const fetched = await fetchLatest();
108
+
109
+ // Record the attempt either way: a failure that never updates lastCheckedAt would retry every
110
+ // single session until the registry recovers, defeating the throttle's whole purpose. The
111
+ // failure itself is still returned to the caller, never swallowed into a false pass.
112
+ mkdirSync(dirname(sf), { recursive: true });
113
+ writeFileSync(sf, JSON.stringify({ lastCheckedAt: now, lastResult: fetched.ok ? fetched.version : `CHECK_FAILED: ${fetched.reason}` }, null, 2) + "\n");
114
+
115
+ if (!fetched.ok) {
116
+ return { status: "CHECK_FAILED", reason: fetched.reason };
117
+ }
118
+
119
+ const installed = installedVersion(dir);
120
+ if (!installed) {
121
+ return { status: "UNSTAMPED", latest: fetched.version };
122
+ }
123
+
124
+ const newer = higherVersion(installed, fetched.version) === fetched.version && installed !== fetched.version;
125
+ return newer
126
+ ? { status: "UPDATE_AVAILABLE", installed, latest: fetched.version }
127
+ : { status: "UP_TO_DATE", installed, latest: fetched.version };
128
+ }
129
+
130
+ // One line, for the preamble and for `evda check-update`'s own stdout. Never throws.
131
+ export function summarize(result) {
132
+ switch (result.status) {
133
+ case "SKIPPED_THROTTLED":
134
+ return `update check: skipped, checked within the last hour (${new Date(result.checkedAt).toISOString()})`;
135
+ case "CHECK_FAILED":
136
+ return `update check: CHECK_FAILED, update status UNKNOWN (${result.reason})`;
137
+ case "UNSTAMPED":
138
+ return `update check: this record predates version stamping; latest is ${result.latest}. Run "evda upgrade" once to start tracking it.`;
139
+ case "UP_TO_DATE":
140
+ return `update check: up to date (${result.installed})`;
141
+ case "UPDATE_AVAILABLE":
142
+ return `update check: ${result.installed} -> ${result.latest} available. Run "npx -y -p create-evda@latest evda update" to take it.`;
143
+ default:
144
+ return `update check: CHECK_FAILED, update status UNKNOWN (unrecognized result shape)`;
145
+ }
146
+ }