create-evda 0.1.9 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
@@ -76,6 +76,9 @@ function oid(bytes) {
76
76
  return parts.join(".");
77
77
  }
78
78
 
79
+ // The TDX verifier (tdx-attestation.mjs) reads Intel's PCK certificate extensions with these.
80
+ export { tlv as derTlv, children as derChildren, oid as derOid };
81
+
79
82
  /** The OIDC issuer and the SAN URIs and emails of a Fulcio leaf, read from its DER. */
80
83
  export function certificateIdentity(der) {
81
84
  const buf = Buffer.from(der);
@@ -3,7 +3,7 @@
3
3
  // The six rules, each implemented once below:
4
4
  // 1. The file is copied to <record root>/.backup/people-migration-<YYYY-MM-DD>/ first.
5
5
  // 2. Frontmatter is added and nothing below it changes: kind, id (filename slug), schemaVersion,
6
- // name (the H1), observations. A file that already has frontmatter is skipped and reported.
6
+ // name (the H1), observations.
7
7
  // 3. One observation per dated H2/H3 heading: text is the heading text, since is its date at the
8
8
  // precision written (day or month). A range uses its start. A year-only heading stays prose.
9
9
  // 4. supersededBy only when a heading says it replaces an earlier one AND exactly one earlier
@@ -14,7 +14,12 @@
14
14
  // The owner is provenance owner; the person is provenance person with by naming them.
15
15
  // 6. Undated sections stay prose.
16
16
  // Before a file is reported done, the bytes after the frontmatter are hashed against the original.
17
- // A doc with no dated heading is left untouched and reported: a profile needs one observation.
17
+ // A profile needs one observation, so a doc with no dated heading gets one dated the day it is typed,
18
+ // saying no section carries a date yet (HQ-2024: doctor fails an untyped file in a person folder, so
19
+ // every file there has to be typeable). A doc with no H1 takes its name from its file name.
20
+ // A file that already has frontmatter keeps every key it has; only the missing person-profile keys
21
+ // are added below them. One that already says a kind, or is sealed or just noted, is skipped.
22
+ // gather apply runs the same plan on a page it places in a person folder (lib/gather-apply.mjs).
18
23
 
19
24
  import { createHash } from "node:crypto";
20
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -23,6 +28,7 @@ import { findRoot, holderOf, readManifest } from "./manifest.mjs";
23
28
  import { readEntry } from "./kinds.mjs";
24
29
  import { parseYaml, toYaml } from "./yaml.mjs";
25
30
  import { carries, frontmatterOf, heldBy, sealedSections, NOTED, SEALED } from "./circles.mjs";
31
+ import { baseName } from "./person-folders.mjs";
26
32
 
27
33
  // The lines of a body with every line of a sealed section blanked, so nothing in one becomes an
28
34
  // observation, a name or a quote in frontmatter every reader shows (HQ-1414). Line numbers are kept.
@@ -105,18 +111,40 @@ export function speakerQuote(line) {
105
111
  return null;
106
112
  }
107
113
 
108
- // Plan the frontmatter for one doc. Pure: reads nothing from disk.
109
- export function planProfile({ fileName, text, owner }) {
114
+ const today = () => { const d = new Date(); return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; };
115
+
116
+ // A file's own frontmatter: its YAML lines and where its body starts, or null when it has none.
117
+ function ownFrontmatter(text) {
118
+ const lines = text.split("\n");
119
+ if (lines[0] !== "---") return null;
120
+ const end = lines.indexOf("---", 1);
121
+ if (end === -1) return null;
122
+ return { head: lines.slice(1, end), body: lines.slice(end + 1).join("\n") };
123
+ }
124
+
125
+ // Plan the frontmatter for one doc. Pure: reads nothing from disk. `date` dates the one observation
126
+ // a doc with no dated heading gets. Returns { entry, frontmatter, bodyAt, notes, problems } where
127
+ // the file becomes frontmatter + the original from byte bodyAt on, or { skip }.
128
+ export function planProfile({ fileName, text, owner, date = today() }) {
110
129
  const notes = [];
111
130
  // A sealed or just-noted file always has frontmatter, so it is skipped here and never rewritten.
112
131
  // EVDA_CARRY_FALSIFY=migrate-people:noted lets a noted one through, as a migration that read past
113
132
  // its frontmatter would, to show this skip is what keeps it noted.
114
133
  const falsifiedNoted = !carries("migrate-people", NOTED) && frontmatterOf(text)?.[NOTED] === true && !heldBy(frontmatterOf(text), "migrate-people");
115
- if (/^---\r?\n/.test(text) && !falsifiedNoted) return { skip: "already has frontmatter" };
116
- const lines = openLines(text);
134
+ let own = null;
135
+ if (/^---\r?\n/.test(text) && !falsifiedNoted) {
136
+ own = ownFrontmatter(text);
137
+ const front = frontmatterOf(text);
138
+ if (!own || !front) return { skip: "already has frontmatter that does not read as YAML" };
139
+ if (front.kind !== undefined) return { skip: `already has frontmatter saying kind ${front.kind}` };
140
+ if (heldBy(front, "migrate-people") || front[SEALED] === true || front[NOTED] === true) return { skip: "already has frontmatter that seals it or holds it noted; never rewritten" };
141
+ }
142
+ const body = own ? own.body : text;
143
+ const bodyAt = own ? Buffer.byteLength(text, "utf8") - Buffer.byteLength(body, "utf8") : 0;
144
+ const lines = openLines(body);
117
145
  const h1 = lines.find((l) => /^# \S/.test(l));
118
- if (!h1) return { skip: "has no H1 to take the name from" };
119
- const name = h1.slice(2).trim();
146
+ const name = h1 ? h1.slice(2).trim() : baseName(fileName.replace(/\.md$/i, "")).trim();
147
+ if (!name) return { skip: "has no H1 and no file name to take the name from" };
120
148
  const first = name.split(/\s+/)[0];
121
149
  const ownerFirst = String(owner).split(/\s+/)[0];
122
150
 
@@ -167,7 +195,11 @@ export function planProfile({ fileName, text, owner }) {
167
195
  }
168
196
  }
169
197
 
170
- if (observations.length === 0) return { skip: "has no dated H2 or H3 heading, so there is no observation; left as prose" };
198
+ // A profile holds at least one observation. With no dated section, the one it gets says so.
199
+ if (observations.length === 0) {
200
+ observations.push({ obs: { id: "typed", since: date, text: "Typed as a person profile; none of its sections carries a date yet." }, order: 0 });
201
+ notes.push(`no dated H2 or H3 heading, so its one observation is dated ${date}, the day it was typed`);
202
+ }
171
203
 
172
204
  observations.sort((a, b) => (key(a.obs.since) < key(b.obs.since) ? -1 : key(a.obs.since) > key(b.obs.since) ? 1 : a.order - b.order));
173
205
  const entry = {
@@ -177,20 +209,22 @@ export function planProfile({ fileName, text, owner }) {
177
209
  name,
178
210
  observations: observations.map((o) => o.obs),
179
211
  };
180
- const frontmatter = `---\n${toYaml(entry)}---\n`;
212
+ // Keys the file already has stay as they are, above the ones added.
213
+ const front = own ? frontmatterOf(text) : {};
214
+ const missing = Object.fromEntries(Object.entries(entry).filter(([k]) => !(k in front)));
215
+ const kept = own && own.head.length ? `${own.head.join("\n")}\n` : "";
216
+ const frontmatter = `---\n${kept}${toYaml(missing)}---\n`;
181
217
  const problems = readEntry("person-profile", parseYaml(frontmatter.slice(4, -4)), { owner }).problems;
182
- return { entry, frontmatter, notes, problems };
218
+ return { entry, frontmatter, bodyAt, notes, problems };
183
219
  }
184
220
 
185
- // The body below a written frontmatter is the original, byte for byte.
186
- export function bodyMatches(original, written, frontmatter) {
221
+ // The body below a written frontmatter is the original's body (from byte bodyAt), byte for byte.
222
+ export function bodyMatches(original, written, frontmatter, bodyAt = 0) {
187
223
  const head = Buffer.from(frontmatter, "utf8");
188
224
  if (!written.subarray(0, head.length).equals(head)) return false;
189
- return sha256(written.subarray(head.length)) === sha256(original);
225
+ return sha256(written.subarray(head.length)) === sha256(original.subarray(bodyAt));
190
226
  }
191
227
 
192
- const today = () => { const d = new Date(); return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; };
193
-
194
228
  // Migrate files. Returns one result per file: { file, status: done|would|skipped|failed, ... }.
195
229
  export function migratePeople(files, { dry = false, date = today(), root = null } = {}) {
196
230
  const results = [];
@@ -203,7 +237,7 @@ export function migratePeople(files, { dry = false, date = today(), root = null
203
237
  const owner = (() => { const h = holderOf(readManifest(recordRoot)); return h.id ?? h.name; })();
204
238
  if (!owner) { Object.assign(r, { status: "failed", why: "evda.json names no owner" }); continue; }
205
239
  const original = readFileSync(file);
206
- const plan = planProfile({ fileName: basename(file), text: original.toString("utf8"), owner });
240
+ const plan = planProfile({ fileName: basename(file), text: original.toString("utf8"), owner, date });
207
241
  if (plan.skip) { Object.assign(r, { status: "skipped", why: plan.skip }); continue; }
208
242
  Object.assign(r, { frontmatter: plan.frontmatter, notes: plan.notes, observations: plan.entry.observations.length });
209
243
  if (plan.problems.length) { Object.assign(r, { status: "failed", why: `the frontmatter would not read as a person-profile: ${plan.problems.join("; ")}` }); continue; }
@@ -217,14 +251,14 @@ export function migratePeople(files, { dry = false, date = today(), root = null
217
251
  }
218
252
  copyFileSync(file, r.backup);
219
253
  if (sha256(readFileSync(r.backup)) !== sha256(original)) { Object.assign(r, { status: "failed", why: "the backup does not match the original; nothing written" }); continue; }
220
- writeFileSync(file, Buffer.concat([Buffer.from(plan.frontmatter, "utf8"), original]));
254
+ writeFileSync(file, Buffer.concat([Buffer.from(plan.frontmatter, "utf8"), original.subarray(plan.bodyAt)]));
221
255
  const written = readFileSync(file);
222
- if (!bodyMatches(original, written, plan.frontmatter)) {
256
+ if (!bodyMatches(original, written, plan.frontmatter, plan.bodyAt)) {
223
257
  writeFileSync(file, original);
224
258
  Object.assign(r, { status: "failed", why: "the body below the frontmatter did not hash to the original; restored the original" });
225
259
  continue;
226
260
  }
227
- r.bodySha256 = sha256(original);
261
+ r.bodySha256 = sha256(original.subarray(plan.bodyAt));
228
262
  r.status = "done";
229
263
  }
230
264
  return results;
@@ -0,0 +1,90 @@
1
+ // Person folders (HQ-2024): one file per person, named for the person, nothing beside it.
2
+ //
3
+ // A folder whose manifest says `type: person` holds one file per person, named `First Last.md`.
4
+ // Timeline and patterns are sections of that file, never side files, and processing docs live in a
5
+ // sibling `Processing/` folder that the profile links. A record that grows a folder for some people
6
+ // and a file for others, two docs for one person, or a stub that says "redirects to" ends up with
7
+ // updates landing in whichever copy a search hits first, while the other goes stale.
8
+ //
9
+ // The layout rules, read across every person folder in the record at once:
10
+ // 1. no subfolder inside a person folder
11
+ // 2. a parenthetical in a name only when another person file shares the base name
12
+ // (a short first name counts: Phil Rauch and Philip Rauch)
13
+ // 3. no redirect stubs: a file whose text says it redirects is a second doc for one person
14
+ // Doctor (section 12c) reports each as a failure. The README a preset writes says the same rule.
15
+ //
16
+ // FALSIFIED 2026-10-12: test/person-folders.test.mjs run against origin/main (6fc2610), which had
17
+ // none of these rules -> 7 of 7 failed; the subfolder, lone-parenthetical and redirect-stub records
18
+ // each got "OK 12 check(s)", exit 0 ("actual: 0, expected: 1").
19
+
20
+ import { readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { outsideRecord } from "./record-walk.mjs";
23
+
24
+ export const PROCESSING_DIR = "Processing";
25
+ export const README_NAME = "README.md";
26
+
27
+ // The README a preset writes into a person folder it makes. Plain words; it is the rule doctor checks.
28
+ export const PERSON_FOLDER_README = [
29
+ "# People",
30
+ "",
31
+ "One file per person, named `First Last.md`. No folder per person.",
32
+ "",
33
+ "- Timeline, patterns and anything else about someone are sections of their file, never side files.",
34
+ `- A parenthetical goes in a name only when two people share it: \`Sam Lee (school).md\` beside \`Sam Lee (work).md\`.`,
35
+ `- Processing docs (working through something about a person) go in \`${PROCESSING_DIR}/\` beside this folder, and the person's file links them.`,
36
+ "- No redirect stubs. When two files turn out to be one person, merge them into one and repoint the links.",
37
+ "- Each file opens with `kind: person-profile` frontmatter, so `evda doctor` reads it.",
38
+ "",
39
+ "`evda doctor` fails a record that breaks any of these.",
40
+ "",
41
+ ].join("\n");
42
+
43
+ // "Sam Lee (work)" -> "Sam Lee".
44
+ export const baseName = (stem) => stem.replace(/\s*\(.*\)\s*$/, "");
45
+
46
+ // One name to a reader: the same surname, and one first name starting the other.
47
+ export function sameName(a, b) {
48
+ const x = a.split(/\s+/).filter(Boolean);
49
+ const y = b.split(/\s+/).filter(Boolean);
50
+ if (!x.length || !y.length || x.slice(1).join(" ") !== y.slice(1).join(" ")) return false;
51
+ const [p, q] = [x[0].toLowerCase(), y[0].toLowerCase()];
52
+ return p.startsWith(q) || q.startsWith(p);
53
+ }
54
+
55
+ const REDIRECT = /^>?\s*\**redirect/im;
56
+ const ENTRY = /\.(md|json|ya?ml)$/i;
57
+
58
+ // The layout problems across `dirs` (absolute paths of every `type: person` folder), with `skip` a
59
+ // set of absolute paths that are not person files (folder manifests, declared views). Returns
60
+ // { problems: [{ path, kind, message }], files } where files counts the person files read.
61
+ export function personFolderProblems(dirs, { skip = new Set() } = {}) {
62
+ const problems = [];
63
+ const people = [];
64
+ for (const d of dirs) {
65
+ let names;
66
+ try { names = readdirSync(d).sort(); } catch { continue; }
67
+ for (const name of names) {
68
+ if (name.startsWith(".") || outsideRecord(name)) continue;
69
+ const p = join(d, name);
70
+ let st;
71
+ try { st = statSync(p); } catch { continue; }
72
+ if (st.isDirectory()) {
73
+ problems.push({ path: p, kind: "folder", message: `is a folder inside a person folder. One file per person, named First Last; timeline and patterns are sections of that file, and processing docs go in ${PROCESSING_DIR}/ beside the person folder, linked from the profile.` });
74
+ continue;
75
+ }
76
+ if (skip.has(p) || name === README_NAME || !ENTRY.test(name)) continue;
77
+ people.push({ path: p, stem: name.replace(ENTRY, "") });
78
+ }
79
+ }
80
+ for (const { path, stem } of people) {
81
+ const base = baseName(stem);
82
+ if (base !== stem && !people.some((o) => o.path !== path && sameName(baseName(o.stem), base))) {
83
+ problems.push({ path, kind: "parenthetical", message: `carries a parenthetical and no other person file is named ${base}. A parenthetical only tells apart people who share a name; name it ${base}.` });
84
+ }
85
+ if (REDIRECT.test(readFileSync(path, "utf8"))) {
86
+ problems.push({ path, kind: "redirect", message: `is a redirect stub, a second doc for one person. Merge anything in it into the profile it points to, delete it, and repoint its links.` });
87
+ }
88
+ }
89
+ return { problems, files: people.length };
90
+ }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // The hook entry point inside a Cowork plugin that `evda plugin` builds for one record (HQ-2802).
3
+ //
4
+ // Cowork reads no folder's .claude/settings.json, so the hooks `evda hook install` puts there never
5
+ // run in it; a plugin's hooks/hooks.json does run there. This file is what that hooks.json names:
6
+ //
7
+ // node evda-hook.mjs keep <record> Stop: the record's own keep-record hook
8
+ // node evda-hook.mjs prompt <record> UserPromptSubmit: the record's own prompt-filing hook
9
+ // node evda-hook.mjs guard <record> PreToolUse: the read guard the record's settings name
10
+ //
11
+ // It holds no hook logic. It runs the copies `evda hook install` keeps current in the record
12
+ // (.claude/hooks/evda-keep-record.mjs, .claude/hooks/evda-file-prompt.mjs) and the guard command
13
+ // the record's settings register, so the plugin files exactly what a Claude Code session in the
14
+ // record would, and a `evda hook install` or `evda update` reaches the plugin without a rebuild.
15
+ //
16
+ // ONLY IN COWORK. A plugin installed on a claude.ai account also loads in the owner's Claude Code
17
+ // terminal sessions, in every project. There the record's project hooks already run in sessions
18
+ // opened in it, and a session in another project is not the record's: filing it would put work
19
+ // conversations in the day. So it acts only when CLAUDE_CODE_ENTRYPOINT is "local-agent", the
20
+ // value the Claude desktop app gives the Claude Code process behind a Cowork task.
21
+ // EVDA_PLUGIN_HOOKS=always acts everywhere (for tests and an owner who wants it); =never, nowhere.
22
+ //
23
+ // THE RECORD. The path baked in at build time, when it is there. A Cowork task can run in a VM
24
+ // that sees the owner's folders mounted under another path, so when the baked path is missing the
25
+ // record is the one at or above the task's cwd whose folder has the same name.
26
+ //
27
+ // Copied byte for byte into the plugin as scripts/evda-hook.mjs; node: built-ins only, because the
28
+ // plugin is a zip with no packages. Any error of its own exits 0 with no output: a broken hook must
29
+ // never block the owner's message or trap a session.
30
+
31
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
32
+ import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
33
+ import { spawnSync } from "node:child_process";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ export const HOOK_FILES = { keep: ".claude/hooks/evda-keep-record.mjs", prompt: ".claude/hooks/evda-file-prompt.mjs" };
37
+ const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
38
+
39
+ export function active(env = process.env) {
40
+ if (env.EVDA_PLUGIN_HOOKS === "never") return false;
41
+ return env.EVDA_PLUGIN_HOOKS === "always" || env.CLAUDE_CODE_ENTRYPOINT === "local-agent";
42
+ }
43
+
44
+ function recordAt(from) {
45
+ let d = resolve(from);
46
+ for (;;) {
47
+ if (existsSync(join(d, "evda.json"))) return d;
48
+ const up = dirname(d);
49
+ if (up === d) return null;
50
+ d = up;
51
+ }
52
+ }
53
+
54
+ // The record this plugin was built for, as this process sees it, or null.
55
+ export function resolveRecord(baked, cwd) {
56
+ if (baked && existsSync(join(baked, "evda.json"))) return resolve(baked);
57
+ if (!cwd) return null;
58
+ const found = recordAt(cwd);
59
+ return found && baked && basename(found) === basename(baked) ? found : null;
60
+ }
61
+
62
+ const inside = (root, p) => { const r = relative(root, resolve(p)); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
63
+
64
+ // One hook event in; { stdout, stderr, code } out. Never throws.
65
+ export function handle(mode, baked, input, env = process.env) {
66
+ const none = { stdout: "", stderr: "", code: 0 };
67
+ try {
68
+ if (!active(env)) return none;
69
+ let payload;
70
+ try { payload = JSON.parse(input); } catch { return none; }
71
+ const root = resolveRecord(baked, payload?.cwd);
72
+ if (!root) return none;
73
+ if (mode === "keep" || mode === "prompt") {
74
+ const hook = join(root, HOOK_FILES[mode]);
75
+ if (!existsSync(hook)) return none;
76
+ // The record's hooks find the record from cwd; a Cowork task's cwd may be anywhere.
77
+ const p = { ...payload, cwd: payload.cwd && inside(root, payload.cwd) ? payload.cwd : root };
78
+ const r = spawnSync(process.execPath, [hook], { input: JSON.stringify(p), encoding: "utf8", timeout: 25000 });
79
+ return { stdout: r.stdout ?? "", stderr: "", code: 0 };
80
+ }
81
+ if (mode === "guard") {
82
+ const settings = JSON.parse(readFileSync(join(root, ".claude", "settings.json"), "utf8"));
83
+ const cmd = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).find((c) => typeof c === "string" && GUARD_MARK.test(c));
84
+ if (!cmd) return none;
85
+ const r = spawnSync(cmd, { shell: true, input, encoding: "utf8", timeout: 25000 });
86
+ return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status === 2 ? 2 : 0 };
87
+ }
88
+ return none;
89
+ } catch {
90
+ return none;
91
+ }
92
+ }
93
+
94
+ const entry = (() => {
95
+ try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
96
+ })();
97
+
98
+ if (entry) {
99
+ const [mode, baked] = process.argv.slice(2);
100
+ let input = "";
101
+ process.stdin.setEncoding("utf8");
102
+ process.stdin.on("data", (d) => (input += d));
103
+ process.stdin.on("end", () => {
104
+ const r = handle(mode, baked, input);
105
+ if (r.stdout) process.stdout.write(r.stdout);
106
+ if (r.stderr) process.stderr.write(r.stderr);
107
+ process.exit(r.code);
108
+ });
109
+ }
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
+