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 CHANGED
@@ -4,6 +4,29 @@ Plain-English notes on how Iris behaves differently after each version. Not a co
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ On a machine that does not write the day (`EVDA_DAY_WRITER` set), the Claude Code prompt hook no
8
+ longer appends to the local day file. It sends each message to the writer through the command in
9
+ `EVDA_DAY_WRITER_SEND` (evda_write's arguments as JSON on stdin), and a message the writer cannot take
10
+ waits in this machine's own outbox (`EVDA_PROMPT_OUTBOX`, default `~/.evda/outbox`) and goes with the
11
+ next one, filed once. The keep-record hook's reminder there names evda_write. The day-writer guard
12
+ now sees a day file named through a variable the same command set, `$HOME` or `~`. `evda doctor`
13
+ warns when evda.json says `"filing": "everything"` and no prompt hook is installed (HQ-2817).
14
+
15
+ An EVÐA made with `evda adopt` now has the steps for turning training off. Iris gives those steps
16
+ only from the "Turning training off" section of `README.md`, and an adopted folder kept its own
17
+ README, which never had that section, or the ones on what each AI company says and running a model
18
+ on your own computer. The adopt now adds those three sections after your README as one block marked
19
+ as EVÐA's, leaving your text exactly as it was, or writes the whole README when you had none.
20
+ `evda adopt --revert` takes the block back out, and `evda upgrade` keeps it current. `evda doctor`
21
+ fails a record whose `AGENTS.md` sends Iris to a `README.md` section that is not there, and says to
22
+ run `evda upgrade`, which adds it.
23
+
24
+ `evda doctor` run from a git checkout now says, just before its verdict, when that checkout is behind
25
+ origin/main or is not main, so a pass from an old branch no longer reads as main's pass. It fetches
26
+ main first with a five-second limit; offline, with no origin, or from an installed copy it still runs
27
+ every check and says it could not compare. On current main it says nothing extra, and the exit code is
28
+ unchanged. `EVDA_DOCTOR_FETCH=0` skips the fetch.
29
+
7
30
  When a filing names pages your EVÐA already has (a person, a project, a topic) or open tickets on your
8
31
  board, and the conversation adds to none of them, Iris is now asked once, naming them, whether what you said adds to one; adding
9
32
  nothing is a fine answer. In Claude Code the record's Stop hook asks before the session stops, and in
package/README.md CHANGED
@@ -178,7 +178,7 @@ Writes:
178
178
 
179
179
  The training instructions in the scaffolded README and in setup come from one table, `lib/setup.mjs`, with a link to each provider's page.
180
180
 
181
- The tree above is what the scaffolder creates and not a requirement. `evda.json` carries a `mapping` from roles to paths, and every tool reads roles. An existing folder of notes becomes an EVÐA by adding `evda.json` with a mapping that names where things already are, and nothing moves. `evda adopt <dir>` writes that mapping: an Iris Vault (`00_Inbox` through `06_Metadata`) maps without a question, any other folder is placed by its name or named back to the owner to answer with `--map <folder>=<role>`, and it adds `AGENTS.md`, `.evda/`, the Claude Code hooks and one `@AGENTS.md` line at the end of the owner's `CLAUDE.md`. Every other file stays byte for byte, and `evda adopt --revert <manifest>` puts the folder back exactly as it was. A chat app runs it through `evda_adopt`, which writes nothing without `confirmed: true`.
181
+ The tree above is what the scaffolder creates and not a requirement. `evda.json` carries a `mapping` from roles to paths, and every tool reads roles. An existing folder of notes becomes an EVÐA by adding `evda.json` with a mapping that names where things already are, and nothing moves. `evda adopt <dir>` writes that mapping: an Iris Vault (`00_Inbox` through `06_Metadata`) maps without a question, any other folder is placed by its name or named back to the owner to answer with `--map <folder>=<role>`, and it adds `AGENTS.md`, `.evda/`, the Claude Code hooks, one `@AGENTS.md` line at the end of the owner's `CLAUDE.md`, and the `README.md` sections `AGENTS.md` sends the agent to (turning training off, what each AI company says, running a model locally) as one marked block after the owner's own `README.md`, which `evda upgrade` keeps current. Every other file stays byte for byte, and `evda adopt --revert <manifest>` puts the folder back exactly as it was. A chat app runs it through `evda_adopt`, which writes nothing without `confirmed: true`.
182
182
 
183
183
  ### Four roles, and records inside records
184
184
 
package/bin/evda.mjs CHANGED
@@ -729,7 +729,8 @@ async function main(argv) {
729
729
 
730
730
  case "doctor": {
731
731
  const { dir, via } = pickDir(args);
732
- return doctor(dir, { via }).exit;
732
+ // The package root this doctor runs from, so it can say whether that is current main (HQ-2736).
733
+ return doctor(dir, { via, checkout: fileURLToPath(new URL("..", import.meta.url)) }).exit;
733
734
  }
734
735
 
735
736
  case "guard":
@@ -946,6 +947,11 @@ async function main(argv) {
946
947
  const parts = splitFence(text);
947
948
  if (!parts) throw new EvdaError(`${a.rel} has no evda:generated fence. The owner has taken the whole file; there is nothing the installer owns to replace, and nothing was changed.`);
948
949
  const shipped = shippedGenerated();
950
+ // HQ-2812: the README.md sections the operating layer sends the agent to, kept as EVÐA's
951
+ // marked block (added when one is missing, refreshed when it is there); the owner's text is
952
+ // never touched.
953
+ const { ensureReadme, readmeLine } = await import("../lib/readme.mjs");
954
+ console.log(`upgrade: ${readmeLine(ensureReadme(dir, { agents: replaceFence(text, shipped) }))}`);
949
955
  if (parts.generated === shipped) {
950
956
  stampInstalledVersion(dir, packageVersion());
951
957
  console.log("upgrade: the generated block already matches what this tool ships. Nothing outside it was read.");
package/create-evda.mjs CHANGED
@@ -40,8 +40,9 @@ import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/pres
40
40
  import { FILING_MODES } from "./lib/day-file.mjs";
41
41
  import { installPromptHook } from "./lib/hooks.mjs";
42
42
  import { defaultParts } from "./lib/circle-plan.mjs";
43
- import { PLATFORMS, PLATFORM_KEYS, PARTS_LINE, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection, termsSection, readerFor, INTRO } from "./lib/setup.mjs";
43
+ import { PLATFORMS, PLATFORM_KEYS, PARTS_LINE, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, readerFor, INTRO } from "./lib/setup.mjs";
44
44
  import { describeReaders, parseReaders } from "./lib/readers.mjs";
45
+ import { renderReadme } from "./lib/readme.mjs";
45
46
 
46
47
  const HERE = dirname(fileURLToPath(import.meta.url));
47
48
  const TEMPLATES = join(HERE, "templates");
@@ -188,7 +189,7 @@ export function scaffold(opts) {
188
189
  // agent asks at first connect.
189
190
  if (opts.filing) manifest.filing = opts.filing;
190
191
  writeIfAbsent(join(dir, "evda.json"), JSON.stringify(manifest, null, 2) + "\n");
191
- writeIfAbsent(join(dir, "README.md"), render("README.md", { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() }));
192
+ writeIfAbsent(join(dir, "README.md"), renderReadme());
192
193
  // The owner section of AGENTS.md holds the setup answers in the owner's words. The AI line is
193
194
  // written only when training off was confirmed; empty, it is what makes the agent ask.
194
195
  const answers = {
@@ -231,12 +232,6 @@ export function scaffold(opts) {
231
232
  return { dir, manifest, pointer, guard, hook, promptHook };
232
233
  }
233
234
 
234
- // The local-model doc as a README section: its title dropped and its headings one level down.
235
- function localModelSection() {
236
- const text = readFileSync(join(HERE, "docs", "local-model.md"), "utf8");
237
- return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
238
- }
239
-
240
235
  // Day one. The record's first daily file records the one true thing that has happened to it: it
241
236
  // was created. It says so in three lines and says where the owner's words go, then gets out of the
242
237
  // way. It is not an example note, it asks nothing, and it schedules nothing; every later file
package/lib/adopt.mjs CHANGED
@@ -9,12 +9,16 @@
9
9
  // does not is never guessed at: it is named back, and the owner answers with `--map`.
10
10
  // - What is written: evda.json, AGENTS.md (the one create-evda ships), the GEMINI.md loader when
11
11
  // absent, the `@AGENTS.md` line appended to the owner's CLAUDE.md (or the loader when there is
12
- // none), folder manifests in the record folders, .evda/, and the Claude Code hooks in
13
- // .claude/settings.json. Every other file that was there stays byte for byte.
12
+ // none), folder manifests in the record folders, .evda/, the Claude Code hooks in
13
+ // .claude/settings.json, and the README.md sections AGENTS.md sends the agent to (training off,
14
+ // what each AI company says, a local model: HQ-2812), as one marked block after the owner's
15
+ // README, or the whole README create-evda writes when there is none. Every other file that was
16
+ // there stays byte for byte.
14
17
  // - Every write is listed in a reversal manifest outside the folder. `revertAdopt` restores the
15
18
  // folder byte for byte, folder times included, and refuses when a file the adopt wrote or changed
16
- // was edited since, unless forced. Two edits never block it: the owner's own edits to CLAUDE.md
17
- // (only the added line is taken out) and `evda upgrade` replacing AGENTS.md's generated block.
19
+ // was edited since, unless forced. Edits that never block it: the owner's own edits to CLAUDE.md
20
+ // or README.md (only the added line, or the added block, is taken out), `evda upgrade` replacing
21
+ // AGENTS.md's generated block, and `evda upgrade` refreshing the README block.
18
22
  //
19
23
  // EVDA_ADOPT_FALSIFY=<mode,...>, for the tests: guess-unplaced places an unknown folder in
20
24
  // knowledge instead of asking; claude-rewrite writes CLAUDE.md as the loader instead of appending;
@@ -40,6 +44,8 @@ import { readPointer, setPointer, pointerPath } from "./locate.mjs";
40
44
  import { stampInstalledVersion } from "./update-check.mjs";
41
45
  import { doctor } from "./doctor.mjs";
42
46
  import { everyChatSetupLines } from "./every-chat.mjs";
47
+ import { outsideRecord } from "./record-walk.mjs";
48
+ import { ensureReadme, withoutBlock, README_REL } from "./readme.mjs";
43
49
 
44
50
  export const ADOPT_VERSION = 1;
45
51
  export const ADOPT_FALSIFY_MODES = ["guess-unplaced", "claude-rewrite", "revert-ignores-edits", "revert-not-exact"];
@@ -56,13 +62,13 @@ export const CLAUDE_LINE = "@AGENTS.md";
56
62
  // The starter kit's seven folders. Five or more of them at the top is an Iris Vault.
57
63
  export const IRIS_VAULT_DIRS = ["00_Inbox", "01_Projects", "02_Areas", "03_Resources", "04_Archive", "05_Attachments", "06_Metadata"];
58
64
  // Folders no one keeps notes in: tooling, never asked about.
59
- const IGNORED = new Set(["node_modules", "dist", "build"]);
65
+ const IGNORED = new Set(["dist", "build"]); // plus outsideRecord: node_modules, any *.nosync
60
66
  // What the adopt writes under these is derived state, rebuilt by the tooling, so an edit there never
61
67
  // blocks a revert.
62
68
  const DERIVED = [".evda/"];
63
69
  // The files whose bytes are kept before the adopt runs, because the adopt (or an installer it calls)
64
70
  // may change them. Anything else that changes is a bug, and the adopt says so.
65
- const MAY_CHANGE = ["CLAUDE.md", "GEMINI.md", "AGENTS.md", "evda.json", ".claude/settings.json"];
71
+ const MAY_CHANGE = ["CLAUDE.md", "GEMINI.md", "AGENTS.md", "evda.json", ".claude/settings.json", README_REL];
66
72
 
67
73
  // The Iris Vault mapping, as the reference instance (the starter kit's author's own vault) keeps it.
68
74
  // `make: true` is a folder the role needs that the starter kit does not ship.
@@ -119,7 +125,7 @@ export function parseMapArgs(values) {
119
125
  export function proposeAdoption(dirIn, { map = {} } = {}) {
120
126
  const dir = resolve(dirIn);
121
127
  if (!existsSync(dir) || !statSync(dir).isDirectory()) throw refuse(`${dir} is not a folder.`, "NOT_A_FOLDER");
122
- const top = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !IGNORED.has(d.name)).map((d) => d.name).sort();
128
+ const top = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !IGNORED.has(d.name) && !outsideRecord(d.name)).map((d) => d.name).sort();
123
129
  const iris = IRIS_VAULT_DIRS.filter((d) => top.includes(d)).length >= 5;
124
130
  const entries = [];
125
131
  const problems = [];
@@ -181,7 +187,7 @@ export function proposalLines(p) {
181
187
  function walk(root, { files, dirs }, rel = "") {
182
188
  for (const d of readdirSync(join(root, rel), { withFileTypes: true })) {
183
189
  const r = rel ? `${rel}/${d.name}` : d.name;
184
- if (d.name === ".git" || d.name === "node_modules") continue;
190
+ if (d.name === ".git" || outsideRecord(d.name)) continue;
185
191
  const abs = join(root, r);
186
192
  const st = lstatSync(abs);
187
193
  if (st.isDirectory()) { dirs.set(r, { atimeMs: st.atimeMs, mtimeMs: st.mtimeMs }); walk(root, { files, dirs }, r); }
@@ -233,8 +239,12 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
233
239
  if (existsSync(join(dir, "evda.json"))) throw refuse(`${dir} already holds evda.json, so it is already an EVDA. Nothing was written. \`evda update\` keeps it current.`, "ALREADY_ADOPTED");
234
240
  const p = proposeAdoption(dir, { map });
235
241
  const questions = adoptQuestions(p);
242
+ const agentsText = fillOwnerSection(render("AGENTS.md"), { agentName, owner, purpose: "", voice: "", ai: "" });
236
243
  const result = { dir, dry, proposal: p, questions, lines: proposalLines(p) };
237
- if (dry) return result;
244
+ if (dry) {
245
+ const rd = ensureReadme(dir, { agents: agentsText, dry: true });
246
+ return { ...result, readme: rd, lines: [...result.lines, ` ${dryReadmeLine(rd)}`] };
247
+ }
238
248
  if (questions.length) throw new EvdaError(["REFUSED: some folders need your answer before anything is written. Nothing was written.", ...questions.map((q) => ` ${q}`)].join("\n"), { code: "NEEDS_ANSWERS", exit: 2 });
239
249
  for (const f of ["AGENTS.md"]) if (existsSync(join(dir, f))) throw refuse(`${f} is already in ${dir}, and the adopt writes its own; it never overwrites a file of yours. Rename it, or map it by hand in evda.json. Nothing was written.`, "AGENTS_EXISTS");
240
250
 
@@ -267,7 +277,10 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
267
277
  if (filing) manifest.filing = filing;
268
278
  writeNew("evda.json", JSON.stringify(manifest, null, 2) + "\n");
269
279
  // 3. The operating layer, the one create-evda ships, with the owner section filled from the answers.
270
- writeNew("AGENTS.md", fillOwnerSection(render("AGENTS.md"), { agentName, owner, purpose: "", voice: "", ai: "" }));
280
+ writeNew("AGENTS.md", agentsText);
281
+ // 3b. The README sections AGENTS.md cites (HQ-2812): appended as one marked block, or the whole
282
+ // README when there is none. The owner's text is never touched.
283
+ const readme = ensureReadme(dir, { agents: agentsText });
271
284
  // 4. Loaders. The owner's CLAUDE.md is theirs: one line is added at its end and nothing else changes.
272
285
  const claude = join(dir, "CLAUDE.md");
273
286
  let claudeAdded = null;
@@ -313,7 +326,7 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
313
326
  const kept = before.kept.get(rel);
314
327
  if (kept ? kept.equals(buf) : was.size === st.size && was.mtimeMs === st.mtimeMs) continue;
315
328
  if (!kept) { unexpected.push(rel); continue; }
316
- modified.push({ path: rel, before: kept.toString("base64"), mode: was.mode, atimeMs: was.atimeMs, mtimeMs: was.mtimeMs, sha256: sha256(buf), ...(rel === "CLAUDE.md" && claudeAdded ? { added: claudeAdded } : {}) });
329
+ modified.push({ path: rel, before: kept.toString("base64"), mode: was.mode, atimeMs: was.atimeMs, mtimeMs: was.mtimeMs, sha256: sha256(buf), ...(rel === "CLAUDE.md" && claudeAdded ? { added: claudeAdded } : {}), ...(rel === README_REL && readme.added ? { added: readme.added, block: true } : {}) });
317
330
  }
318
331
  const dirs = [...after.dirs.keys()].filter((d) => !before.dirs.has(d));
319
332
  const dirTimes = [...before.dirs].filter(([d]) => existsSync(join(dir, d))).map(([d, t]) => ({ path: d, ...t }));
@@ -337,7 +350,7 @@ export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName =
337
350
  `adopted ${dir} in place. Nothing moved.`,
338
351
  ...proposalLines(p).slice(1),
339
352
  ` wrote ${created.length} new file(s): ${created.map((c) => c.path).join(", ")}`,
340
- ` changed ${modified.length} file(s) of yours: ${modified.map((m) => m.path + (m.added ? " (one line added at the end)" : "")).join(", ") || "none"}`,
353
+ ` changed ${modified.length} file(s) of yours: ${modified.map((m) => m.path + (m.block ? ` (EVÐA's block added at the end: ${readme.citations.map((c) => `"${c}"`).join(", ")})` : m.added ? " (one line added at the end)" : "")).join(", ") || "none"}`,
341
354
  ...(dirs.length ? [` made ${dirs.length} folder(s): ${dirs.join(", ")}`] : []),
342
355
  ...hookLines.map((l) => ` ${l}`),
343
356
  ` filing: ${filing === "everything" ? "everything you say is filed automatically" : filing === "asked" ? "only when you ask" : "not answered yet; your agent asks the first time you connect"}`,
@@ -360,6 +373,14 @@ export function connectLines(dir, filing) {
360
373
  return out;
361
374
  }
362
375
 
376
+ // What a dry run says about README.md, naming the sections it would add.
377
+ function dryReadmeLine(r) {
378
+ const names = r.citations.map((c) => `"${c}"`).join(", ");
379
+ if (r.action === "written") return `${README_REL}: would be written, with ${names}`;
380
+ if (r.action === "appended") return `${README_REL}: EVÐA's block would be added at the end, with ${names}; your text above it stays as it is`;
381
+ return `${README_REL}: already has ${names}; left alone`;
382
+ }
383
+
363
384
  // ---- revert ----
364
385
 
365
386
  export function loadAdoptManifest(path) {
@@ -406,7 +427,14 @@ export function revertAdopt(path, { force = false, now = new Date(), env = proce
406
427
  for (const f of m.modified) {
407
428
  const a = join(root, f.path);
408
429
  if (hashOf(a) === f.sha256) continue;
409
- if (f.added && existsSync(a) && withoutLine(readFileSync(a, "utf8"), f.added) !== null) { ownerEdits.push(f.path); continue; }
430
+ if (f.block && existsSync(a)) {
431
+ // The README block: refreshed by `evda upgrade` and nothing else, the bytes before come back
432
+ // whole; edited around by the owner, their edits stay and only the block goes.
433
+ const rest = withoutBlock(readFileSync(a, "utf8"), f.added);
434
+ if (rest !== null && Buffer.from(rest).equals(Buffer.from(f.before, "base64"))) continue;
435
+ if (rest !== null) { ownerEdits.push(f.path); continue; }
436
+ }
437
+ if (f.added && !f.block && existsSync(a) && withoutLine(readFileSync(a, "utf8"), f.added) !== null) { ownerEdits.push(f.path); continue; }
410
438
  edited.push(f.path);
411
439
  }
412
440
  if (edited.length && !force && !falsified("revert-ignores-edits")) {
@@ -421,7 +449,7 @@ export function revertAdopt(path, { force = false, now = new Date(), env = proce
421
449
  const st = existsSync(a) ? statSync(a) : null;
422
450
  if (ownerEdits.includes(f.path) && !force) {
423
451
  // Their edits stay: only the line the adopt added is taken out.
424
- writeFileSync(a, withoutLine(readFileSync(a, "utf8"), f.added));
452
+ writeFileSync(a, f.block ? withoutBlock(readFileSync(a, "utf8"), f.added) : withoutLine(readFileSync(a, "utf8"), f.added));
425
453
  if (st) utimesSync(a, st.atimeMs / 1000, st.mtimeMs / 1000);
426
454
  } else {
427
455
  mkdirSync(dirname(a), { recursive: true });
@@ -460,7 +488,7 @@ export function revertAdopt(path, { force = false, now = new Date(), env = proce
460
488
  export function revertLines(r) {
461
489
  const out = [`Reverted the adopt of ${r.target}.`, ` removed ${r.removed} file(s) the adopt wrote`, ` restored ${r.restored} file(s) of yours to their bytes before it`];
462
490
  if (r.derived) out.push(` removed .evda/, which the adopt made, with ${r.derived} file(s) the tooling wrote into it since`);
463
- for (const f of r.ownerEdits) out.push(` kept your edits to ${f}; only the ${CLAUDE_LINE} line the adopt added was taken out`);
491
+ for (const f of r.ownerEdits) out.push(` kept your edits to ${f}; only the ${f === README_REL ? "EVÐA block" : `${CLAUDE_LINE} line`} the adopt added was taken out`);
464
492
  if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the adopt were reverted anyway: ${r.edited.join(", ")}`);
465
493
  for (const d of r.kept) out.push(` kept ${d}/, which holds files the adopt did not write`);
466
494
  if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
@@ -9,13 +9,19 @@
9
9
  // The protocol, version 1. Over TLS to the host, on one connection:
10
10
  // client -> host POST /.well-known/evda-attestation {"v":1,"nonce":"<base64, 32 bytes>"}
11
11
  // host -> client {"v":1,"platform":"tdx","quote":"<base64>","collateral":{tcbInfo,
12
- // tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain}}
12
+ // tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain},
13
+ // "wrapKey":"<base64, 32 bytes, optional>"}
13
14
  // The VM puts reportDataFor(nonce, its TLS public key) in the quote's REPORTDATA. The client takes the
14
15
  // TLS key from the connection it is holding, never from the response body, so a host cannot name a key
15
16
  // other than the one it is actually serving with. Delivery then goes over a connection pinned to that
16
17
  // key (SHA-256 of its SPKI DER); a connection presenting any other key is refused before anything is
17
18
  // written to it.
18
19
  //
20
+ // A host that will receive circle keys also names `wrapKey`, the X25519 public key it made inside the
21
+ // VM, and binds it into REPORTDATA beside the nonce and TLS key (reportDataFor's second form). The
22
+ // client has to take that key from the body, so the binding is what makes it trustworthy: a key the
23
+ // quote does not bind is refused as NOT_BOUND. lib/attested-wrap.mjs wraps circle keys to it.
24
+ //
19
25
  // The pinned measurement is the client's own configuration, never something a host supplies. With no
20
26
  // pinned measurement the gate refuses: an unconfigured client is not a client that trusts everyone.
21
27
  //
@@ -45,6 +51,8 @@ export const REFUSAL = Object.freeze({
45
51
  NOT_PROVEN: "not-proven",
46
52
  WRONG_CONNECTION: "wrong-connection",
47
53
  NOT_DELIVERED: "not-delivered",
54
+ NO_WRAP_KEY: "no-wrap-key",
55
+ NOTHING_TO_WRAP: "nothing-to-wrap",
48
56
  });
49
57
 
50
58
  // What a person reads. Each says that nothing was sent and why, in words that need no TDX.
@@ -59,12 +67,14 @@ const SAID = {
59
67
  [REFUSAL.NOT_PROVEN]: "the server could not prove it is a locked, up to date part of the chip",
60
68
  [REFUSAL.WRONG_CONNECTION]: "the connection is not the one the server proved, so someone else could be in the middle",
61
69
  [REFUSAL.NOT_DELIVERED]: "the server was proven, and sending to it then failed",
70
+ [REFUSAL.NO_WRAP_KEY]: "the server did not prove it made a key of its own to receive your keys",
71
+ [REFUSAL.NOTHING_TO_WRAP]: "no keys were named to send",
62
72
  };
63
73
 
64
74
  const sha256Hex = (b) => createHash("sha256").update(b).digest("hex");
65
75
 
66
76
  /** A refusal: nothing was sent. `reason` is the detail for a log, `message` the sentence for a person. */
67
- function refuse(code, reason) {
77
+ export function refuse(code, reason) {
68
78
  return { ok: false, sent: false, code, reason, message: `Nothing was sent: ${SAID[code]}.` };
69
79
  }
70
80
 
@@ -122,7 +132,13 @@ export function parseAttestationResponse(body) {
122
132
  if (r.platform !== "tdx") return { error: `the response is for platform ${JSON.stringify(r.platform ?? null)}, not tdx` };
123
133
  if (typeof r.quote !== "string" || r.quote.length === 0) return { error: "the response carries no quote", missing: true };
124
134
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(r.quote)) return { error: "the quote is not base64" };
125
- return { quote: Buffer.from(r.quote, "base64"), collateral: r.collateral ?? null };
135
+ let wrapKey = null;
136
+ if (r.wrapKey != null) {
137
+ if (typeof r.wrapKey !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(r.wrapKey)) return { error: "the wrap key is not base64" };
138
+ wrapKey = Buffer.from(r.wrapKey, "base64");
139
+ if (wrapKey.length !== 32) return { error: `the wrap key is ${wrapKey.length} bytes, not 32` };
140
+ }
141
+ return { quote: Buffer.from(r.quote, "base64"), collateral: r.collateral ?? null, wrapKey };
126
142
  }
127
143
 
128
144
  // The verifier's reasons, sorted into what a person needs to know. The tests hold one case per code,
@@ -173,26 +189,28 @@ export function createAttestedHostGate({ policy, nonces = createNonceBook(), clo
173
189
  if (!answer.tlsPublicKey) return refuse(REFUSAL.WRONG_CONNECTION, "the transport did not report the TLS key of the connection the quote came over");
174
190
 
175
191
  const v = verifyTdxAttestation({
176
- quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey,
192
+ quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey, wrapPublicKey: parsed.wrapKey ?? undefined,
177
193
  expected: policy.measurements, minTcb: policy.minTcb, collateral: parsed.collateral, now: clock(), trust,
178
194
  });
179
195
  if (!v.ok) return refuse(classify(v.reason), v.reason);
180
- return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb });
196
+ return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb, wrapKey: v.wrapPublicKey ?? null });
181
197
  },
182
198
  };
183
199
  }
184
200
 
185
- function session({ pin, measurements, tcb }) {
201
+ function session({ pin, measurements, tcb, wrapKey }) {
186
202
  return {
187
203
  ok: true,
188
204
  pin,
189
205
  measurements,
190
206
  tcb,
207
+ // The VM's key-wrapping key, only when the quote bound it; null when the host named none.
208
+ wrapKey,
191
209
  async send(payload, { deliver } = {}) {
192
210
  if (typeof deliver !== "function") return refuse(REFUSAL.NOT_DELIVERED, "no delivery transport was given");
193
211
  let bytes;
194
212
  try {
195
- bytes = typeof payload === "function" ? await payload({ pin, measurements }) : payload;
213
+ bytes = typeof payload === "function" ? await payload({ pin, measurements, wrapKey }) : payload;
196
214
  } catch (err) {
197
215
  return refuse(REFUSAL.NOT_DELIVERED, `the payload could not be prepared: ${err?.message ?? err}`);
198
216
  }
@@ -0,0 +1,147 @@
1
+ // Circle keys go to a hosted EVÐA only sealed to a key born inside the attested VM. (HQ-2083, Spec 08
2
+ // section 8: the VM holds a person's circle keys and EVÐA's operators cannot read them.)
3
+ //
4
+ // The VM side, createEnclaveKey(): the VM makes an X25519 key pair when it starts, keeps the private
5
+ // half in this closure (inside TDX-encrypted memory on a c3 VM), and puts the public half into every
6
+ // quote's REPORTDATA beside the nonce and TLS key (tdx-attestation.mjs reportDataFor, second form). It
7
+ // names the public half as `wrapKey` in its attestation answer (lib/attested-host.mjs).
8
+ //
9
+ // The client side, wrapCircleKeysTo(session, ...): `session` is what gate.open returned. Only a session
10
+ // whose quote verified under the pinned measurement AND bound the wrap key gets anything; the client's
11
+ // key holder is not asked to seal a single circle key before that. The holder is the HostCrypto shape
12
+ // every EVÐA device already has (`sealCircleKeyTo(circleId, epoch, publicKey)`, contracts/src/host.ts),
13
+ // so the raw scalar never passes through this file on a real device.
14
+ //
15
+ // The envelope is the circle key delivery wire (xania/seal/v1, the same bytes `sealCircleKeyTo` makes
16
+ // and `importCircleKey` opens, pinned by ekati's circle-seal vectors):
17
+ // [version 0x01][ephemeral X25519 public:32][ChaCha20-Poly1305 ciphertext][tag:16]
18
+ // key||nonce = HKDF-SHA256(ECDH, salt empty, info "xania/seal/v1" || ephemeral public || recipient public, 44)
19
+ // AAD = the 33-byte header.
20
+ // sealToKey and openSealed are the dependency-free copies person-record and the VM use; the test opens
21
+ // the frozen vectors with them, so a drift from the wire fails there.
22
+ //
23
+ // The payload the client delivers, version 1:
24
+ // { "v": 1, "wrapKey": "<base64>", "keys": [{ "circleId", "epoch", "sealedKey": "<base64>" }] }
25
+ //
26
+ // Not here yet: the Swift port of the client side in iris-mobile, the VM's server that holds what it
27
+ // unwrapped, and a signed key-delivery string (the relay path signs deliveries because a relay could
28
+ // plant a key; here the gate's pinned TLS key carries the payload, so the host is already the proven VM).
29
+
30
+ import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync } from "node:crypto";
31
+ import { REFUSAL, refuse } from "./attested-host.mjs";
32
+ import { reportDataFor } from "./tdx-attestation.mjs";
33
+
34
+ /** The length of a key-wrapping public key: raw X25519. */
35
+ export const WRAP_KEY_BYTES = 32;
36
+
37
+ /** The version of the wrapped-keys payload. */
38
+ export const WRAPPED_KEYS_VERSION = 1;
39
+
40
+ const SEAL_VERSION = 1;
41
+ const SEAL_KDF_DOMAIN = Buffer.from("xania/seal/v1", "utf8");
42
+ const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
43
+
44
+ const rawPublic = (keyObject) => keyObject.export({ type: "spki", format: "der" }).subarray(-32);
45
+ const x25519Public = (raw) => {
46
+ const b = Buffer.from(raw);
47
+ if (b.length !== WRAP_KEY_BYTES) throw new Error(`an X25519 public key is 32 bytes, not ${b.length}`);
48
+ return createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, b]), format: "der", type: "spki" });
49
+ };
50
+
51
+ function sealDerive(shared, ephemeralPublic, recipientPublic) {
52
+ const okm = Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), Buffer.concat([SEAL_KDF_DOMAIN, ephemeralPublic, recipientPublic]), 44));
53
+ return { key: okm.subarray(0, 32), nonce: okm.subarray(32, 44) };
54
+ }
55
+
56
+ /** Seals `plaintext` to the raw X25519 `recipientPublicKey`, with a fresh ephemeral key per call. */
57
+ export function sealToKey(recipientPublicKey, plaintext) {
58
+ const recipient = Buffer.from(recipientPublicKey);
59
+ const ephemeral = generateKeyPairSync("x25519");
60
+ const ephemeralPublic = rawPublic(ephemeral.publicKey);
61
+ const shared = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: x25519Public(recipient) });
62
+ const header = Buffer.concat([Buffer.from([SEAL_VERSION]), ephemeralPublic]);
63
+ const { key, nonce } = sealDerive(shared, ephemeralPublic, recipient);
64
+ const cipher = createCipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
65
+ cipher.setAAD(header, { plaintextLength: plaintext.length });
66
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]);
67
+ return Buffer.concat([header, body, cipher.getAuthTag()]);
68
+ }
69
+
70
+ /** Opens an envelope with the X25519 private KeyObject it was sealed to. Throws when it does not open. */
71
+ export function openSealed(privateKey, envelope) {
72
+ const buf = Buffer.from(envelope);
73
+ if (buf.length < 1 + 32 + 16) throw new Error(`the envelope is ${buf.length} bytes, too short to be one`);
74
+ if (buf[0] !== SEAL_VERSION) throw new Error(`unknown envelope version ${buf[0]}`);
75
+ const header = buf.subarray(0, 33);
76
+ const ephemeralPublic = buf.subarray(1, 33);
77
+ const ciphertext = buf.subarray(33, buf.length - 16);
78
+ const shared = diffieHellman({ privateKey, publicKey: x25519Public(ephemeralPublic) });
79
+ const { key, nonce } = sealDerive(shared, ephemeralPublic, rawPublic(createPublicKey(privateKey)));
80
+ const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
81
+ decipher.setAAD(header, { plaintextLength: ciphertext.length });
82
+ decipher.setAuthTag(buf.subarray(buf.length - 16));
83
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
84
+ }
85
+
86
+ // ─── the VM ──────────────────────────────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * The VM's key-wrapping key, made inside the VM. Returns a frozen object holding the public half and two
90
+ * functions; the private half is reachable only through them.
91
+ * reportDataFor(nonce, tlsPublicKey): the REPORTDATA the VM asks the TDX module to sign.
92
+ * unwrapAll(payload): opens a wrapped-keys payload addressed to this key. Throws when the payload
93
+ * names another key or any envelope does not open. Returns [{ circleId, epoch, scalar }].
94
+ */
95
+ export function createEnclaveKey() {
96
+ const pair = generateKeyPairSync("x25519");
97
+ const publicKey = rawPublic(pair.publicKey);
98
+ return Object.freeze({
99
+ publicKey,
100
+ reportDataFor: (nonce, tlsPublicKey) => reportDataFor(nonce, tlsPublicKey, publicKey),
101
+ unwrapAll(payload) {
102
+ if (payload?.v !== WRAPPED_KEYS_VERSION) throw new Error(`the payload speaks version ${JSON.stringify(payload?.v ?? null)}, not ${WRAPPED_KEYS_VERSION}`);
103
+ if (!Buffer.from(String(payload.wrapKey ?? ""), "base64").equals(publicKey)) throw new Error("the payload was wrapped to another key");
104
+ if (!Array.isArray(payload.keys)) throw new Error("the payload carries no keys");
105
+ return payload.keys.map((k) => {
106
+ const scalar = openSealed(pair.privateKey, Buffer.from(String(k.sealedKey ?? ""), "base64"));
107
+ if (scalar.length !== 32) throw new Error("a circle key is 32 bytes");
108
+ return { circleId: k.circleId, epoch: k.epoch, scalar };
109
+ });
110
+ },
111
+ });
112
+ }
113
+
114
+ // ─── the client ──────────────────────────────────────────────────────────────────────────────────
115
+
116
+ function circlesProblem(circles) {
117
+ if (!Array.isArray(circles) || circles.length === 0) return "no circle keys were named";
118
+ for (const c of circles) {
119
+ if (typeof c?.circleId !== "string" || c.circleId.length === 0) return "a circle key was named without its circle";
120
+ if (!Number.isInteger(c.epoch) || c.epoch < 1) return `circle ${c.circleId} was named with epoch ${JSON.stringify(c.epoch ?? null)}, not a whole number from 1`;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Wraps the named circle keys to the attested VM's own key and delivers them once over the attested
127
+ * connection. `session` is gate.open's result; `crypto` has `sealCircleKeyTo(circleId, epoch, publicKey)`;
128
+ * `circles` is [{ circleId, epoch }]; `deliver` is the transport session.send takes.
129
+ * Returns session.send's result, or a refusal with nothing sealed and nothing sent.
130
+ */
131
+ export async function wrapCircleKeysTo(session, { crypto, circles, deliver } = {}) {
132
+ if (!session?.ok) return session?.code ? session : refuse(REFUSAL.NOT_PROVEN, "no attested session was given");
133
+ const wrapKey = session.wrapKey;
134
+ if (!wrapKey) return refuse(REFUSAL.NO_WRAP_KEY, "the host's quote bound no key-wrapping key");
135
+ if (Buffer.from(wrapKey).length !== WRAP_KEY_BYTES) return refuse(REFUSAL.NO_WRAP_KEY, "the session's wrap key is not 32 bytes");
136
+ const problem = circlesProblem(circles);
137
+ if (problem) return refuse(REFUSAL.NOTHING_TO_WRAP, problem);
138
+ if (typeof crypto?.sealCircleKeyTo !== "function") return refuse(REFUSAL.NOTHING_TO_WRAP, "no key holder was given to seal the circle keys");
139
+ return session.send(async ({ wrapKey: bound }) => {
140
+ const keys = [];
141
+ for (const { circleId, epoch } of circles) {
142
+ const sealed = await crypto.sealCircleKeyTo(circleId, epoch, new Uint8Array(bound));
143
+ keys.push({ circleId, epoch, sealedKey: Buffer.from(sealed).toString("base64") });
144
+ }
145
+ return { v: WRAPPED_KEYS_VERSION, wrapKey: Buffer.from(bound).toString("base64"), keys };
146
+ }, { deliver });
147
+ }
@@ -0,0 +1,88 @@
1
+ // Is the doctor that is about to give a verdict the doctor on origin/main? (HQ-2736)
2
+ //
3
+ // On 2026-10-19 a session checked a vault change with `evda doctor` from a checkout sitting on a
4
+ // branch merged two weeks earlier, read exit 0, and the change broke every evda PR's Argus
5
+ // baseline, because the doctor on origin/main refused what the stale one passed. The verdict was
6
+ // true of the code that ran; nothing said that code was not main's.
7
+ //
8
+ // So the doctor says, before its verdict, when its own checkout is behind origin/main or not on
9
+ // main. It says nothing on current main. It never changes the exit code: the exit is the record's
10
+ // verdict, and Argus runs the doctor from PR branches, where "not on main" is the expected state.
11
+ //
12
+ // The comparison target is origin/main after one bounded `git fetch` (FETCH_TIMEOUT_MS, no
13
+ // prompts, ssh in batch mode), because the local ref alone goes stale with the checkout: a
14
+ // checkout nobody fetched in two weeks would compare equal to its own two-week-old origin/main
15
+ // and read as current, which is the same false clean. A fetch only moves remote-tracking refs,
16
+ // never the working tree. EVDA_DOCTOR_FETCH=0 skips it and compares against the ref as last
17
+ // fetched, and says so. A failed fetch, no origin remote, or no git at all still lets every check
18
+ // run; the doctor then says it could not compare, and why.
19
+
20
+ import { spawnSync } from "node:child_process";
21
+
22
+ export const FETCH_TIMEOUT_MS = 5000;
23
+ const GIT_TIMEOUT_MS = 3000;
24
+
25
+ function git(root, args, timeout = GIT_TIMEOUT_MS, env = process.env) {
26
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout, env, stdio: ["ignore", "pipe", "pipe"] });
27
+ if (r.error) return { ok: false, out: "", err: r.error.code === "ETIMEDOUT" ? `timed out after ${timeout}ms` : String(r.error.message) };
28
+ // git's reason is its first `fatal:` line; the lines after it are advice ("and the repository exists.").
29
+ const errLines = (r.stderr ?? "").trim().split("\n").filter(Boolean);
30
+ const err = (errLines.find((l) => l.startsWith("fatal:")) ?? errLines[0] ?? "").replace(/^fatal:\s*/, "");
31
+ return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err };
32
+ }
33
+
34
+ const short = (sha) => sha.slice(0, 7);
35
+
36
+ // Returns { state, lines }. `lines` is what the doctor prints; it is empty exactly when the
37
+ // checkout is main at origin/main as of a fetch that just succeeded.
38
+ // state: "current" | "behind" | "off-main" | "not-compared" | "not-a-checkout"
39
+ export function checkoutFreshness(root, { fetch = process.env.EVDA_DOCTOR_FETCH !== "0", fetchTimeoutMs = FETCH_TIMEOUT_MS } = {}) {
40
+ const top = git(root, ["rev-parse", "--show-toplevel"]);
41
+ if (!top.ok) {
42
+ return { state: "not-a-checkout", lines: [`could not compare this doctor against origin/main: ${root} is not a git checkout (an installed or copied doctor). Its verdict is this copy's.`] };
43
+ }
44
+ const head = git(root, ["rev-parse", "HEAD"]);
45
+ if (!head.ok) return { state: "not-compared", lines: [`could not compare this doctor against origin/main: the checkout has no commit (${head.err}).`] };
46
+ const branch = git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).out || null;
47
+ const where = branch ? `branch ${branch}` : "a detached HEAD";
48
+
49
+ if (!git(root, ["remote", "get-url", "origin"]).ok) {
50
+ return { state: "not-compared", lines: [`could not compare this doctor against origin/main: the checkout has no origin remote. It runs from ${where} at ${short(head.out)}.`] };
51
+ }
52
+
53
+ let fetchNote = null;
54
+ if (fetch) {
55
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
56
+ if (!env.GIT_SSH_COMMAND) env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=3";
57
+ const f = git(root, ["fetch", "--quiet", "--no-tags", "origin", "main"], fetchTimeoutMs, env);
58
+ if (!f.ok) fetchNote = `git fetch failed (${f.err || "no reason given"})`;
59
+ } else {
60
+ fetchNote = "fetch skipped (EVDA_DOCTOR_FETCH=0)";
61
+ }
62
+
63
+ const ref = git(root, ["rev-parse", "--verify", "-q", "refs/remotes/origin/main"]);
64
+ if (!ref.ok) {
65
+ return { state: "not-compared", lines: [`could not compare this doctor against origin/main: ${fetchNote ?? "the fetch succeeded"} and there is no origin/main ref. It runs from ${where} at ${short(head.out)}.`] };
66
+ }
67
+ const behind = Number(git(root, ["rev-list", "--count", `HEAD..${ref.out}`]).out || 0);
68
+ const ahead = Number(git(root, ["rev-list", "--count", `${ref.out}..HEAD`]).out || 0);
69
+ const onMain = branch === "main";
70
+ const against = fetchNote ? `origin/main as last fetched (${short(ref.out)}; ${fetchNote}, so main may be further ahead)` : `origin/main ${short(ref.out)}`;
71
+
72
+ const lines = [];
73
+ let state;
74
+ if (behind > 0) {
75
+ state = "behind";
76
+ lines.push(`this doctor runs from ${where} at ${short(head.out)}, ${behind} commit(s) behind ${against}. Its verdict can differ from main's; rerun from an up-to-date main before trusting it.`);
77
+ } else if (!onMain) {
78
+ state = "off-main";
79
+ lines.push(`this doctor runs from ${where} at ${short(head.out)}, not main (${ahead} commit(s) ahead of ${against}). Its verdict is this branch's, which main may not share.`);
80
+ } else {
81
+ state = "current";
82
+ }
83
+ if (fetchNote && state === "current") {
84
+ state = "not-compared";
85
+ lines.push(`could not compare this doctor against current origin/main: ${fetchNote}. It matches ${against.replace(/ \(.*$/, "")} at ${short(ref.out)}.`);
86
+ }
87
+ return { state, lines };
88
+ }
package/lib/circles.mjs CHANGED
@@ -74,6 +74,7 @@ import { resolveRole } from "./roles.mjs";
74
74
  import { boundaries, insideAny } from "./records.mjs";
75
75
  import { parseYaml, toYaml } from "./yaml.mjs";
76
76
  import { EvdaError } from "./errors.mjs";
77
+ import { outsideRecord } from "./record-walk.mjs";
77
78
 
78
79
  export const ECO_SCHEMA_VERSION = "0.1";
79
80
  // atlas-arch:yaml-encoding: the eco manifest is a config file the substrate names by its suffix, not an entity key
@@ -544,13 +545,14 @@ export function frontmatterOf(text) {
544
545
  }
545
546
  }
546
547
 
547
- // Every markdown file under the record root, skipping dot-directories and node_modules. The mark
548
+ // Every markdown file under the record root, skipping dot-directories and what outsideRecord names
549
+ // (node_modules, any *.nosync). The mark
548
550
  // walk reads the whole record, not only the record roles: a released fact may live anywhere.
549
551
  export function markdownFiles(root) {
550
552
  const out = [];
551
553
  const walk = (abs) => {
552
554
  for (const name of readdirSync(abs).sort()) {
553
- if (name.startsWith(".") || name === "node_modules") continue;
555
+ if (name.startsWith(".") || outsideRecord(name)) continue;
554
556
  const p = join(abs, name);
555
557
  // A dangling symlink (target lives only on another machine, e.g. raw media kept
556
558
  // outside the synced vault) has nothing to stat. It is not markdown content either