taskchef 2.0.0 → 3.0.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.
package/src/workspace.js CHANGED
@@ -17,6 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import path from "node:path";
19
19
  import { promisify } from "node:util";
20
+ import lockfile from "proper-lockfile";
20
21
 
21
22
  const execFile = promisify(execFileCallback);
22
23
  const DISPATCHER_INSTRUCTIONS_URL = new URL(
@@ -28,9 +29,12 @@ const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -
28
29
  const TASKCHEF_SKILL_NAMES = [
29
30
  "taskchef-bootstrap",
30
31
  "taskchef-delegate",
31
- "taskchef-reconcile",
32
+ "taskchef-report",
32
33
  ];
34
+ const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
33
35
  const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
36
+ const DISPATCH_FILE_NAME = "tasks.jsonl";
37
+ const DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
34
38
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
35
39
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
36
40
  const PROJECT_FIELDS = new Set([
@@ -41,27 +45,22 @@ const PROJECT_FIELDS = new Set([
41
45
  "description",
42
46
  ]);
43
47
  const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepo", "description"]);
44
- const TASK_FIELDS = new Set([
48
+ const DISPATCH_FIELDS = new Set([
45
49
  "schemaVersion",
46
50
  "id",
47
51
  "project",
48
52
  "title",
49
53
  "instruction",
50
- "status",
51
54
  "threadId",
52
- "result",
53
55
  "createdAt",
54
- "updatedAt",
55
56
  ]);
56
- const RESULT_FIELDS = new Set(["message", "githubPRs", "githubIssues"]);
57
- const CREATE_TASK_FIELDS = new Set(["id", "project", "title", "instruction"]);
58
- const TASK_STATUSES = new Set(["pending", "running", "blocked", "finished"]);
59
- const STATUS_TRANSITIONS = {
60
- pending: new Set(["pending", "running"]),
61
- running: new Set(["running", "blocked", "finished"]),
62
- blocked: new Set(["blocked", "running", "finished"]),
63
- finished: new Set(["finished", "running", "blocked"]),
64
- };
57
+ const RECORD_DISPATCH_FIELDS = new Set([
58
+ "id",
59
+ "project",
60
+ "title",
61
+ "instruction",
62
+ "threadId",
63
+ ]);
65
64
 
66
65
  function requireExactFields(value, fields, name) {
67
66
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -140,6 +139,31 @@ async function managedRegularFileExists(filePath) {
140
139
  return true;
141
140
  }
142
141
 
142
+ async function withDispatchLock(workspaceRoot, operation) {
143
+ const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
144
+ const lockPath = path.join(workspaceRoot, DISPATCH_LOCK_NAME);
145
+ const release = await lockfile.lock(dispatchPath, {
146
+ realpath: false,
147
+ lockfilePath: lockPath,
148
+ stale: 5_000,
149
+ update: 1_000,
150
+ retries: { retries: 70, factor: 1, minTimeout: 100, maxTimeout: 100 },
151
+ });
152
+ try {
153
+ return await operation();
154
+ } finally {
155
+ await release();
156
+ }
157
+ }
158
+
159
+ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
160
+ if (dispatches.length === 0) return;
161
+ const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
162
+ const content = await readFile(dispatchPath, "utf8");
163
+ const appended = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
164
+ await writeTextAtomic(dispatchPath, `${content}${appended}`);
165
+ }
166
+
143
167
  async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
144
168
  await mkdir(path.dirname(filePath), { recursive: true });
145
169
  if (exclusive && (await pathExists(filePath))) {
@@ -265,7 +289,7 @@ async function findLegacySkillLinks(workspaceRoot) {
265
289
  }
266
290
 
267
291
  const links = [];
268
- for (const skillName of TASKCHEF_SKILL_NAMES) {
292
+ for (const skillName of LEGACY_TASKCHEF_SKILL_NAMES) {
269
293
  const skillPath = path.join(skillsDirectory, skillName);
270
294
  const details = await lstat(skillPath).catch((error) => {
271
295
  if (error.code === "ENOENT") return null;
@@ -515,18 +539,33 @@ export async function validateConfig(config, { checkPaths = true } = {}) {
515
539
  };
516
540
  }
517
541
 
542
+ async function ensureDispatchFile(workspaceRoot) {
543
+ const filePath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
544
+ const exists = await managedRegularFileExists(filePath);
545
+ if (!exists) {
546
+ await writeFile(filePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" });
547
+ }
548
+ return { path: filePath, action: exists ? "unchanged" : "created" };
549
+ }
550
+
518
551
  export async function initializeWorkspace(workspaceRoot) {
519
552
  const requestedRoot = path.resolve(workspaceRoot);
520
553
  await mkdir(requestedRoot, { recursive: true });
521
554
  const root = await realpath(requestedRoot);
522
- const { legacySkills } = await ensureWorkspaceSkills(root);
523
- await ensureManagedDirectory(root, "tasks");
524
555
  const configPath = path.join(root, "taskchef.json");
525
556
  const configExists = await managedRegularFileExists(configPath);
526
557
  const config = configExists
527
558
  ? await readConfig(root, { checkPaths: false })
528
559
  : { schemaVersion: 1, projects: [] };
560
+ const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
561
+ const existingDispatches = configExists && (await managedRegularFileExists(dispatchPath))
562
+ ? await readDispatchesUnlocked(root)
563
+ : [];
564
+ const legacyTaskRecords = await inspectLegacyTaskRecords(root, config, existingDispatches);
565
+ const { legacySkills } = await ensureWorkspaceSkills(root);
529
566
  if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
567
+ const tasks = await ensureDispatchFile(root);
568
+ const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
530
569
  const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
531
570
  if (!configExists) await unlink(configPath).catch(() => {});
532
571
  throw error;
@@ -534,9 +573,10 @@ export async function initializeWorkspace(workspaceRoot) {
534
573
  return {
535
574
  workspace: root,
536
575
  config: { path: configPath, action: configExists ? "unchanged" : "created", value: config },
537
- tasks: { path: path.join(root, "tasks"), action: "ready" },
576
+ tasks,
538
577
  instructions,
539
578
  legacySkills,
579
+ legacyTasks,
540
580
  };
541
581
  }
542
582
 
@@ -589,21 +629,6 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
589
629
  else projects[index] = project;
590
630
  }
591
631
  const config = await validateConfig({ schemaVersion: 1, projects });
592
- if (replace) {
593
- const currentPaths = new Set(current.projects.map((project) => project.path));
594
- const configuredPaths = new Set(config.projects.map((project) => project.path));
595
- const removedPaths = new Set(
596
- [...currentPaths].filter((projectPath) => !configuredPaths.has(projectPath)),
597
- );
598
- const newlyOrphaned = (await listTasks(root, { checkProjects: false })).filter(
599
- (task) => removedPaths.has(task.project),
600
- );
601
- if (newlyOrphaned.length > 0) {
602
- throw new Error(
603
- `replacement would orphan ${newlyOrphaned.length} task record(s); remove referenced projects with --force first`,
604
- );
605
- }
606
- }
607
632
  await writeJsonAtomic(path.join(root, "taskchef.json"), config);
608
633
  return {
609
634
  mode: replace ? "replace" : "merge",
@@ -613,7 +638,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
613
638
  };
614
639
  }
615
640
 
616
- export async function removeProject(workspaceRoot, name, { force = false } = {}) {
641
+ export async function removeProject(workspaceRoot, name) {
617
642
  const root = path.resolve(workspaceRoot);
618
643
  const config = await readConfig(root, { checkPaths: false });
619
644
  const index = config.projects.findIndex(
@@ -621,215 +646,287 @@ export async function removeProject(workspaceRoot, name, { force = false } = {})
621
646
  );
622
647
  if (index === -1) throw new Error(`configured project not found: ${name}`);
623
648
  const [project] = config.projects.slice(index, index + 1);
624
- const referenced = (await listTasks(root, { checkProjects: false })).filter(
625
- (task) => task.project === project.path,
626
- );
627
- if (referenced.length > 0 && !force) {
628
- throw new Error(
629
- `project is referenced by ${referenced.length} task record(s); pass --force to remove it`,
630
- );
631
- }
632
649
  const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
633
650
  await writeJsonAtomic(path.join(root, "taskchef.json"), { schemaVersion: 1, projects });
634
- return { project, referencedTaskCount: referenced.length };
651
+ return { project };
635
652
  }
636
653
 
637
- function validateGithubUrl(value, kind, name) {
638
- requireString(value, name);
639
- let url;
640
- try {
641
- url = new URL(value);
642
- } catch {
643
- throw new Error(`${name} must be a canonical GitHub URL`);
644
- }
645
- const segment = kind === "pull" ? "pull" : "issues";
646
- const pattern = new RegExp(`^/[^/]+/[^/]+/${segment}/[1-9]\\d*$`);
647
- if (
648
- url.protocol !== "https:" ||
649
- url.hostname !== "github.com" ||
650
- url.username ||
651
- url.password ||
652
- url.port ||
653
- url.search ||
654
- url.hash ||
655
- !pattern.test(url.pathname)
656
- ) {
657
- throw new Error(`${name} must be a canonical GitHub ${kind} URL`);
658
- }
659
- return value;
660
- }
661
-
662
- export function validateResult(result) {
663
- if (result === null) return null;
664
- requireExactFields(result, RESULT_FIELDS, "result");
665
- requireString(result.message, "result.message");
666
- for (const field of ["githubPRs", "githubIssues"]) {
667
- if (!Array.isArray(result[field])) throw new Error(`result.${field} must be an array`);
668
- }
669
- result.githubPRs.forEach((value, index) =>
670
- validateGithubUrl(value, "pull", `result.githubPRs[${index}]`));
671
- result.githubIssues.forEach((value, index) =>
672
- validateGithubUrl(value, "issue", `result.githubIssues[${index}]`));
654
+ async function validateDispatchShape(dispatch, name = "task") {
655
+ requireExactFields(dispatch, DISPATCH_FIELDS, name);
656
+ if (dispatch.schemaVersion !== 1) throw new Error(`unsupported ${name} schemaVersion`);
657
+ const id = requireSafeId(dispatch.id, `${name}.id`);
658
+ const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
673
659
  return {
674
- message: result.message,
675
- githubPRs: [...result.githubPRs],
676
- githubIssues: [...result.githubIssues],
660
+ schemaVersion: 1,
661
+ id,
662
+ project,
663
+ title: requireString(dispatch.title, `${name}.title`).trim(),
664
+ instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
665
+ threadId: requireString(dispatch.threadId, `${name}.threadId`).trim(),
666
+ createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
677
667
  };
678
668
  }
679
669
 
680
- function validateTaskShape(task) {
681
- requireExactFields(task, TASK_FIELDS, "task");
682
- if (task.schemaVersion !== 1) throw new Error("unsupported task schemaVersion");
683
- requireSafeId(task.id);
684
- requireString(task.project, "project");
685
- requireString(task.title, "title");
686
- requireString(task.instruction, "instruction");
687
- if (!TASK_STATUSES.has(task.status)) throw new Error(`unsupported task status: ${task.status}`);
688
- if (task.threadId !== null) requireString(task.threadId, "threadId");
689
- if (task.status === "pending" && task.threadId !== null) {
690
- throw new Error("a pending task must not have a threadId");
670
+ async function readDispatchesUnlocked(root) {
671
+ await readConfig(root, { checkPaths: false });
672
+ const filePath = path.join(root, DISPATCH_FILE_NAME);
673
+ if (!(await managedRegularFileExists(filePath))) {
674
+ throw new Error(`task log does not exist: ${filePath}`);
691
675
  }
692
- if (task.status !== "pending" && task.threadId === null) {
693
- throw new Error(`a ${task.status} task requires a threadId`);
676
+ const content = await readFile(filePath, "utf8");
677
+ if (content.length > 0 && !content.endsWith("\n")) {
678
+ throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
694
679
  }
695
- validateResult(task.result);
696
- requireTimestamp(task.createdAt, "createdAt");
697
- requireTimestamp(task.updatedAt, "updatedAt");
698
- if (Date.parse(task.updatedAt) < Date.parse(task.createdAt)) {
699
- throw new Error("updatedAt must not be earlier than createdAt");
680
+ const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
681
+ const dispatches = [];
682
+ for (const [index, line] of lines.entries()) {
683
+ if (line.trim().length === 0) {
684
+ throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
685
+ }
686
+ let value;
687
+ try {
688
+ value = JSON.parse(line);
689
+ } catch (error) {
690
+ throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
691
+ }
692
+ dispatches.push(await validateDispatchShape(value, `task line ${index + 1}`));
693
+ }
694
+ const ids = new Set();
695
+ const threadIds = new Set();
696
+ for (const dispatch of dispatches) {
697
+ if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
698
+ if (threadIds.has(dispatch.threadId)) {
699
+ throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
700
+ }
701
+ ids.add(dispatch.id);
702
+ threadIds.add(dispatch.threadId);
700
703
  }
701
- return task;
704
+ return dispatches;
705
+ }
706
+
707
+ export async function listTasks(workspaceRoot) {
708
+ const root = await realpath(path.resolve(workspaceRoot));
709
+ return readDispatchesUnlocked(root);
702
710
  }
703
711
 
704
- export async function createTask(workspaceRoot, input, { now } = {}) {
712
+ export async function recordTask(workspaceRoot, input, { now } = {}) {
713
+ requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
705
714
  const root = await realpath(path.resolve(workspaceRoot));
706
- requireExactFields(input, CREATE_TASK_FIELDS, "task creation input");
707
715
  const config = await readConfig(root);
708
- const id = requireSafeId(input.id);
709
- const project = await canonicalDirectory(input.project);
710
- if (!config.projects.some((configuredProject) => configuredProject.path === project)) {
711
- throw new Error(`project is not configured in taskchef.json: ${project}`);
712
- }
713
- const createdAt = now ?? new Date().toISOString();
714
- requireTimestamp(createdAt, "createdAt");
715
- const task = {
716
+ const projectPath = await canonicalDirectory(input.project);
717
+ const project = config.projects.find((candidate) => candidate.path === projectPath);
718
+ if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
719
+ const dispatch = await validateDispatchShape({
716
720
  schemaVersion: 1,
717
- id,
721
+ id: input.id,
718
722
  project,
719
- title: requireString(input.title, "title"),
720
- instruction: requireString(input.instruction, "instruction"),
721
- status: "pending",
722
- threadId: null,
723
- result: null,
724
- createdAt,
725
- updatedAt: createdAt,
726
- };
727
- const tasksDirectory = await ensureManagedDirectory(root, "tasks");
728
- const taskDirectory = path.join(tasksDirectory, id);
729
- if (await lstat(taskDirectory).catch((error) => {
730
- if (error.code === "ENOENT") return null;
731
- throw error;
732
- })) throw new Error(`task already exists: ${id}`);
733
- await mkdir(taskDirectory, { recursive: false });
734
- await writeJsonAtomic(path.join(taskDirectory, "task.json"), task, { exclusive: true });
735
- return task;
723
+ title: input.title,
724
+ instruction: input.instruction,
725
+ threadId: input.threadId,
726
+ createdAt: now ?? new Date().toISOString(),
727
+ });
728
+ await withDispatchLock(root, async () => {
729
+ const existing = await readDispatchesUnlocked(root);
730
+ if (existing.some((item) => item.id === dispatch.id)) {
731
+ throw new Error(`task already exists: ${dispatch.id}`);
732
+ }
733
+ if (existing.some((item) => item.threadId === dispatch.threadId)) {
734
+ throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
735
+ }
736
+ await appendDispatchesAtomic(root, [dispatch]);
737
+ });
738
+ return dispatch;
736
739
  }
737
740
 
738
741
  export async function readTask(workspaceRoot, taskId) {
739
742
  const id = requireSafeId(taskId, "taskId");
740
- const root = await realpath(path.resolve(workspaceRoot));
741
- const tasksDirectory = await ensureManagedDirectory(root, "tasks");
742
- const taskDirectory = path.join(tasksDirectory, id);
743
- const taskDirectoryDetails = await lstat(taskDirectory);
744
- if (taskDirectoryDetails.isSymbolicLink() || !taskDirectoryDetails.isDirectory()) {
745
- throw new Error(`task path is not a real directory: ${taskDirectory}`);
746
- }
747
- const filePath = path.join(taskDirectory, "task.json");
748
- const fileDetails = await lstat(filePath);
749
- if (fileDetails.isSymbolicLink() || !fileDetails.isFile()) {
750
- throw new Error(`task record is not a regular file: ${filePath}`);
751
- }
752
- const task = validateTaskShape(JSON.parse(await readFile(filePath, "utf8")));
753
- if (task.id !== id) throw new Error(`task ID does not match directory: ${id}`);
754
- return task;
743
+ const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
744
+ if (!dispatch) throw new Error(`task not found: ${id}`);
745
+ return dispatch;
746
+ }
747
+
748
+ export async function filterTasks(workspaceRoot, { project = null } = {}) {
749
+ const dispatches = await listTasks(workspaceRoot);
750
+ if (project === null) return dispatches;
751
+ const value = requireString(project, "project");
752
+ const filtered = dispatches.filter(
753
+ (dispatch) =>
754
+ dispatch.project.name.toLowerCase() === value.toLowerCase() ||
755
+ dispatch.project.path === value,
756
+ );
757
+ return filtered;
755
758
  }
756
759
 
757
- export async function updateTask(workspaceRoot, taskId, patch, { now } = {}) {
758
- const allowed = new Set(["status", "threadId", "result"]);
759
- const unexpected = Object.keys(patch).find((key) => !allowed.has(key));
760
- if (unexpected) throw new Error(`unsupported task update field: ${unexpected}`);
761
- if (Object.keys(patch).length === 0) throw new Error("task update must not be empty");
762
- const current = await readTask(workspaceRoot, taskId);
763
- const status = patch.status ?? current.status;
764
- if (!TASK_STATUSES.has(status)) throw new Error(`unsupported task status: ${status}`);
765
- if (!STATUS_TRANSITIONS[current.status].has(status)) {
766
- throw new Error(`unsupported task transition: ${current.status} -> ${status}`);
767
- }
768
- const threadId = patch.threadId === undefined ? current.threadId : patch.threadId;
769
- if (threadId !== null) requireString(threadId, "threadId");
770
- if (current.threadId && threadId !== current.threadId) {
771
- throw new Error("threadId cannot be replaced once recorded");
772
- }
773
- const updated = {
774
- ...current,
775
- status,
776
- threadId,
777
- result: patch.result === undefined ? current.result : validateResult(patch.result),
778
- updatedAt: now ?? new Date().toISOString(),
779
- };
780
- requireTimestamp(updated.updatedAt, "updatedAt");
781
- if (Date.parse(updated.updatedAt) < Date.parse(current.updatedAt)) {
782
- throw new Error("updatedAt must not move backwards");
760
+ export async function buildTaskSummary(workspaceRoot) {
761
+ const dispatches = await listTasks(workspaceRoot);
762
+ const projectCounts = new Map();
763
+ for (const dispatch of dispatches) {
764
+ projectCounts.set(
765
+ dispatch.project.name,
766
+ (projectCounts.get(dispatch.project.name) ?? 0) + 1,
767
+ );
783
768
  }
784
- validateTaskShape(updated);
785
- const filePath = path.join(path.resolve(workspaceRoot), "tasks", current.id, "task.json");
786
- await writeJsonAtomic(filePath, updated);
787
- return updated;
769
+ return {
770
+ schemaVersion: 1,
771
+ taskCount: dispatches.length,
772
+ projectCounts: Object.fromEntries(
773
+ [...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right)),
774
+ ),
775
+ };
788
776
  }
789
777
 
790
- export async function listTasks(workspaceRoot, { checkProjects = true } = {}) {
791
- const root = await realpath(path.resolve(workspaceRoot));
792
- await readConfig(root, { checkPaths: checkProjects });
793
- const tasksDirectory = await ensureManagedDirectory(root, "tasks");
794
- const entries = await readdir(tasksDirectory, { withFileTypes: true });
795
- const tasks = [];
778
+ const LEGACY_TASK_FIELDS = new Set([
779
+ "schemaVersion",
780
+ "id",
781
+ "project",
782
+ "title",
783
+ "instruction",
784
+ "status",
785
+ "threadId",
786
+ "result",
787
+ "createdAt",
788
+ "updatedAt",
789
+ ]);
790
+
791
+ function validateLegacyTask(task, taskId) {
792
+ requireExactFields(task, LEGACY_TASK_FIELDS, `legacy task ${taskId}`);
793
+ if (task.schemaVersion !== 1) throw new Error(`legacy task ${taskId} has unsupported schemaVersion`);
794
+ if (requireSafeId(task.id) !== taskId) throw new Error(`legacy task ID does not match directory: ${taskId}`);
795
+ requireString(task.project, `legacy task ${taskId}.project`);
796
+ requireString(task.title, `legacy task ${taskId}.title`);
797
+ requireString(task.instruction, `legacy task ${taskId}.instruction`);
798
+ requireTimestamp(task.createdAt, `legacy task ${taskId}.createdAt`);
799
+ if (task.threadId === null) {
800
+ throw new Error(`legacy pending task ${taskId} has no executor thread and cannot be migrated`);
801
+ }
802
+ requireString(task.threadId, `legacy task ${taskId}.threadId`);
803
+ return task;
804
+ }
805
+
806
+ async function inspectLegacyTaskRecords(workspaceRoot, config, existingDispatches = []) {
807
+ const tasksPath = path.join(workspaceRoot, "tasks");
808
+ const details = await lstat(tasksPath).catch((error) => {
809
+ if (error.code === "ENOENT") return null;
810
+ throw error;
811
+ });
812
+ if (details === null) return { tasksPath, entries: [], records: [], action: "not-found" };
813
+ if (details.isSymbolicLink() || !details.isDirectory()) {
814
+ throw new Error(`legacy tasks path is not a real directory: ${tasksPath}`);
815
+ }
816
+ const entries = await readdir(tasksPath, { withFileTypes: true });
817
+ const records = [];
818
+ const ids = new Set();
819
+ const threadIds = new Set();
796
820
  for (const entry of entries) {
797
- if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
798
- if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
799
- tasks.push(await readTask(root, entry.name));
821
+ if (!entry.isDirectory() || !SAFE_ID.test(entry.name)) {
822
+ throw new Error(`unexpected legacy task entry: ${entry.name}`);
823
+ }
824
+ const taskDirectory = path.join(tasksPath, entry.name);
825
+ const taskEntries = await readdir(taskDirectory, { withFileTypes: true });
826
+ if (taskEntries.length === 0) {
827
+ records.push({ dispatch: null, taskPath: null, taskDirectory, taskId: entry.name });
828
+ continue;
829
+ }
830
+ if (
831
+ taskEntries.length !== 1 ||
832
+ taskEntries[0].name !== "task.json" ||
833
+ !taskEntries[0].isFile()
834
+ ) {
835
+ throw new Error(`legacy task directory must contain only task.json: ${entry.name}`);
836
+ }
837
+ const taskPath = path.join(taskDirectory, "task.json");
838
+ const task = validateLegacyTask(JSON.parse(await readFile(taskPath, "utf8")), entry.name);
839
+ const existing = existingDispatches.find((dispatch) => dispatch.id === task.id);
840
+ let dispatch;
841
+ if (existing) {
842
+ if (task.project.trim() !== existing.project.path) {
843
+ throw new Error(`legacy task conflicts with task history: ${task.id}`);
844
+ }
845
+ const comparable = {
846
+ title: task.title.trim(),
847
+ instruction: task.instruction.trim(),
848
+ threadId: task.threadId.trim(),
849
+ createdAt: task.createdAt,
850
+ };
851
+ for (const [field, value] of Object.entries(comparable)) {
852
+ if (existing[field] !== value) {
853
+ throw new Error(`legacy task conflicts with task history: ${task.id}`);
854
+ }
855
+ }
856
+ dispatch = existing;
857
+ } else {
858
+ const project = config.projects.find((candidate) => candidate.path === task.project) ??
859
+ await inspectProject({ path: task.project });
860
+ dispatch = await validateDispatchShape({
861
+ schemaVersion: 1,
862
+ id: task.id,
863
+ project,
864
+ title: task.title,
865
+ instruction: task.instruction,
866
+ threadId: task.threadId,
867
+ createdAt: task.createdAt,
868
+ });
869
+ }
870
+ if (ids.has(dispatch.id)) throw new Error(`duplicate legacy task ID: ${dispatch.id}`);
871
+ if (threadIds.has(dispatch.threadId)) {
872
+ throw new Error(`duplicate legacy task threadId: ${dispatch.threadId}`);
873
+ }
874
+ ids.add(dispatch.id);
875
+ threadIds.add(dispatch.threadId);
876
+ records.push({ dispatch, taskPath, taskDirectory, taskId: task.id });
800
877
  }
801
- return tasks.sort((left, right) => left.id.localeCompare(right.id));
878
+ return {
879
+ tasksPath,
880
+ entries,
881
+ records,
882
+ action: entries.length === 0 ? "removed-empty" : "migrated",
883
+ };
802
884
  }
803
885
 
804
- export async function filterTasks(workspaceRoot, { statuses = [], project = null } = {}) {
805
- const config = await readConfig(workspaceRoot);
806
- const statusSet = new Set(statuses);
807
- for (const status of statusSet) {
808
- if (!TASK_STATUSES.has(status)) throw new Error(`unsupported task status: ${status}`);
809
- }
810
- let projectPath = null;
811
- if (project !== null) {
812
- const configured = config.projects.find(
813
- (candidate) => candidate.name.toLowerCase() === project.toLowerCase() || candidate.path === project,
886
+ async function migrateLegacyTaskRecords(workspaceRoot, inspection) {
887
+ if (inspection.action === "not-found") return { action: "not-found", migratedCount: 0 };
888
+ const migrations = await withDispatchLock(workspaceRoot, async () => {
889
+ const existing = await readDispatchesUnlocked(workspaceRoot);
890
+ const existingById = new Map(existing.map((dispatch) => [dispatch.id, dispatch]));
891
+ const threadIds = new Set(existing.map((dispatch) => dispatch.threadId));
892
+ const pending = [];
893
+ for (const { dispatch, taskPath, taskDirectory, taskId } of inspection.records) {
894
+ if (dispatch === null) {
895
+ if (!existingById.has(taskId)) {
896
+ throw new Error(`empty legacy task directory has no matching dispatch: ${taskId}`);
897
+ }
898
+ pending.push({ dispatch: null, taskPath, taskDirectory });
899
+ continue;
900
+ }
901
+ const previous = existingById.get(dispatch.id);
902
+ if (previous && JSON.stringify(previous) !== JSON.stringify(dispatch)) {
903
+ throw new Error(`legacy task conflicts with dispatch: ${dispatch.id}`);
904
+ }
905
+ if (!previous && threadIds.has(dispatch.threadId)) {
906
+ throw new Error(`legacy task threadId is already recorded: ${dispatch.threadId}`);
907
+ }
908
+ if (!previous) {
909
+ pending.push({ dispatch, taskPath, taskDirectory });
910
+ existingById.set(dispatch.id, dispatch);
911
+ threadIds.add(dispatch.threadId);
912
+ } else {
913
+ pending.push({ dispatch: null, taskPath, taskDirectory });
914
+ }
915
+ }
916
+ await appendDispatchesAtomic(
917
+ workspaceRoot,
918
+ pending.filter((item) => item.dispatch).map((item) => item.dispatch),
814
919
  );
815
- if (!configured) throw new Error(`configured project not found: ${project}`);
816
- projectPath = configured.path;
920
+ return pending;
921
+ });
922
+ for (const migration of migrations) {
923
+ if (migration.taskPath !== null) await unlink(migration.taskPath);
924
+ await rmdir(migration.taskDirectory);
817
925
  }
818
- return (await listTasks(workspaceRoot)).filter(
819
- (task) =>
820
- (statusSet.size === 0 || statusSet.has(task.status)) &&
821
- (projectPath === null || task.project === projectPath),
822
- );
823
- }
824
-
825
- export async function buildTaskSummary(workspaceRoot) {
826
- const tasks = await listTasks(workspaceRoot);
926
+ await rmdir(inspection.tasksPath);
827
927
  return {
828
- schemaVersion: 1,
829
- taskCount: tasks.length,
830
- statusCounts: Object.fromEntries(
831
- [...TASK_STATUSES].map((status) => [status, tasks.filter((task) => task.status === status).length]),
832
- ),
928
+ action: inspection.action,
929
+ migratedCount: migrations.filter((item) => item.dispatch).length,
833
930
  };
834
931
  }
835
932
 
@@ -844,17 +941,13 @@ export async function doctorWorkspace(workspaceRoot) {
844
941
  checks.push({ name, status: "fail", message: error.message });
845
942
  }
846
943
  };
847
- let config = null;
848
944
  await check("configuration", async () => {
849
- config = await readConfig(root);
945
+ const config = await readConfig(root);
850
946
  return `${config.projects.length} configured project(s) valid`;
851
947
  });
852
- await check("tasks-directory", async () => {
853
- const details = await lstat(path.join(root, "tasks"));
854
- if (details.isSymbolicLink() || !details.isDirectory()) {
855
- throw new Error("tasks path is not a real directory");
856
- }
857
- return "tasks directory ready";
948
+ await check("task-log", async () => {
949
+ const dispatches = await listTasks(root);
950
+ return `${dispatches.length} task record(s) valid`;
858
951
  });
859
952
  await check("instructions", async () => {
860
953
  const filePath = path.join(root, "AGENTS.md");
@@ -872,40 +965,11 @@ export async function doctorWorkspace(workspaceRoot) {
872
965
  }
873
966
  return "no legacy TaskChef skill links";
874
967
  });
875
- await check("task-records", async () => {
876
- const taskRoot = path.join(root, "tasks");
877
- const entries = await readdir(taskRoot, { withFileTypes: true });
878
- let count = 0;
879
- for (const entry of entries) {
880
- if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
881
- if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
882
- const task = await readTask(root, entry.name);
883
- if (task.id !== entry.name) throw new Error(`task ID does not match directory: ${entry.name}`);
884
- if (config && !config.projects.some((project) => project.path === task.project)) {
885
- throw new Error(`task ${task.id} references an unconfigured project`);
886
- }
887
- count += 1;
968
+ await check("legacy-tasks", async () => {
969
+ if (await pathExists(path.join(root, "tasks"))) {
970
+ throw new Error("legacy tasks directory remains; run workspace init to migrate it");
888
971
  }
889
- return `${count} task record(s) valid`;
972
+ return "no legacy task records";
890
973
  });
891
974
  return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
892
975
  }
893
-
894
- export async function buildReconciliationCandidates(
895
- workspaceRoot,
896
- { includeFinished = false } = {},
897
- ) {
898
- const includedStatuses = includeFinished
899
- ? ["running", "blocked", "finished"]
900
- : ["running", "blocked"];
901
- const includedStatusSet = new Set(includedStatuses);
902
- const tasks = (await listTasks(workspaceRoot)).filter(
903
- (task) => task.threadId !== null && includedStatusSet.has(task.status),
904
- );
905
- return {
906
- schemaVersion: 1,
907
- candidateCount: tasks.length,
908
- includedStatuses,
909
- tasks,
910
- };
911
- }