create-evda 0.1.5 → 0.1.6
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 +100 -0
- package/README.md +74 -7
- package/bin/evda.mjs +391 -6
- package/create-evda.mjs +54 -10
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +425 -36
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +118 -0
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +118 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +24 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +58 -0
- package/templates/AGENTS.md +118 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +30 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// `evda gather apply` and `evda gather revert`: the owner said yes to a gather proposal, so its
|
|
2
|
+
// placements are written into a record, and every write can be taken back. Spec 07, "Gathering a
|
|
3
|
+
// pile". The proposal and its questions are lib/gather.mjs.
|
|
4
|
+
//
|
|
5
|
+
// Apply:
|
|
6
|
+
// - Into a new record (a folder that does not exist or is empty): the record is made the way
|
|
7
|
+
// create-evda makes one, with the proposal's preset applied, and then filled.
|
|
8
|
+
// - Into an existing record: the pile is adopted, never re-sorted. An item whose bytes the record
|
|
9
|
+
// already holds, anywhere, is left where it is. A folder the proposal names is used when the
|
|
10
|
+
// record has it; else the record's own folder of the same type in the same role (a daily note
|
|
11
|
+
// goes to the record's journal folder, whatever it is called); else it is made, given its
|
|
12
|
+
// folder.evda.json, and declared under its role in evda.json. No file already in the record
|
|
13
|
+
// is moved, renamed or rewritten, except the manifests that say what a folder holds.
|
|
14
|
+
// - Copy is the default; the sources are not touched. With --move a placed file leaves its
|
|
15
|
+
// source; directories stay. Refused, unplaced and reference-only files never move.
|
|
16
|
+
// - References stay references: a Contacts card is a `contacts:` line on a person page, a
|
|
17
|
+
// bookmark is a new entry holding a `url:` reference, and the card or the bookmark is not
|
|
18
|
+
// copied. The answers on the proposal decide which person pages are made, which projects say
|
|
19
|
+
// `status: finished` (in their README's frontmatter, the project staying where it is), and
|
|
20
|
+
// whether a circle is proposed for sensitive notes: a proposal file under proposals/, never a
|
|
21
|
+
// circle. No share mark and no circle rule is ever written.
|
|
22
|
+
// - A chat export (HQ-1385) adds the owner's messages the record does not hold yet to its day
|
|
23
|
+
// files, each conversation one `## HH:MM` section at its date and time. A day file that exists is
|
|
24
|
+
// appended to, its old bytes kept for revert; one that does not is made.
|
|
25
|
+
// - Every file written, every manifest changed and every directory made is listed in a reversal
|
|
26
|
+
// manifest outside the record and the sources, with hashes, and doctor runs on the result.
|
|
27
|
+
//
|
|
28
|
+
// Revert restores the record and the sources to their bytes before the apply: moved files go back
|
|
29
|
+
// with their mode and modification time, written files are removed, changed manifests get their
|
|
30
|
+
// old bytes and times back, and directories the apply made are removed when empty. A written file
|
|
31
|
+
// edited since the apply is named and the revert refused, unless --force.
|
|
32
|
+
//
|
|
33
|
+
// EVDA_GATHER_FALSIFY=<mode,...>, for the tests (APPLY_FALSIFY_MODES):
|
|
34
|
+
// apply-writes-share the sensitive notes get a share: mark naming the proposed circle
|
|
35
|
+
// revert-not-exact revert leaves the changed manifests as the apply wrote them
|
|
36
|
+
// reapply-resorts nothing in the record counts as present, so a second gather places it all again
|
|
37
|
+
// move-without-flag apply moves the sources when --move was not given
|
|
38
|
+
// revert-ignores-edits revert removes a placed file the owner edited, without --force
|
|
39
|
+
|
|
40
|
+
import { createHash } from "node:crypto";
|
|
41
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
43
|
+
import { tmpdir } from "node:os";
|
|
44
|
+
import { openEvda, readManifest, findRoot } from "./manifest.mjs";
|
|
45
|
+
import { fourRoles, roleForPath, fourRoleOf } from "./roles.mjs";
|
|
46
|
+
import { subRecords } from "./records.mjs";
|
|
47
|
+
import { folderManifests, folderFor, FOLDER_MANIFEST } from "./folders.mjs";
|
|
48
|
+
import { loadPreset, applyPreset, manifestFor } from "./presets.mjs";
|
|
49
|
+
import { reference } from "./references.mjs";
|
|
50
|
+
import { proposeCircle, onlyKeys, refuse } from "./tools.mjs";
|
|
51
|
+
import { doctor } from "./doctor.mjs";
|
|
52
|
+
import { gatherDir, loadProposal, checkOut, presentIndex, presentAt, GATHERED, chatPlan, dayDirOf } from "./gather.mjs";
|
|
53
|
+
import { dayAppend } from "./chat-export.mjs";
|
|
54
|
+
import { EvdaError } from "./errors.mjs";
|
|
55
|
+
import { carries, NOTED, SEALED } from "./circles.mjs";
|
|
56
|
+
import { parseArgs, scaffold } from "../create-evda.mjs";
|
|
57
|
+
|
|
58
|
+
export const APPLY_VERSION = 1;
|
|
59
|
+
export const APPLY_FALSIFY_MODES = ["apply-writes-share", "revert-not-exact", "reapply-resorts", "move-without-flag", "revert-ignores-edits"];
|
|
60
|
+
const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
61
|
+
const posix = (p) => p.split(sep).join("/");
|
|
62
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
63
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "folder";
|
|
64
|
+
const within = (abs, root) => abs === root || abs.startsWith(root + sep);
|
|
65
|
+
|
|
66
|
+
// ---- writes, each one recorded ----
|
|
67
|
+
|
|
68
|
+
// Every write the apply makes goes through here, so the reversal manifest is complete by
|
|
69
|
+
// construction: a file made, a file changed (its old bytes and times kept), a directory made.
|
|
70
|
+
function tracker(root) {
|
|
71
|
+
const t = { root, created: [], modified: [], dirs: [], moved: [], stash: new Map() };
|
|
72
|
+
const abs = (rel) => join(root, rel);
|
|
73
|
+
t.mkdirp = (rel) => {
|
|
74
|
+
if (!rel || rel === ".") return;
|
|
75
|
+
const segs = rel.split("/");
|
|
76
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
77
|
+
const sub = segs.slice(0, i).join("/");
|
|
78
|
+
if (existsSync(abs(sub))) continue;
|
|
79
|
+
mkdirSync(abs(sub));
|
|
80
|
+
t.dirs.push(sub);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
t.writeNew = (rel, buf, times = null) => {
|
|
84
|
+
t.mkdirp(posix(dirname(rel)));
|
|
85
|
+
writeFileSync(abs(rel), buf, { flag: "wx" });
|
|
86
|
+
if (times) utimesSync(abs(rel), times.atime, times.mtime);
|
|
87
|
+
t.created.push({ path: rel, sha256: sha256(buf), size: buf.length });
|
|
88
|
+
};
|
|
89
|
+
// Change a file that is already there (a manifest). Its bytes, mode and times are kept first.
|
|
90
|
+
t.modify = (rel, buf) => {
|
|
91
|
+
if (!t.stash.has(rel)) {
|
|
92
|
+
if (existsSync(abs(rel))) {
|
|
93
|
+
const st = statSync(abs(rel));
|
|
94
|
+
t.stash.set(rel, { path: rel, before: readFileSync(abs(rel)).toString("base64"), mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
|
|
95
|
+
} else t.stash.set(rel, { path: rel, before: null });
|
|
96
|
+
}
|
|
97
|
+
t.mkdirp(posix(dirname(rel)));
|
|
98
|
+
writeFileSync(abs(rel), buf);
|
|
99
|
+
};
|
|
100
|
+
// What the apply made or changed, with the hash each has now.
|
|
101
|
+
t.finish = () => {
|
|
102
|
+
t.modified = [...t.stash.values()].map((m) => ({ ...m, sha256: sha256(readFileSync(abs(m.path))) }));
|
|
103
|
+
return t;
|
|
104
|
+
};
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every file and directory under `root`, relative, hidden ones included; symlinks are listed as files.
|
|
109
|
+
function tree(root) {
|
|
110
|
+
const files = [];
|
|
111
|
+
const dirs = [];
|
|
112
|
+
const go = (d) => {
|
|
113
|
+
for (const n of readdirSync(d).sort()) {
|
|
114
|
+
const a = join(d, n);
|
|
115
|
+
const st = lstatSync(a);
|
|
116
|
+
const rel = posix(relative(root, a));
|
|
117
|
+
if (st.isDirectory()) { dirs.push(rel); go(a); } else files.push(rel);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
if (existsSync(root)) go(root);
|
|
121
|
+
return { files, dirs };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- frontmatter ----
|
|
125
|
+
|
|
126
|
+
// Add keys to a markdown file's frontmatter, making one when it has none. A key it already has is
|
|
127
|
+
// left as it is. `lines` are the YAML lines of each new key, keyed by name.
|
|
128
|
+
function withFrontmatter(buf, add) {
|
|
129
|
+
const text = buf.toString("utf8");
|
|
130
|
+
const keys = Object.keys(add);
|
|
131
|
+
if (!keys.length) return buf;
|
|
132
|
+
if (text.startsWith("---\n")) {
|
|
133
|
+
const end = text.indexOf("\n---", 3);
|
|
134
|
+
if (end !== -1) {
|
|
135
|
+
const head = text.slice(4, end);
|
|
136
|
+
const missing = keys.filter((k) => !new RegExp(`^${k}\\s*:`, "m").test(head));
|
|
137
|
+
if (!missing.length) return buf;
|
|
138
|
+
return Buffer.from(`${text.slice(0, end)}\n${missing.map((k) => add[k]).join("\n")}${text.slice(end)}`, "utf8");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return Buffer.from(`---\n${keys.map((k) => add[k]).join("\n")}\n---\n${text}`, "utf8");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const q = (s) => JSON.stringify(s);
|
|
145
|
+
const referencesYaml = (refs) => ["references:", ...refs.map((r) => { const v = reference(r); return ` - ref: ${q(v.ref)}\n label: ${q(v.label)}`; })].join("\n");
|
|
146
|
+
const refLabel = (ref, name) => (ref.startsWith("url:") ? ref.slice(4).replace(/^https?:\/\//, "") : name);
|
|
147
|
+
|
|
148
|
+
// ---- the record ----
|
|
149
|
+
|
|
150
|
+
// Where the apply writes: an existing record, or a new one made here with the proposal's preset.
|
|
151
|
+
// Returns { evda, made } where `made` lists what making it wrote.
|
|
152
|
+
function ensureRecord(target, p, { owner = "", dry = false } = {}) {
|
|
153
|
+
const exists = existsSync(target);
|
|
154
|
+
if (exists && !statSync(target).isDirectory()) throw refuse(`${target} is a file. Name a record, or a folder to make one in.`, "BAD_TARGET");
|
|
155
|
+
const empty = !exists || readdirSync(target).length === 0;
|
|
156
|
+
if (!empty) {
|
|
157
|
+
if (!existsSync(join(target, "evda.json"))) throw refuse(`${target} is not an EVDA and is not empty. Name a record to grow, or a new folder to make one in.`, "NOT_A_RECORD");
|
|
158
|
+
return { evda: openEvda(target), made: null, existed: true };
|
|
159
|
+
}
|
|
160
|
+
// A dry run makes the record in a temporary folder, plans against it, and removes it.
|
|
161
|
+
const dir = dry ? join(mkdtempSync(join(tmpdir(), "evda-gather-dry-")), basename(target)) : target;
|
|
162
|
+
const before = tree(dir);
|
|
163
|
+
const create = createRecord(dir, p.preset.id, owner);
|
|
164
|
+
const after = tree(dir);
|
|
165
|
+
const made = {
|
|
166
|
+
files: after.files.filter((f) => !before.files.includes(f)),
|
|
167
|
+
dirs: after.dirs.filter((d) => !before.dirs.includes(d)),
|
|
168
|
+
root: !exists,
|
|
169
|
+
preset: create.preset,
|
|
170
|
+
};
|
|
171
|
+
return { evda: openEvda(dir), made, existed: false, scratch: dry ? dirname(dir) : null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createRecord(dir, presetId, owner) {
|
|
175
|
+
const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : []), "--preset", presetId]);
|
|
176
|
+
scaffold(opts);
|
|
177
|
+
const plan = applyPreset(dir, presetId);
|
|
178
|
+
return { preset: plan.id };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- the plan ----
|
|
182
|
+
|
|
183
|
+
// The folder a proposal folder lands in, in this record.
|
|
184
|
+
function folderResolver(evda, p) {
|
|
185
|
+
const preset = loadPreset(p.preset.id);
|
|
186
|
+
const subs = subRecords(evda.dir);
|
|
187
|
+
const four = fourRoles(evda, { subRecords: subs });
|
|
188
|
+
const inSub = (rel) => subs.some((s) => rel === s || rel.startsWith(`${s}/`));
|
|
189
|
+
const manifests = folderManifests(evda).filter((m) => m.type && !inSub(posix(relative(evda.dir, m.dir))));
|
|
190
|
+
const cache = new Map();
|
|
191
|
+
return (f) => {
|
|
192
|
+
if (cache.has(f.path)) return cache.get(f.path);
|
|
193
|
+
let r;
|
|
194
|
+
const abs = join(evda.dir, f.path);
|
|
195
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) r = { path: f.path, how: "exists" };
|
|
196
|
+
else if (!falsified("reapply-resorts") && f.type) {
|
|
197
|
+
const same = manifests
|
|
198
|
+
.map((m) => ({ m, rel: posix(relative(evda.dir, m.dir)) }))
|
|
199
|
+
.filter(({ m, rel }) => rel !== "" && m.type === f.type && roleForPath(four, rel)?.role === f.role)
|
|
200
|
+
.sort((a, b) => a.rel.split("/").length - b.rel.split("/").length || a.rel.localeCompare(b.rel));
|
|
201
|
+
r = same.length ? { path: same[0].rel, how: "adopted" } : null;
|
|
202
|
+
}
|
|
203
|
+
if (!r) {
|
|
204
|
+
const pf = preset.folders.find((x) => x.path === f.path);
|
|
205
|
+
r = { path: f.path, how: "create", type: f.type ?? null, role: f.role ?? null, connectors: pf?.connectors ?? [] };
|
|
206
|
+
}
|
|
207
|
+
cache.set(f.path, r);
|
|
208
|
+
return r;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The proposal folder a destination sits under: the longest one at or above it.
|
|
213
|
+
const topOf = (p, to) => p.folders.filter((f) => to === f.path || to.startsWith(`${f.path}/`)).sort((a, b) => b.path.length - a.path.length)[0] ?? null;
|
|
214
|
+
|
|
215
|
+
function sourceAbs(p, item) {
|
|
216
|
+
const s = p.sources.find((x) => x.id === item.source);
|
|
217
|
+
return existsSync(s.path) && statSync(s.path).isFile() ? s.path : join(s.path, item.path);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The whole apply as a list of intended writes, before any is made. Pure over the record's state.
|
|
221
|
+
function plan(evda, p, { mode }) {
|
|
222
|
+
const resolveFolder = folderResolver(evda, p);
|
|
223
|
+
const index = presentIndex(evda.dir);
|
|
224
|
+
const items = new Map(p.items.map((i) => [i.id, i]));
|
|
225
|
+
const out = { writes: [], present: [], changed: [], left: [], refused: [], referenceOnly: [], folders: new Map() };
|
|
226
|
+
const taken = new Set();
|
|
227
|
+
const claim = (to) => {
|
|
228
|
+
let t = to;
|
|
229
|
+
for (let n = 2; taken.has(t) || existsSync(join(evda.dir, t)); n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
|
|
230
|
+
taken.add(t);
|
|
231
|
+
return t;
|
|
232
|
+
};
|
|
233
|
+
const land = (to) => {
|
|
234
|
+
const f = topOf(p, to);
|
|
235
|
+
if (!f) return { to, folder: null };
|
|
236
|
+
const r = resolveFolder(f);
|
|
237
|
+
out.folders.set(f.path, { proposal: f, ...r });
|
|
238
|
+
return { to: r.path + to.slice(f.path.length), folder: f.path };
|
|
239
|
+
};
|
|
240
|
+
// The finished projects, and the placement that is each one's README.
|
|
241
|
+
const finished = new Map();
|
|
242
|
+
for (const pl of p.placements) {
|
|
243
|
+
if (!pl.fields?.status) continue;
|
|
244
|
+
const i = items.get(pl.item);
|
|
245
|
+
const key = `${i.source}:${i.project}`;
|
|
246
|
+
const proj = p.projects.find((x) => x.id === key);
|
|
247
|
+
const dir = pl.to.slice(0, pl.to.length - posix(relative(i.project, i.path)).replace(/\.txt$/i, ".md").length).replace(/\/$/, "");
|
|
248
|
+
const f = finished.get(key) ?? { name: proj?.name ?? basename(i.project), dir, readme: null, status: pl.fields.status };
|
|
249
|
+
if (/^readme\.(md|txt)$/i.test(basename(i.path)) && dirname(i.path) === i.project) f.readme = pl.item;
|
|
250
|
+
finished.set(key, f);
|
|
251
|
+
}
|
|
252
|
+
const statusFor = new Map([...finished.values()].filter((f) => f.readme).map((f) => [f.readme, f.status]));
|
|
253
|
+
|
|
254
|
+
for (const pl of p.placements) {
|
|
255
|
+
const i = items.get(pl.item);
|
|
256
|
+
if (pl.status === "refused") { out.refused.push({ item: pl.item, why: pl.why }); continue; }
|
|
257
|
+
if (pl.status === "unplaced") { out.left.push({ item: pl.item, why: pl.why }); continue; }
|
|
258
|
+
if (pl.status === "present") { out.present.push({ item: pl.item, at: pl.at }); continue; }
|
|
259
|
+
if (pl.status === "reference" && !pl.to) { out.referenceOnly.push({ item: pl.item, refs: (i.cards ?? []).map((c) => c.ref) }); continue; }
|
|
260
|
+
if (pl.status === "create") {
|
|
261
|
+
const { to, folder } = land(pl.to);
|
|
262
|
+
if (existsSync(join(evda.dir, to))) { out.present.push({ item: pl.item, at: to }); continue; }
|
|
263
|
+
const name = basename(pl.to, ".md");
|
|
264
|
+
const refs = (pl.refs ?? []).map((ref) => ({ ref, label: refLabel(ref, name) }));
|
|
265
|
+
const body = Buffer.from(`${refs.length ? `---\n${referencesYaml(refs)}\n---\n` : ""}# ${name}\n`, "utf8");
|
|
266
|
+
out.writes.push({ kind: "page", item: pl.item, to: claim(to), folder, bytes: body, schemes: refs.map((r) => r.ref.split(":")[0]) });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Placed or a reference entry: the item's bytes are read again and must be what was gathered.
|
|
270
|
+
const at = presentAt(index, i.sha256);
|
|
271
|
+
if (at) { out.present.push({ item: pl.item, at }); continue; }
|
|
272
|
+
const src = sourceAbs(p, i);
|
|
273
|
+
let buf;
|
|
274
|
+
try { buf = readFileSync(src); } catch { out.changed.push({ item: pl.item, why: "is no longer at its source" }); continue; }
|
|
275
|
+
if (sha256(buf) !== i.sha256) { out.changed.push({ item: pl.item, why: "changed since the gather; gather again to place it" }); continue; }
|
|
276
|
+
const { to, folder } = land(pl.to);
|
|
277
|
+
if (pl.status === "reference") {
|
|
278
|
+
const name = basename(pl.to, ".md");
|
|
279
|
+
const refs = (pl.refs ?? []).map((ref) => ({ ref, label: refLabel(ref, name) }));
|
|
280
|
+
// A new entry made from a sealed or just-noted item carries its flag (HQ-1414).
|
|
281
|
+
const flags = [[SEALED, i.sealed], [NOTED, i.noted]].filter(([f, on]) => on && carries("gather-apply", f)).map(([f]) => `${f}: true\n`).join("");
|
|
282
|
+
out.writes.push({ kind: "reference", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes: Buffer.from(`---\n${flags}${referencesYaml(refs)}\n---\n# ${name}\n`, "utf8"), schemes: refs.map((r) => r.ref.split(":")[0]) });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const add = {};
|
|
286
|
+
const md = /\.md$/i.test(to);
|
|
287
|
+
if (md && pl.refs?.length) add.references = referencesYaml(pl.refs.map((ref) => ({ ref, label: refLabel(ref, i.heading ?? basename(to, ".md")) })));
|
|
288
|
+
if (md && statusFor.has(pl.item)) add.status = `status: ${statusFor.get(pl.item)}`;
|
|
289
|
+
if (md && pl.circle && falsified("apply-writes-share")) add.share = `share: [${pl.circle}]`;
|
|
290
|
+
// The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
|
|
291
|
+
// record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
|
|
292
|
+
let bytes = withFrontmatter(buf, add);
|
|
293
|
+
for (const flag of [NOTED, SEALED]) if (md && !carries("gather-apply", flag)) bytes = Buffer.from(bytes.toString("utf8").replace(new RegExp(`^${flag}:[ \\t]*true[ \\t]*\\n`, "m"), ""), "utf8");
|
|
294
|
+
const st = statSync(src);
|
|
295
|
+
out.writes.push({
|
|
296
|
+
kind: mode === "move" ? "move" : "copy", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes, original: buf,
|
|
297
|
+
schemes: md && add.references ? pl.refs.map((r) => r.split(":")[0]) : [],
|
|
298
|
+
times: { atime: st.atimeMs / 1000, mtime: st.mtimeMs / 1000 }, mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// A finished project with no README placed gets one, holding only its status and its name.
|
|
302
|
+
for (const f of finished.values()) {
|
|
303
|
+
if (f.readme) continue;
|
|
304
|
+
const { to, folder } = land(`${f.dir}/README.md`);
|
|
305
|
+
out.writes.push({ kind: "page", item: `status:${f.name}`, to: claim(to), folder, bytes: Buffer.from(`---\nstatus: ${f.status}\n---\n# ${f.name}\n`, "utf8"), schemes: [] });
|
|
306
|
+
}
|
|
307
|
+
// The folders the writes land in: made, with their manifest and role, or the record's own.
|
|
308
|
+
out.roles = [];
|
|
309
|
+
const mapping = evda.manifest.mapping;
|
|
310
|
+
const declared = new Set(Object.keys(mapping));
|
|
311
|
+
const four = fourRoles(evda, { subRecords: subRecords(evda.dir) });
|
|
312
|
+
for (const f of [...out.folders.values()].filter((x) => x.how === "create" && out.writes.some((w) => w.folder === x.proposal.path))) {
|
|
313
|
+
if (roleForPath(four, f.path)) continue;
|
|
314
|
+
// The proposal's own key for this folder, or for the nearest folder above it.
|
|
315
|
+
const candidates = Object.entries(p.mapping ?? {}).filter(([, v]) => f.path === v || f.path.startsWith(`${v}/`)).sort((a, b) => b[1].length - a[1].length);
|
|
316
|
+
const [key0, rel] = candidates[0] ?? [`${f.role ?? "collections"}/${slug(basename(f.path))}`, f.path];
|
|
317
|
+
if (out.roles.some((r) => r.rel === rel) || Object.values(mapping).includes(rel)) continue;
|
|
318
|
+
let key = key0;
|
|
319
|
+
if (declared.has(key)) {
|
|
320
|
+
const base = `${fourRoleOf(key0) ?? f.role}/${slug(basename(rel))}`;
|
|
321
|
+
key = base;
|
|
322
|
+
for (let n = 2; declared.has(key); n++) key = `${base}-${n}`;
|
|
323
|
+
}
|
|
324
|
+
declared.add(key);
|
|
325
|
+
out.roles.push({ key, rel });
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- apply ----
|
|
331
|
+
|
|
332
|
+
export async function applyGather(p, into, { mode = "copy", dry = false, now = new Date(), owner = "", manifestOut = null, env = process.env, proposalPath = null } = {}) {
|
|
333
|
+
if (!["copy", "move"].includes(mode)) throw refuse(`mode is copy or move, not ${JSON.stringify(mode)}.`, "BAD_MODE");
|
|
334
|
+
if (falsified("move-without-flag")) mode = "move";
|
|
335
|
+
const open = (p.questions ?? []).filter((x) => x.answer === undefined);
|
|
336
|
+
if (open.length) throw refuse(`the proposal has ${open.length} open question(s), and the owner decides every placement before anything is written: ${open.map((x) => `[${x.id}] ${x.text}`).join(" ")} Answer them with evda gather answer, then apply.`, "OPEN_QUESTIONS");
|
|
337
|
+
const target = resolve(into);
|
|
338
|
+
for (const s of p.sources) {
|
|
339
|
+
if (within(target, s.path)) throw refuse(`${target} is inside the source ${s.path}. The record is written outside every source.`, "INTO_SOURCE");
|
|
340
|
+
if (within(s.path, target)) throw refuse(`the source ${s.path} is inside ${target}. Gather a pile from outside the record it grows.`, "SOURCE_IN_RECORD");
|
|
341
|
+
}
|
|
342
|
+
const outer = findRoot(dirname(target));
|
|
343
|
+
if (outer && !existsSync(join(target, "evda.json"))) throw refuse(`${target} is inside the EVDA at ${outer}. A new record is made outside every record; to grow ${outer}, name it.`, "INTO_RECORD");
|
|
344
|
+
const manifestAbs = dry ? null : checkOut(manifestOut ?? join(gatherDir(env), `${p.id}.apply-${now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "")}.json`), p);
|
|
345
|
+
if (manifestAbs && existsSync(manifestAbs)) throw refuse(`a reversal manifest is already at ${manifestAbs}. Name another with --manifest.`, "MANIFEST_EXISTS");
|
|
346
|
+
|
|
347
|
+
const rec = ensureRecord(target, p, { owner, dry });
|
|
348
|
+
const evda = rec.evda;
|
|
349
|
+
const pl = plan(evda, p, { mode });
|
|
350
|
+
pl.chats = chatWrites(evda, p);
|
|
351
|
+
const result = { dry, mode, target, made: rec.made, plan: pl, manifest: manifestAbs, proposal: p.id };
|
|
352
|
+
result.counts = counts(pl, p);
|
|
353
|
+
if (dry) {
|
|
354
|
+
if (rec.scratch) rmSync(rec.scratch, { recursive: true, force: true });
|
|
355
|
+
result.lines = applyLines(result, p);
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const t = tracker(evda.dir);
|
|
360
|
+
// 1. Folders the writes need that the record does not have, each with its manifest.
|
|
361
|
+
const needSchemes = new Map();
|
|
362
|
+
for (const w of pl.writes) for (const s of w.schemes) {
|
|
363
|
+
const f = pl.folders.get(w.folder);
|
|
364
|
+
const key = f ? f.path : posix(dirname(w.to));
|
|
365
|
+
needSchemes.set(key, new Set([...(needSchemes.get(key) ?? []), s]));
|
|
366
|
+
}
|
|
367
|
+
for (const f of pl.folders.values()) {
|
|
368
|
+
if (f.how !== "create" || !pl.writes.some((w) => w.folder === f.proposal.path)) continue;
|
|
369
|
+
t.mkdirp(f.path);
|
|
370
|
+
if (f.type) {
|
|
371
|
+
const connectors = [...new Set([...(f.connectors ?? []), ...(needSchemes.get(f.path) ?? [])])];
|
|
372
|
+
t.writeNew(`${f.path}/${FOLDER_MANIFEST}`, Buffer.from(JSON.stringify(manifestFor({ type: f.type, connectors }), null, 2) + "\n"));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// 2. The roles those folders fill, in evda.json. Only added, never replaced.
|
|
376
|
+
if (pl.roles.length) {
|
|
377
|
+
const m = readManifest(evda.dir);
|
|
378
|
+
for (const r of pl.roles) m.mapping[r.key] ??= r.rel;
|
|
379
|
+
t.modify("evda.json", Buffer.from(JSON.stringify(m, null, 2) + "\n"));
|
|
380
|
+
}
|
|
381
|
+
// 3. The files.
|
|
382
|
+
const ledger = [];
|
|
383
|
+
for (const w of pl.writes) {
|
|
384
|
+
t.writeNew(w.to, w.bytes, w.times ?? null);
|
|
385
|
+
if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString() });
|
|
386
|
+
if (w.kind === "move") {
|
|
387
|
+
unlinkSync(w.source);
|
|
388
|
+
// A file the apply changed on the way in keeps its source bytes here, so revert can put them back.
|
|
389
|
+
t.moved.push({ source: w.source, to: w.to, sha256: w.sourceSha, mode: w.mode, atimeMs: w.atimeMs, mtimeMs: w.mtimeMs, ...(sha256(w.bytes) !== w.sourceSha ? { bytes: w.original.toString("base64") } : {}) });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// 3b. The owner's chat messages no agent filed (HQ-1385), appended to their days.
|
|
393
|
+
for (const [rel, adds] of pl.chats.byFile) {
|
|
394
|
+
const abs = join(evda.dir, rel);
|
|
395
|
+
const current = existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
396
|
+
const bytes = Buffer.from((current ?? "") + dayAppend(current, adds), "utf8");
|
|
397
|
+
if (current === null) t.writeNew(rel, bytes);
|
|
398
|
+
else t.modify(rel, bytes);
|
|
399
|
+
}
|
|
400
|
+
// 4. A written reference resolves through a connector its folder declares; one it lacks is added.
|
|
401
|
+
const manifestsNow = folderManifests(openEvda(evda.dir));
|
|
402
|
+
for (const w of pl.writes) {
|
|
403
|
+
if (!w.schemes.length) continue;
|
|
404
|
+
const f = folderFor(join(evda.dir, w.to), manifestsNow);
|
|
405
|
+
if (!f) continue;
|
|
406
|
+
const m = JSON.parse(readFileSync(f.abs, "utf8"));
|
|
407
|
+
const has = Array.isArray(m.connectors) ? m.connectors : [];
|
|
408
|
+
const add = w.schemes.filter((s) => !has.includes(s));
|
|
409
|
+
if (!add.length) continue;
|
|
410
|
+
m.connectors = [...has, ...new Set(add)];
|
|
411
|
+
f.connectors = m.connectors;
|
|
412
|
+
t.modify(posix(relative(evda.dir, f.abs)), Buffer.from(JSON.stringify(m, null, 2) + "\n"));
|
|
413
|
+
}
|
|
414
|
+
// 5. The record's list of what it gathered, so a second gather knows a file it changed on the way in.
|
|
415
|
+
if (ledger.length) {
|
|
416
|
+
const g = join(evda.dir, GATHERED);
|
|
417
|
+
const before = existsSync(g) ? readFileSync(g, "utf8") : "";
|
|
418
|
+
t.modify(GATHERED, Buffer.from(before + ledger.map((l) => JSON.stringify(l)).join("\n") + "\n"));
|
|
419
|
+
}
|
|
420
|
+
// 6. A circle the answers asked for: a proposal file, never a circle.
|
|
421
|
+
result.proposed = [];
|
|
422
|
+
for (const c of p.circles ?? []) {
|
|
423
|
+
const f = [...pl.folders.values()].find((x) => c.covers === x.proposal.path || c.covers.startsWith(`${x.proposal.path}/`));
|
|
424
|
+
const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
|
|
425
|
+
if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
|
|
426
|
+
const before = tree(evda.dir);
|
|
427
|
+
const r = proposeCircle(evda, { name: c.name, who: "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
|
|
428
|
+
const after = tree(evda.dir);
|
|
429
|
+
for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
|
|
430
|
+
t.created.push({ path: r.path, sha256: sha256(readFileSync(join(evda.dir, r.path))), size: statSync(join(evda.dir, r.path)).size });
|
|
431
|
+
result.proposed.push(r.path);
|
|
432
|
+
}
|
|
433
|
+
t.finish();
|
|
434
|
+
|
|
435
|
+
// The reversal manifest, written before doctor so a failing doctor still leaves a way back.
|
|
436
|
+
const manifest = {
|
|
437
|
+
evdaGatherApply: APPLY_VERSION,
|
|
438
|
+
proposal: p.id,
|
|
439
|
+
proposalPath,
|
|
440
|
+
target: evda.dir,
|
|
441
|
+
mode,
|
|
442
|
+
at: now.toISOString(),
|
|
443
|
+
made: rec.made ? { root: rec.made.root, preset: rec.made.preset, files: rec.made.files.map((f) => ({ path: f, sha256: sha256(readFileSync(join(evda.dir, f))) })), dirs: rec.made.dirs } : null,
|
|
444
|
+
created: t.created,
|
|
445
|
+
// A file the making of the record wrote and the apply then changed is simply made: revert removes it.
|
|
446
|
+
modified: t.modified.filter((m) => !rec.made?.files.includes(m.path)),
|
|
447
|
+
dirs: t.dirs,
|
|
448
|
+
moved: t.moved,
|
|
449
|
+
placed: pl.writes.map((w) => ({ item: w.item, kind: w.kind, from: w.source ?? null, to: w.to })),
|
|
450
|
+
chats: [...pl.chats.byFile.keys()],
|
|
451
|
+
counts: result.counts,
|
|
452
|
+
};
|
|
453
|
+
mkdirSync(dirname(manifestAbs), { recursive: true });
|
|
454
|
+
writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" });
|
|
455
|
+
|
|
456
|
+
const lines = [];
|
|
457
|
+
const d = doctor(evda.dir, { out: (l) => lines.push(l), via: "gather apply" });
|
|
458
|
+
result.doctor = { exit: d.exit, lines, failures: d.failures, warnings: d.warnings.length };
|
|
459
|
+
result.lines = applyLines(result, p);
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The chat exports' additions, grouped by day file: { byFile: Map(rel -> adds), added, filed, secret,
|
|
464
|
+
// changed: [why] }. The record is read as it is now, so what an agent filed since the gather counts.
|
|
465
|
+
function chatWrites(evda, p) {
|
|
466
|
+
const out = { byFile: new Map(), added: 0, filed: 0, secret: 0, conversations: 0, changed: [] };
|
|
467
|
+
if (!(p.chats ?? []).length) return out;
|
|
468
|
+
const dayDir = dayDirOf(evda);
|
|
469
|
+
if (!dayDir) { out.changed.push("the record maps no day role, so the chat messages have no day to go to"); return out; }
|
|
470
|
+
for (const c of p.chats) {
|
|
471
|
+
let r;
|
|
472
|
+
try { r = chatPlan(evda, p.sources.find((x) => x.id === c.source), c); } catch (e) { out.changed.push(e.message.replace(/^REFUSED: /, "").replace(/ Nothing was written\.$/, "")); continue; }
|
|
473
|
+
out.filed += r.counts.filed;
|
|
474
|
+
out.secret += r.counts.secret;
|
|
475
|
+
for (const a of r.adds) {
|
|
476
|
+
const rel = posix(relative(evda.dir, join(dayDir, `${a.date}.md`)));
|
|
477
|
+
out.byFile.set(rel, [...(out.byFile.get(rel) ?? []), a]);
|
|
478
|
+
out.added += a.words.length;
|
|
479
|
+
out.conversations += 1;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const [rel, adds] of out.byFile) out.byFile.set(rel, adds.sort((a, b) => a.time.localeCompare(b.time)));
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function counts(pl, p) {
|
|
487
|
+
const by = (k) => pl.writes.filter((w) => w.kind === k).length;
|
|
488
|
+
const placed = by("copy") + by("move") + by("reference") + by("page");
|
|
489
|
+
return {
|
|
490
|
+
placed, copied: by("copy"), moved: by("move"), references: by("reference"), pages: by("page"),
|
|
491
|
+
asked: (p.questions ?? []).length, left: pl.left.length, refused: pl.refused.length, present: pl.present.length,
|
|
492
|
+
changed: pl.changed.length, referenceOnly: pl.referenceOnly.length,
|
|
493
|
+
chatAdded: pl.chats?.added ?? 0, chatFiled: pl.chats?.filed ?? 0, chatSecret: pl.chats?.secret ?? 0, chatDays: pl.chats?.byFile.size ?? 0,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function checkedLine(c) {
|
|
498
|
+
return `CHECKED ${c.placed} items placed, ${c.asked} asked, ${c.left} left for a person`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function applyLines(r, p) {
|
|
502
|
+
const c = r.counts;
|
|
503
|
+
const out = [];
|
|
504
|
+
const verb = r.dry ? "would" : "did";
|
|
505
|
+
if (r.made) out.push(`${r.dry ? "Would make" : "Made"} a new EVDA at ${r.target} with the ${p.preset.title} preset (${r.made.files.length} file(s)).`);
|
|
506
|
+
else out.push(`${r.dry ? "Would grow" : "Grew"} the EVDA at ${r.target}; nothing already in it was moved.`);
|
|
507
|
+
for (const f of r.plan.folders.values()) {
|
|
508
|
+
if (f.how === "adopted") out.push(` adopted ${f.proposal.path}/ as the record's own ${f.path}/ (type ${f.proposal.type}, ${f.proposal.role})`);
|
|
509
|
+
else if (f.how === "create" && r.plan.writes.some((w) => w.folder === f.proposal.path)) out.push(` ${verb === "did" ? "made " : "make "} ${f.path}/${f.type ? ` with ${FOLDER_MANIFEST} (type ${f.type})` : ""}`);
|
|
510
|
+
}
|
|
511
|
+
for (const x of r.plan.roles) out.push(` ${verb === "did" ? "declared" : "declare "} ${x.key} -> ${x.rel} in evda.json`);
|
|
512
|
+
out.push(` ${verb === "did" ? (r.mode === "move" ? "moved " : "copied ") : (r.mode === "move" ? "move " : "copy ")} ${c.copied + c.moved} file(s)${r.mode === "move" ? ", each gone from its source" : "; the sources are untouched"}`);
|
|
513
|
+
if (c.references) out.push(` ${verb === "did" ? "wrote " : "write "} ${c.references} entr${c.references === 1 ? "y" : "ies"} holding references; the bookmarks and link lists stay where they are`);
|
|
514
|
+
if (c.pages) out.push(` ${verb === "did" ? "wrote " : "write "} ${c.pages} page(s) the answers asked for`);
|
|
515
|
+
if (c.referenceOnly) out.push(` kept ${c.referenceOnly} export(s) as references only, never copied`);
|
|
516
|
+
if (c.present) out.push(` present ${c.present} item(s) already in the record, left where they are`);
|
|
517
|
+
for (const x of r.plan.changed) out.push(` changed ${x.item} ${x.why}`);
|
|
518
|
+
if ((p.chats ?? []).length) {
|
|
519
|
+
out.push(` ${verb === "did" ? "added " : "add "} ${c.chatAdded} message(s) of the owner's from ${p.chats.length} chat export(s) to ${c.chatDays} day file(s), each conversation under its date and time; ${c.chatFiled} already in the record${c.chatSecret ? `, ${c.chatSecret} left out for holding something shaped like a secret` : ""}`);
|
|
520
|
+
for (const why of r.plan.chats.changed) out.push(` changed ${why}`);
|
|
521
|
+
}
|
|
522
|
+
for (const x of r.plan.left) out.push(` left ${x.item}: ${x.why}`);
|
|
523
|
+
for (const x of r.plan.refused) out.push(` refused ${x.item}: left where it is`);
|
|
524
|
+
for (const x of r.proposed ?? []) out.push(` proposed ${x} (a proposal file; no circle was created, and no file was marked for sharing)`);
|
|
525
|
+
if (!r.dry) {
|
|
526
|
+
out.push(`Reversal manifest: ${r.manifest} (evda gather revert ${r.manifest})`);
|
|
527
|
+
const last = r.doctor.lines[r.doctor.lines.length - 1] ?? "";
|
|
528
|
+
out.push(`doctor: ${last.trim()}`);
|
|
529
|
+
for (const f of r.doctor.failures) out.push(` FAIL ${f}`);
|
|
530
|
+
} else out.push("Nothing was written.");
|
|
531
|
+
out.push(checkedLine(c));
|
|
532
|
+
if ((p.chats ?? []).length) out.push(`CHECKED ${c.chatAdded + c.chatFiled + c.chatSecret} chat message(s) of the owner's ${c.chatAdded} added, ${c.chatFiled} already filed, ${c.chatSecret} secret-shaped`);
|
|
533
|
+
out.push(`CHECKED ${c.copied + c.moved} file(s) ${r.mode === "move" ? "moved" : "copied"}, ${c.references} reference entr${c.references === 1 ? "y" : "ies"}, ${c.pages} page(s), ${c.present} already in the record, ${c.refused} refused, ${c.changed} changed since the gather`);
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---- revert ----
|
|
538
|
+
|
|
539
|
+
export function loadApplyManifest(path) {
|
|
540
|
+
const abs = resolve(path);
|
|
541
|
+
if (!existsSync(abs)) throw refuse(`no reversal manifest at ${abs}.`, "NO_MANIFEST");
|
|
542
|
+
let m;
|
|
543
|
+
try { m = JSON.parse(readFileSync(abs, "utf8")); } catch (e) { throw refuse(`${abs} is not valid JSON (${e.message}).`, "BAD_MANIFEST"); }
|
|
544
|
+
if (m?.evdaGatherApply !== APPLY_VERSION || !Array.isArray(m.created)) throw refuse(`${abs} is not a gather reversal manifest.`, "BAD_MANIFEST");
|
|
545
|
+
return { m, abs };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const hashOf = (abs) => (existsSync(abs) && lstatSync(abs).isFile() ? sha256(readFileSync(abs)) : null);
|
|
549
|
+
|
|
550
|
+
export function revertGather(path, { force = false, now = new Date() } = {}) {
|
|
551
|
+
const { m, abs } = loadApplyManifest(path);
|
|
552
|
+
if (m.reverted) throw new EvdaError(`REFUSED: ${abs} was already reverted at ${m.reverted}. Nothing was changed.`, { code: "ALREADY_REVERTED", exit: 2 });
|
|
553
|
+
const root = m.target;
|
|
554
|
+
const files = [...(m.made?.files ?? []), ...m.created];
|
|
555
|
+
const edited = [];
|
|
556
|
+
const gone = [];
|
|
557
|
+
for (const f of files) {
|
|
558
|
+
const h = hashOf(join(root, f.path));
|
|
559
|
+
if (h === null) gone.push(f.path);
|
|
560
|
+
else if (h !== f.sha256) edited.push(f.path);
|
|
561
|
+
}
|
|
562
|
+
for (const f of m.modified) if (hashOf(join(root, f.path)) !== f.sha256) edited.push(f.path);
|
|
563
|
+
const blocked = m.moved.filter((x) => existsSync(x.source)).map((x) => x.source);
|
|
564
|
+
if ((edited.length || blocked.length) && !force && !falsified("revert-ignores-edits")) {
|
|
565
|
+
throw new EvdaError("REFUSED: " + [
|
|
566
|
+
edited.length ? `${edited.length} file(s) changed since the apply: ${edited.join(", ")}.` : "",
|
|
567
|
+
blocked.length ? `${blocked.length} source path(s) hold a file again: ${blocked.join(", ")}.` : "",
|
|
568
|
+
"Reverting would lose those changes. Pass --force to revert anyway. Nothing was changed.",
|
|
569
|
+
].filter(Boolean).join(" "), { code: "EDITED_SINCE_APPLY", exit: 2 });
|
|
570
|
+
}
|
|
571
|
+
// Moved files first, back to their sources with their mode and times.
|
|
572
|
+
let restored = 0;
|
|
573
|
+
for (const x of m.moved) {
|
|
574
|
+
const placed = join(root, x.to);
|
|
575
|
+
const bytes = x.bytes ? Buffer.from(x.bytes, "base64") : existsSync(placed) ? readFileSync(placed) : null;
|
|
576
|
+
if (!bytes) continue;
|
|
577
|
+
mkdirSync(dirname(x.source), { recursive: true });
|
|
578
|
+
writeFileSync(x.source, bytes);
|
|
579
|
+
chmodSync(x.source, x.mode);
|
|
580
|
+
utimesSync(x.source, x.atimeMs / 1000, x.mtimeMs / 1000);
|
|
581
|
+
restored += 1;
|
|
582
|
+
}
|
|
583
|
+
let removed = 0;
|
|
584
|
+
for (const f of files) { const a = join(root, f.path); if (existsSync(a)) { unlinkSync(a); removed += 1; } }
|
|
585
|
+
let put = 0;
|
|
586
|
+
if (!falsified("revert-not-exact")) {
|
|
587
|
+
for (const f of m.modified) {
|
|
588
|
+
const a = join(root, f.path);
|
|
589
|
+
if (f.before === null) { if (existsSync(a)) unlinkSync(a); put += 1; continue; }
|
|
590
|
+
writeFileSync(a, Buffer.from(f.before, "base64"));
|
|
591
|
+
chmodSync(a, f.mode);
|
|
592
|
+
utimesSync(a, f.atimeMs / 1000, f.mtimeMs / 1000);
|
|
593
|
+
put += 1;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Directories the apply made, deepest first, when nothing the owner added is in them.
|
|
597
|
+
const kept = [];
|
|
598
|
+
const dirs = [...(m.made?.dirs ?? []), ...m.dirs].sort((a, b) => b.split("/").length - a.split("/").length || b.localeCompare(a));
|
|
599
|
+
for (const d of dirs) {
|
|
600
|
+
const a = join(root, d);
|
|
601
|
+
if (!existsSync(a)) continue;
|
|
602
|
+
if (readdirSync(a).length) { kept.push(d); continue; }
|
|
603
|
+
rmdirSync(a);
|
|
604
|
+
}
|
|
605
|
+
if (m.made?.root && existsSync(root) && readdirSync(root).length === 0) rmdirSync(root);
|
|
606
|
+
m.reverted = now.toISOString();
|
|
607
|
+
writeFileSync(abs, JSON.stringify(m, null, 2) + "\n");
|
|
608
|
+
return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0 };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function revertLines(r) {
|
|
612
|
+
const out = [`Reverted the gather into ${r.target}.`];
|
|
613
|
+
if (r.restored) out.push(` returned ${r.restored} file(s) to their sources, with their mode and modification time`);
|
|
614
|
+
out.push(` removed ${r.removed} file(s) the apply wrote`);
|
|
615
|
+
if (r.put) out.push(` restored ${r.put} file(s) the apply changed (evda.json, a folder manifest, the gathered list) to their bytes before it`);
|
|
616
|
+
if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the apply were removed: ${r.edited.join(", ")}`);
|
|
617
|
+
for (const d of r.kept) out.push(` kept ${d}/, which holds files the apply did not write`);
|
|
618
|
+
if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
|
|
619
|
+
out.push(`CHECKED ${r.removed + r.restored + r.put} file(s) reverted ${r.kept.length} folder(s) kept`);
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ---- MCP ----
|
|
624
|
+
|
|
625
|
+
export async function gatherApplyTool(evda, args = {}, { now = new Date(), env = process.env } = {}) {
|
|
626
|
+
onlyKeys("evda_gather_apply", args, ["proposal", "into", "move", "confirmed"], "It writes the proposal into a record after the owner's yes, and every write is listed in a reversal manifest.");
|
|
627
|
+
if (typeof args.proposal !== "string" || !args.proposal.trim()) throw refuse('evda_gather_apply needs "proposal", the id evda_gather returned.', "NO_PROPOSAL");
|
|
628
|
+
if (args.confirmed !== true) throw refuse('evda_gather_apply needs "confirmed": true, and that is true only when the owner said yes to applying this proposal in this conversation. Show them evda_gather\'s summary and the answers first, and wait.', "NOT_CONFIRMED");
|
|
629
|
+
if (args.move !== undefined && typeof args.move !== "boolean") throw refuse('evda_gather_apply "move" is true or false; copy is the default.', "BAD_MODE");
|
|
630
|
+
if (args.into !== undefined && (typeof args.into !== "string" || !args.into.trim())) throw refuse('evda_gather_apply "into" is a path: the record to grow, or a new folder to make one in.', "BAD_TARGET");
|
|
631
|
+
const { p, abs } = loadProposal(args.proposal, env);
|
|
632
|
+
const into = args.into ?? evda?.dir;
|
|
633
|
+
if (!into) throw refuse('evda_gather_apply needs "into": no record is open.', "BAD_TARGET");
|
|
634
|
+
return applyGather(p, into, { mode: args.move ? "move" : "copy", now, env, proposalPath: abs });
|
|
635
|
+
}
|