taskchef 3.0.2 → 3.0.3
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/.codex-plugin/plugin.json +1 -1
- package/BACKLOG.md +15 -0
- package/README.md +28 -7
- package/SPEC.md +79 -33
- package/index.js +18 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +2 -3
- package/skills/taskchef-delegate/SKILL.md +75 -11
- package/skills/taskchef-report/SKILL.md +20 -10
- package/src/cli.js +19 -1
- package/src/delegation.js +555 -0
- package/src/workspace.js +53 -172
package/src/workspace.js
CHANGED
|
@@ -18,6 +18,7 @@ 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";
|
|
21
22
|
|
|
22
23
|
const execFile = promisify(execFileCallback);
|
|
23
24
|
const DISPATCHER_INSTRUCTIONS_URL = new URL(
|
|
@@ -164,6 +165,12 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
|
164
165
|
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
async function writeDispatchesAtomic(workspaceRoot, dispatches) {
|
|
169
|
+
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
170
|
+
const content = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
|
|
171
|
+
await writeTextAtomic(dispatchPath, content);
|
|
172
|
+
}
|
|
173
|
+
|
|
167
174
|
async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
|
|
168
175
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
169
176
|
if (exclusive && (await pathExists(filePath))) {
|
|
@@ -558,14 +565,12 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
558
565
|
? await readConfig(root, { checkPaths: false })
|
|
559
566
|
: { schemaVersion: 1, projects: [] };
|
|
560
567
|
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const legacyTaskRecords = await inspectLegacyTaskRecords(root, config, existingDispatches);
|
|
568
|
+
if (configExists && (await managedRegularFileExists(dispatchPath))) {
|
|
569
|
+
await readDispatchesUnlocked(root);
|
|
570
|
+
}
|
|
565
571
|
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
566
572
|
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
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;
|
|
@@ -576,7 +581,6 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
576
581
|
tasks,
|
|
577
582
|
instructions,
|
|
578
583
|
legacySkills,
|
|
579
|
-
legacyTasks,
|
|
580
584
|
};
|
|
581
585
|
}
|
|
582
586
|
|
|
@@ -656,15 +660,24 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
656
660
|
if (dispatch.schemaVersion !== 1) throw new Error(`unsupported ${name} schemaVersion`);
|
|
657
661
|
const id = requireSafeId(dispatch.id, `${name}.id`);
|
|
658
662
|
const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
|
|
659
|
-
|
|
663
|
+
const normalized = {
|
|
660
664
|
schemaVersion: 1,
|
|
661
665
|
id,
|
|
662
666
|
project,
|
|
663
667
|
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
664
668
|
instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
|
|
665
|
-
threadId:
|
|
669
|
+
threadId: dispatch.threadId === null
|
|
670
|
+
? null
|
|
671
|
+
: normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
|
|
666
672
|
createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
|
|
667
673
|
};
|
|
674
|
+
if (
|
|
675
|
+
normalized.threadId === null &&
|
|
676
|
+
parseTaskChefMarker(normalized.instruction) !== normalized.id
|
|
677
|
+
) {
|
|
678
|
+
throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
|
|
679
|
+
}
|
|
680
|
+
return normalized;
|
|
668
681
|
}
|
|
669
682
|
|
|
670
683
|
async function readDispatchesUnlocked(root) {
|
|
@@ -695,11 +708,11 @@ async function readDispatchesUnlocked(root) {
|
|
|
695
708
|
const threadIds = new Set();
|
|
696
709
|
for (const dispatch of dispatches) {
|
|
697
710
|
if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
|
|
698
|
-
if (threadIds.has(dispatch.threadId)) {
|
|
711
|
+
if (dispatch.threadId !== null && threadIds.has(dispatch.threadId)) {
|
|
699
712
|
throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
|
|
700
713
|
}
|
|
701
714
|
ids.add(dispatch.id);
|
|
702
|
-
threadIds.add(dispatch.threadId);
|
|
715
|
+
if (dispatch.threadId !== null) threadIds.add(dispatch.threadId);
|
|
703
716
|
}
|
|
704
717
|
return dispatches;
|
|
705
718
|
}
|
|
@@ -730,7 +743,10 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
730
743
|
if (existing.some((item) => item.id === dispatch.id)) {
|
|
731
744
|
throw new Error(`task already exists: ${dispatch.id}`);
|
|
732
745
|
}
|
|
733
|
-
if (
|
|
746
|
+
if (
|
|
747
|
+
dispatch.threadId !== null &&
|
|
748
|
+
existing.some((item) => item.threadId === dispatch.threadId)
|
|
749
|
+
) {
|
|
734
750
|
throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
|
|
735
751
|
}
|
|
736
752
|
await appendDispatchesAtomic(root, [dispatch]);
|
|
@@ -738,6 +754,32 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
738
754
|
return dispatch;
|
|
739
755
|
}
|
|
740
756
|
|
|
757
|
+
export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
758
|
+
const id = requireSafeId(taskId, "taskId");
|
|
759
|
+
const durableThreadId = normalizeDurableThreadId(threadId);
|
|
760
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
761
|
+
return withDispatchLock(root, async () => {
|
|
762
|
+
const dispatches = await readDispatchesUnlocked(root);
|
|
763
|
+
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
764
|
+
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
765
|
+
const dispatch = dispatches[index];
|
|
766
|
+
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
767
|
+
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
768
|
+
}
|
|
769
|
+
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
770
|
+
if (dispatch.threadId !== null) {
|
|
771
|
+
throw new Error(`task already has a different threadId: ${id}`);
|
|
772
|
+
}
|
|
773
|
+
if (dispatches.some((item) => item.threadId === durableThreadId)) {
|
|
774
|
+
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
775
|
+
}
|
|
776
|
+
const resolved = { ...dispatch, threadId: durableThreadId };
|
|
777
|
+
dispatches[index] = resolved;
|
|
778
|
+
await writeDispatchesAtomic(root, dispatches);
|
|
779
|
+
return resolved;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
741
783
|
export async function readTask(workspaceRoot, taskId) {
|
|
742
784
|
const id = requireSafeId(taskId, "taskId");
|
|
743
785
|
const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
|
|
@@ -775,161 +817,6 @@ export async function buildTaskSummary(workspaceRoot) {
|
|
|
775
817
|
};
|
|
776
818
|
}
|
|
777
819
|
|
|
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
820
|
export async function doctorWorkspace(workspaceRoot) {
|
|
934
821
|
const root = path.resolve(workspaceRoot);
|
|
935
822
|
const checks = [];
|
|
@@ -965,11 +852,5 @@ export async function doctorWorkspace(workspaceRoot) {
|
|
|
965
852
|
}
|
|
966
853
|
return "no legacy TaskChef skill links";
|
|
967
854
|
});
|
|
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
855
|
return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
|
|
975
856
|
}
|