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.
- package/CHANGELOG.md +95 -0
- package/README.md +12 -5
- package/bin/evda.mjs +129 -6
- package/create-evda.mjs +14 -11
- package/lib/adopt.mjs +516 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +287 -0
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- 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 +121 -12
- package/lib/day-writer.mjs +39 -5
- package/lib/doctor.mjs +60 -5
- package/lib/every-chat.mjs +7 -11
- package/lib/folders.mjs +2 -1
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +120 -19
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +84 -12
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +90 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +28 -6
- package/lib/prompt-hook.mjs +115 -4
- package/lib/publish.mjs +55 -10
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +432 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/adopt.mjs
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// evda adopt (HQ-2797): an existing folder of notes becomes an EVDA where it is.
|
|
2
|
+
//
|
|
3
|
+
// `evda gather` moves a pile into create-evda's layout. Someone who already lives in their own
|
|
4
|
+
// folders (an old Iris Vault, an Obsidian vault) needs the opposite: the folders stay, and evda.json
|
|
5
|
+
// names where each role already is. Spec 05, "The layout is declared, not imposed."
|
|
6
|
+
//
|
|
7
|
+
// - The Iris Vault layout (00_Inbox through 06_Metadata) is recognized and mapped without asking.
|
|
8
|
+
// Any other folder is placed by its name when the name says what it holds; a folder whose name
|
|
9
|
+
// does not is never guessed at: it is named back, and the owner answers with `--map`.
|
|
10
|
+
// - What is written: evda.json, AGENTS.md (the one create-evda ships), the GEMINI.md loader when
|
|
11
|
+
// absent, the `@AGENTS.md` line appended to the owner's CLAUDE.md (or the loader when there is
|
|
12
|
+
// none), folder manifests in the record folders, .evda/, the Claude Code hooks in
|
|
13
|
+
// .claude/settings.json, and the README.md sections AGENTS.md sends the agent to (training off,
|
|
14
|
+
// what each AI company says, a local model: HQ-2812), as one marked block after the owner's
|
|
15
|
+
// README, or the whole README create-evda writes when there is none. Every other file that was
|
|
16
|
+
// there stays byte for byte.
|
|
17
|
+
// - Every write is listed in a reversal manifest outside the folder. `revertAdopt` restores the
|
|
18
|
+
// folder byte for byte, folder times included, and refuses when a file the adopt wrote or changed
|
|
19
|
+
// was edited since, unless forced. Edits that never block it: the owner's own edits to CLAUDE.md
|
|
20
|
+
// or README.md (only the added line, or the added block, is taken out), `evda upgrade` replacing
|
|
21
|
+
// AGENTS.md's generated block, and `evda upgrade` refreshing the README block.
|
|
22
|
+
//
|
|
23
|
+
// EVDA_ADOPT_FALSIFY=<mode,...>, for the tests: guess-unplaced places an unknown folder in
|
|
24
|
+
// knowledge instead of asking; claude-rewrite writes CLAUDE.md as the loader instead of appending;
|
|
25
|
+
// revert-ignores-edits reverts over edited files without --force; revert-not-exact skips restoring
|
|
26
|
+
// the files the adopt changed.
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync, chmodSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { EvdaError } from "./errors.mjs";
|
|
34
|
+
import { SCHEMA_VERSION, writeVersion, openEvda } from "./manifest.mjs";
|
|
35
|
+
import { ROLES, fourRoleOf } from "./roles.mjs";
|
|
36
|
+
import { FOLDER_MANIFEST } from "./folders.mjs";
|
|
37
|
+
import { FILING_MODES } from "./day-file.mjs";
|
|
38
|
+
import { fillOwnerSection, filingKey, DEFAULT_AGENT_NAME } from "./setup.mjs";
|
|
39
|
+
import { splitFence } from "./agents.mjs";
|
|
40
|
+
import { installHook, installPromptHook } from "./hooks.mjs";
|
|
41
|
+
import { installGuard, clients } from "./connect.mjs";
|
|
42
|
+
import { reindex } from "./index.mjs";
|
|
43
|
+
import { readPointer, setPointer, pointerPath } from "./locate.mjs";
|
|
44
|
+
import { stampInstalledVersion } from "./update-check.mjs";
|
|
45
|
+
import { doctor } from "./doctor.mjs";
|
|
46
|
+
import { everyChatSetupLines } from "./every-chat.mjs";
|
|
47
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
48
|
+
import { ensureReadme, withoutBlock, README_REL } from "./readme.mjs";
|
|
49
|
+
|
|
50
|
+
export const ADOPT_VERSION = 1;
|
|
51
|
+
export const ADOPT_FALSIFY_MODES = ["guess-unplaced", "claude-rewrite", "revert-ignores-edits", "revert-not-exact"];
|
|
52
|
+
const falsified = (mode) => (process.env.EVDA_ADOPT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
53
|
+
|
|
54
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const TEMPLATES = join(HERE, "..", "templates");
|
|
56
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
57
|
+
const posix = (p) => p.split(sep).join("/");
|
|
58
|
+
const refuse = (msg, code) => new EvdaError(`REFUSED: ${msg}`, { code, exit: 2 });
|
|
59
|
+
|
|
60
|
+
// The line added to the owner's CLAUDE.md. Doctor reads exactly this shape as the import.
|
|
61
|
+
export const CLAUDE_LINE = "@AGENTS.md";
|
|
62
|
+
// The starter kit's seven folders. Five or more of them at the top is an Iris Vault.
|
|
63
|
+
export const IRIS_VAULT_DIRS = ["00_Inbox", "01_Projects", "02_Areas", "03_Resources", "04_Archive", "05_Attachments", "06_Metadata"];
|
|
64
|
+
// Folders no one keeps notes in: tooling, never asked about.
|
|
65
|
+
const IGNORED = new Set(["dist", "build"]); // plus outsideRecord: node_modules, any *.nosync
|
|
66
|
+
// What the adopt writes under these is derived state, rebuilt by the tooling, so an edit there never
|
|
67
|
+
// blocks a revert.
|
|
68
|
+
const DERIVED = [".evda/"];
|
|
69
|
+
// The files whose bytes are kept before the adopt runs, because the adopt (or an installer it calls)
|
|
70
|
+
// may change them. Anything else that changes is a bug, and the adopt says so.
|
|
71
|
+
const MAY_CHANGE = ["CLAUDE.md", "GEMINI.md", "AGENTS.md", "evda.json", ".claude/settings.json", README_REL];
|
|
72
|
+
|
|
73
|
+
// The Iris Vault mapping, as the reference instance (the starter kit's author's own vault) keeps it.
|
|
74
|
+
// `make: true` is a folder the role needs that the starter kit does not ship.
|
|
75
|
+
const IRIS_VAULT_MAP = [
|
|
76
|
+
{ key: "record/daily", path: "06_Metadata/Daily Journal", make: true },
|
|
77
|
+
{ key: "record/captures", path: "00_Inbox" },
|
|
78
|
+
{ key: "record/people", path: "02_Areas/Relationships/People", make: true },
|
|
79
|
+
{ key: "collections", path: "01_Projects" },
|
|
80
|
+
{ key: "collections/areas", path: "02_Areas" },
|
|
81
|
+
{ key: "collections/archive", path: "04_Archive" },
|
|
82
|
+
{ key: "collections/attachments", path: "05_Attachments" },
|
|
83
|
+
{ key: "knowledge", path: "03_Resources" },
|
|
84
|
+
{ key: "knowledge/self", path: "02_Areas/Self-Knowledge" },
|
|
85
|
+
{ key: "knowledge/reference", path: "06_Metadata/Reference" },
|
|
86
|
+
{ key: "knowledge/templates", path: "06_Metadata/Templates" },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// A folder's name, read for what it holds: "02_Daily Notes" -> "daily notes".
|
|
90
|
+
const bare = (name) => name.replace(/^\d+[\s._-]*/, "").trim().toLowerCase();
|
|
91
|
+
const NAME_ROLES = [
|
|
92
|
+
[/^(daily|daily notes|dailies|journal|journals|diary|days|daily journal)$/, "record/daily"],
|
|
93
|
+
[/^(inbox|captures|capture)$/, "record/captures"],
|
|
94
|
+
[/^(people|persons|contacts)$/, "record/people"],
|
|
95
|
+
[/^(projects?)$/, "collections"],
|
|
96
|
+
[/^(areas?)$/, "collections/areas"],
|
|
97
|
+
[/^(archives?)$/, "collections/archive"],
|
|
98
|
+
[/^(attachments|assets|media|files)$/, "collections/attachments"],
|
|
99
|
+
[/^(resources|notes|wiki|knowledge|references?)$/, "knowledge"],
|
|
100
|
+
[/^(templates)$/, "knowledge/templates"],
|
|
101
|
+
];
|
|
102
|
+
const roleFromName = (name) => NAME_ROLES.find(([re]) => re.test(bare(name)))?.[1] ?? null;
|
|
103
|
+
const facetSlug = (name) => bare(name).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "folder";
|
|
104
|
+
|
|
105
|
+
// Which keys an owner may map a folder to: the record facets and any four-role key.
|
|
106
|
+
export function adoptableKey(key) {
|
|
107
|
+
if (["record/daily", "record/captures", "record/people"].includes(key)) return true;
|
|
108
|
+
if (ROLES[key] && !ROLES[key].four) return false;
|
|
109
|
+
return fourRoleOf(key) !== null && /^[a-z]+(\/[a-z0-9][a-z0-9-]*)?$/.test(key);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Parse `--map` answers: "<folder>=<key>" or "<folder>=none".
|
|
113
|
+
export function parseMapArgs(values) {
|
|
114
|
+
const map = {};
|
|
115
|
+
for (const v of values) {
|
|
116
|
+
const i = v.lastIndexOf("=");
|
|
117
|
+
if (i <= 0) throw refuse(`--map takes <folder>=<role>, got ${JSON.stringify(v)}.`, "BAD_MAP");
|
|
118
|
+
map[v.slice(0, i).replace(/\/+$/, "")] = v.slice(i + 1).trim();
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The mapping this folder would get, without writing anything:
|
|
124
|
+
// { dir, layout: "iris-vault" | "folder", entries: [{ key, path, make, why }], unplaced, ignored, problems }.
|
|
125
|
+
export function proposeAdoption(dirIn, { map = {} } = {}) {
|
|
126
|
+
const dir = resolve(dirIn);
|
|
127
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw refuse(`${dir} is not a folder.`, "NOT_A_FOLDER");
|
|
128
|
+
const top = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && !IGNORED.has(d.name) && !outsideRecord(d.name)).map((d) => d.name).sort();
|
|
129
|
+
const iris = IRIS_VAULT_DIRS.filter((d) => top.includes(d)).length >= 5;
|
|
130
|
+
const entries = [];
|
|
131
|
+
const problems = [];
|
|
132
|
+
const has = (rel) => existsSync(join(dir, rel)) && statSync(join(dir, rel)).isDirectory();
|
|
133
|
+
const answered = new Set(Object.keys(map));
|
|
134
|
+
for (const [folder, key] of Object.entries(map)) {
|
|
135
|
+
if (key === "none") continue;
|
|
136
|
+
if (!adoptableKey(key)) { problems.push(`--map ${folder}=${key}: "${key}" is not a role a folder can fill. Use record/daily, record/captures, record/people, or journal, work, collections, knowledge (each with an optional /facet), or none.`); continue; }
|
|
137
|
+
if (resolve(dir, folder) === dir || !resolve(dir, folder).startsWith(dir + sep)) { problems.push(`--map ${folder}: the folder must be inside ${dir}.`); continue; }
|
|
138
|
+
entries.push({ key, path: posix(folder), make: !has(folder), why: "your answer" });
|
|
139
|
+
}
|
|
140
|
+
const taken = () => new Set(entries.map((e) => e.key));
|
|
141
|
+
const add = (e) => {
|
|
142
|
+
if (answered.has(e.path) || entries.some((x) => x.path === e.path)) return true;
|
|
143
|
+
if (taken().has(e.key)) return false;
|
|
144
|
+
entries.push(e);
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
const unplaced = [];
|
|
148
|
+
if (iris) {
|
|
149
|
+
for (const m of IRIS_VAULT_MAP) if (m.make || has(m.path)) add({ key: m.key, path: m.path, make: !has(m.path), why: "Iris Vault layout" });
|
|
150
|
+
}
|
|
151
|
+
for (const name of top) {
|
|
152
|
+
if (answered.has(name) || entries.some((e) => e.path === name)) continue;
|
|
153
|
+
if (iris && IRIS_VAULT_DIRS.includes(name)) continue;
|
|
154
|
+
let key = roleFromName(name);
|
|
155
|
+
if (key && taken().has(key)) {
|
|
156
|
+
const role = fourRoleOf(key);
|
|
157
|
+
key = key.startsWith("record/") ? null : `${role}/${facetSlug(name)}`;
|
|
158
|
+
if (key && taken().has(key)) key = null;
|
|
159
|
+
}
|
|
160
|
+
if (!key && falsified("guess-unplaced")) key = `knowledge/${facetSlug(name)}`;
|
|
161
|
+
if (key) add({ key, path: name, make: false, why: `its name, "${name}"` });
|
|
162
|
+
else unplaced.push(name);
|
|
163
|
+
}
|
|
164
|
+
const keys = entries.map((e) => e.key);
|
|
165
|
+
for (const k of new Set(keys)) if (keys.filter((x) => x === k).length > 1) problems.push(`"${k}" is answered for ${entries.filter((e) => e.key === k).map((e) => e.path).join(" and ")}; one folder fills one key. Give the others a facet, such as ${fourRoleOf(k) ?? "knowledge"}/<name>.`);
|
|
166
|
+
const paths = entries.map((e) => e.path);
|
|
167
|
+
for (const p of new Set(paths)) if (paths.filter((x) => x === p).length > 1) problems.push(`${p} is mapped twice; a folder fills one role.`);
|
|
168
|
+
if (!keys.includes("record/daily")) problems.push('no folder holds the daily notes, which is where the owner\'s words are filed. Name one with --map "<folder>=record/daily" (it is made if it is not there).');
|
|
169
|
+
return { dir, layout: iris ? "iris-vault" : "folder", entries, unplaced, ignored: Object.entries(map).filter(([, k]) => k === "none").map(([f]) => f), problems };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The questions a proposal still needs answered, in plain words, one per folder.
|
|
173
|
+
export function adoptQuestions(p) {
|
|
174
|
+
const out = p.unplaced.map((f) => `${f}/: what does this folder hold? Answer with --map "${f}=<role>": journal (dated writing), collections (things kept together), knowledge (what you learn from), work (efforts with an end), or none to leave it out of the record.`);
|
|
175
|
+
return [...out, ...p.problems];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function proposalLines(p) {
|
|
179
|
+
const out = [`${p.dir}: ${p.layout === "iris-vault" ? "an Iris Vault. Its folders map without asking." : "a folder of notes."}`];
|
|
180
|
+
for (const e of p.entries) out.push(` ${e.key.padEnd(24)} ${e.path}${e.make ? " (made, empty)" : ""} [${e.why}]`);
|
|
181
|
+
for (const f of p.ignored) out.push(` ${"(left out)".padEnd(24)} ${f} [your answer]`);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- snapshot: what the folder holds before, so the adopt can say what it changed ----
|
|
186
|
+
|
|
187
|
+
function walk(root, { files, dirs }, rel = "") {
|
|
188
|
+
for (const d of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
189
|
+
const r = rel ? `${rel}/${d.name}` : d.name;
|
|
190
|
+
if (d.name === ".git" || outsideRecord(d.name)) continue;
|
|
191
|
+
const abs = join(root, r);
|
|
192
|
+
const st = lstatSync(abs);
|
|
193
|
+
if (st.isDirectory()) { dirs.set(r, { atimeMs: st.atimeMs, mtimeMs: st.mtimeMs }); walk(root, { files, dirs }, r); }
|
|
194
|
+
else files.set(r, { size: st.size, mtimeMs: st.mtimeMs, mode: st.mode & 0o7777, atimeMs: st.atimeMs });
|
|
195
|
+
}
|
|
196
|
+
return { files, dirs };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function snapshot(root) {
|
|
200
|
+
const s = walk(root, { files: new Map(), dirs: new Map() });
|
|
201
|
+
const rootSt = statSync(root);
|
|
202
|
+
s.dirs.set("", { atimeMs: rootSt.atimeMs, mtimeMs: rootSt.mtimeMs });
|
|
203
|
+
const kept = new Map();
|
|
204
|
+
const keep = (rel) => { if (s.files.has(rel)) kept.set(rel, readFileSync(join(root, rel))); };
|
|
205
|
+
for (const rel of MAY_CHANGE) keep(rel);
|
|
206
|
+
for (const rel of s.files.keys()) if (rel.startsWith(".claude/hooks/") || rel.startsWith(".evda/")) keep(rel);
|
|
207
|
+
s.kept = kept;
|
|
208
|
+
return s;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- adopt ----
|
|
212
|
+
|
|
213
|
+
function render(name) {
|
|
214
|
+
return readFileSync(join(TEMPLATES, name), "utf8");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function localDate(d = new Date()) {
|
|
218
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
219
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "record";
|
|
223
|
+
|
|
224
|
+
export function adoptDir(env = process.env) {
|
|
225
|
+
return env.EVDA_ADOPT_DIR ?? join(env.HOME ?? homedir(), ".evda", "adopt");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A person folder fails doctor for every note without person-profile frontmatter, so its manifest is
|
|
229
|
+
// written only when the folder holds no note yet.
|
|
230
|
+
function noNotes(abs) {
|
|
231
|
+
return !existsSync(abs) || readdirSync(abs).every((n) => n.startsWith(".") || n === "README.md" || !/\.md$/i.test(n));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const FOLDER_TYPES = { "record/daily": "record", "record/captures": "capture", "record/people": "person" };
|
|
235
|
+
|
|
236
|
+
export function adopt(dirIn, { map = {}, filing = null, owner = "", agentName = null, dry = false, hooks = true, manifestOut = null, pointer = true, now = new Date(), env = process.env, version = null } = {}) {
|
|
237
|
+
const dir = resolve(dirIn);
|
|
238
|
+
if (filing !== null && !FILING_MODES.includes(filing)) throw refuse(`filing is ${FILING_MODES.join(" or ")}, got ${JSON.stringify(filing)}.`, "BAD_FILING");
|
|
239
|
+
if (existsSync(join(dir, "evda.json"))) throw refuse(`${dir} already holds evda.json, so it is already an EVDA. Nothing was written. \`evda update\` keeps it current.`, "ALREADY_ADOPTED");
|
|
240
|
+
const p = proposeAdoption(dir, { map });
|
|
241
|
+
const questions = adoptQuestions(p);
|
|
242
|
+
const agentsText = fillOwnerSection(render("AGENTS.md"), { agentName, owner, purpose: "", voice: "", ai: "" });
|
|
243
|
+
const result = { dir, dry, proposal: p, questions, lines: proposalLines(p) };
|
|
244
|
+
if (dry) {
|
|
245
|
+
const rd = ensureReadme(dir, { agents: agentsText, dry: true });
|
|
246
|
+
return { ...result, readme: rd, lines: [...result.lines, ` ${dryReadmeLine(rd)}`] };
|
|
247
|
+
}
|
|
248
|
+
if (questions.length) throw new EvdaError(["REFUSED: some folders need your answer before anything is written. Nothing was written.", ...questions.map((q) => ` ${q}`)].join("\n"), { code: "NEEDS_ANSWERS", exit: 2 });
|
|
249
|
+
for (const f of ["AGENTS.md"]) if (existsSync(join(dir, f))) throw refuse(`${f} is already in ${dir}, and the adopt writes its own; it never overwrites a file of yours. Rename it, or map it by hand in evda.json. Nothing was written.`, "AGENTS_EXISTS");
|
|
250
|
+
|
|
251
|
+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
|
|
252
|
+
const manifestAbs = resolve(manifestOut ?? join(adoptDir(env), `${slug(dir.split(sep).pop())}-${stamp}.json`));
|
|
253
|
+
if (manifestAbs === dir || manifestAbs.startsWith(dir + sep)) throw refuse("the reversal manifest goes outside the folder it reverses. Name another with --manifest.", "MANIFEST_INSIDE");
|
|
254
|
+
if (existsSync(manifestAbs)) throw refuse(`a reversal manifest is already at ${manifestAbs}. Name another with --manifest.`, "MANIFEST_EXISTS");
|
|
255
|
+
|
|
256
|
+
const before = snapshot(dir);
|
|
257
|
+
const today = localDate(now);
|
|
258
|
+
const name = dir.split(sep).pop();
|
|
259
|
+
const madeDirs = [];
|
|
260
|
+
const mkdirp = (rel) => {
|
|
261
|
+
const segs = rel.split("/");
|
|
262
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
263
|
+
const sub = segs.slice(0, i).join("/");
|
|
264
|
+
if (existsSync(join(dir, sub))) continue;
|
|
265
|
+
mkdirSync(join(dir, sub));
|
|
266
|
+
madeDirs.push(sub);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const writeNew = (rel, text) => { mkdirp(posix(dirname(rel)) === "." ? "" : posix(dirname(rel))); writeFileSync(join(dir, rel), text, { flag: "wx" }); };
|
|
270
|
+
|
|
271
|
+
// 1. Folders a role needs that are not there.
|
|
272
|
+
for (const e of p.entries) if (e.make) mkdirp(e.path);
|
|
273
|
+
// 2. The manifest.
|
|
274
|
+
const mapping = { agents: "AGENTS.md" };
|
|
275
|
+
for (const e of p.entries) mapping[e.key] = e.path;
|
|
276
|
+
const manifest = { evda: SCHEMA_VERSION, id: `${slug(name)}.atlas`, name, owner: { name: owner ?? "", kind: "person" }, created: today, layers: ["record"], mapping, subrecords: "bounded", adopted: { from: p.layout, date: today } };
|
|
277
|
+
if (filing) manifest.filing = filing;
|
|
278
|
+
writeNew("evda.json", JSON.stringify(manifest, null, 2) + "\n");
|
|
279
|
+
// 3. The operating layer, the one create-evda ships, with the owner section filled from the answers.
|
|
280
|
+
writeNew("AGENTS.md", agentsText);
|
|
281
|
+
// 3b. The README sections AGENTS.md cites (HQ-2812): appended as one marked block, or the whole
|
|
282
|
+
// README when there is none. The owner's text is never touched.
|
|
283
|
+
const readme = ensureReadme(dir, { agents: agentsText });
|
|
284
|
+
// 4. Loaders. The owner's CLAUDE.md is theirs: one line is added at its end and nothing else changes.
|
|
285
|
+
const claude = join(dir, "CLAUDE.md");
|
|
286
|
+
let claudeAdded = null;
|
|
287
|
+
if (existsSync(claude) && !falsified("claude-rewrite")) {
|
|
288
|
+
const text = readFileSync(claude, "utf8");
|
|
289
|
+
if (!new RegExp(`^${CLAUDE_LINE.replace(/[.]/g, "\\.")}\\s*$`, "m").test(text)) {
|
|
290
|
+
claudeAdded = `${text === "" || text.endsWith("\n") ? "" : "\n"}\n${CLAUDE_LINE}\n`;
|
|
291
|
+
writeFileSync(claude, text + claudeAdded);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
writeFileSync(claude, render("CLAUDE.md").replaceAll("{{agents}}", "AGENTS.md"));
|
|
295
|
+
}
|
|
296
|
+
if (!existsSync(join(dir, "GEMINI.md"))) writeNew("GEMINI.md", render("GEMINI.md").replaceAll("{{agents}}", "AGENTS.md"));
|
|
297
|
+
// 5. What each record folder holds.
|
|
298
|
+
for (const [key, type] of Object.entries(FOLDER_TYPES)) {
|
|
299
|
+
const rel = mapping[key];
|
|
300
|
+
if (!rel || existsSync(join(dir, rel, FOLDER_MANIFEST))) continue;
|
|
301
|
+
if (type === "person" && !noNotes(join(dir, rel))) continue;
|
|
302
|
+
writeNew(`${rel}/${FOLDER_MANIFEST}`, JSON.stringify({ type, connectors: [], views: [] }, null, 2) + "\n");
|
|
303
|
+
}
|
|
304
|
+
// 6. .evda/, then the hooks `evda hook install` adds, then the index.
|
|
305
|
+
writeVersion(dir, SCHEMA_VERSION);
|
|
306
|
+
if (version) stampInstalledVersion(dir, version);
|
|
307
|
+
const hookLines = [];
|
|
308
|
+
if (hooks) {
|
|
309
|
+
const h = installHook(dir);
|
|
310
|
+
hookLines.push(`Claude Code Stop hook: ${h.action} (${h.settings} runs ${h.hook}, in this folder only)`);
|
|
311
|
+
const g = installGuard(dir);
|
|
312
|
+
hookLines.push(`read guard: ${g.action} ${posix(relative(dir, g.path))}`);
|
|
313
|
+
if (filing === "everything") { const ph = installPromptHook(dir); hookLines.push(`prompt hook: ${ph.action} (${ph.settings} runs ${ph.hook}, which files each message you send)`); }
|
|
314
|
+
}
|
|
315
|
+
reindex(openEvda(dir));
|
|
316
|
+
|
|
317
|
+
// 7. What changed, read back from the folder rather than trusted from the steps above.
|
|
318
|
+
const after = walk(dir, { files: new Map(), dirs: new Map() });
|
|
319
|
+
const created = [];
|
|
320
|
+
const modified = [];
|
|
321
|
+
const unexpected = [];
|
|
322
|
+
for (const [rel, st] of after.files) {
|
|
323
|
+
const was = before.files.get(rel);
|
|
324
|
+
const buf = readFileSync(join(dir, rel));
|
|
325
|
+
if (!was) { created.push({ path: rel, sha256: sha256(buf), ...(rel === "AGENTS.md" ? { outside: sha256(Buffer.from(outsideFence(buf.toString("utf8")))) } : {}) }); continue; }
|
|
326
|
+
const kept = before.kept.get(rel);
|
|
327
|
+
if (kept ? kept.equals(buf) : was.size === st.size && was.mtimeMs === st.mtimeMs) continue;
|
|
328
|
+
if (!kept) { unexpected.push(rel); continue; }
|
|
329
|
+
modified.push({ path: rel, before: kept.toString("base64"), mode: was.mode, atimeMs: was.atimeMs, mtimeMs: was.mtimeMs, sha256: sha256(buf), ...(rel === "CLAUDE.md" && claudeAdded ? { added: claudeAdded } : {}), ...(rel === README_REL && readme.added ? { added: readme.added, block: true } : {}) });
|
|
330
|
+
}
|
|
331
|
+
const dirs = [...after.dirs.keys()].filter((d) => !before.dirs.has(d));
|
|
332
|
+
const dirTimes = [...before.dirs].filter(([d]) => existsSync(join(dir, d))).map(([d, t]) => ({ path: d, ...t }));
|
|
333
|
+
|
|
334
|
+
let pointerAction = "left alone";
|
|
335
|
+
let wrotePointer = false;
|
|
336
|
+
if (pointer) {
|
|
337
|
+
const existing = readPointer(env);
|
|
338
|
+
if (existing === null) { setPointer(dir, env); pointerAction = "written"; wrotePointer = true; }
|
|
339
|
+
else pointerAction = `already points at ${existing}; run \`evda use ${dir}\` to switch`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const record = { evdaAdopt: ADOPT_VERSION, target: dir, adopted: now.toISOString(), layout: p.layout, mapping, created, modified, dirs, dirTimes, unexpected, pointer: wrotePointer ? { path: pointerPath(env), target: dir } : null };
|
|
343
|
+
mkdirSync(dirname(manifestAbs), { recursive: true });
|
|
344
|
+
writeFileSync(manifestAbs, JSON.stringify(record, null, 2) + "\n", { flag: "wx" });
|
|
345
|
+
|
|
346
|
+
// Doctor, after the manifest, so a failing doctor still leaves the way back.
|
|
347
|
+
const doctorLines = [];
|
|
348
|
+
const report = doctor(dir, { out: (l) => doctorLines.push(l), via: "evda adopt" });
|
|
349
|
+
const lines = [
|
|
350
|
+
`adopted ${dir} in place. Nothing moved.`,
|
|
351
|
+
...proposalLines(p).slice(1),
|
|
352
|
+
` wrote ${created.length} new file(s): ${created.map((c) => c.path).join(", ")}`,
|
|
353
|
+
` changed ${modified.length} file(s) of yours: ${modified.map((m) => m.path + (m.block ? ` (EVÐA's block added at the end: ${readme.citations.map((c) => `"${c}"`).join(", ")})` : m.added ? " (one line added at the end)" : "")).join(", ") || "none"}`,
|
|
354
|
+
...(dirs.length ? [` made ${dirs.length} folder(s): ${dirs.join(", ")}`] : []),
|
|
355
|
+
...hookLines.map((l) => ` ${l}`),
|
|
356
|
+
` filing: ${filing === "everything" ? "everything you say is filed automatically" : filing === "asked" ? "only when you ask" : "not answered yet; your agent asks the first time you connect"}`,
|
|
357
|
+
` locator ${pointerPath(env)}: ${pointerAction}`,
|
|
358
|
+
`CHECKED ${before.files.size} file(s) that were here ${before.files.size - modified.length - unexpected.length} unchanged, ${modified.length} changed by the adopt and kept in the manifest, ${unexpected.length} changed unexpectedly`,
|
|
359
|
+
...(unexpected.length ? [`FAIL adopt changed ${unexpected.join(", ")}, which it does not keep the bytes of. That is a bug in evda adopt.`] : []),
|
|
360
|
+
`Reversal manifest: ${manifestAbs} (evda adopt --revert ${manifestAbs})`,
|
|
361
|
+
"",
|
|
362
|
+
...doctorLines,
|
|
363
|
+
];
|
|
364
|
+
return { ...result, manifest: manifestAbs, record, doctor: report, lines, exit: report.exit === 0 && !unexpected.length ? 0 : 1 };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// What the owner pastes into the Claude desktop app (and Cowork) to reach the record, and the
|
|
368
|
+
// every-chat line, from the sources create-evda prints them from.
|
|
369
|
+
export function connectLines(dir, filing) {
|
|
370
|
+
const out = ["Connect your AI to it through the evda MCP server. No key and no model call in EVÐA:"];
|
|
371
|
+
for (const c of clients(dir)) if (c.key !== "project" && c.key !== "every-chat") { out.push(` ${c.label}:`); for (const l of c.text.split("\n")) out.push(` ${l}`); }
|
|
372
|
+
out.push("", ...everyChatSetupLines(filing ?? null));
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// What a dry run says about README.md, naming the sections it would add.
|
|
377
|
+
function dryReadmeLine(r) {
|
|
378
|
+
const names = r.citations.map((c) => `"${c}"`).join(", ");
|
|
379
|
+
if (r.action === "written") return `${README_REL}: would be written, with ${names}`;
|
|
380
|
+
if (r.action === "appended") return `${README_REL}: EVÐA's block would be added at the end, with ${names}; your text above it stays as it is`;
|
|
381
|
+
return `${README_REL}: already has ${names}; left alone`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- revert ----
|
|
385
|
+
|
|
386
|
+
export function loadAdoptManifest(path) {
|
|
387
|
+
const abs = resolve(path);
|
|
388
|
+
if (!existsSync(abs)) throw refuse(`no adopt manifest at ${abs}.`, "NO_MANIFEST");
|
|
389
|
+
let m;
|
|
390
|
+
try { m = JSON.parse(readFileSync(abs, "utf8")); } catch (e) { throw refuse(`${abs} is not valid JSON (${e.message}).`, "BAD_MANIFEST"); }
|
|
391
|
+
if (m?.evdaAdopt !== ADOPT_VERSION || !Array.isArray(m.created) || !Array.isArray(m.modified)) throw refuse(`${abs} is not an adopt reversal manifest.`, "BAD_MANIFEST");
|
|
392
|
+
return { m, abs };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const countFiles = (abs) => readdirSync(abs, { withFileTypes: true }).reduce((n, d) => n + (d.isDirectory() ? countFiles(join(abs, d.name)) : 1), 0);
|
|
396
|
+
const hashOf = (abs) => (existsSync(abs) && lstatSync(abs).isFile() ? sha256(readFileSync(abs)) : null);
|
|
397
|
+
|
|
398
|
+
// AGENTS.md as the owner edits it: everything outside the generated block, which `evda upgrade` owns.
|
|
399
|
+
const outsideFence = (text) => { try { const p = splitFence(text); return p ? p.before + p.after : text; } catch { return text; } };
|
|
400
|
+
|
|
401
|
+
// CLAUDE.md with the adopt's one line taken back out, or null when that line is not there to take.
|
|
402
|
+
function withoutLine(text, added) {
|
|
403
|
+
if (text.endsWith(added)) return text.slice(0, -added.length);
|
|
404
|
+
const lines = text.split("\n");
|
|
405
|
+
const at = lines.map((l, i) => (l.trim() === CLAUDE_LINE ? i : -1)).filter((i) => i >= 0);
|
|
406
|
+
if (at.length !== 1) return null;
|
|
407
|
+
const i = at[0];
|
|
408
|
+
const drop = i > 0 && lines[i - 1] === "" ? [i - 1, i] : [i];
|
|
409
|
+
return lines.filter((_, j) => !drop.includes(j)).join("\n");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function revertAdopt(path, { force = false, now = new Date(), env = process.env } = {}) {
|
|
413
|
+
const { m, abs } = loadAdoptManifest(path);
|
|
414
|
+
if (m.reverted) throw refuse(`${abs} was already reverted at ${m.reverted}. Nothing was changed.`, "ALREADY_REVERTED");
|
|
415
|
+
const root = m.target;
|
|
416
|
+
const edited = [];
|
|
417
|
+
const gone = [];
|
|
418
|
+
for (const f of m.created) {
|
|
419
|
+
const a = join(root, f.path);
|
|
420
|
+
const h = hashOf(a);
|
|
421
|
+
if (h === null) { gone.push(f.path); continue; }
|
|
422
|
+
if (h === f.sha256 || DERIVED.some((d) => f.path.startsWith(d))) continue;
|
|
423
|
+
if (f.outside && sha256(Buffer.from(outsideFence(readFileSync(a, "utf8")))) === f.outside) continue;
|
|
424
|
+
edited.push(f.path);
|
|
425
|
+
}
|
|
426
|
+
const ownerEdits = [];
|
|
427
|
+
for (const f of m.modified) {
|
|
428
|
+
const a = join(root, f.path);
|
|
429
|
+
if (hashOf(a) === f.sha256) continue;
|
|
430
|
+
if (f.block && existsSync(a)) {
|
|
431
|
+
// The README block: refreshed by `evda upgrade` and nothing else, the bytes before come back
|
|
432
|
+
// whole; edited around by the owner, their edits stay and only the block goes.
|
|
433
|
+
const rest = withoutBlock(readFileSync(a, "utf8"), f.added);
|
|
434
|
+
if (rest !== null && Buffer.from(rest).equals(Buffer.from(f.before, "base64"))) continue;
|
|
435
|
+
if (rest !== null) { ownerEdits.push(f.path); continue; }
|
|
436
|
+
}
|
|
437
|
+
if (f.added && !f.block && existsSync(a) && withoutLine(readFileSync(a, "utf8"), f.added) !== null) { ownerEdits.push(f.path); continue; }
|
|
438
|
+
edited.push(f.path);
|
|
439
|
+
}
|
|
440
|
+
if (edited.length && !force && !falsified("revert-ignores-edits")) {
|
|
441
|
+
throw refuse(`${edited.length} file(s) the adopt wrote or changed were edited since: ${edited.join(", ")}. Reverting would lose those edits. Pass --force to revert anyway. Nothing was changed.`, "EDITED_SINCE_ADOPT");
|
|
442
|
+
}
|
|
443
|
+
let removed = 0;
|
|
444
|
+
for (const f of m.created) { const a = join(root, f.path); if (existsSync(a)) { unlinkSync(a); removed += 1; } }
|
|
445
|
+
let restored = 0;
|
|
446
|
+
if (!falsified("revert-not-exact")) {
|
|
447
|
+
for (const f of m.modified) {
|
|
448
|
+
const a = join(root, f.path);
|
|
449
|
+
const st = existsSync(a) ? statSync(a) : null;
|
|
450
|
+
if (ownerEdits.includes(f.path) && !force) {
|
|
451
|
+
// Their edits stay: only the line the adopt added is taken out.
|
|
452
|
+
writeFileSync(a, f.block ? withoutBlock(readFileSync(a, "utf8"), f.added) : withoutLine(readFileSync(a, "utf8"), f.added));
|
|
453
|
+
if (st) utimesSync(a, st.atimeMs / 1000, st.mtimeMs / 1000);
|
|
454
|
+
} else {
|
|
455
|
+
mkdirSync(dirname(a), { recursive: true });
|
|
456
|
+
writeFileSync(a, Buffer.from(f.before, "base64"));
|
|
457
|
+
chmodSync(a, f.mode);
|
|
458
|
+
utimesSync(a, f.atimeMs / 1000, f.mtimeMs / 1000);
|
|
459
|
+
}
|
|
460
|
+
restored += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// A derived folder the adopt made (.evda/) goes whole: what the tooling wrote into it since (the
|
|
464
|
+
// update check's throttle, a rebuilt index) is state it rebuilds, never the owner's.
|
|
465
|
+
let derived = 0;
|
|
466
|
+
for (const d of m.dirs) if (DERIVED.includes(`${d}/`) && existsSync(join(root, d))) { derived += countFiles(join(root, d)); rmSync(join(root, d), { recursive: true }); }
|
|
467
|
+
const kept = [];
|
|
468
|
+
for (const d of [...m.dirs].sort((a, b) => b.split("/").length - a.split("/").length || b.localeCompare(a))) {
|
|
469
|
+
const a = join(root, d);
|
|
470
|
+
if (!existsSync(a)) continue;
|
|
471
|
+
if (readdirSync(a).length) { kept.push(d); continue; }
|
|
472
|
+
rmdirSync(a);
|
|
473
|
+
}
|
|
474
|
+
let times = 0;
|
|
475
|
+
for (const d of [...m.dirTimes].sort((a, b) => b.path.split("/").length - a.path.split("/").length)) {
|
|
476
|
+
const a = join(root, d.path);
|
|
477
|
+
if (!existsSync(a) || !statSync(a).isDirectory()) continue;
|
|
478
|
+
utimesSync(a, d.atimeMs / 1000, d.mtimeMs / 1000);
|
|
479
|
+
times += 1;
|
|
480
|
+
}
|
|
481
|
+
let pointer = "left alone";
|
|
482
|
+
if (m.pointer && readPointer({ ...env, EVDA_CURRENT_FILE: m.pointer.path }) === m.pointer.target) { unlinkSync(m.pointer.path); pointer = `removed ${m.pointer.path}, which the adopt wrote`; }
|
|
483
|
+
m.reverted = now.toISOString();
|
|
484
|
+
writeFileSync(abs, JSON.stringify(m, null, 2) + "\n");
|
|
485
|
+
return { manifest: abs, target: root, removed, restored, derived, kept, gone, edited, ownerEdits: force ? [] : ownerEdits, forced: force && edited.length > 0, times, pointer };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function revertLines(r) {
|
|
489
|
+
const out = [`Reverted the adopt of ${r.target}.`, ` removed ${r.removed} file(s) the adopt wrote`, ` restored ${r.restored} file(s) of yours to their bytes before it`];
|
|
490
|
+
if (r.derived) out.push(` removed .evda/, which the adopt made, with ${r.derived} file(s) the tooling wrote into it since`);
|
|
491
|
+
for (const f of r.ownerEdits) out.push(` kept your edits to ${f}; only the ${f === README_REL ? "EVÐA block" : `${CLAUDE_LINE} line`} the adopt added was taken out`);
|
|
492
|
+
if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the adopt were reverted anyway: ${r.edited.join(", ")}`);
|
|
493
|
+
for (const d of r.kept) out.push(` kept ${d}/, which holds files the adopt did not write`);
|
|
494
|
+
if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
|
|
495
|
+
out.push(` locator: ${r.pointer}`);
|
|
496
|
+
out.push(`CHECKED ${r.removed + r.restored} file(s) reverted ${r.times} folder time(s) restored, ${r.kept.length} folder(s) kept`);
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---- the terminal question, and the MCP tool ----
|
|
501
|
+
|
|
502
|
+
export const FILING_QUESTION = (agent) => `Should ${agent || DEFAULT_AGENT_NAME} file everything you say automatically, or only when you ask? (everything)`;
|
|
503
|
+
export { filingKey };
|
|
504
|
+
|
|
505
|
+
export function adoptTool(args = {}, { env = process.env, version = null } = {}) {
|
|
506
|
+
const allowed = ["dir", "map", "filing", "owner", "dry", "confirmed"];
|
|
507
|
+
const extra = Object.keys(args).filter((k) => !allowed.includes(k));
|
|
508
|
+
if (extra.length) throw refuse(`evda_adopt does not take ${extra.join(", ")}. It takes ${allowed.join(", ")}.`, "BAD_ARGS");
|
|
509
|
+
if (typeof args.dir !== "string" || !args.dir.trim()) throw refuse('evda_adopt needs "dir", the folder of notes to adopt where it is.', "NO_DIR");
|
|
510
|
+
if (args.map !== undefined && (args.map === null || typeof args.map !== "object" || Array.isArray(args.map) || Object.values(args.map).some((v) => typeof v !== "string"))) throw refuse('evda_adopt "map" is an object of folder -> role, from the owner\'s answers.', "BAD_MAP");
|
|
511
|
+
if (args.filing !== undefined && !FILING_MODES.includes(args.filing)) throw refuse(`evda_adopt "filing" is ${FILING_MODES.join(" or ")}, the owner's answer.`, "BAD_FILING");
|
|
512
|
+
const opts = { map: args.map ?? {}, filing: args.filing ?? null, owner: typeof args.owner === "string" ? args.owner : "", env, version };
|
|
513
|
+
if (args.dry === true) return adopt(args.dir, { ...opts, dry: true });
|
|
514
|
+
if (args.confirmed !== true) throw refuse('evda_adopt needs "confirmed": true, and that is true only when the owner said yes to adopting this folder in this conversation. Call it with dry: true first, show them the mapping, ask about each folder it names, and wait.', "NOT_CONFIRMED");
|
|
515
|
+
return adopt(args.dir, opts);
|
|
516
|
+
}
|
package/lib/agents.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { EvdaError } from "./errors.mjs";
|
|
|
16
16
|
|
|
17
17
|
export const FENCE_OPEN = "<!-- evda:generated -->";
|
|
18
18
|
export const FENCE_CLOSE = "<!-- /evda:generated -->";
|
|
19
|
-
export const DIGEST_BUDGET =
|
|
19
|
+
export const DIGEST_BUDGET = 6144;
|
|
20
20
|
export const TEMPLATE_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "AGENTS.md");
|
|
21
21
|
|
|
22
22
|
// {before, generated, after} where generated INCLUDES both fence lines, or null if no fence.
|