taskchef 5.7.2 → 5.8.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/.codex-plugin/plugin.json +2 -2
- package/BACKLOG.md +4 -4
- package/README.md +78 -45
- package/SPEC.md +106 -89
- package/docs/delegation-design.md +158 -254
- package/hooks/hooks.json +18 -0
- package/hooks/taskchef-initial-prompt.js +17 -0
- package/index.js +6 -1
- package/package.json +2 -1
- package/skills/taskchef-bootstrap/SKILL.md +6 -3
- package/skills/taskchef-delegate/SKILL.md +49 -91
- package/skills/taskchef-report/SKILL.md +60 -27
- package/src/cli.js +8 -2
- package/src/delegation.js +109 -359
- package/src/hook.js +60 -0
- package/src/mcp.js +35 -2
- package/src/workspace-path.js +5 -1
- package/src/workspace.js +211 -14
package/src/workspace-path.js
CHANGED
|
@@ -37,8 +37,12 @@ export function resolveWorkspacePath({
|
|
|
37
37
|
source = "explicit";
|
|
38
38
|
value = explicit;
|
|
39
39
|
}
|
|
40
|
+
const expanded = expandHome(value, homedir);
|
|
41
|
+
if (source === "environment" && !path.isAbsolute(expanded)) {
|
|
42
|
+
throw new Error(`${TASKCHEF_WORKSPACE_ENV} must be an absolute path or start with ~/`);
|
|
43
|
+
}
|
|
40
44
|
return {
|
|
41
|
-
workspace: path.resolve(cwd,
|
|
45
|
+
workspace: path.resolve(cwd, expanded),
|
|
42
46
|
source,
|
|
43
47
|
};
|
|
44
48
|
}
|
package/src/workspace.js
CHANGED
|
@@ -46,8 +46,10 @@ const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url))
|
|
|
46
46
|
const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
47
47
|
const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
|
|
48
48
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
49
|
-
const
|
|
49
|
+
const CURRENT_CONFIG_SCHEMA_VERSION = 2;
|
|
50
|
+
const CURRENT_TASK_SCHEMA_VERSION = 3;
|
|
50
51
|
const LEGACY_SCHEMA_VERSION = 1;
|
|
52
|
+
const PREVIOUS_SCHEMA_VERSION = 2;
|
|
51
53
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
52
54
|
const PROJECT_FIELDS = new Set([
|
|
53
55
|
"name",
|
|
@@ -66,6 +68,20 @@ const DISPATCH_FIELDS = new Set([
|
|
|
66
68
|
"instruction",
|
|
67
69
|
"threadId",
|
|
68
70
|
"createdAt",
|
|
71
|
+
"status",
|
|
72
|
+
"summary",
|
|
73
|
+
"turnId",
|
|
74
|
+
"updatedAt",
|
|
75
|
+
"updatedBy",
|
|
76
|
+
]);
|
|
77
|
+
const LEGACY_DISPATCH_FIELDS = new Set([
|
|
78
|
+
"schemaVersion",
|
|
79
|
+
"id",
|
|
80
|
+
"project",
|
|
81
|
+
"title",
|
|
82
|
+
"instruction",
|
|
83
|
+
"threadId",
|
|
84
|
+
"createdAt",
|
|
69
85
|
]);
|
|
70
86
|
const RECORD_DISPATCH_FIELDS = new Set([
|
|
71
87
|
"id",
|
|
@@ -74,6 +90,10 @@ const RECORD_DISPATCH_FIELDS = new Set([
|
|
|
74
90
|
"instruction",
|
|
75
91
|
"threadId",
|
|
76
92
|
]);
|
|
93
|
+
const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
|
|
94
|
+
const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
|
|
95
|
+
const TASK_UPDATE_SOURCES = new Set(["dispatcher", "hook", "mcp"]);
|
|
96
|
+
const MAX_RESULT_SUMMARY_LENGTH = 2_000;
|
|
77
97
|
|
|
78
98
|
function requireExactFields(value, fields, name) {
|
|
79
99
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -101,6 +121,22 @@ function requireTimestamp(value, name) {
|
|
|
101
121
|
return value;
|
|
102
122
|
}
|
|
103
123
|
|
|
124
|
+
function optionalString(value, name, { maxLength = null } = {}) {
|
|
125
|
+
if (value === null) return null;
|
|
126
|
+
const normalized = requireString(value, name).trim();
|
|
127
|
+
if (maxLength !== null && normalized.length > maxLength) {
|
|
128
|
+
throw new Error(`${name} must be at most ${maxLength} characters`);
|
|
129
|
+
}
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requireEnum(value, allowed, name) {
|
|
134
|
+
if (!allowed.has(value)) {
|
|
135
|
+
throw new Error(`${name} must be one of: ${[...allowed].join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
104
140
|
export function requireSafeId(value, name = "id") {
|
|
105
141
|
requireString(value, name);
|
|
106
142
|
if (!SAFE_ID.test(value)) {
|
|
@@ -556,11 +592,11 @@ async function inspectProject(input, index = 0) {
|
|
|
556
592
|
|
|
557
593
|
export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
558
594
|
requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
|
|
559
|
-
if (![LEGACY_SCHEMA_VERSION,
|
|
595
|
+
if (![LEGACY_SCHEMA_VERSION, CURRENT_CONFIG_SCHEMA_VERSION].includes(config.schemaVersion)) {
|
|
560
596
|
throw new Error("unsupported configuration schemaVersion");
|
|
561
597
|
}
|
|
562
598
|
return {
|
|
563
|
-
schemaVersion:
|
|
599
|
+
schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
|
|
564
600
|
projects: await normalizeProjects(config.projects, {
|
|
565
601
|
checkPaths,
|
|
566
602
|
allowLegacyGithubRepo: config.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
@@ -597,14 +633,14 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
597
633
|
: null;
|
|
598
634
|
const config = configExists
|
|
599
635
|
? await readConfig(root, { checkPaths: false })
|
|
600
|
-
: { schemaVersion:
|
|
636
|
+
: { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION, projects: [] };
|
|
601
637
|
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
602
638
|
if (configExists && (await managedRegularFileExists(dispatchPath))) {
|
|
603
639
|
await readDispatchesUnlocked(root);
|
|
604
640
|
}
|
|
605
641
|
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
606
642
|
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
607
|
-
else if (storedConfigVersion !==
|
|
643
|
+
else if (storedConfigVersion !== CURRENT_CONFIG_SCHEMA_VERSION) {
|
|
608
644
|
await writeJsonAtomic(configPath, config);
|
|
609
645
|
}
|
|
610
646
|
const tasks = await ensureDispatchFile(root);
|
|
@@ -620,7 +656,7 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
620
656
|
path: configPath,
|
|
621
657
|
action: !configExists
|
|
622
658
|
? "created"
|
|
623
|
-
: storedConfigVersion ===
|
|
659
|
+
: storedConfigVersion === CURRENT_CONFIG_SCHEMA_VERSION ? "unchanged" : "migrated",
|
|
624
660
|
value: config,
|
|
625
661
|
},
|
|
626
662
|
tasks,
|
|
@@ -673,7 +709,7 @@ export async function addProject(workspaceRoot, input) {
|
|
|
673
709
|
const project = await inspectProject(input);
|
|
674
710
|
assertWorkspaceOutsideProject(root, project.path);
|
|
675
711
|
const updated = await validateConfig({
|
|
676
|
-
schemaVersion:
|
|
712
|
+
schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
|
|
677
713
|
projects: [...config.projects, project],
|
|
678
714
|
});
|
|
679
715
|
await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
|
|
@@ -710,7 +746,10 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
710
746
|
if (index === -1) projects.push(project);
|
|
711
747
|
else projects[index] = project;
|
|
712
748
|
}
|
|
713
|
-
const config = await validateConfig({
|
|
749
|
+
const config = await validateConfig({
|
|
750
|
+
schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
|
|
751
|
+
projects,
|
|
752
|
+
});
|
|
714
753
|
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
715
754
|
return {
|
|
716
755
|
mode: replace ? "replace" : "merge",
|
|
@@ -732,7 +771,7 @@ export async function removeProject(workspaceRoot, name) {
|
|
|
732
771
|
const [project] = config.projects.slice(index, index + 1);
|
|
733
772
|
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
734
773
|
await writeJsonAtomic(path.join(root, "taskchef.json"), {
|
|
735
|
-
schemaVersion:
|
|
774
|
+
schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
|
|
736
775
|
projects,
|
|
737
776
|
});
|
|
738
777
|
return { project };
|
|
@@ -744,17 +783,28 @@ async function validateDispatchShape(
|
|
|
744
783
|
name = "task",
|
|
745
784
|
{ allowLegacyHeading = false } = {},
|
|
746
785
|
) {
|
|
747
|
-
|
|
748
|
-
|
|
786
|
+
const supportedVersions = [
|
|
787
|
+
LEGACY_SCHEMA_VERSION,
|
|
788
|
+
PREVIOUS_SCHEMA_VERSION,
|
|
789
|
+
CURRENT_TASK_SCHEMA_VERSION,
|
|
790
|
+
];
|
|
791
|
+
if (!supportedVersions.includes(dispatch?.schemaVersion)) {
|
|
749
792
|
throw new Error(`unsupported ${name} schemaVersion`);
|
|
750
793
|
}
|
|
794
|
+
requireExactFields(
|
|
795
|
+
dispatch,
|
|
796
|
+
dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
797
|
+
? DISPATCH_FIELDS
|
|
798
|
+
: LEGACY_DISPATCH_FIELDS,
|
|
799
|
+
name,
|
|
800
|
+
);
|
|
751
801
|
const id = requireSafeId(dispatch.id, `${name}.id`);
|
|
752
802
|
const project = await normalizeProject(dispatch.project, 0, {
|
|
753
803
|
checkPath: false,
|
|
754
804
|
allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
755
805
|
});
|
|
756
806
|
const normalized = {
|
|
757
|
-
schemaVersion:
|
|
807
|
+
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
758
808
|
id,
|
|
759
809
|
project,
|
|
760
810
|
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
@@ -763,7 +813,36 @@ async function validateDispatchShape(
|
|
|
763
813
|
? null
|
|
764
814
|
: normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
|
|
765
815
|
createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
|
|
816
|
+
status: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
817
|
+
? requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`)
|
|
818
|
+
: null,
|
|
819
|
+
summary: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
820
|
+
? optionalString(dispatch.summary, `${name}.summary`, {
|
|
821
|
+
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
822
|
+
})
|
|
823
|
+
: null,
|
|
824
|
+
turnId: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
825
|
+
? optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 })
|
|
826
|
+
: null,
|
|
827
|
+
updatedAt: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
828
|
+
? requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`)
|
|
829
|
+
: null,
|
|
830
|
+
updatedBy: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
831
|
+
? requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`)
|
|
832
|
+
: null,
|
|
766
833
|
};
|
|
834
|
+
if (normalized.status === "working" && normalized.summary !== null) {
|
|
835
|
+
throw new Error(`${name}.summary must be null while status is working`);
|
|
836
|
+
}
|
|
837
|
+
if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
|
|
838
|
+
throw new Error(`${name}.summary is required for status ${normalized.status}`);
|
|
839
|
+
}
|
|
840
|
+
if (
|
|
841
|
+
normalized.updatedAt !== null
|
|
842
|
+
&& Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
|
|
843
|
+
) {
|
|
844
|
+
throw new Error(`${name}.updatedAt must not be earlier than createdAt`);
|
|
845
|
+
}
|
|
767
846
|
if (
|
|
768
847
|
normalized.threadId === null &&
|
|
769
848
|
parseTaskChefMarker(normalized.instruction, { allowLegacyHeading }) !== normalized.id
|
|
@@ -833,14 +912,20 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
833
912
|
const projectPath = await canonicalDirectory(input.project);
|
|
834
913
|
const project = config.projects.find((candidate) => candidate.path === projectPath);
|
|
835
914
|
if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
|
|
915
|
+
const createdAt = now ?? new Date().toISOString();
|
|
836
916
|
const dispatch = await validateDispatchShape({
|
|
837
|
-
schemaVersion:
|
|
917
|
+
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
838
918
|
id: input.id,
|
|
839
919
|
project,
|
|
840
920
|
title: input.title,
|
|
841
921
|
instruction: input.instruction,
|
|
842
922
|
threadId: input.threadId,
|
|
843
|
-
createdAt
|
|
923
|
+
createdAt,
|
|
924
|
+
status: "working",
|
|
925
|
+
summary: null,
|
|
926
|
+
turnId: null,
|
|
927
|
+
updatedAt: createdAt,
|
|
928
|
+
updatedBy: "dispatcher",
|
|
844
929
|
});
|
|
845
930
|
const existing = await readDispatchesUnlocked(root);
|
|
846
931
|
if (existing.some((item) => item.id === dispatch.id)) {
|
|
@@ -886,6 +971,118 @@ export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
|
886
971
|
});
|
|
887
972
|
}
|
|
888
973
|
|
|
974
|
+
function dispatchLineWithState(dispatch, patch) {
|
|
975
|
+
return JSON.stringify({
|
|
976
|
+
...dispatch,
|
|
977
|
+
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
978
|
+
...patch,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export async function startTaskFromHook(
|
|
983
|
+
workspaceRoot,
|
|
984
|
+
taskId,
|
|
985
|
+
threadId,
|
|
986
|
+
turnId,
|
|
987
|
+
{ now } = {},
|
|
988
|
+
) {
|
|
989
|
+
const id = requireSafeId(taskId, "taskId");
|
|
990
|
+
const durableThreadId = normalizeDurableThreadId(threadId);
|
|
991
|
+
const currentTurnId = optionalString(turnId, "turnId", { maxLength: 256 });
|
|
992
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
993
|
+
return withWorkspaceLock(root, async () => {
|
|
994
|
+
const records = await readDispatchRecordsUnlocked(root);
|
|
995
|
+
const dispatches = records.map((record) => record.normalized);
|
|
996
|
+
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
997
|
+
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
998
|
+
const dispatch = dispatches[index];
|
|
999
|
+
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
1000
|
+
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
1001
|
+
}
|
|
1002
|
+
if (dispatch.threadId !== null && dispatch.threadId !== durableThreadId) {
|
|
1003
|
+
throw new Error(`task already has a different threadId: ${id}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (
|
|
1006
|
+
dispatch.threadId === null
|
|
1007
|
+
&& dispatches.some((item) => item.id !== id && item.threadId === durableThreadId)
|
|
1008
|
+
) {
|
|
1009
|
+
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
1010
|
+
}
|
|
1011
|
+
if (dispatch.threadId === durableThreadId && dispatch.updatedBy === "mcp") {
|
|
1012
|
+
return dispatch;
|
|
1013
|
+
}
|
|
1014
|
+
if (dispatch.threadId === durableThreadId && dispatch.updatedBy === "hook") {
|
|
1015
|
+
return dispatch;
|
|
1016
|
+
}
|
|
1017
|
+
const updatedAt = now ?? new Date().toISOString();
|
|
1018
|
+
const started = await validateDispatchShape({
|
|
1019
|
+
...dispatch,
|
|
1020
|
+
threadId: durableThreadId,
|
|
1021
|
+
status: "working",
|
|
1022
|
+
summary: null,
|
|
1023
|
+
turnId: currentTurnId,
|
|
1024
|
+
updatedAt,
|
|
1025
|
+
updatedBy: "hook",
|
|
1026
|
+
});
|
|
1027
|
+
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1028
|
+
? dispatchLineWithState(started, {})
|
|
1029
|
+
: record.line);
|
|
1030
|
+
await writeDispatchLinesAtomic(root, lines);
|
|
1031
|
+
return started;
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
|
|
1036
|
+
requireExactFields(
|
|
1037
|
+
input,
|
|
1038
|
+
new Set(["taskId", "threadId", "turnId", "status", "summary"]),
|
|
1039
|
+
"task result",
|
|
1040
|
+
);
|
|
1041
|
+
const id = requireSafeId(input.taskId, "taskId");
|
|
1042
|
+
const threadId = input.threadId === null
|
|
1043
|
+
? null
|
|
1044
|
+
: normalizeDurableThreadId(input.threadId, "threadId");
|
|
1045
|
+
const turnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
|
|
1046
|
+
const status = requireEnum(input.status, RESULT_STATUSES, "status");
|
|
1047
|
+
const summary = optionalString(input.summary, "summary", {
|
|
1048
|
+
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
1049
|
+
});
|
|
1050
|
+
if (summary === null) throw new Error("summary must be a non-empty string");
|
|
1051
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
1052
|
+
return withWorkspaceLock(root, async () => {
|
|
1053
|
+
const records = await readDispatchRecordsUnlocked(root);
|
|
1054
|
+
const dispatches = records.map((record) => record.normalized);
|
|
1055
|
+
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
1056
|
+
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
1057
|
+
const dispatch = dispatches[index];
|
|
1058
|
+
if (dispatch.threadId === null) {
|
|
1059
|
+
if (threadId !== null || turnId !== null || status !== "failed") {
|
|
1060
|
+
throw new Error(`task without a durable threadId accepts only failed with null thread/turn IDs: ${id}`);
|
|
1061
|
+
}
|
|
1062
|
+
} else {
|
|
1063
|
+
if (threadId !== dispatch.threadId) {
|
|
1064
|
+
throw new Error(`task result threadId does not match recorded threadId: ${id}`);
|
|
1065
|
+
}
|
|
1066
|
+
if (turnId === null) {
|
|
1067
|
+
throw new Error(`task result turnId is required for a linked task: ${id}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const updated = await validateDispatchShape({
|
|
1071
|
+
...dispatch,
|
|
1072
|
+
status,
|
|
1073
|
+
summary,
|
|
1074
|
+
turnId,
|
|
1075
|
+
updatedAt: now ?? new Date().toISOString(),
|
|
1076
|
+
updatedBy: "mcp",
|
|
1077
|
+
});
|
|
1078
|
+
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1079
|
+
? dispatchLineWithState(updated, {})
|
|
1080
|
+
: record.line);
|
|
1081
|
+
await writeDispatchLinesAtomic(root, lines);
|
|
1082
|
+
return updated;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
889
1086
|
export async function readTask(workspaceRoot, taskId) {
|
|
890
1087
|
const id = requireSafeId(taskId, "taskId");
|
|
891
1088
|
const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
|