@anhlp/alp-core 0.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +7 -0
  4. package/dist/adapter.d.ts +20 -0
  5. package/dist/adapter.js +30 -0
  6. package/dist/atomic.d.ts +2 -0
  7. package/dist/atomic.js +28 -0
  8. package/dist/backup.d.ts +27 -0
  9. package/dist/backup.js +582 -0
  10. package/dist/beads.d.ts +65 -0
  11. package/dist/beads.js +213 -0
  12. package/dist/delegation.d.ts +2 -0
  13. package/dist/delegation.js +32 -0
  14. package/dist/errors.d.ts +4 -0
  15. package/dist/errors.js +11 -0
  16. package/dist/formulas.d.ts +25 -0
  17. package/dist/formulas.js +201 -0
  18. package/dist/hook-run.d.ts +44 -0
  19. package/dist/hook-run.js +60 -0
  20. package/dist/hooks.d.ts +35 -0
  21. package/dist/hooks.js +30 -0
  22. package/dist/index.d.ts +30 -0
  23. package/dist/index.js +63 -0
  24. package/dist/init.d.ts +30 -0
  25. package/dist/init.js +88 -0
  26. package/dist/library-edit.d.ts +90 -0
  27. package/dist/library-edit.js +315 -0
  28. package/dist/library.d.ts +28 -0
  29. package/dist/library.js +108 -0
  30. package/dist/log.d.ts +37 -0
  31. package/dist/log.js +156 -0
  32. package/dist/migrations.d.ts +9 -0
  33. package/dist/migrations.js +95 -0
  34. package/dist/models.d.ts +11 -0
  35. package/dist/models.js +44 -0
  36. package/dist/permissions.d.ts +14 -0
  37. package/dist/permissions.js +229 -0
  38. package/dist/profiles.d.ts +27 -0
  39. package/dist/profiles.js +122 -0
  40. package/dist/promptsafe.d.ts +1 -0
  41. package/dist/promptsafe.js +13 -0
  42. package/dist/providers.d.ts +90 -0
  43. package/dist/providers.js +101 -0
  44. package/dist/resolver.d.ts +41 -0
  45. package/dist/resolver.js +240 -0
  46. package/dist/safe-path.d.ts +2 -0
  47. package/dist/safe-path.js +24 -0
  48. package/dist/shell.d.ts +16 -0
  49. package/dist/shell.js +22 -0
  50. package/dist/state-schema.d.ts +9 -0
  51. package/dist/state-schema.js +54 -0
  52. package/dist/task-rows.d.ts +47 -0
  53. package/dist/task-rows.js +45 -0
  54. package/dist/tasks.d.ts +128 -0
  55. package/dist/tasks.js +835 -0
  56. package/dist/teams.d.ts +69 -0
  57. package/dist/teams.js +159 -0
  58. package/dist/template-path.d.ts +2 -0
  59. package/dist/template-path.js +10 -0
  60. package/dist/trust.d.ts +8 -0
  61. package/dist/trust.js +47 -0
  62. package/dist/types.d.ts +51 -0
  63. package/dist/upgrade.d.ts +10 -0
  64. package/dist/upgrade.js +200 -0
  65. package/dist/user-settings.d.ts +6 -0
  66. package/dist/user-settings.js +72 -0
  67. package/dist/validation.d.ts +17 -0
  68. package/dist/validation.js +295 -0
  69. package/dist/verify.d.ts +15 -0
  70. package/dist/verify.js +123 -0
  71. package/dist/workflow.d.ts +14 -0
  72. package/dist/workflow.js +24 -0
  73. package/package.json +309 -0
  74. package/src/adapter.d.ts +20 -0
  75. package/src/adapter.js +29 -0
  76. package/src/atomic.d.ts +2 -0
  77. package/src/atomic.js +28 -0
  78. package/src/backup.d.ts +27 -0
  79. package/src/backup.js +570 -0
  80. package/src/beads.js +236 -0
  81. package/src/delegation.js +28 -0
  82. package/src/errors.js +7 -0
  83. package/src/formulas.d.ts +25 -0
  84. package/src/formulas.js +202 -0
  85. package/src/hook-run.js +57 -0
  86. package/src/hooks.js +36 -0
  87. package/src/index.js +31 -0
  88. package/src/init.js +92 -0
  89. package/src/library-edit.js +346 -0
  90. package/src/library.js +117 -0
  91. package/src/log.d.ts +37 -0
  92. package/src/log.js +177 -0
  93. package/src/migrations.d.ts +9 -0
  94. package/src/migrations.js +111 -0
  95. package/src/models.js +45 -0
  96. package/src/permissions.d.ts +14 -0
  97. package/src/permissions.js +242 -0
  98. package/src/profiles.js +139 -0
  99. package/src/promptsafe.d.ts +1 -0
  100. package/src/promptsafe.js +19 -0
  101. package/src/providers.js +122 -0
  102. package/src/resolver.js +238 -0
  103. package/src/safe-path.js +20 -0
  104. package/src/shell.js +28 -0
  105. package/src/state-schema.d.ts +9 -0
  106. package/src/state-schema.js +60 -0
  107. package/src/task-rows.js +44 -0
  108. package/src/tasks.d.ts +128 -0
  109. package/src/tasks.js +881 -0
  110. package/src/teams.js +170 -0
  111. package/src/template-path.js +8 -0
  112. package/src/trust.js +59 -0
  113. package/src/types.d.ts +51 -0
  114. package/src/upgrade.js +195 -0
  115. package/src/user-settings.d.ts +6 -0
  116. package/src/user-settings.js +76 -0
  117. package/src/validation.d.ts +17 -0
  118. package/src/validation.js +302 -0
  119. package/src/verify.d.ts +15 -0
  120. package/src/verify.js +127 -0
  121. package/src/workflow.js +28 -0
  122. package/templates/ALP.md +52 -0
  123. package/templates/agents/lead/AGENT.md +90 -0
  124. package/templates/agents/main/AGENT.md +150 -0
  125. package/templates/agents/oracle/AGENT.md +44 -0
  126. package/templates/agents/peer/AGENT.md +55 -0
  127. package/templates/agents/reviewer/AGENT.md +80 -0
  128. package/templates/agents/supervisor/AGENT.md +72 -0
  129. package/templates/providers/antigravity.json +6 -0
  130. package/templates/providers/opencode.json +7 -0
  131. package/templates/role-skills.json +8 -0
  132. package/templates/skills/bug-loop/SKILL.md +44 -0
  133. package/templates/skills/bug-loop/references/test-proof.md +25 -0
  134. package/templates/skills/goal-griller/SKILL.md +49 -0
  135. package/templates/skills/prompt-leverage/SKILL.md +37 -0
  136. package/templates/skills/prompt-leverage/references/brief-template.md +27 -0
  137. package/templates/skills/sequence-execution-plan/SKILL.md +42 -0
  138. package/templates/skills/sequence-execution-plan/references/plan-template.md +22 -0
  139. package/templates/skills/smart-commits/SKILL.md +40 -0
  140. package/templates/skills/xia/SKILL.md +42 -0
  141. package/templates/teams/cafe/HOUSE_RULES.md +1 -0
  142. package/templates/teams/cafe/team.json +15 -0
  143. package/templates/teams/pho/HOUSE_RULES.md +1 -0
  144. package/templates/teams/pho/team.json +14 -0
package/dist/backup.js ADDED
@@ -0,0 +1,582 @@
1
+ // packages/core/src/backup.js
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { chmod, lstat, mkdir, open, readdir, readFile, readlink, rm, rmdir, stat, symlink, utimes } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { Readable } from "node:stream";
7
+ import { StringDecoder } from "node:string_decoder";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { createGunzip, createGzip } from "node:zlib";
10
+ import { AlpError } from "./errors.js";
11
+ import { moveFile, writeAtomic } from "./atomic.js";
12
+ import { STATE_SCHEMA, assertStateSupported, readStateSchema } from "./state-schema.js";
13
+ import { withTaskLock } from "./tasks.js";
14
+ var BACKUP_FORMAT = 1;
15
+ var BACKUP_EXTENSION = ".alpbackup";
16
+ var STATE_UNITS = ["state/schema.json", "state/sessions", "state/timeline", "state/receipts", "state/recall.json", "state/pause.json", "state/trust.json", "state/web-workspaces.json"];
17
+ var CLEARED_UNITS = ["state/live.json", "state/migration.json"];
18
+ var HOME_UNITS = [
19
+ ...STATE_UNITS,
20
+ "settings.json",
21
+ "library.json",
22
+ "role-skills.json",
23
+ "lessons.md",
24
+ "agents",
25
+ "skills",
26
+ "teams",
27
+ "hooks",
28
+ "mcp",
29
+ "providers",
30
+ "formulas",
31
+ "boards",
32
+ "runs"
33
+ ];
34
+ var PROJECT_UNITS = ["ALP.md", ".alp/settings.json", ".alp/lessons.md", ".alp/tasks", ".alp/agents", ".alp/skills", ".alp/teams", ".alp/hooks", ".alp/mcp", ".alp/formulas"];
35
+ var CHUNK = 1024 * 1024;
36
+ var AUTOMATIC_KEEP = 5;
37
+ var JOURNAL = "restore-journal.json";
38
+ var RESTORED = path.join("state", "restored.json");
39
+ var transient = (name) => name.endsWith(".tmp") || name === ".lock" || /\.pre-restore-[0-9a-f]+$/.test(name) || /^\.restore-[0-9a-f]+$/.test(name);
40
+ var backupsDir = (home) => path.join(home, "backups");
41
+ var journalFile = (home) => path.join(home, JOURNAL);
42
+ var stamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
43
+ var exists = (file) => lstat(file).then(() => true, () => false);
44
+ var invalid = (message, options) => new AlpError("BACKUP_INVALID", message, options);
45
+ function safeRelative(value) {
46
+ return typeof value === "string" && value.length > 0 && value.length < 4096 && !value.startsWith("/") && !/^[A-Za-z]:/.test(value) && !value.includes("\\") && !value.includes("\0") && value.split("/").every((part) => part && part !== "." && part !== "..");
47
+ }
48
+ async function sessionRecords(home) {
49
+ const directory = path.join(home, "state", "sessions");
50
+ const records = [];
51
+ for (const name of await readdir(directory).catch(() => [])) {
52
+ if (!name.endsWith(".json")) continue;
53
+ try {
54
+ records.push(JSON.parse(await readFile(path.join(directory, name), "utf8")));
55
+ } catch {
56
+ }
57
+ }
58
+ return records;
59
+ }
60
+ async function knownProjects(home) {
61
+ const roots = /* @__PURE__ */ new Set();
62
+ for (const record of await sessionRecords(home)) {
63
+ const root = record?.session?.projectRoot ?? record?.spec?.cwd;
64
+ if (typeof root === "string" && path.isAbsolute(root)) roots.add(path.resolve(root));
65
+ }
66
+ const found = [];
67
+ for (const root of [...roots].sort()) if (await exists(path.join(root, ".alp"))) found.push(root);
68
+ return found;
69
+ }
70
+ async function externalReferences(home) {
71
+ const references = [];
72
+ for (const record of await sessionRecords(home)) {
73
+ const session = record?.session;
74
+ if (session?.threadId) references.push({ sessionId: record.id, runtime: session.runtime, threadId: session.threadId, projectRoot: session.projectRoot });
75
+ }
76
+ return references;
77
+ }
78
+ async function* unitEntries(base, unit) {
79
+ const full = path.join(base, unit);
80
+ const info = await lstat(full).catch((error) => {
81
+ if (error?.code === "ENOENT") return void 0;
82
+ throw error;
83
+ });
84
+ if (!info) return;
85
+ async function* walk(relative, info2) {
86
+ const file = path.join(base, relative);
87
+ if (info2.isSymbolicLink()) yield { t: "link", path: relative, target: await readlink(file) };
88
+ else if (info2.isDirectory()) {
89
+ yield { t: "dir", path: relative, mode: info2.mode & 511 };
90
+ for (const name of (await readdir(file)).sort()) {
91
+ if (transient(name)) continue;
92
+ const child = await lstat(path.join(file, name)).catch(() => void 0);
93
+ if (child) yield* walk(`${relative}/${name}`, child);
94
+ }
95
+ } else if (info2.isFile()) yield { t: "file", path: relative, mode: info2.mode & 511, file };
96
+ }
97
+ yield* walk(unit, info);
98
+ }
99
+ async function unitKind(base, unit) {
100
+ const info = await lstat(path.join(base, unit)).catch((error) => {
101
+ if (error?.code === "ENOENT") return void 0;
102
+ throw error;
103
+ });
104
+ return !info ? "absent" : info.isDirectory() ? "dir" : info.isSymbolicLink() ? "link" : "file";
105
+ }
106
+ async function createBackup({ home, out, units = HOME_UNITS, projects = [], by = "alp", kind = "manual" }) {
107
+ return holdTaskLocks(projects.map((root) => path.resolve(root)), () => writeBackup({ home, out, units, projects, by, kind }));
108
+ }
109
+ async function writeBackup({ home, out, units, projects, by, kind }) {
110
+ const withState = units.some((unit) => unit.startsWith("state/"));
111
+ const stateSchema = withState ? await readStateSchema(home).then((info) => info.schema, () => null) : null;
112
+ const header = {
113
+ t: "header",
114
+ format: "alp-backup",
115
+ version: BACKUP_FORMAT,
116
+ kind,
117
+ by,
118
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
119
+ home,
120
+ stateSchema,
121
+ units: await Promise.all(units.map(async (unit) => ({ path: unit, kind: await unitKind(home, unit) }))),
122
+ projects: await Promise.all(projects.map(async (root) => ({ root: path.resolve(root), units: await Promise.all(PROJECT_UNITS.map(async (unit) => ({ path: unit, kind: await unitKind(root, unit) }))) }))),
123
+ external: withState ? await externalReferences(home) : []
124
+ };
125
+ const digest = createHash("sha256");
126
+ const summary = { file: out, files: 0, bytes: 0, projects: header.projects.map((project) => project.root), stateSchema, sessions: 0, tasks: 0 };
127
+ const line = (record) => {
128
+ const text = `${JSON.stringify(record)}
129
+ `;
130
+ digest.update(text);
131
+ return text;
132
+ };
133
+ async function* area(name, base, unitList) {
134
+ for (const { path: unit, kind: kind2 } of unitList) {
135
+ if (kind2 === "absent") continue;
136
+ for await (const entry of unitEntries(base, unit)) {
137
+ if (entry.t !== "file") {
138
+ yield line({ ...entry, area: name });
139
+ continue;
140
+ }
141
+ const content = await readFile(entry.file);
142
+ summary.files++;
143
+ summary.bytes += content.length;
144
+ if (name === "home" && /^state\/sessions\/[^/]+\.json$/.test(entry.path)) summary.sessions++;
145
+ if (name !== "home" && /^\.alp\/tasks\/[^/]+\.json$/.test(entry.path)) summary.tasks++;
146
+ const chunks = Math.max(1, Math.ceil(content.length / CHUNK));
147
+ yield line({ t: "file", area: name, path: entry.path, mode: entry.mode, size: content.length, sha256: createHash("sha256").update(content).digest("hex"), chunks });
148
+ for (let index = 0; index < chunks; index++) yield line({ t: "data", b: content.subarray(index * CHUNK, (index + 1) * CHUNK).toString("base64") });
149
+ }
150
+ }
151
+ }
152
+ async function* lines() {
153
+ yield line(header);
154
+ yield* area("home", home, header.units);
155
+ for (const [index, project] of header.projects.entries()) yield* area(`project:${index}`, project.root, project.units);
156
+ yield `${JSON.stringify({ t: "end", files: summary.files, bytes: summary.bytes, sha256: digest.digest("hex") })}
157
+ `;
158
+ }
159
+ await mkdir(path.dirname(out), { recursive: true, mode: 448 });
160
+ const partial = `${out}.${randomBytes(4).toString("hex")}.partial`;
161
+ try {
162
+ await pipeline(Readable.from(lines()), createGzip(), createWriteStream(partial, { mode: 384 }));
163
+ await moveFile(partial, out);
164
+ } catch (error) {
165
+ await rm(partial, { force: true }).catch(() => {
166
+ });
167
+ throw error;
168
+ }
169
+ return summary;
170
+ }
171
+ async function automaticBackup(home, kind, { units = HOME_UNITS, projects = [], by } = {}) {
172
+ const directory = backupsDir(home);
173
+ const out = path.join(directory, `${kind}-${stamp()}-${randomBytes(2).toString("hex")}${BACKUP_EXTENSION}`);
174
+ await createBackup({ home, out, units, projects, by, kind });
175
+ const prefix = `${kind}-`;
176
+ const same = (await readdir(directory)).filter((name) => name.startsWith(prefix) && name.endsWith(BACKUP_EXTENSION)).sort();
177
+ for (const name of same.slice(0, Math.max(0, same.length - AUTOMATIC_KEEP))) await rm(path.join(directory, name), { force: true });
178
+ return out;
179
+ }
180
+ async function readBackup(file, { onHeader, visit }) {
181
+ if (!await stat(file).then((info) => info.isFile(), () => false)) throw new AlpError("BACKUP_NOT_FOUND", `${file} is not a backup file`);
182
+ const digest = createHash("sha256");
183
+ let header;
184
+ let end;
185
+ let pending;
186
+ let files = 0;
187
+ let bytes = 0;
188
+ let lineNumber = 0;
189
+ const written = /* @__PURE__ */ new Map();
190
+ async function take(text) {
191
+ lineNumber++;
192
+ if (end) throw invalid(`line ${lineNumber} follows the end of the backup`);
193
+ let record;
194
+ try {
195
+ record = JSON.parse(text);
196
+ } catch {
197
+ throw invalid(`line ${lineNumber} is not JSON`);
198
+ }
199
+ if (record?.t === "end") {
200
+ end = record;
201
+ return;
202
+ }
203
+ digest.update(`${text}
204
+ `);
205
+ if (!header) {
206
+ if (record?.t !== "header" || record.format !== "alp-backup") throw invalid("it does not start with an ALP backup header");
207
+ if (record.version !== BACKUP_FORMAT) throw new AlpError("BACKUP_UNSUPPORTED", `backup format ${record.version} is not one this ALP reads (${BACKUP_FORMAT}); restore it with the ALP that made it, or a newer one`);
208
+ if (record.stateSchema !== null && record.stateSchema !== void 0 && !(Number.isSafeInteger(record.stateSchema) && record.stateSchema >= 1)) throw invalid("its header names no valid state schema");
209
+ if (Number.isSafeInteger(record.stateSchema) && record.stateSchema > STATE_SCHEMA) throw new AlpError("BACKUP_SCHEMA_TOO_NEW", `the backup holds state schema ${record.stateSchema}${record.by ? ` from ${record.by}` : ""}; this ALP knows schema ${STATE_SCHEMA} at most. Restore it with that ALP version or a newer one`);
210
+ if (!Array.isArray(record.units) || !Array.isArray(record.projects) || ![...record.units, ...record.projects.flatMap((project) => project?.units ?? [])].every((unit2) => safeRelative(unit2?.path) && ["file", "dir", "link", "absent"].includes(unit2.kind))) throw invalid("its header lists units ALP cannot use");
211
+ if (!record.projects.every((project) => typeof project?.root === "string" && path.isAbsolute(project.root) && Array.isArray(project.units))) throw invalid("its header lists projects without an absolute root");
212
+ for (const list of [record.units, ...record.projects.map((project) => project.units)]) {
213
+ const paths = list.map((unit2) => unit2.path);
214
+ if (paths.some((one, index) => paths.some((other, at) => at !== index && (one === other || one.startsWith(`${other}/`))))) throw invalid("its header lists a unit twice, or one inside another");
215
+ }
216
+ header = record;
217
+ await onHeader?.(header);
218
+ return;
219
+ }
220
+ if (pending) {
221
+ if (record?.t !== "data" || typeof record.b !== "string") throw invalid(`${pending.entry.path} is cut short`);
222
+ const chunk = Buffer.from(record.b, "base64");
223
+ pending.hash.update(chunk);
224
+ pending.size += chunk.length;
225
+ await pending.write?.(chunk);
226
+ if (--pending.left > 0) return;
227
+ const done = pending;
228
+ pending = void 0;
229
+ await done.close();
230
+ return;
231
+ }
232
+ const area = record?.area;
233
+ const units = area === "home" ? header.units : /^project:\d+$/.test(area ?? "") ? header.projects[Number(area.slice(8))]?.units : void 0;
234
+ if (!units) throw invalid(`line ${lineNumber} belongs to no area of the backup`);
235
+ if (!safeRelative(record.path)) throw invalid(`line ${lineNumber} names an unsafe path`);
236
+ const unit = units.find((candidate) => record.path === candidate.path || record.path.startsWith(`${candidate.path}/`));
237
+ if (!unit || unit.kind === "absent" || record.path !== unit.path && unit.kind !== "dir") throw invalid(`${record.path} lies outside the units the backup declares`);
238
+ const seen = written.get(area) ?? written.set(area, { paths: /* @__PURE__ */ new Set(), links: [] }).get(area);
239
+ if (seen.paths.has(record.path) || seen.links.some((link) => record.path.startsWith(`${link}/`))) throw invalid(`${record.path} comes twice, or lies under a link`);
240
+ seen.paths.add(record.path);
241
+ if (record.t === "link") seen.links.push(record.path);
242
+ if (record.t === "dir" || record.t === "link") {
243
+ if (record.t === "link" && typeof record.target !== "string") throw invalid(`link ${record.path} has no target`);
244
+ await visit({ ...record, unit: unit.path });
245
+ return;
246
+ }
247
+ if (record.t !== "file" || !Number.isSafeInteger(record.size) || record.size < 0 || typeof record.sha256 !== "string" || !Number.isSafeInteger(record.chunks) || record.chunks < 1) throw invalid(`line ${lineNumber} is not a backup entry`);
248
+ files++;
249
+ bytes += record.size;
250
+ const sink = await visit({ ...record, unit: unit.path });
251
+ const hash = createHash("sha256");
252
+ const state = {
253
+ entry: record,
254
+ hash,
255
+ size: 0,
256
+ left: record.chunks,
257
+ write: sink?.write,
258
+ abort: sink?.abort,
259
+ async close() {
260
+ await sink?.close?.();
261
+ if (state.size !== record.size || hash.digest("hex") !== record.sha256) throw invalid(`${record.path} does not match its checksum`);
262
+ }
263
+ };
264
+ pending = state;
265
+ }
266
+ let buffered = "";
267
+ const decoder = new StringDecoder("utf8");
268
+ let refused;
269
+ try {
270
+ await pipeline(createReadStream(file), createGunzip(), async function(source) {
271
+ for await (const chunk of source) {
272
+ buffered += decoder.write(chunk);
273
+ let newline;
274
+ while ((newline = buffered.indexOf("\n")) >= 0) {
275
+ const text = buffered.slice(0, newline);
276
+ buffered = buffered.slice(newline + 1);
277
+ if (text) await take(text).catch((error) => {
278
+ refused ??= error;
279
+ throw error;
280
+ });
281
+ }
282
+ }
283
+ });
284
+ } catch (error) {
285
+ await pending?.abort?.();
286
+ if (refused) throw refused;
287
+ if (error instanceof AlpError) throw error;
288
+ throw invalid(`it cannot be read (${error?.code ?? error?.message ?? error})`, { cause: error });
289
+ }
290
+ buffered += decoder.end();
291
+ if (pending) await pending.abort?.();
292
+ if (buffered) throw invalid("its last line is cut short");
293
+ if (!header) throw invalid("it is empty");
294
+ if (pending) throw invalid(`${pending.entry.path} is cut short`);
295
+ if (!end) throw invalid("it has no end: the file was cut short");
296
+ if (end.files !== files || end.bytes !== bytes || end.sha256 !== digest.digest("hex")) throw invalid("its end does not match its content");
297
+ return { header, files, bytes };
298
+ }
299
+ async function verifyBackup(file) {
300
+ let sessions = 0;
301
+ let tasks = 0;
302
+ const { header, files, bytes } = await readBackup(file, {
303
+ visit(entry) {
304
+ if (entry.t !== "file") return;
305
+ if (entry.area === "home" && /^state\/sessions\/[^/]+\.json$/.test(entry.path)) sessions++;
306
+ if (entry.area !== "home" && /^\.alp\/tasks\/[^/]+\.json$/.test(entry.path)) tasks++;
307
+ }
308
+ });
309
+ return {
310
+ file,
311
+ kind: header.kind,
312
+ by: header.by,
313
+ createdAt: header.createdAt,
314
+ home: header.home,
315
+ stateSchema: header.stateSchema,
316
+ units: header.units.filter((unit) => unit.kind !== "absent").map((unit) => unit.path),
317
+ projects: header.projects.map((project) => project.root),
318
+ sessions,
319
+ tasks,
320
+ files,
321
+ bytes,
322
+ external: header.external?.length ?? 0
323
+ };
324
+ }
325
+ async function settleJournal(journal, { releaseLocks = false } = {}) {
326
+ if (journal.phase === "committed") {
327
+ for (const unit of journal.units) await rm(unit.aside, { recursive: true, force: true });
328
+ } else {
329
+ for (const unit of [...journal.units].reverse()) {
330
+ if (await exists(unit.aside)) {
331
+ await rm(unit.target, { recursive: true, force: true });
332
+ await moveFile(unit.aside, unit.target);
333
+ } else if (!unit.existed && unit.staged && !await exists(unit.staged)) {
334
+ await rm(unit.target, { recursive: true, force: true });
335
+ }
336
+ }
337
+ if (journal.marker) await rm(journal.marker, { force: true });
338
+ }
339
+ for (const directory of journal.staging) await rm(directory, { recursive: true, force: true });
340
+ for (const directory of journal.created ?? []) await rmdir(directory).catch(() => {
341
+ });
342
+ if (releaseLocks) for (const lock of journal.locks ?? []) await rmdir(lock).catch(() => {
343
+ });
344
+ }
345
+ async function recoverInterruptedRestore(home) {
346
+ let journal;
347
+ try {
348
+ journal = JSON.parse(await readFile(journalFile(home), "utf8"));
349
+ } catch (error) {
350
+ if (error?.code === "ENOENT") return void 0;
351
+ throw new AlpError("RESTORE_JOURNAL_UNREADABLE", `${journalFile(home)} records an interrupted restore but cannot be read; see docs/alp/data-recovery.md before starting ALP`, { cause: error });
352
+ }
353
+ if (journal?.version !== void 0 && journal.version !== 1) throw new AlpError("RESTORE_JOURNAL_UNREADABLE", `${journalFile(home)} records a restore by another ALP version (journal ${journal.version}); finish it with that version`);
354
+ if (!Array.isArray(journal?.units) || !Array.isArray(journal.staging)) throw new AlpError("RESTORE_JOURNAL_UNREADABLE", `${journalFile(home)} records an interrupted restore but does not say what moved; see docs/alp/data-recovery.md before starting ALP`);
355
+ await settleJournal(journal, { releaseLocks: true });
356
+ await rm(journalFile(home), { force: true });
357
+ return { outcome: journal.phase === "committed" ? "completed" : "rolled back", file: journal.file, safety: journal.safety };
358
+ }
359
+ async function holdTaskLocks(roots, work) {
360
+ const held = [];
361
+ const touch = setInterval(() => {
362
+ const now = /* @__PURE__ */ new Date();
363
+ for (const lock of held) void utimes(lock, now, now).catch(() => {
364
+ });
365
+ }, 2e3);
366
+ touch.unref?.();
367
+ const take = async ([first, ...rest]) => {
368
+ if (first === void 0) return work();
369
+ if (!await exists(path.join(first, ".alp", "tasks"))) return take(rest);
370
+ return withTaskLock(first, () => {
371
+ held.push(path.join(first, ".alp", "tasks", ".lock"));
372
+ return take(rest);
373
+ });
374
+ };
375
+ try {
376
+ return await take(roots);
377
+ } finally {
378
+ clearInterval(touch);
379
+ }
380
+ }
381
+ async function restoreBackup({ home, file, projects = true, safety = true, clearLive = true, replaceNewer = false, by = "alp", beforeSwap }) {
382
+ const interrupted = await recoverInterruptedRestore(home);
383
+ try {
384
+ assertStateSupported(await readStateSchema(home), home);
385
+ } catch (error) {
386
+ if (error?.code !== "STATE_SCHEMA_UNREADABLE" && !(error?.code === "STATE_SCHEMA_TOO_NEW" && replaceNewer)) throw error;
387
+ }
388
+ const id = randomBytes(6).toString("hex");
389
+ const staging = /* @__PURE__ */ new Map();
390
+ const skipped = [];
391
+ let header;
392
+ const warnings = [];
393
+ const areaBase = (area) => area === "home" ? home : header.projects[Number(area.slice(8))].root;
394
+ const directories = [];
395
+ const journal = { version: 1, id, file: path.resolve(file), by, startedAt: (/* @__PURE__ */ new Date()).toISOString(), phase: "staging", units: [], staging: [], created: [], locks: [] };
396
+ const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + "\n");
397
+ const abandon = async () => {
398
+ await settleJournal(journal);
399
+ await rm(journalFile(home), { force: true });
400
+ };
401
+ try {
402
+ await readBackup(file, {
403
+ async onHeader(found) {
404
+ header = found;
405
+ staging.set("home", path.join(home, `.restore-${id}`));
406
+ for (const [index, project] of header.projects.entries()) {
407
+ if (!projects) continue;
408
+ if (!await stat(project.root).then((info) => info.isDirectory(), () => false)) {
409
+ skipped.push({ root: project.root, reason: "the directory does not exist" });
410
+ continue;
411
+ }
412
+ if (!await exists(path.join(project.root, ".alp"))) journal.created.push(path.join(project.root, ".alp"));
413
+ staging.set(`project:${index}`, path.join(project.root, ".alp", `.restore-${id}`));
414
+ }
415
+ journal.staging = [...staging.values()];
416
+ await record();
417
+ },
418
+ async visit(entry) {
419
+ const root = staging.get(entry.area);
420
+ if (!root) return void 0;
421
+ const target = path.join(root, ...entry.path.split("/"));
422
+ await mkdir(path.dirname(target), { recursive: true, mode: 448 });
423
+ if (entry.t === "dir") {
424
+ await mkdir(target, { recursive: true, mode: 448 });
425
+ directories.push([target, entry.mode]);
426
+ return void 0;
427
+ }
428
+ if (entry.t === "link") {
429
+ await symlink(entry.target, target);
430
+ return void 0;
431
+ }
432
+ const handle = await open(target, "wx", 384);
433
+ return {
434
+ write: (chunk) => handle.write(chunk).then(() => {
435
+ }),
436
+ close: async () => {
437
+ await handle.close();
438
+ await chmod(target, entry.mode & 511 || 384).catch(() => {
439
+ });
440
+ },
441
+ abort: () => handle.close().catch(() => {
442
+ })
443
+ };
444
+ }
445
+ });
446
+ for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 511 | 448).catch(() => {
447
+ });
448
+ } catch (error) {
449
+ await abandon();
450
+ throw error;
451
+ }
452
+ for (const [area, root] of staging) {
453
+ const base = area === "home" ? "state/sessions" : ".alp/tasks";
454
+ for (const name of await readdir(path.join(root, ...base.split("/"))).catch(() => [])) {
455
+ if (!name.endsWith(".json")) continue;
456
+ try {
457
+ JSON.parse(await readFile(path.join(root, ...base.split("/"), name), "utf8"));
458
+ } catch {
459
+ warnings.push(`${area === "home" ? "" : `${areaBase(area)}/`}${base}/${name} is not readable JSON; ALP skips it as before`);
460
+ }
461
+ }
462
+ }
463
+ const plan = [];
464
+ for (const [area, root] of staging) {
465
+ const base = areaBase(area);
466
+ const units = area === "home" ? header.units : header.projects[Number(area.slice(8))].units;
467
+ const known = area === "home" ? HOME_UNITS : PROJECT_UNITS;
468
+ for (const unit of units) {
469
+ if (!known.includes(unit.path)) {
470
+ warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`);
471
+ continue;
472
+ }
473
+ const target = path.join(base, ...unit.path.split("/"));
474
+ const staged = unit.kind === "absent" ? void 0 : path.join(root, ...unit.path.split("/"));
475
+ if (staged && !await exists(staged)) {
476
+ await abandon();
477
+ throw invalid(`${unit.path} is declared but missing from the backup`);
478
+ }
479
+ plan.push({ area, unit: unit.path, target, staged, aside: `${target}.pre-restore-${id}`, existed: await exists(target) });
480
+ }
481
+ if (area === "home" && clearLive && units.some((unit) => unit.path === "state/sessions")) {
482
+ for (const unit of CLEARED_UNITS) {
483
+ const target = path.join(base, ...unit.split("/"));
484
+ plan.push({ area, unit, target, staged: void 0, aside: `${target}.pre-restore-${id}`, existed: await exists(target) });
485
+ }
486
+ }
487
+ }
488
+ const roots = [...staging.keys()].filter((area) => area !== "home").map(areaBase);
489
+ let safetyFile;
490
+ if (safety) {
491
+ try {
492
+ safetyFile = await automaticBackup(home, "pre-restore", { units: header.units.map((unit) => unit.path).filter((unit) => HOME_UNITS.includes(unit)), projects: roots, by });
493
+ } catch (error) {
494
+ await abandon();
495
+ throw new AlpError("RESTORE_SAFETY_BACKUP_FAILED", `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
496
+ }
497
+ }
498
+ const locked = /* @__PURE__ */ new Set();
499
+ for (const root of roots) if (await exists(path.join(root, ".alp", "tasks"))) locked.add(root);
500
+ const marking = clearLive && plan.some((entry) => entry.area === "home" && entry.unit === "state/sessions");
501
+ Object.assign(journal, { safety: safetyFile, phase: "swapping", units: plan, locks: [...locked].map((root) => path.join(root, ".alp", "tasks", ".lock")), ...marking ? { marker: path.join(home, RESTORED) } : {} });
502
+ await record();
503
+ let failure;
504
+ try {
505
+ await holdTaskLocks(roots, async () => {
506
+ try {
507
+ for (const [index, entry] of plan.entries()) {
508
+ await beforeSwap?.(index, plan.length);
509
+ if (entry.existed) await moveFile(entry.target, entry.aside);
510
+ if (entry.staged) {
511
+ await mkdir(path.dirname(entry.target), { recursive: true, mode: 448 });
512
+ if (entry.unit === ".alp/tasks" && locked.has(areaBase(entry.area))) await mkdir(path.join(entry.staged, ".lock")).catch(() => {
513
+ });
514
+ await moveFile(entry.staged, entry.target);
515
+ }
516
+ }
517
+ if (marking) {
518
+ await mkdir(path.join(home, "state"), { recursive: true, mode: 448 });
519
+ await writeAtomic(path.join(home, RESTORED), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), from: path.resolve(file), by }) + "\n");
520
+ }
521
+ await beforeSwap?.(plan.length, plan.length);
522
+ journal.phase = "committed";
523
+ await record();
524
+ } catch (error) {
525
+ try {
526
+ await abandon();
527
+ } catch (rollback) {
528
+ failure = new AlpError("RESTORE_ROLLBACK_FAILED", `restore failed (${error?.message ?? error}) and moving the data back failed (${rollback?.message ?? rollback}); ${journalFile(home)} records what is where, and the next alp restore or alpd start tries again`, { cause: error });
529
+ return;
530
+ }
531
+ failure = new AlpError("RESTORE_FAILED", `restore failed and was rolled back; the data is as it was: ${error?.message ?? error}`, { cause: error });
532
+ }
533
+ });
534
+ } catch (error) {
535
+ if (journal.phase !== "committed" && !failure) {
536
+ await abandon();
537
+ throw new AlpError("RESTORE_FAILED", `restore failed and was rolled back; the data is as it was: ${error?.message ?? error}`, { cause: error });
538
+ }
539
+ }
540
+ if (failure) throw failure;
541
+ await settleJournal(journal);
542
+ await rm(journalFile(home), { force: true });
543
+ return {
544
+ file: path.resolve(file),
545
+ safety: safetyFile,
546
+ stateSchema: header.stateSchema,
547
+ interrupted,
548
+ units: plan.map((entry) => ({ area: entry.area === "home" ? "home" : areaBase(entry.area), unit: entry.unit, present: !!entry.staged })),
549
+ projects: roots,
550
+ skipped,
551
+ warnings
552
+ };
553
+ }
554
+ async function readRestoredMarker(home) {
555
+ const text = await readFile(path.join(home, RESTORED), "utf8").catch(() => void 0);
556
+ if (text === void 0) return void 0;
557
+ try {
558
+ return JSON.parse(text);
559
+ } catch {
560
+ return { at: void 0 };
561
+ }
562
+ }
563
+ async function clearRestoredMarker(home) {
564
+ await rm(path.join(home, RESTORED), { force: true });
565
+ }
566
+ export {
567
+ BACKUP_EXTENSION,
568
+ BACKUP_FORMAT,
569
+ CLEARED_UNITS,
570
+ HOME_UNITS,
571
+ PROJECT_UNITS,
572
+ STATE_UNITS,
573
+ automaticBackup,
574
+ backupsDir,
575
+ clearRestoredMarker,
576
+ createBackup,
577
+ knownProjects,
578
+ readRestoredMarker,
579
+ recoverInterruptedRestore,
580
+ restoreBackup,
581
+ verifyBackup
582
+ };
@@ -0,0 +1,65 @@
1
+ /** Tasks as beads issues, sorted by id like bd export. */
2
+ export function exportBeads(tasks: any): {
3
+ metadata?: {
4
+ alp: {
5
+ formula?: any;
6
+ step?: any;
7
+ compacted?: any;
8
+ closed?: any;
9
+ handoff?: any;
10
+ gates?: any;
11
+ paths?: any;
12
+ status?: string | undefined;
13
+ };
14
+ } | undefined;
15
+ external_ref?: any;
16
+ source_system?: any;
17
+ defer_until?: any;
18
+ closed_at?: any;
19
+ close_reason?: any;
20
+ created_at: any;
21
+ created_by: any;
22
+ updated_at: any;
23
+ dependencies?: {
24
+ issue_id: any;
25
+ depends_on_id: any;
26
+ type: any;
27
+ }[] | undefined;
28
+ labels?: any;
29
+ assignee?: any;
30
+ status: any;
31
+ priority: any;
32
+ issue_type: any;
33
+ description?: any;
34
+ id: any;
35
+ title: any;
36
+ }[];
37
+ /** Parses JSONL, keeping the line number of every record or error. */
38
+ export function parseJsonl(text: any): {
39
+ records: any[];
40
+ errors: any[];
41
+ };
42
+ /**
43
+ * Upserts beads issues into the project's tasks, as bd import does: an issue
44
+ * whose id is an ALP task id, or that ALP imported before (external beads id),
45
+ * updates that task; any other becomes a new task. Missing records are never deleted.
46
+ * @returns {Promise<{ created: Array<{ id: string, from: string }>, updated: Array<{ id: string, from: string }>, skipped: Array<{ line: number, reason: string }>, warnings: string[] }>}
47
+ */
48
+ export function importBeads(projectRoot: any, records: any, { dryRun, by }?: {
49
+ dryRun?: boolean | undefined;
50
+ by?: string | undefined;
51
+ }): Promise<{
52
+ created: Array<{
53
+ id: string;
54
+ from: string;
55
+ }>;
56
+ updated: Array<{
57
+ id: string;
58
+ from: string;
59
+ }>;
60
+ skipped: Array<{
61
+ line: number;
62
+ reason: string;
63
+ }>;
64
+ warnings: string[];
65
+ }>;