taskchef 3.0.2 → 4.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/workspace.js CHANGED
@@ -18,6 +18,11 @@ import { fileURLToPath } from "node:url";
18
18
  import path from "node:path";
19
19
  import { promisify } from "node:util";
20
20
  import lockfile from "proper-lockfile";
21
+ import { normalizeDurableThreadId, parseTaskChefMarker } from "./delegation.js";
22
+ import {
23
+ canonicalGithubRepository,
24
+ normalizeGithubRepositories,
25
+ } from "./github.js";
21
26
 
22
27
  const execFile = promisify(execFileCallback);
23
28
  const DISPATCHER_INSTRUCTIONS_URL = new URL(
@@ -36,15 +41,18 @@ const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url))
36
41
  const DISPATCH_FILE_NAME = "tasks.jsonl";
37
42
  const DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
38
43
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
44
+ const CURRENT_SCHEMA_VERSION = 2;
45
+ const LEGACY_SCHEMA_VERSION = 1;
39
46
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
40
47
  const PROJECT_FIELDS = new Set([
41
48
  "name",
42
49
  "path",
43
50
  "isGitRepository",
44
51
  "githubRepo",
52
+ "githubRepos",
45
53
  "description",
46
54
  ]);
47
- const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepo", "description"]);
55
+ const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
48
56
  const DISPATCH_FIELDS = new Set([
49
57
  "schemaVersion",
50
58
  "id",
@@ -164,6 +172,12 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
164
172
  await writeTextAtomic(dispatchPath, `${content}${appended}`);
165
173
  }
166
174
 
175
+ async function writeDispatchLinesAtomic(workspaceRoot, lines) {
176
+ const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
177
+ const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
178
+ await writeTextAtomic(dispatchPath, content);
179
+ }
180
+
167
181
  async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
168
182
  await mkdir(path.dirname(filePath), { recursive: true });
169
183
  if (exclusive && (await pathExists(filePath))) {
@@ -362,39 +376,23 @@ export async function canonicalDirectory(projectPath) {
362
376
  return requested;
363
377
  }
364
378
 
365
- function validateGithubRepository(value, name) {
366
- if (value === null) return null;
367
- requireString(value, name);
368
- let url;
369
- try {
370
- url = new URL(value);
371
- } catch {
372
- throw new Error(`${name} must be a canonical GitHub repository URL or null`);
373
- }
374
- if (
375
- url.protocol !== "https:" ||
376
- url.hostname !== "github.com" ||
377
- url.username ||
378
- url.password ||
379
- url.port ||
380
- url.search ||
381
- url.hash ||
382
- !/^\/[^/]+\/[^/]+$/.test(url.pathname) ||
383
- url.pathname.endsWith(".git")
384
- ) {
385
- throw new Error(`${name} must be a canonical GitHub repository URL or null`);
386
- }
387
- return value;
388
- }
389
-
390
- async function normalizeProject(project, index, { checkPath = true } = {}) {
379
+ async function normalizeProject(
380
+ project,
381
+ index,
382
+ { checkPath = true, allowLegacyGithubRepo = false } = {},
383
+ ) {
391
384
  const field = `projects[${index}]`;
392
385
  if (!project || typeof project !== "object" || Array.isArray(project)) {
393
386
  throw new Error(`${field} must be an object`);
394
387
  }
395
388
  const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
396
389
  if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
397
- for (const required of ["name", "path", "isGitRepository", "githubRepo"]) {
390
+ const repositoryField = allowLegacyGithubRepo ? "githubRepo" : "githubRepos";
391
+ const unsupportedRepositoryField = allowLegacyGithubRepo ? "githubRepos" : "githubRepo";
392
+ if (unsupportedRepositoryField in project) {
393
+ throw new Error(`${field} has unsupported field: ${unsupportedRepositoryField}`);
394
+ }
395
+ for (const required of ["name", "path", "isGitRepository", repositoryField]) {
398
396
  if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
399
397
  }
400
398
  const name = requireString(project.name, `${field}.name`).trim();
@@ -412,15 +410,14 @@ async function normalizeProject(project, index, { checkPath = true } = {}) {
412
410
  throw new Error(`${field}.path must be a normalized absolute path`);
413
411
  }
414
412
  }
415
- const githubRepo = validateGithubRepository(project.githubRepo, `${field}.githubRepo`);
416
- if (!project.isGitRepository && githubRepo !== null) {
417
- throw new Error(`${field}.githubRepo must be null for a non-Git project`);
418
- }
413
+ const githubRepos = normalizeGithubRepositories(project[repositoryField], `${field}.${repositoryField}`, {
414
+ allowLegacyScalar: allowLegacyGithubRepo,
415
+ });
419
416
  const normalized = {
420
417
  name,
421
418
  path: projectPath,
422
419
  isGitRepository: project.isGitRepository,
423
- githubRepo,
420
+ githubRepos,
424
421
  };
425
422
  if ("description" in project) {
426
423
  normalized.description = requireString(
@@ -431,11 +428,17 @@ async function normalizeProject(project, index, { checkPath = true } = {}) {
431
428
  return normalized;
432
429
  }
433
430
 
434
- async function normalizeProjects(projects, { checkPaths = true } = {}) {
431
+ async function normalizeProjects(
432
+ projects,
433
+ { checkPaths = true, allowLegacyGithubRepo = false } = {},
434
+ ) {
435
435
  if (!Array.isArray(projects)) throw new Error("projects must be an array");
436
436
  const normalized = [];
437
437
  for (const [index, project] of projects.entries()) {
438
- normalized.push(await normalizeProject(project, index, { checkPath: checkPaths }));
438
+ normalized.push(await normalizeProject(project, index, {
439
+ checkPath: checkPaths,
440
+ allowLegacyGithubRepo,
441
+ }));
439
442
  }
440
443
  if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
441
444
  throw new Error("project paths must not contain duplicates");
@@ -451,16 +454,8 @@ async function normalizeProjects(projects, { checkPaths = true } = {}) {
451
454
 
452
455
  function normalizeGithubRemote(remote) {
453
456
  if (typeof remote !== "string" || remote.trim().length === 0) return null;
454
- const value = remote.trim();
455
- const scpMatch = value.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
456
- if (scpMatch) return `https://github.com/${scpMatch[1]}/${scpMatch[2]}`;
457
- const sshMatch = value.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
458
- if (sshMatch) return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
459
457
  try {
460
- const url = new URL(value);
461
- if (url.hostname !== "github.com") return null;
462
- const match = url.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?$/);
463
- return match ? `https://github.com/${match[1]}/${match[2]}` : null;
458
+ return canonicalGithubRepository(remote, "GitHub origin");
464
459
  } catch {
465
460
  return null;
466
461
  }
@@ -500,10 +495,10 @@ async function inspectProject(input, index = 0) {
500
495
  if (isGitRepository && gitRoot !== projectPath) {
501
496
  throw new Error(`project must be the Git repository root: ${projectPath}`);
502
497
  }
503
- let githubRepo = null;
498
+ let githubRepos = [];
504
499
  if (isGitRepository) {
505
- if ("githubRepo" in input) {
506
- githubRepo = validateGithubRepository(input.githubRepo, `${field}.githubRepo`);
500
+ if ("githubRepos" in input) {
501
+ githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
507
502
  } else {
508
503
  const remote = await execFile("git", ["remote", "get-url", "origin"], {
509
504
  cwd: projectPath,
@@ -511,10 +506,11 @@ async function inspectProject(input, index = 0) {
511
506
  if (error.code === 2 && /No such remote/i.test(error.stderr ?? "")) return null;
512
507
  throw gitInspectionError("failed to inspect GitHub origin", error);
513
508
  });
514
- githubRepo = normalizeGithubRemote(remote);
509
+ const detected = normalizeGithubRemote(remote);
510
+ githubRepos = detected === null ? [] : [detected];
515
511
  }
516
- } else if ("githubRepo" in input && input.githubRepo !== null) {
517
- throw new Error(`${field}.githubRepo must be null for a non-Git project`);
512
+ } else if ("githubRepos" in input) {
513
+ githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
518
514
  }
519
515
  const project = {
520
516
  name: "name" in input
@@ -522,7 +518,7 @@ async function inspectProject(input, index = 0) {
522
518
  : path.basename(projectPath),
523
519
  path: projectPath,
524
520
  isGitRepository,
525
- githubRepo,
521
+ githubRepos,
526
522
  };
527
523
  if ("description" in input) {
528
524
  project.description = requireString(input.description, `${field}.description`).trim();
@@ -532,10 +528,15 @@ async function inspectProject(input, index = 0) {
532
528
 
533
529
  export async function validateConfig(config, { checkPaths = true } = {}) {
534
530
  requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
535
- if (config.schemaVersion !== 1) throw new Error("unsupported configuration schemaVersion");
531
+ if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(config.schemaVersion)) {
532
+ throw new Error("unsupported configuration schemaVersion");
533
+ }
536
534
  return {
537
- schemaVersion: 1,
538
- projects: await normalizeProjects(config.projects, { checkPaths }),
535
+ schemaVersion: CURRENT_SCHEMA_VERSION,
536
+ projects: await normalizeProjects(config.projects, {
537
+ checkPaths,
538
+ allowLegacyGithubRepo: config.schemaVersion === LEGACY_SCHEMA_VERSION,
539
+ }),
539
540
  };
540
541
  }
541
542
 
@@ -554,29 +555,38 @@ export async function initializeWorkspace(workspaceRoot) {
554
555
  const root = await realpath(requestedRoot);
555
556
  const configPath = path.join(root, "taskchef.json");
556
557
  const configExists = await managedRegularFileExists(configPath);
558
+ const storedConfigVersion = configExists
559
+ ? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
560
+ : null;
557
561
  const config = configExists
558
562
  ? await readConfig(root, { checkPaths: false })
559
- : { schemaVersion: 1, projects: [] };
563
+ : { schemaVersion: CURRENT_SCHEMA_VERSION, projects: [] };
560
564
  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
+ if (configExists && (await managedRegularFileExists(dispatchPath))) {
566
+ await readDispatchesUnlocked(root);
567
+ }
565
568
  const { legacySkills } = await ensureWorkspaceSkills(root);
566
569
  if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
570
+ else if (storedConfigVersion !== CURRENT_SCHEMA_VERSION) {
571
+ await writeJsonAtomic(configPath, config);
572
+ }
567
573
  const tasks = await ensureDispatchFile(root);
568
- const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
569
574
  const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
570
575
  if (!configExists) await unlink(configPath).catch(() => {});
571
576
  throw error;
572
577
  });
573
578
  return {
574
579
  workspace: root,
575
- config: { path: configPath, action: configExists ? "unchanged" : "created", value: config },
580
+ config: {
581
+ path: configPath,
582
+ action: !configExists
583
+ ? "created"
584
+ : storedConfigVersion === CURRENT_SCHEMA_VERSION ? "unchanged" : "migrated",
585
+ value: config,
586
+ },
576
587
  tasks,
577
588
  instructions,
578
589
  legacySkills,
579
- legacyTasks,
580
590
  };
581
591
  }
582
592
 
@@ -600,7 +610,7 @@ export async function addProject(workspaceRoot, input) {
600
610
  const config = await readConfig(root);
601
611
  const project = await inspectProject(input);
602
612
  const updated = await validateConfig({
603
- schemaVersion: 1,
613
+ schemaVersion: CURRENT_SCHEMA_VERSION,
604
614
  projects: [...config.projects, project],
605
615
  });
606
616
  await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
@@ -620,6 +630,12 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
620
630
  if (!("description" in mergedInput) && existing?.description) {
621
631
  mergedInput.description = existing.description;
622
632
  }
633
+ if (existing && !replace) {
634
+ const importedRepositories = "githubRepos" in mergedInput
635
+ ? normalizeGithubRepositories(mergedInput.githubRepos, `projects[${index}].githubRepos`)
636
+ : [];
637
+ mergedInput.githubRepos = [...existing.githubRepos, ...importedRepositories];
638
+ }
623
639
  imported.push(await inspectProject(mergedInput, index));
624
640
  }
625
641
  const projects = replace ? [] : [...current.projects];
@@ -628,7 +644,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
628
644
  if (index === -1) projects.push(project);
629
645
  else projects[index] = project;
630
646
  }
631
- const config = await validateConfig({ schemaVersion: 1, projects });
647
+ const config = await validateConfig({ schemaVersion: CURRENT_SCHEMA_VERSION, projects });
632
648
  await writeJsonAtomic(path.join(root, "taskchef.json"), config);
633
649
  return {
634
650
  mode: replace ? "replace" : "merge",
@@ -647,27 +663,44 @@ export async function removeProject(workspaceRoot, name) {
647
663
  if (index === -1) throw new Error(`configured project not found: ${name}`);
648
664
  const [project] = config.projects.slice(index, index + 1);
649
665
  const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
650
- await writeJsonAtomic(path.join(root, "taskchef.json"), { schemaVersion: 1, projects });
666
+ await writeJsonAtomic(path.join(root, "taskchef.json"), {
667
+ schemaVersion: CURRENT_SCHEMA_VERSION,
668
+ projects,
669
+ });
651
670
  return { project };
652
671
  }
653
672
 
654
673
  async function validateDispatchShape(dispatch, name = "task") {
655
674
  requireExactFields(dispatch, DISPATCH_FIELDS, name);
656
- if (dispatch.schemaVersion !== 1) throw new Error(`unsupported ${name} schemaVersion`);
675
+ if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(dispatch.schemaVersion)) {
676
+ throw new Error(`unsupported ${name} schemaVersion`);
677
+ }
657
678
  const id = requireSafeId(dispatch.id, `${name}.id`);
658
- const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
659
- return {
660
- schemaVersion: 1,
679
+ const project = await normalizeProject(dispatch.project, 0, {
680
+ checkPath: false,
681
+ allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
682
+ });
683
+ const normalized = {
684
+ schemaVersion: CURRENT_SCHEMA_VERSION,
661
685
  id,
662
686
  project,
663
687
  title: requireString(dispatch.title, `${name}.title`).trim(),
664
688
  instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
665
- threadId: requireString(dispatch.threadId, `${name}.threadId`).trim(),
689
+ threadId: dispatch.threadId === null
690
+ ? null
691
+ : normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
666
692
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
667
693
  };
694
+ if (
695
+ normalized.threadId === null &&
696
+ parseTaskChefMarker(normalized.instruction) !== normalized.id
697
+ ) {
698
+ throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
699
+ }
700
+ return normalized;
668
701
  }
669
702
 
670
- async function readDispatchesUnlocked(root) {
703
+ async function readDispatchRecordsUnlocked(root) {
671
704
  await readConfig(root, { checkPaths: false });
672
705
  const filePath = path.join(root, DISPATCH_FILE_NAME);
673
706
  if (!(await managedRegularFileExists(filePath))) {
@@ -678,7 +711,7 @@ async function readDispatchesUnlocked(root) {
678
711
  throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
679
712
  }
680
713
  const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
681
- const dispatches = [];
714
+ const records = [];
682
715
  for (const [index, line] of lines.entries()) {
683
716
  if (line.trim().length === 0) {
684
717
  throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
@@ -689,19 +722,27 @@ async function readDispatchesUnlocked(root) {
689
722
  } catch (error) {
690
723
  throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
691
724
  }
692
- dispatches.push(await validateDispatchShape(value, `task line ${index + 1}`));
725
+ records.push({
726
+ line,
727
+ raw: value,
728
+ normalized: await validateDispatchShape(value, `task line ${index + 1}`),
729
+ });
693
730
  }
694
731
  const ids = new Set();
695
732
  const threadIds = new Set();
696
- for (const dispatch of dispatches) {
733
+ for (const { normalized: dispatch } of records) {
697
734
  if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
698
- if (threadIds.has(dispatch.threadId)) {
735
+ if (dispatch.threadId !== null && threadIds.has(dispatch.threadId)) {
699
736
  throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
700
737
  }
701
738
  ids.add(dispatch.id);
702
- threadIds.add(dispatch.threadId);
739
+ if (dispatch.threadId !== null) threadIds.add(dispatch.threadId);
703
740
  }
704
- return dispatches;
741
+ return records;
742
+ }
743
+
744
+ async function readDispatchesUnlocked(root) {
745
+ return (await readDispatchRecordsUnlocked(root)).map((record) => record.normalized);
705
746
  }
706
747
 
707
748
  export async function listTasks(workspaceRoot) {
@@ -717,7 +758,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
717
758
  const project = config.projects.find((candidate) => candidate.path === projectPath);
718
759
  if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
719
760
  const dispatch = await validateDispatchShape({
720
- schemaVersion: 1,
761
+ schemaVersion: CURRENT_SCHEMA_VERSION,
721
762
  id: input.id,
722
763
  project,
723
764
  title: input.title,
@@ -730,7 +771,10 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
730
771
  if (existing.some((item) => item.id === dispatch.id)) {
731
772
  throw new Error(`task already exists: ${dispatch.id}`);
732
773
  }
733
- if (existing.some((item) => item.threadId === dispatch.threadId)) {
774
+ if (
775
+ dispatch.threadId !== null &&
776
+ existing.some((item) => item.threadId === dispatch.threadId)
777
+ ) {
734
778
  throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
735
779
  }
736
780
  await appendDispatchesAtomic(root, [dispatch]);
@@ -738,6 +782,35 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
738
782
  return dispatch;
739
783
  }
740
784
 
785
+ export async function resolveTask(workspaceRoot, taskId, threadId) {
786
+ const id = requireSafeId(taskId, "taskId");
787
+ const durableThreadId = normalizeDurableThreadId(threadId);
788
+ const root = await realpath(path.resolve(workspaceRoot));
789
+ return withDispatchLock(root, async () => {
790
+ const records = await readDispatchRecordsUnlocked(root);
791
+ const dispatches = records.map((record) => record.normalized);
792
+ const index = dispatches.findIndex((dispatch) => dispatch.id === id);
793
+ if (index === -1) throw new Error(`task not found: ${id}`);
794
+ const dispatch = dispatches[index];
795
+ if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
796
+ throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
797
+ }
798
+ if (dispatch.threadId === durableThreadId) return dispatch;
799
+ if (dispatch.threadId !== null) {
800
+ throw new Error(`task already has a different threadId: ${id}`);
801
+ }
802
+ if (dispatches.some((item) => item.threadId === durableThreadId)) {
803
+ throw new Error(`threadId is already recorded: ${durableThreadId}`);
804
+ }
805
+ const resolved = { ...dispatch, threadId: durableThreadId };
806
+ const lines = records.map((record, recordIndex) => recordIndex === index
807
+ ? JSON.stringify({ ...record.raw, threadId: durableThreadId })
808
+ : record.line);
809
+ await writeDispatchLinesAtomic(root, lines);
810
+ return resolved;
811
+ });
812
+ }
813
+
741
814
  export async function readTask(workspaceRoot, taskId) {
742
815
  const id = requireSafeId(taskId, "taskId");
743
816
  const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
@@ -775,161 +848,6 @@ export async function buildTaskSummary(workspaceRoot) {
775
848
  };
776
849
  }
777
850
 
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();
820
- for (const entry of entries) {
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 });
877
- }
878
- return {
879
- tasksPath,
880
- entries,
881
- records,
882
- action: entries.length === 0 ? "removed-empty" : "migrated",
883
- };
884
- }
885
-
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),
919
- );
920
- return pending;
921
- });
922
- for (const migration of migrations) {
923
- if (migration.taskPath !== null) await unlink(migration.taskPath);
924
- await rmdir(migration.taskDirectory);
925
- }
926
- await rmdir(inspection.tasksPath);
927
- return {
928
- action: inspection.action,
929
- migratedCount: migrations.filter((item) => item.dispatch).length,
930
- };
931
- }
932
-
933
851
  export async function doctorWorkspace(workspaceRoot) {
934
852
  const root = path.resolve(workspaceRoot);
935
853
  const checks = [];
@@ -965,11 +883,5 @@ export async function doctorWorkspace(workspaceRoot) {
965
883
  }
966
884
  return "no legacy TaskChef skill links";
967
885
  });
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");
971
- }
972
- return "no legacy task records";
973
- });
974
886
  return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
975
887
  }