taskchef 7.28.1 → 8.0.0

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/src/backups.js ADDED
@@ -0,0 +1,359 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readdir,
9
+ rename,
10
+ rm,
11
+ stat,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { sha256, semanticHash, taskChefError } from "./state-store.js";
16
+ import { TASKCHEF_VERSION } from "./version.js";
17
+
18
+ export const BACKUP_FILES = ["taskchef.json", "tasks.jsonl", "AGENTS.md", "config-audit.jsonl"];
19
+ const MAX_BACKUP_BYTES = 512 * 1024 * 1024;
20
+ const MAX_SOURCE_FILE_BYTES = 64 * 1024 * 1024;
21
+
22
+ async function privateDirectory(directory) {
23
+ await mkdir(directory, { recursive: true, mode: 0o700 });
24
+ const details = await lstat(directory);
25
+ if (!details.isDirectory() || details.isSymbolicLink()) {
26
+ throw taskChefError("BACKUP_FAILED", `backup path is not a real directory: ${directory}`);
27
+ }
28
+ if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
29
+ throw taskChefError("BACKUP_FAILED", `backup path is not owned by the current user: ${directory}`);
30
+ }
31
+ await chmod(directory, 0o700);
32
+ }
33
+
34
+ async function filePayload(workspace, name, validateFile) {
35
+ const filePath = path.join(workspace, name);
36
+ try {
37
+ const details = await lstat(filePath);
38
+ if (!details.isFile() || details.isSymbolicLink()) {
39
+ throw taskChefError("BACKUP_FAILED", `managed state is not a regular file: ${filePath}`);
40
+ }
41
+ if (details.size > MAX_SOURCE_FILE_BYTES) {
42
+ throw taskChefError("BACKUP_FAILED", `managed state exceeds the backup size limit: ${filePath}`);
43
+ }
44
+ const bytes = await readFile(filePath);
45
+ let valid = true;
46
+ try {
47
+ if (name === "taskchef.json") {
48
+ const config = JSON.parse(bytes.toString("utf8"));
49
+ valid = config?.schemaVersion === 2 && Array.isArray(config.projects);
50
+ } else if (name.endsWith(".jsonl")) {
51
+ for (const line of bytes.toString("utf8").trimEnd().split("\n")) {
52
+ if (line) JSON.parse(line);
53
+ }
54
+ }
55
+ if (validateFile) await validateFile(name, bytes);
56
+ } catch {
57
+ valid = false;
58
+ }
59
+ return { present: true, size: bytes.length, sha256: sha256(bytes), bytes, valid };
60
+ } catch (error) {
61
+ if (error.code === "ENOENT") return { present: false, size: 0, sha256: null, bytes: null };
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ async function readManifest(snapshotPath) {
67
+ const manifest = JSON.parse(await readFile(path.join(snapshotPath, "manifest.json"), "utf8"));
68
+ if (manifest.schemaVersion !== 1 || manifest.workspace.length === 0) {
69
+ throw new Error("unsupported backup manifest");
70
+ }
71
+ return manifest;
72
+ }
73
+
74
+ export async function verifyBackup(workspace, id) {
75
+ if (!/^[0-9TZ.-]+-[0-9a-f-]{36}$/i.test(id)) throw new Error("invalid backup ID");
76
+ const snapshotPath = path.join(workspace, "backups", id);
77
+ let manifest = null;
78
+ try {
79
+ const snapshotDetails = await lstat(snapshotPath);
80
+ if (!snapshotDetails.isDirectory() || snapshotDetails.isSymbolicLink()
81
+ || (snapshotDetails.mode & 0o077) !== 0
82
+ || (typeof process.getuid === "function" && snapshotDetails.uid !== process.getuid())) {
83
+ throw new Error("backup snapshot permissions or ownership are unsafe");
84
+ }
85
+ manifest = await readManifest(snapshotPath);
86
+ if (manifest.workspace !== workspace) throw new Error("backup belongs to a different workspace");
87
+ if (manifest.id !== id) throw new Error("backup manifest ID does not match its directory");
88
+ const snapshotNames = await readdir(snapshotPath);
89
+ const allowedNames = new Set(["manifest.json", ...BACKUP_FILES.filter(
90
+ (name) => manifest.files?.[name]?.present,
91
+ )]);
92
+ const unexpected = snapshotNames.find((name) => !allowedNames.has(name));
93
+ if (unexpected) throw new Error(`backup contains an unexpected payload: ${unexpected}`);
94
+ for (const name of BACKUP_FILES) {
95
+ const expected = manifest.files[name];
96
+ if (!expected) throw new Error(`manifest is missing ${name}`);
97
+ if (!expected.present) continue;
98
+ const details = await lstat(path.join(snapshotPath, name));
99
+ if (!details.isFile() || details.isSymbolicLink() || (details.mode & 0o077) !== 0
100
+ || (typeof process.getuid === "function" && details.uid !== process.getuid())) {
101
+ throw new Error(`backup payload permissions are unsafe: ${name}`);
102
+ }
103
+ const bytes = await readFile(path.join(snapshotPath, name));
104
+ if (bytes.length !== expected.size || sha256(bytes) !== expected.sha256) {
105
+ throw new Error(`backup payload failed verification: ${name}`);
106
+ }
107
+ }
108
+ const manifestDetails = await lstat(path.join(snapshotPath, "manifest.json"));
109
+ if (!manifestDetails.isFile() || manifestDetails.isSymbolicLink()
110
+ || (manifestDetails.mode & 0o077) !== 0
111
+ || (typeof process.getuid === "function" && manifestDetails.uid !== process.getuid())) {
112
+ throw new Error("backup manifest permissions are unsafe");
113
+ }
114
+ if (manifest.validationStatus !== "valid") {
115
+ return {
116
+ id,
117
+ usable: false,
118
+ integrityValid: true,
119
+ path: snapshotPath,
120
+ manifest,
121
+ error: "backup contains invalid source state and is forensic-only",
122
+ };
123
+ }
124
+ return { id, usable: true, integrityValid: true, path: snapshotPath, manifest };
125
+ } catch (error) {
126
+ return {
127
+ id,
128
+ usable: false,
129
+ integrityValid: false,
130
+ path: snapshotPath,
131
+ ...(manifest ? { manifest } : {}),
132
+ error: error.message,
133
+ };
134
+ }
135
+ }
136
+
137
+ export async function listBackups(workspace) {
138
+ const root = path.join(workspace, "backups");
139
+ const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
140
+ if (error.code === "ENOENT") return [];
141
+ throw error;
142
+ });
143
+ const backups = [];
144
+ for (const entry of entries.filter((item) => item.isDirectory() && !item.name.startsWith(".tmp-"))) {
145
+ backups.push(await verifyBackup(workspace, entry.name));
146
+ }
147
+ backups.sort((left, right) => right.id.localeCompare(left.id));
148
+ const backupBytes = (item) => item.manifest?.files
149
+ ? Object.values(item.manifest.files)
150
+ .reduce((sum, file) => sum + (file.present ? file.size : 0), 0)
151
+ : 0;
152
+ const usableBytes = backups.filter((item) => item.usable)
153
+ .reduce((sum, item) => sum + backupBytes(item), 0);
154
+ const forensicBytes = backups.filter((item) => item.integrityValid && !item.usable)
155
+ .reduce((sum, item) => sum + backupBytes(item), 0);
156
+ const corruptBytes = backups.filter((item) => !item.integrityValid)
157
+ .reduce((sum, item) => sum + backupBytes(item), 0);
158
+ return {
159
+ schemaVersion: 1,
160
+ backupCount: backups.length,
161
+ usableCount: backups.filter((item) => item.usable).length,
162
+ forensicCount: backups.filter((item) => item.integrityValid && !item.usable).length,
163
+ corruptCount: backups.filter((item) => !item.integrityValid).length,
164
+ usableBytes,
165
+ forensicBytes,
166
+ corruptBytes,
167
+ totalBytes: usableBytes + forensicBytes + corruptBytes,
168
+ backups,
169
+ };
170
+ }
171
+
172
+ export async function createBackupUnlocked(workspace, {
173
+ reason = "manual",
174
+ now = () => new Date().toISOString(),
175
+ validateFile = null,
176
+ } = {}) {
177
+ const root = path.join(workspace, "backups");
178
+ await privateDirectory(root);
179
+ const payloads = Object.fromEntries(await Promise.all(BACKUP_FILES.map(async (name) => [
180
+ name, await filePayload(workspace, name, validateFile),
181
+ ])));
182
+ const aggregateHash = semanticHash(Object.fromEntries(
183
+ BACKUP_FILES.map((name) => [name, {
184
+ present: payloads[name].present,
185
+ size: payloads[name].size,
186
+ sha256: payloads[name].sha256,
187
+ }]),
188
+ ), "taskchef-backup-v1");
189
+ const existing = (await listBackups(workspace)).backups.find(
190
+ (item) => item.usable && item.manifest.aggregateHash === aggregateHash,
191
+ );
192
+ if (existing) return { ...existing, deduplicated: true, reason };
193
+
194
+ const timestamp = now().replaceAll(":", "-").replaceAll(".", "-");
195
+ const id = `${timestamp}-${randomUUID()}`;
196
+ const staging = path.join(root, `.tmp-${id}`);
197
+ const destination = path.join(root, id);
198
+ await mkdir(staging, { mode: 0o700 });
199
+ try {
200
+ const files = {};
201
+ for (const name of BACKUP_FILES) {
202
+ const payload = payloads[name];
203
+ files[name] = {
204
+ present: payload.present,
205
+ size: payload.size,
206
+ sha256: payload.sha256,
207
+ valid: payload.present ? payload.valid : null,
208
+ };
209
+ if (!payload.present) continue;
210
+ const handle = await open(path.join(staging, name), "wx", 0o600);
211
+ try {
212
+ await handle.writeFile(payload.bytes);
213
+ await handle.sync();
214
+ } finally {
215
+ await handle.close();
216
+ }
217
+ }
218
+ let projectCount = null;
219
+ let configSchemaVersion = null;
220
+ let taskCount = null;
221
+ try {
222
+ const config = JSON.parse(payloads["taskchef.json"].bytes.toString("utf8"));
223
+ projectCount = Array.isArray(config.projects) ? config.projects.length : null;
224
+ configSchemaVersion = config.schemaVersion ?? null;
225
+ } catch {}
226
+ if (payloads["tasks.jsonl"].present && payloads["tasks.jsonl"].valid) {
227
+ taskCount = payloads["tasks.jsonl"].bytes.toString("utf8").trimEnd().split("\n")
228
+ .filter(Boolean).length;
229
+ }
230
+ const manifest = {
231
+ schemaVersion: 1,
232
+ id,
233
+ createdAt: now(),
234
+ reason,
235
+ taskChefVersion: TASKCHEF_VERSION,
236
+ workspace,
237
+ aggregateHash,
238
+ validationStatus: Object.values(payloads)
239
+ .every((payload) => !payload.present || payload.valid) ? "valid" : "invalid",
240
+ configSchemaVersion,
241
+ projectCount,
242
+ taskCount,
243
+ auditBoundary: payloads["config-audit.jsonl"].present
244
+ ? payloads["config-audit.jsonl"].sha256
245
+ : null,
246
+ files,
247
+ };
248
+ const manifestHandle = await open(path.join(staging, "manifest.json"), "wx", 0o600);
249
+ try {
250
+ await manifestHandle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
251
+ await manifestHandle.sync();
252
+ } finally {
253
+ await manifestHandle.close();
254
+ }
255
+ const stagingHandle = await open(staging, "r");
256
+ try { await stagingHandle.sync(); } finally { await stagingHandle.close(); }
257
+ await rename(staging, destination);
258
+ const rootHandle = await open(root, "r");
259
+ try { await rootHandle.sync(); } finally { await rootHandle.close(); }
260
+ const verified = await verifyBackup(workspace, id);
261
+ if (!verified.usable && !verified.integrityValid) throw new Error(verified.error);
262
+ if (!verified.usable) {
263
+ return { ...verified, deduplicated: false, forensic: true, reason };
264
+ }
265
+ const retention = await pruneBackupsUnlocked(workspace, { apply: true });
266
+ return {
267
+ ...verified,
268
+ deduplicated: false,
269
+ reason,
270
+ storagePressure: retention.remainingBytes > MAX_BACKUP_BYTES,
271
+ };
272
+ } catch (error) {
273
+ await rm(staging, { recursive: true, force: true }).catch(() => {});
274
+ throw taskChefError("BACKUP_FAILED", `backup creation failed: ${error.message}`);
275
+ }
276
+ }
277
+
278
+ export async function pruneBackupsUnlocked(workspace, { apply = false } = {}) {
279
+ const inventory = await listBackups(workspace);
280
+ const usable = inventory.backups.filter((item) => item.usable);
281
+ const preferred = new Set(usable.slice(0, 30).map((item) => item.id));
282
+ const daily = new Set();
283
+ const newestTime = usable.length > 0 ? Date.parse(usable[0].manifest.createdAt) : 0;
284
+ for (const item of usable) {
285
+ const day = item.manifest.createdAt.slice(0, 10);
286
+ const dayTime = Date.parse(`${day}T00:00:00.000Z`);
287
+ const ageDays = Math.floor((newestTime - dayTime) / 86_400_000);
288
+ if (ageDays >= 0 && ageDays < 30 && !daily.has(day)) {
289
+ daily.add(day);
290
+ preferred.add(item.id);
291
+ }
292
+ }
293
+ let totalBytes = 0;
294
+ for (const item of usable) {
295
+ totalBytes += Object.values(item.manifest.files).reduce((sum, file) => sum + file.size, 0);
296
+ }
297
+ const protectedIds = new Set(usable.slice(0, 1).map((item) => item.id));
298
+ const preRestore = usable.find((item) => item.manifest.reason === "pre-restore");
299
+ if (preRestore) protectedIds.add(preRestore.id);
300
+ const audit = await readFile(path.join(workspace, "config-audit.jsonl"), "utf8").catch(() => "");
301
+ const transactions = new Map();
302
+ for (const line of audit.trimEnd().split("\n")) {
303
+ if (!line) continue;
304
+ try {
305
+ const record = JSON.parse(line);
306
+ if (record.transactionId) transactions.set(record.transactionId, record);
307
+ } catch {}
308
+ }
309
+ for (const record of transactions.values()) {
310
+ if (new Set(["prepared", "recovery-required"]).has(record.phase) && record.backupId) {
311
+ protectedIds.add(record.backupId);
312
+ }
313
+ }
314
+ const candidates = [
315
+ ...usable.filter((item) => !preferred.has(item.id)).reverse(),
316
+ ...usable.filter((item) => preferred.has(item.id)).reverse(),
317
+ ].filter((item, index, items) =>
318
+ !protectedIds.has(item.id) && items.findIndex((candidate) => candidate.id === item.id) === index);
319
+ const removed = [];
320
+ for (const item of candidates) {
321
+ if (preferred.has(item.id) && totalBytes <= MAX_BACKUP_BYTES) continue;
322
+ const bytes = Object.values(item.manifest.files).reduce((sum, file) => sum + file.size, 0);
323
+ if (apply) await rm(item.path, { recursive: true });
324
+ totalBytes -= bytes;
325
+ removed.push(item.id);
326
+ }
327
+ return { schemaVersion: 1, apply, removed, remainingBytes: totalBytes };
328
+ }
329
+
330
+ export async function readBackupConfig(workspace, id) {
331
+ const verified = await verifyBackup(workspace, id);
332
+ if (!verified.usable) throw taskChefError("BACKUP_FAILED", verified.error);
333
+ const entry = verified.manifest.files["taskchef.json"];
334
+ if (!entry.present) throw taskChefError("BACKUP_FAILED", "backup has no taskchef.json");
335
+ return JSON.parse(await readFile(path.join(verified.path, "taskchef.json"), "utf8"));
336
+ }
337
+
338
+ export async function readBackupPayloads(workspace, id, names, {
339
+ allowForensic = false,
340
+ allowMissing = false,
341
+ } = {}) {
342
+ const verified = await verifyBackup(workspace, id);
343
+ if (!verified.usable && !(allowForensic && verified.integrityValid)) {
344
+ throw taskChefError("BACKUP_FAILED", verified.error);
345
+ }
346
+ const payloads = {};
347
+ for (const name of names) {
348
+ if (!BACKUP_FILES.includes(name)) throw taskChefError("INVALID_INPUT", "unsupported backup payload");
349
+ if (!verified.manifest.files[name].present) {
350
+ if (allowMissing) {
351
+ payloads[name] = null;
352
+ continue;
353
+ }
354
+ throw taskChefError("BACKUP_FAILED", `backup has no ${name}`);
355
+ }
356
+ payloads[name] = await readFile(path.join(verified.path, name));
357
+ }
358
+ return { verified, payloads };
359
+ }
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { resolveWorkspacePath } from "./workspace-path.js";
7
7
 
8
8
  import {
9
9
  addProject,
10
+ createWorkspaceBackup,
10
11
  buildCopilotBrief,
11
12
  buildTaskSummary,
12
13
  doctorWorkspace,
@@ -14,12 +15,19 @@ import {
14
15
  importProjects,
15
16
  initializeWorkspace,
16
17
  listProjects,
18
+ listWorkspaceBackups,
19
+ pruneWorkspaceBackups,
20
+ readProjectIndex,
21
+ readWorkspaceMaintenance,
17
22
  listTasks,
18
23
  migrateTaskLog,
19
24
  prepareDispatch,
20
25
  recordTask,
21
26
  removeProject,
27
+ restoreWorkspaceBackup,
22
28
  requireSafeId,
29
+ updateProject,
30
+ verifyWorkspaceBackup,
23
31
  } from "./workspace.js";
24
32
 
25
33
  const BLANK_TABLE_CELL = Symbol("blank table cell");
@@ -111,6 +119,30 @@ function print(value, args, human) {
111
119
  process.stdout.write(`${human ? human(value) : JSON.stringify(value, null, 2)}\n`);
112
120
  }
113
121
 
122
+ async function mutationOptions(args) {
123
+ const confirmedPath = option(args, "--confirm-removed", null);
124
+ let confirmRemoved;
125
+ if (confirmedPath !== null) {
126
+ confirmRemoved = JSON.parse(await readFile(path.resolve(confirmedPath), "utf8"));
127
+ }
128
+ const numberOption = (name) => {
129
+ const value = option(args, name, null);
130
+ if (value === null) return undefined;
131
+ const parsed = Number(value);
132
+ if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`);
133
+ return parsed;
134
+ };
135
+ return {
136
+ dryRun: args.includes("--dry-run"),
137
+ expectedConfigHash: option(args, "--expect-config-hash", undefined),
138
+ expectedProjectCount: numberOption("--expect-project-count"),
139
+ expectedAfterCount: numberOption("--expect-after-count"),
140
+ confirmPlan: option(args, "--confirm-plan", undefined),
141
+ confirmRemoved,
142
+ confirmCountCollapse: option(args, "--confirm-count-collapse", undefined),
143
+ };
144
+ }
145
+
114
146
  function table(headers, rows) {
115
147
  const display = (value) => {
116
148
  if (value === BLANK_TABLE_CELL) return "";
@@ -300,7 +332,7 @@ async function projectAdd(args) {
300
332
  if (!args[2] || args[2].startsWith("--")) throw new Error("project add requires a path");
301
333
  validateCommandArgs(args, 3, {
302
334
  values: ["--workspace", "--name", "--description", "--github-repo"],
303
- switches: ["--json", "--no-github"],
335
+ switches: ["--json", "--no-github", "--dry-run"],
304
336
  repeatable: ["--github-repo"],
305
337
  });
306
338
  if (args.includes("--no-github") && args.includes("--github-repo")) {
@@ -313,8 +345,13 @@ async function projectAdd(args) {
313
345
  if (description !== null) input.description = description;
314
346
  if (args.includes("--no-github")) input.githubRepos = [];
315
347
  else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
316
- const project = await addProject(workspaceRoot(args), input);
317
- print(project, args, (value) => `Added ${value.name}: ${value.path}`);
348
+ const result = await addProject(workspaceRoot(args), input, {
349
+ dryRun: args.includes("--dry-run"),
350
+ });
351
+ print(result, args, (value) => {
352
+ const project = value.project ?? value;
353
+ return `${args.includes("--dry-run") ? "Would add" : "Added"} ${project.name}: ${project.path}`;
354
+ });
318
355
  return 0;
319
356
  }
320
357
 
@@ -322,8 +359,11 @@ async function projectImport(args) {
322
359
  const hasSource = Boolean(args[2] && !args[2].startsWith("--"));
323
360
  const source = hasSource ? args[2] : "-";
324
361
  validateCommandArgs(args, hasSource ? 3 : 2, {
325
- values: ["--workspace"],
326
- switches: ["--json", "--replace"],
362
+ values: [
363
+ "--workspace", "--expect-config-hash", "--expect-project-count", "--expect-after-count",
364
+ "--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
365
+ ],
366
+ switches: ["--json", "--replace", "--dry-run"],
327
367
  });
328
368
  const content = source === "-"
329
369
  ? await readStdin()
@@ -331,6 +371,7 @@ async function projectImport(args) {
331
371
  if (content.trim().length === 0) throw new Error("project import input is empty");
332
372
  const result = await importProjects(workspaceRoot(args), JSON.parse(content), {
333
373
  replace: args.includes("--replace"),
374
+ ...(await mutationOptions(args)),
334
375
  });
335
376
  print(result, args, (value) =>
336
377
  `Imported ${value.importedCount} project(s); ${value.projectCount} configured (${value.mode}).`);
@@ -338,9 +379,14 @@ async function projectImport(args) {
338
379
  }
339
380
 
340
381
  async function projectList(args) {
341
- validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
342
- const projects = await listProjects(workspaceRoot(args));
343
- print({ projectCount: projects.length, projects }, args, (value) => table(
382
+ validateCommandArgs(args, 2, {
383
+ values: ["--workspace"],
384
+ switches: ["--json", "--allow-missing-paths"],
385
+ });
386
+ const result = await readProjectIndex(workspaceRoot(args), {
387
+ checkPaths: !args.includes("--allow-missing-paths"),
388
+ });
389
+ print(result, args, (value) => table(
344
390
  ["NAME", "KIND", "GITHUB REPOSITORY", "PATH"],
345
391
  projectRows(value.projects),
346
392
  ));
@@ -364,14 +410,128 @@ async function dispatchPrepare(args) {
364
410
  async function projectRemove(args) {
365
411
  if (!args[2] || args[2].startsWith("--")) throw new Error("project remove requires a name");
366
412
  validateCommandArgs(args, 3, {
367
- values: ["--workspace"],
368
- switches: ["--json"],
413
+ values: [
414
+ "--workspace", "--expect-config-hash", "--expect-project-count", "--expect-after-count",
415
+ "--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
416
+ ],
417
+ switches: ["--json", "--dry-run"],
369
418
  });
370
- const result = await removeProject(workspaceRoot(args), args[2]);
371
- print(result, args, (value) => `Removed ${value.project.name}: ${value.project.path}`);
419
+ const result = await removeProject(workspaceRoot(args), args[2], await mutationOptions(args));
420
+ print(result, args, (value) =>
421
+ `${args.includes("--dry-run") ? "Would remove" : "Removed"} ${value.project.name}: ${value.project.path}`);
372
422
  return 0;
373
423
  }
374
424
 
425
+ async function projectUpdate(args) {
426
+ const hasName = Boolean(args[2] && !args[2].startsWith("--"));
427
+ validateCommandArgs(args, hasName ? 3 : 2, {
428
+ values: [
429
+ "--workspace", "--path", "--name", "--description", "--github-repo",
430
+ "--expect-config-hash",
431
+ ],
432
+ switches: ["--json", "--clear-description", "--no-github", "--refresh-git", "--dry-run"],
433
+ repeatable: ["--github-repo"],
434
+ });
435
+ const selectedPath = option(args, "--path", null);
436
+ if (hasName === Boolean(selectedPath)) throw new Error("project update requires exactly one name or --path selector");
437
+ if (args.includes("--description") && args.includes("--clear-description")) {
438
+ throw new Error("--description and --clear-description cannot be combined");
439
+ }
440
+ if (args.includes("--no-github") && args.includes("--github-repo")) {
441
+ throw new Error("--no-github and --github-repo cannot be combined");
442
+ }
443
+ const input = {};
444
+ if (args.includes("--name")) input.name = option(args, "--name", null);
445
+ if (args.includes("--description")) input.description = option(args, "--description", null);
446
+ if (args.includes("--clear-description")) input.clearDescription = true;
447
+ if (args.includes("--no-github")) input.githubRepos = [];
448
+ else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
449
+ if (args.includes("--refresh-git")) input.refreshGit = true;
450
+ const result = await updateProject(
451
+ workspaceRoot(args),
452
+ selectedPath ? { path: selectedPath } : { name: args[2] },
453
+ input,
454
+ await mutationOptions(args),
455
+ );
456
+ print(result, args, (value) => value.changed
457
+ ? `Updated ${value.project.name}: ${value.project.path}`
458
+ : `Unchanged ${value.project.name}: ${value.project.path}`);
459
+ return 0;
460
+ }
461
+
462
+ async function backupCommand(args) {
463
+ const action = args[1];
464
+ if (action === "list") {
465
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
466
+ const result = await listWorkspaceBackups(workspaceRoot(args));
467
+ print(result, args, (value) => value.backups.map((item) =>
468
+ `${item.id} ${item.usable ? "usable" : item.integrityValid ? "forensic" : "corrupt"}`)
469
+ .join("\n") || "No backups.");
470
+ return 0;
471
+ }
472
+ if (action === "create") {
473
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
474
+ const result = await createWorkspaceBackup(workspaceRoot(args), { reason: "manual" });
475
+ print(result, args, (value) => `Created backup ${value.id}.`);
476
+ return 0;
477
+ }
478
+ if (action === "verify" && args[2]) {
479
+ validateCommandArgs(args, 3, { values: ["--workspace"], switches: ["--json"] });
480
+ const result = await verifyWorkspaceBackup(workspaceRoot(args), args[2]);
481
+ print(result, args, (value) => `${value.id}: ${value.usable ? "usable" : value.error}`);
482
+ return result.usable ? 0 : 1;
483
+ }
484
+ if (action === "prune") {
485
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json", "--apply", "--dry-run"] });
486
+ if (args.includes("--apply") && args.includes("--dry-run")) throw new Error("--apply and --dry-run conflict");
487
+ const result = await pruneWorkspaceBackups(workspaceRoot(args), { apply: args.includes("--apply") });
488
+ print(result, args, (value) => `${value.apply ? "Pruned" : "Would prune"} ${value.removed.length} backup(s).`);
489
+ return 0;
490
+ }
491
+ if (action === "restore") {
492
+ const hasBackupId = Boolean(args[2] && !args[2].startsWith("--"));
493
+ validateCommandArgs(args, hasBackupId ? 3 : 2, {
494
+ values: [
495
+ "--workspace", "--scope", "--expect-config-hash", "--expect-project-count",
496
+ "--expect-after-count", "--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
497
+ "--resume", "--rollback",
498
+ ],
499
+ switches: [
500
+ "--json", "--dry-run", "--approve-restore", "--confirm-offline",
501
+ "--confirm-unreadable-current",
502
+ ],
503
+ });
504
+ const resume = option(args, "--resume", null);
505
+ const rollback = option(args, "--rollback", null);
506
+ if ([hasBackupId, resume !== null, rollback !== null].filter(Boolean).length !== 1) {
507
+ throw new Error("backup restore requires exactly one backup ID, --resume ID, or --rollback ID");
508
+ }
509
+ let backupId = hasBackupId ? args[2] : null;
510
+ let maintenanceTransaction = null;
511
+ if (resume !== null || rollback !== null) {
512
+ const maintenance = await readWorkspaceMaintenance(workspaceRoot(args));
513
+ maintenanceTransaction = resume ?? rollback;
514
+ if (!maintenance.active || maintenance.transactionId !== maintenanceTransaction) {
515
+ throw new Error("maintenance transaction not found");
516
+ }
517
+ backupId = resume !== null ? maintenance.backupId : maintenance.safetyBackupId;
518
+ }
519
+ const result = await restoreWorkspaceBackup(workspaceRoot(args), backupId, {
520
+ ...(await mutationOptions(args)),
521
+ scope: resume !== null || rollback !== null ? "state" : option(args, "--scope", "projects"),
522
+ approveRestore: args.includes("--approve-restore"),
523
+ confirmOffline: args.includes("--confirm-offline"),
524
+ confirmUnreadableCurrent: args.includes("--confirm-unreadable-current"),
525
+ maintenanceTransaction,
526
+ });
527
+ print(result, args, (value) => value.dryRun
528
+ ? `Restore preview ${value.beforeCount} -> ${value.afterCount}.`
529
+ : `Restored backup ${value.backupId} (${value.scope}).`);
530
+ return 0;
531
+ }
532
+ throw new Error("backup command requires list, create, verify, prune, or restore");
533
+ }
534
+
375
535
  async function taskRecord(args) {
376
536
  validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
377
537
  const dispatch = await recordTask(workspaceRoot(args), await readJsonStdin());
@@ -520,8 +680,10 @@ Usage:
520
680
  taskchef workspace migrate [--json] [--workspace <path>]
521
681
  taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
522
682
  taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
523
- taskchef project list [--json] [--workspace <path>]
524
- taskchef project remove <name> [--json] [--workspace <path>]
683
+ taskchef project list [--allow-missing-paths] [--json] [--workspace <path>]
684
+ taskchef project update <name> [fields] [--dry-run] [--expect-config-hash <sha256>] [--json]
685
+ taskchef project remove <name> --dry-run [--json] [--workspace <path>]
686
+ taskchef backup list|create|verify|restore|prune [options]
525
687
  taskchef dispatch prepare [--json] [--workspace <path>]
526
688
  taskchef task record [--json] [--workspace <path>]
527
689
  taskchef task brief [<task-id-or-8-character-prefix> | --project <name-or-path>] [--all] [--json] [--workspace <path>]
@@ -536,8 +698,8 @@ Project import reads a JSON
536
698
  array from a file, or from standard input when the source is '-' or omitted.
537
699
  Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
538
700
  ~/.agents/taskchef.
539
- The foreground dashboard binds to 127.0.0.1 and reads the canonical task log
540
- without modifying dispatcher-workspace files. The dispatcher MCP may reuse a
701
+ The foreground dashboard binds to 127.0.0.1 and creates one verified startup
702
+ snapshot before reading the canonical task log. The dispatcher MCP may reuse a
541
703
  compatible foreground server on port 3210; neither mode terminates a listener
542
704
  that already occupies its requested port.
543
705
  `);
@@ -556,7 +718,9 @@ export async function runCli(args) {
556
718
  if (args[0] === "project" && args[1] === "add") return projectAdd(args);
557
719
  if (args[0] === "project" && args[1] === "import") return projectImport(args);
558
720
  if (args[0] === "project" && args[1] === "list") return projectList(args);
721
+ if (args[0] === "project" && args[1] === "update") return projectUpdate(args);
559
722
  if (args[0] === "project" && args[1] === "remove") return projectRemove(args);
723
+ if (args[0] === "backup") return backupCommand(args);
560
724
  if (args[0] === "dispatch" && args[1] === "prepare") return dispatchPrepare(args);
561
725
  if (args[0] === "task" && args[1] === "record") return taskRecord(args);
562
726
  if (args[0] === "task" && args[1] === "brief") return taskBrief(args);