taskchef 7.29.0 → 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/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);
@@ -0,0 +1,99 @@
1
+ import { open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { taskChefError } from "./state-store.js";
4
+
5
+ export async function appendAuditUnlocked(workspace, record) {
6
+ const filePath = path.join(workspace, "config-audit.jsonl");
7
+ const size = await stat(filePath).then((details) => details.size).catch((error) => {
8
+ if (error.code === "ENOENT") return 0;
9
+ throw error;
10
+ });
11
+ if (size >= 10 * 1024 * 1024) {
12
+ const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
13
+ await rename(filePath, path.join(workspace, `config-audit.${timestamp}.jsonl`));
14
+ const archives = (await readdir(workspace))
15
+ .filter((name) => /^config-audit\..+\.jsonl$/.test(name))
16
+ .sort()
17
+ .reverse();
18
+ for (const name of archives.slice(10)) await unlink(path.join(workspace, name));
19
+ }
20
+ const handle = await open(filePath, "a", 0o600);
21
+ try {
22
+ await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, ...record })}\n`);
23
+ await handle.sync();
24
+ } finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+
29
+ async function readLatestAuditRecordsUnlocked(workspace) {
30
+ const filePath = path.join(workspace, "config-audit.jsonl");
31
+ const content = await readFile(filePath, "utf8").catch((error) => {
32
+ if (error.code === "ENOENT") return "";
33
+ throw error;
34
+ });
35
+ const latest = new Map();
36
+ for (const [index, line] of content.trimEnd().split("\n").entries()) {
37
+ if (line.length === 0) continue;
38
+ let record;
39
+ try {
40
+ record = JSON.parse(line);
41
+ } catch {
42
+ throw taskChefError(
43
+ "STATE_RECOVERY_REQUIRED",
44
+ `configuration audit is malformed at line ${index + 1}`,
45
+ );
46
+ }
47
+ if (record.transactionId) latest.set(record.transactionId, record);
48
+ }
49
+ return latest;
50
+ }
51
+
52
+ export async function validateAuditUnlocked(workspace) {
53
+ await readLatestAuditRecordsUnlocked(workspace);
54
+ }
55
+
56
+ export async function reconcileAuditUnlocked(workspace, currentHash) {
57
+ const latest = await readLatestAuditRecordsUnlocked(workspace);
58
+ for (const record of latest.values()) {
59
+ if (record.phase !== "prepared" && record.phase !== "recovery-required") continue;
60
+ if (currentHash === record.beforeHash) {
61
+ await appendAuditUnlocked(workspace, {
62
+ ...record,
63
+ timestamp: new Date().toISOString(),
64
+ phase: "aborted",
65
+ reconciliation: "matched-before",
66
+ });
67
+ } else if (currentHash === record.afterHash) {
68
+ await appendAuditUnlocked(workspace, {
69
+ ...record,
70
+ timestamp: new Date().toISOString(),
71
+ phase: "committed",
72
+ reconciliation: "matched-after",
73
+ });
74
+ } else {
75
+ throw taskChefError(
76
+ "STATE_RECOVERY_REQUIRED",
77
+ `configuration does not match either side of transaction ${record.transactionId}`,
78
+ { transactionId: record.transactionId },
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ export async function supersedeAuditTransactionsUnlocked(workspace, recoveryTransactionId) {
85
+ const latest = await readLatestAuditRecordsUnlocked(workspace);
86
+ const superseded = [];
87
+ for (const record of latest.values()) {
88
+ if (record.transactionId === recoveryTransactionId
89
+ || !new Set(["prepared", "recovery-required"]).has(record.phase)) continue;
90
+ await appendAuditUnlocked(workspace, {
91
+ ...record,
92
+ timestamp: new Date().toISOString(),
93
+ phase: "superseded",
94
+ supersededBy: recoveryTransactionId,
95
+ });
96
+ superseded.push(record.transactionId);
97
+ }
98
+ return superseded;
99
+ }
@@ -0,0 +1,98 @@
1
+ import { semanticHash, taskChefError } from "./state-store.js";
2
+
3
+ function fieldsChanged(before, after) {
4
+ const fields = {};
5
+ for (const key of ["name", "description", "isGitRepository", "githubRepos"]) {
6
+ if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
7
+ fields[key] = { before: before[key] ?? null, after: after[key] ?? null };
8
+ }
9
+ }
10
+ return fields;
11
+ }
12
+
13
+ export function projectSetHash(projects) {
14
+ return semanticHash({ schemaVersion: 1, projects }, "taskchef-project-set-v1");
15
+ }
16
+
17
+ export function configHash(config) {
18
+ return semanticHash(config, "taskchef-config-v1");
19
+ }
20
+
21
+ export function projectDiff(beforeProjects, afterProjects) {
22
+ const before = new Map(beforeProjects.map((project) => [project.path, project]));
23
+ const after = new Map(afterProjects.map((project) => [project.path, project]));
24
+ const added = [];
25
+ const changed = [];
26
+ const removed = [];
27
+ for (const project of afterProjects) {
28
+ const prior = before.get(project.path);
29
+ if (!prior) added.push(project);
30
+ else {
31
+ const fields = fieldsChanged(prior, project);
32
+ if (Object.keys(fields).length) changed.push({ path: project.path, before: prior, after: project, fields });
33
+ }
34
+ }
35
+ for (const project of beforeProjects) {
36
+ if (!after.has(project.path)) removed.push(project);
37
+ }
38
+ return { added, changed, removed };
39
+ }
40
+
41
+ export function mutationPreview(workspace, operation, beforeConfig, afterConfig) {
42
+ const diff = projectDiff(beforeConfig.projects, afterConfig.projects);
43
+ const preview = {
44
+ schemaVersion: 1,
45
+ operation,
46
+ beforeConfigHash: configHash(beforeConfig),
47
+ afterConfigHash: configHash(afterConfig),
48
+ beforeProjectSetHash: projectSetHash(beforeConfig.projects),
49
+ afterProjectSetHash: projectSetHash(afterConfig.projects),
50
+ beforeCount: beforeConfig.projects.length,
51
+ afterCount: afterConfig.projects.length,
52
+ diff,
53
+ projects: afterConfig.projects,
54
+ };
55
+ return {
56
+ ...preview,
57
+ planHash: semanticHash({
58
+ schemaVersion: 1,
59
+ workspace,
60
+ operation,
61
+ beforeConfigHash: preview.beforeConfigHash,
62
+ afterConfigHash: preview.afterConfigHash,
63
+ diff,
64
+ }, "taskchef-mutation-plan-v1"),
65
+ };
66
+ }
67
+
68
+ function exactStringSet(actual, expected) {
69
+ if (!Array.isArray(actual) || new Set(actual).size !== actual.length) return false;
70
+ return actual.length === expected.length
71
+ && [...actual].sort().every((value, index) => value === [...expected].sort()[index]);
72
+ }
73
+
74
+ export function assertMutationAuthorization(preview, authorization = {}, {
75
+ destructive = preview.diff.removed.length > 0,
76
+ replacement = false,
77
+ } = {}) {
78
+ if (authorization.expectConfigHash !== preview.beforeConfigHash) {
79
+ throw taskChefError("STALE_PREVIEW", "configuration changed or --expect-config-hash is missing", preview);
80
+ }
81
+ if (!destructive && !replacement) return;
82
+ const removedNames = preview.diff.removed.map((project) => project.name);
83
+ if (authorization.expectProjectCount !== preview.beforeCount
84
+ || authorization.expectAfterCount !== preview.afterCount
85
+ || authorization.confirmPlan !== preview.planHash
86
+ || !exactStringSet(authorization.confirmRemoved, removedNames)) {
87
+ throw taskChefError(
88
+ "CONFIRMATION_REQUIRED",
89
+ "destructive project mutation requires exact preview-bound count, hash, plan, and removed-name confirmation",
90
+ preview,
91
+ );
92
+ }
93
+ const collapses = preview.beforeCount > 0
94
+ && (preview.afterCount === 0 || preview.afterCount / preview.beforeCount <= 0.5);
95
+ if (collapses && authorization.confirmCountCollapse !== `${preview.beforeCount}:${preview.afterCount}`) {
96
+ throw taskChefError("CONFIRMATION_REQUIRED", "project-count collapse requires exact before:after confirmation", preview);
97
+ }
98
+ }