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.
- package/CHANGELOG.md +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- 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
|
|
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)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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:
|
|
161
|
-
//
|
|
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
|
}
|