create-evda 0.1.8 → 0.1.10

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 (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
package/lib/plugin.mjs ADDED
@@ -0,0 +1,208 @@
1
+ // `evda plugin <dir>`: a Claude plugin for one record, so Claude Cowork reaches it (HQ-2802).
2
+ //
3
+ // Cowork does not start the local servers in claude_desktop_config.json (support.claude.com
4
+ // article 11175166: they "aren't available in Cowork or claude.ai"), and its docs never say it
5
+ // reads a folder's CLAUDE.md, AGENTS.md or .claude/settings.json. It does load a plugin's skills,
6
+ // hooks/hooks.json and a local MCP server the app starts, when the task runs on the owner's
7
+ // computer (claude.com/docs/plugins/platform-support, read 2026-10-21). So a record reaches Cowork
8
+ // only through a plugin, built here for one record with its path baked in:
9
+ //
10
+ // .claude-plugin/plugin.json the manifest (claude.com/docs/plugins/build)
11
+ // .mcp.json the record's server: `evda mcp <record>`, by connect.mjs's rule
12
+ // hooks/hooks.json Stop, UserPromptSubmit and PreToolUse, each through
13
+ // scripts/evda-hook.mjs to the hooks `evda hook install` installs
14
+ // scripts/evda-hook.mjs lib/plugin-hook.mjs, copied (the plugin is a zip with no packages)
15
+ // skills/evda/SKILL.md the shipped evda skill, its description saying to load the
16
+ // operating layer first in every conversation, because Cowork may
17
+ // never read AGENTS.md
18
+ //
19
+ // Two rules from the same page shape it. Cowork ignores a server whose `${user_config.*}` has no
20
+ // default, because it does not prompt, so nothing here asks for configuration: the path is baked
21
+ // in. And claude.ai and Cowork refuse a plugin with a top-level bin/, so there is none.
22
+ //
23
+ // The owner uploads the zip in Customize, then Plugins, then Add, then Upload plugin. Exported for
24
+ // `evda adopt` (HQ-2797): buildPlugin(dir, { out }) and uploadLines(result).
25
+
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { basename, dirname, join, resolve } from "node:path";
28
+ import { deflateRawSync, inflateRawSync } from "node:zlib";
29
+ import { PACKAGE_ROOT, serverCommand, stableNode, installGuard, SKILL_SOURCE } from "./connect.mjs";
30
+ import { installHook, installPromptHook } from "./hooks.mjs";
31
+ import { filingOf } from "./day-file.mjs";
32
+ import { readManifest } from "./manifest.mjs";
33
+ import { EvdaError } from "./errors.mjs";
34
+
35
+ export const PLUGIN_SERVER = "evda";
36
+ export const LAUNCHER_SOURCE = join(PACKAGE_ROOT, "lib", "plugin-hook.mjs");
37
+ export const LAUNCHER_REL = "scripts/evda-hook.mjs";
38
+ export const UPLOAD_STEP = "In the Claude app, open Customize, then Plugins, then Add, then Upload plugin, and choose";
39
+
40
+ const packageVersion = () => JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
41
+
42
+ // A plugin name is lowercase words joined by hyphens, and permanent: one per record folder.
43
+ export function pluginName(dir) {
44
+ const slug = basename(resolve(dir)).normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
45
+ return slug ? `evda-${slug}` : "evda-record";
46
+ }
47
+
48
+ // One shell word, whatever the path holds: spaces, quotes, $ and backticks included.
49
+ export const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
50
+
51
+ // The server command for a plugin. connect.mjs's rule (absolute node and bin when installed
52
+ // somewhere stable), except that the npx form is pinned to this version and names npx by its
53
+ // absolute path with a PATH that finds node: the desktop app starts servers with a minimal PATH,
54
+ // and `@latest` would let an uploaded plugin change under the owner.
55
+ export function pluginServerCommand(dir, { execPath = process.execPath, bin, env = process.env, version = packageVersion() } = {}) {
56
+ const rec = resolve(dir);
57
+ const c = serverCommand(rec, { execPath, env, ...(bin ? { bin } : {}) });
58
+ if (c.command !== "npx") return { command: c.command, args: c.args };
59
+ const node = stableNode(execPath, env);
60
+ const npx = join(dirname(node), process.platform === "win32" ? "npx.cmd" : "npx");
61
+ const sep = process.platform === "win32" ? ";" : ":";
62
+ const path = [dirname(node), ...(process.platform === "win32" ? [] : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"])].join(sep);
63
+ return { command: existsSync(npx) ? npx : "npx", args: ["-y", "-p", `create-evda@${version}`, "evda", "mcp", rec], env: { PATH: path } };
64
+ }
65
+
66
+ // A hook command in shell form, the form every Claude Code version runs. The node this was built
67
+ // with when it is there, else whatever `node` the hook's PATH has (a Cowork VM, another machine).
68
+ export function hookCommand(mode, dir, { execPath = process.execPath, env = process.env } = {}) {
69
+ const node = stableNode(execPath, env);
70
+ return `n=${shq(node)}; [ -x "$n" ] || n=node; "$n" "\${CLAUDE_PLUGIN_ROOT}/${LAUNCHER_REL}" ${mode} ${shq(resolve(dir))}`;
71
+ }
72
+
73
+ function skillText() {
74
+ const shipped = readFileSync(SKILL_SOURCE, "utf8");
75
+ const body = shipped.replace(/^---\n[\s\S]*?\n---\n/, "");
76
+ const description = "Use at the start of every conversation and every task, before your first reply: call evda_operating_layer from this plugin's evda server and follow it for the whole session, because it says who you are for the owner and how their EVÐA record is kept. Also use whenever they talk about their day, a person, what is on their mind, or say evda, Iris, my record, journal this or log this. Files their words verbatim through the evda_* tools and never quizzes.";
77
+ return `---\nname: evda\ndescription: ${description}\n---\n${body}`;
78
+ }
79
+
80
+ // The plugin's files, as { relative path: string }.
81
+ export function pluginFiles(dir, opts = {}) {
82
+ const rec = resolve(dir);
83
+ const manifest = readManifest(rec);
84
+ const filing = filingOf(manifest);
85
+ const name = pluginName(rec);
86
+ const version = opts.version ?? packageVersion();
87
+ const server = pluginServerCommand(rec, { ...opts, version });
88
+ const hook = (mode, timeout, matcher) => ({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: hookCommand(mode, rec, opts), timeout }] });
89
+ const hooks = {
90
+ Stop: [hook("keep", 30)],
91
+ PreToolUse: [hook("guard", 30, "Read|Grep|Bash|NotebookRead")],
92
+ // The prompt hook files every message, so only an owner who chose "everything" gets it (HQ-1385).
93
+ ...(filing === "everything" ? { UserPromptSubmit: [hook("prompt", 15)] } : {}),
94
+ };
95
+ const owner = typeof manifest.owner === "string" ? manifest.owner : manifest.owner?.name;
96
+ return {
97
+ ".claude-plugin/plugin.json": JSON.stringify({
98
+ name,
99
+ displayName: `EVÐA: ${basename(rec)}`,
100
+ version,
101
+ description: `Reach the EVÐA record at ${rec} from Cowork: its evda tools, the hooks that keep it, and the skill that loads its operating layer first.`,
102
+ author: { name: owner || "EVÐA" },
103
+ }, null, 2) + "\n",
104
+ ".mcp.json": JSON.stringify({ mcpServers: { [PLUGIN_SERVER]: { type: "stdio", ...server } } }, null, 2) + "\n",
105
+ "hooks/hooks.json": JSON.stringify({ hooks }, null, 2) + "\n",
106
+ [LAUNCHER_REL]: readFileSync(LAUNCHER_SOURCE, "utf8"),
107
+ "skills/evda/SKILL.md": skillText(),
108
+ "README.md": `# ${name}\n\nThis plugin connects Claude Cowork to one EVÐA record, the folder at \`${rec}\` on this computer. It starts that record's evda MCP server, so Claude can read the record and file the owner's words into it verbatim, and it runs the record's own hooks in Cowork tasks: the one that asks before a task ends with nothing filed${filing === "everything" ? ", the one that files each message as it is sent" : ""}, and the read guard. Nothing leaves this computer through it. Rebuild it with \`evda plugin\` if the folder moves.\n`,
109
+ };
110
+ }
111
+
112
+ // CRC-32 (IEEE), for the zip.
113
+ const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
114
+ function crc32(buf) { let c = 0xffffffff; for (const b of buf) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
115
+
116
+ // A zip of { path: string|Buffer }, deflated, paths at the archive root.
117
+ export function zip(files) {
118
+ const locals = [];
119
+ const centrals = [];
120
+ let offset = 0;
121
+ for (const [path, content] of Object.entries(files)) {
122
+ const data = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
123
+ const comp = deflateRawSync(data);
124
+ const name = Buffer.from(path, "utf8");
125
+ const crc = crc32(data);
126
+ const local = Buffer.alloc(30);
127
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(8, 8);
128
+ local.writeUInt16LE(0, 10); local.writeUInt16LE(0x21, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(comp.length, 18);
129
+ local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);
130
+ const central = Buffer.alloc(46);
131
+ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(0x0314, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8);
132
+ central.writeUInt16LE(8, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0x21, 14); central.writeUInt32LE(crc, 16);
133
+ central.writeUInt32LE(comp.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28);
134
+ central.writeUInt32LE(((0o100644) << 16) >>> 0, 38); central.writeUInt32LE(offset, 42);
135
+ locals.push(local, name, comp);
136
+ centrals.push(central, name);
137
+ offset += 30 + name.length + comp.length;
138
+ }
139
+ const cd = Buffer.concat(centrals);
140
+ const end = Buffer.alloc(22);
141
+ const count = Object.keys(files).length;
142
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(count, 8); end.writeUInt16LE(count, 10);
143
+ end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
144
+ return Buffer.concat([...locals, cd, end]);
145
+ }
146
+
147
+ // The entries of a zip this module wrote, as { path: Buffer }. For tests and doctor-style checks.
148
+ export function unzip(buf) {
149
+ const out = {};
150
+ const endAt = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
151
+ if (endAt < 0) throw new Error("not a zip");
152
+ let p = buf.readUInt32LE(endAt + 16);
153
+ const count = buf.readUInt16LE(endAt + 10);
154
+ for (let i = 0; i < count; i++) {
155
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("bad central directory");
156
+ const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20), nlen = buf.readUInt16LE(p + 28);
157
+ const xlen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32), at = buf.readUInt32LE(p + 42);
158
+ const name = buf.subarray(p + 46, p + 46 + nlen).toString("utf8");
159
+ const dataAt = at + 30 + buf.readUInt16LE(at + 26) + buf.readUInt16LE(at + 28);
160
+ const raw = buf.subarray(dataAt, dataAt + csize);
161
+ out[name] = method === 8 ? inflateRawSync(raw) : Buffer.from(raw);
162
+ p += 46 + nlen + xlen + clen;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ const isOurs = (folder) => {
168
+ try { return JSON.parse(readFileSync(join(folder, ".claude-plugin", "plugin.json"), "utf8")).name?.startsWith("evda-"); } catch { return false; }
169
+ };
170
+
171
+ // Build the plugin folder and its zip. `out` is the zip's path (default
172
+ // <record>/.evda/plugin/<name>.zip, which no device sync carries); the folder sits beside it.
173
+ // Installs or refreshes the record's hooks first, so what the plugin points at is current.
174
+ export function buildPlugin(dir, { out = null, ...opts } = {}) {
175
+ const rec = resolve(dir);
176
+ if (!existsSync(join(rec, "evda.json"))) throw new EvdaError(`REFUSED: ${rec} has no evda.json, so it is not an EVÐA record. Nothing was written.`, { code: "NOT_A_RECORD", exit: 2 });
177
+ const name = pluginName(rec);
178
+ const zipPath = resolve(out ?? join(rec, ".evda", "plugin", `${name}.zip`));
179
+ if (!zipPath.endsWith(".zip")) throw new EvdaError(`REFUSED: --out names the zip to write, so it ends in .zip (got ${zipPath}). Nothing was written.`, { code: "BAD_OUT", exit: 2 });
180
+ const folder = zipPath.slice(0, -4);
181
+ if (existsSync(folder) && readdirSync(folder).length && !isOurs(folder)) throw new EvdaError(`REFUSED: ${folder} already holds something that is not an EVÐA plugin. Nothing was written.`, { code: "OUT_TAKEN", exit: 2 });
182
+
183
+ const installed = [installHook(rec).action];
184
+ installGuard(rec);
185
+ if (filingOf(readManifest(rec)) === "everything") installed.push(installPromptHook(rec).action);
186
+
187
+ const files = pluginFiles(rec, opts);
188
+ rmSync(folder, { recursive: true, force: true });
189
+ for (const [rel, content] of Object.entries(files)) {
190
+ const abs = join(folder, rel);
191
+ mkdirSync(dirname(abs), { recursive: true });
192
+ writeFileSync(abs, content);
193
+ }
194
+ mkdirSync(dirname(zipPath), { recursive: true });
195
+ writeFileSync(zipPath, zip(files));
196
+ return { name, record: rec, folder, zip: zipPath, files: Object.keys(files), bytes: statSync(zipPath).size, hooks: installed };
197
+ }
198
+
199
+ // What to tell the owner once it is built: the one step left is theirs.
200
+ export function uploadLines(r) {
201
+ return [
202
+ `Built the Cowork plugin ${r.name} for ${r.record}:`,
203
+ ` ${r.zip}`,
204
+ `${UPLOAD_STEP} that zip. Cowork then reaches this record in tasks that run on this computer, through its evda tools and hooks.`,
205
+ `It also loads in Claude Code sessions signed in to the same account. Its hooks stay quiet there, because the record's own hooks already run in it; its evda server shows up as a second one, and \`claude plugin disable ${r.name}@synced\` turns the plugin off in Claude Code only. If the folder moves, run \`evda plugin\` again and upload the new zip.`,
206
+ ];
207
+ }
208
+
package/lib/presets.mjs CHANGED
@@ -28,7 +28,10 @@
28
28
  // that are missing, and reports the rest: a manifest that already says the same thing is skipped,
29
29
  // one that says something else is a conflict and is left exactly as it is, and a preset path that
30
30
  // already exists as a file is a conflict. Nothing is moved, renamed or deleted, so applying twice
31
- // writes nothing the second time. A shipped preset that does not validate fails
31
+ // writes nothing the second time. A folder typed person also brings its layout (HQ-2024): a sibling
32
+ // Processing/ for processing docs, and a README inside the folder stating the one-file-per-person
33
+ // rule doctor checks, each only when missing and only when the folder's manifest is the preset's.
34
+ // A shipped preset that does not validate fails
32
35
  // test/presets.test.mjs, so the person running it never meets one.
33
36
 
34
37
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
@@ -40,6 +43,7 @@ import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders
40
43
  import { ROUTINE_KEYS } from "./routines.mjs";
41
44
  import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
42
45
  import { EvdaError } from "./errors.mjs";
46
+ import { PERSON_FOLDER_README, PROCESSING_DIR, README_NAME } from "./person-folders.mjs";
43
47
 
44
48
  export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
45
49
  export const PRESET_FILE = "preset.json";
@@ -223,6 +227,7 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
223
227
  // create directories that do not exist yet
224
228
  // write manifests that do not exist yet ({ path, folder, manifest })
225
229
  // views view files a manifest declares that do not exist yet
230
+ // files files a person folder brings that do not exist yet ({ path, text }): its README
226
231
  // matched manifests that exist and already say what the preset says (skipped)
227
232
  // conflicts manifests that exist and say something else, and preset paths that are files
228
233
  // ({ path, why }); reported, never overwritten
@@ -231,7 +236,7 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
231
236
  export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
232
237
  const evda = openEvda(resolve(root));
233
238
  const preset = loadPreset(id, { dir });
234
- const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
239
+ const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], files: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
235
240
  // The four roles the record does not declare yet. A key it has with another path is a conflict,
236
241
  // left as it is; a path it already maps under a key of the same role is already there.
237
242
  const has = evda.manifest.mapping;
@@ -263,15 +268,29 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
263
268
  const want = manifestFor(f);
264
269
  const mpath = `${f.path}/${FOLDER_MANIFEST}`;
265
270
  const mabs = join(abs, FOLDER_MANIFEST);
271
+ let ours = true;
266
272
  if (!existsSync(mabs)) plan.write.push({ path: mpath, folder: f, manifest: want });
267
- else if (!lstatSync(mabs).isFile()) plan.conflicts.push({ path: mpath, why: "exists and is not a file" });
273
+ else if (!lstatSync(mabs).isFile()) { plan.conflicts.push({ path: mpath, why: "exists and is not a file" }); ours = false; }
268
274
  else {
269
275
  let existing;
270
276
  try { existing = JSON.parse(readFileSync(mabs, "utf8")); } catch { plan.conflicts.push({ path: mpath, why: "exists and is not valid JSON" }); continue; }
271
277
  const diff = differences(existing, want);
272
- if (diff.length) plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` });
278
+ if (diff.length) { plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` }); ours = false; }
273
279
  else plan.matched.push({ path: mpath });
274
280
  }
281
+ if (f.type === "person" && ours) {
282
+ const readme = `${f.path}/${README_NAME}`;
283
+ const rabs = join(abs, README_NAME);
284
+ if (!existsSync(rabs)) plan.files.push({ path: readme, text: PERSON_FOLDER_README });
285
+ else if (!lstatSync(rabs).isFile()) plan.conflicts.push({ path: readme, why: "exists and is not a file" });
286
+ const parent = f.path.includes("/") ? f.path.slice(0, f.path.lastIndexOf("/")) : "";
287
+ const processing = parent ? `${parent}/${PROCESSING_DIR}` : PROCESSING_DIR;
288
+ const pfile = blockedBy(evda.dir, processing);
289
+ if (pfile) {
290
+ if (!blocked.has(pfile)) plan.conflicts.push({ path: pfile, why: `exists as a file, so ${processing}/ for processing docs beside ${f.path} cannot be made there` });
291
+ blocked.add(pfile);
292
+ } else if (!existsSync(join(evda.dir, processing)) && !plan.create.includes(processing)) plan.create.push(processing);
293
+ }
275
294
  for (const v of want.views) {
276
295
  if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
277
296
  }
@@ -295,6 +314,7 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
295
314
  }
296
315
  for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
297
316
  for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
317
+ for (const w of plan.files) writeFileSync(join(evda.dir, w.path), w.text, { flag: "wx" });
298
318
  for (const v of plan.views) {
299
319
  mkdirSync(dirname(resolve(evda.dir, v.path)), { recursive: true });
300
320
  writeFileSync(resolve(evda.dir, v.path), viewHeader(v.view), { flag: "wx" });
@@ -317,7 +337,7 @@ export function diffPreset(root, id, opts = {}) {
317
337
  return applyPreset(root, id, { ...opts, dry: true });
318
338
  }
319
339
 
320
- export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
340
+ export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !(plan.files ?? []).length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
321
341
 
322
342
  // The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
323
343
  export function summary(plan, preset = loadPreset(plan.id)) {
@@ -344,6 +364,7 @@ export function planLines(plan, { diff = false } = {}) {
344
364
  const verb = plan.dry || diff ? "would" : "did";
345
365
  for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
346
366
  for (const w of plan.write) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} type ${w.manifest.type}${w.manifest.connectors.length ? `, connectors ${w.manifest.connectors.join(", ")}` : ""}`);
367
+ for (const w of plan.files ?? []) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} the person folder's rule`);
347
368
  for (const r of plan.roles ?? []) out.push(` ${verb === "did" ? "declared" : "declare "} ${r.key} -> ${r.rel} in evda.json`);
348
369
  for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
349
370
  if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
package/lib/publish.mjs CHANGED
@@ -29,7 +29,9 @@
29
29
  // written, and a hit refuses. A label is its opaque id, its namespace, its own directory, a
30
30
  // member path, or its name between slashes. The exported files' own paths are the released
31
31
  // set (doctor prints them every run) and are left out of that search, since a marked file
32
- // necessarily lives under a keyed circle's member path.
32
+ // necessarily lives under a keyed circle's member path. A markdown file's `sources:`
33
+ // frontmatter field is cut from its exported bytes before this search (HQ-2698): it is
34
+ // provenance, the record's own paths, and is never published.
33
35
  //
34
36
  // The fourth check in the spec (the site holds no credential to anything else) is a property of
35
37
  // where the export is served, not of this code.
@@ -70,6 +72,41 @@ function filesUnder(abs) {
70
72
  const posix = (rel) => rel.split(sep).join("/");
71
73
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
74
 
75
+ // Provenance (HQ-2698). A top-level `sources:` field in a markdown file's frontmatter lists the
76
+ // record paths the file was written from: a blog Companion names the private draft and the journal
77
+ // days behind it. That is the record's own bookkeeping, never part of what a stranger reads, so it
78
+ // is cut from every exported markdown file, whatever folder or rule released it, the way a sealed
79
+ // section is. Only the frontmatter field is cut: a private path anywhere in the body still reaches
80
+ // the stranger check and refuses. The value runs to the next top-level key, so a block list,
81
+ // indented or not, a flow list on one or more lines and a bare string all go with the key.
82
+ const PROVENANCE_KEY = /^(["']?)sources\1[ \t]*:/;
83
+ const isFence = (line) => line.replace(/\r$/, "") === "---";
84
+ const continues = (line) => /^[ \t]/.test(line) || /^-([ \t]|\r?$)/.test(line);
85
+ const blank = (line) => line.replace(/\r$/, "").trim() === "";
86
+ export function withoutProvenance(text) {
87
+ const lines = String(text).split("\n");
88
+ if (!isFence(lines[0])) return { text, cut: 0 };
89
+ let end = 1;
90
+ while (end < lines.length && !isFence(lines[end])) end++;
91
+ if (end >= lines.length) return { text, cut: 0 };
92
+ const kept = [lines[0]];
93
+ let cut = 0;
94
+ for (let i = 1; i < end; i++) {
95
+ if (!PROVENANCE_KEY.test(lines[i])) { kept.push(lines[i]); continue; }
96
+ cut++;
97
+ let j = i + 1;
98
+ for (;;) {
99
+ let k = j;
100
+ while (k < end && blank(lines[k])) k++;
101
+ if (k < end && continues(lines[k])) j = k + 1;
102
+ else break;
103
+ }
104
+ i = j - 1;
105
+ }
106
+ if (!cut) return { text, cut: 0 };
107
+ return { text: [...kept, ...lines.slice(end)].join("\n"), cut };
108
+ }
109
+
73
110
  // Build the export in memory and run the checks. Writes nothing.
74
111
  export function buildPublicExport(evda, { circleName = null, publishedAt = new Date() } = {}) {
75
112
  const circle = publicCircle(evda, circleName);
@@ -114,14 +151,21 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
114
151
  // A sealed section (HQ-1220) is cut out of every markdown file on the way out; the rest of the
115
152
  // file is exported, and nothing marks where the section was. A sealed FILE never gets here:
116
153
  // released() refuses it, and a refusal refuses the publish above.
154
+ // A file's `sources:` provenance is cut the same way (withoutProvenance above, HQ-2698).
117
155
  let sealedCut = 0;
156
+ let provenanceCut = 0;
118
157
  const exportBytes = (abs) => {
119
158
  const bytes = readFileSync(abs);
120
- if (!/\.md$/i.test(abs) || !carries("publish", "sealed")) return bytes;
121
- const cut = withoutSealed(bytes.toString("utf8"));
122
- if (!cut.sections) return bytes;
123
- sealedCut += cut.sections;
124
- return Buffer.from(cut.text, "utf8");
159
+ if (!/\.md$/i.test(abs)) return bytes;
160
+ let text = bytes.toString("utf8");
161
+ let changed = false;
162
+ if (carries("publish", "sealed")) {
163
+ const cut = withoutSealed(text);
164
+ if (cut.sections) { sealedCut += cut.sections; text = cut.text; changed = true; }
165
+ }
166
+ const prov = withoutProvenance(text);
167
+ if (prov.cut) { provenanceCut += 1; text = prov.text; changed = true; }
168
+ return changed ? Buffer.from(text, "utf8") : bytes;
125
169
  };
126
170
  const loaded = files.filter((f) => !consumed.has(f.path)).map((f) => ({ path: f.path, bytes: exportBytes(f.abs), by: f.by }));
127
171
  for (const v of rendered.views) loaded.push({ path: v.path, bytes: v.bytes, by: "view", view: v.view, sources: v.entries.map((e) => ({ path: e.path, sha256: e.sha256 })) });
@@ -157,9 +201,9 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
157
201
  // What identifies a private circle to a stranger is a LABEL: a path segment, the circle's own
158
202
  // directory, or the opaque id that travels on a Change and in an export manifest. A name
159
203
  // matched as a word anywhere in the bytes is not that test, and on a real record it refuses
160
- // every publish there is: his private circles are core, health, money, people, school,
161
- // therapy, wellness and workshop, and his public fact sheet says "10 core mechanics" and
162
- // "help other people understand me" (2026-09-21, HQ-1199). So the name is matched only
204
+ // every publish there is: private circles are named with ordinary words (a circle called
205
+ // "core" or "people"), and a public fact sheet uses those words in plain prose ("10 core
206
+ // mechanics", "help other people understand me") (2026-09-21, HQ-1199). So the name is matched only
163
207
  // between slashes, and the id and namespace, which the word match never looked at, are
164
208
  // matched literally. The gap this leaves, stated so the next reader does not rediscover it:
165
209
  // a distinctive circle name written as bare prose passes.
@@ -188,7 +232,7 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
188
232
  }
189
233
  }
190
234
 
191
- return { manifest, files: loaded, sealedCut };
235
+ return { manifest, files: loaded, sealedCut, provenanceCut };
192
236
  }
193
237
 
194
238
  export function canonicalManifest(manifest) {
@@ -259,6 +303,7 @@ export async function publishCommand(dir, circleName, { out, key }) {
259
303
  console.log(`published ${built.files.length} file(s) from the public circle to ${at} (${built.files.length - marked - views.length} by path, ${marked} by mark${views.length ? `, ${views.length} rendered from ${drawn} entries` : ""})`);
260
304
  for (const f of views) console.log(` view ${f.path} rendered from ${f.sources.length} entries (${f.view})`);
261
305
  if (built.sealedCut) console.log(` ${built.sealedCut} sealed section(s) left out of the files they sit in`);
306
+ if (built.provenanceCut) console.log(` ${built.provenanceCut} file(s) exported without their sources: provenance`);
262
307
  console.log(`CHECKED ${built.files.length} public members, signed by ${signed.publicKey}`);
263
308
  return 0;
264
309
  }