taskchef 5.7.2 → 5.9.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.
@@ -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, expandHome(value, homedir)),
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 CURRENT_SCHEMA_VERSION = 2;
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, CURRENT_SCHEMA_VERSION].includes(config.schemaVersion)) {
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: CURRENT_SCHEMA_VERSION,
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: CURRENT_SCHEMA_VERSION, projects: [] };
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 !== CURRENT_SCHEMA_VERSION) {
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 === CURRENT_SCHEMA_VERSION ? "unchanged" : "migrated",
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: CURRENT_SCHEMA_VERSION,
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({ schemaVersion: CURRENT_SCHEMA_VERSION, projects });
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: CURRENT_SCHEMA_VERSION,
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
- requireExactFields(dispatch, DISPATCH_FIELDS, name);
748
- if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(dispatch.schemaVersion)) {
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: CURRENT_SCHEMA_VERSION,
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
@@ -773,13 +852,8 @@ async function validateDispatchShape(
773
852
  return normalized;
774
853
  }
775
854
 
776
- async function readDispatchRecordsUnlocked(root) {
855
+ async function parseDispatchRecordsUnlocked(root, content) {
777
856
  await readConfig(root, { checkPaths: false });
778
- const filePath = path.join(root, DISPATCH_FILE_NAME);
779
- if (!(await managedRegularFileExists(filePath))) {
780
- throw new Error(`task log does not exist: ${filePath}`);
781
- }
782
- const content = await readFile(filePath, "utf8");
783
857
  if (content.length > 0 && !content.endsWith("\n")) {
784
858
  throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
785
859
  }
@@ -816,6 +890,14 @@ async function readDispatchRecordsUnlocked(root) {
816
890
  return records;
817
891
  }
818
892
 
893
+ async function readDispatchRecordsUnlocked(root) {
894
+ const filePath = path.join(root, DISPATCH_FILE_NAME);
895
+ if (!(await managedRegularFileExists(filePath))) {
896
+ throw new Error(`task log does not exist: ${filePath}`);
897
+ }
898
+ return parseDispatchRecordsUnlocked(root, await readFile(filePath, "utf8"));
899
+ }
900
+
819
901
  async function readDispatchesUnlocked(root) {
820
902
  return (await readDispatchRecordsUnlocked(root)).map((record) => record.normalized);
821
903
  }
@@ -825,6 +907,12 @@ export async function listTasks(workspaceRoot) {
825
907
  return readDispatchesUnlocked(root);
826
908
  }
827
909
 
910
+ export async function parseTaskLogContent(workspaceRoot, content) {
911
+ const root = await realpath(path.resolve(workspaceRoot));
912
+ if (typeof content !== "string") throw new Error("task log content must be a string");
913
+ return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
914
+ }
915
+
828
916
  export async function recordTask(workspaceRoot, input, { now } = {}) {
829
917
  requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
830
918
  const root = await realpath(path.resolve(workspaceRoot));
@@ -833,14 +921,20 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
833
921
  const projectPath = await canonicalDirectory(input.project);
834
922
  const project = config.projects.find((candidate) => candidate.path === projectPath);
835
923
  if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
924
+ const createdAt = now ?? new Date().toISOString();
836
925
  const dispatch = await validateDispatchShape({
837
- schemaVersion: CURRENT_SCHEMA_VERSION,
926
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
838
927
  id: input.id,
839
928
  project,
840
929
  title: input.title,
841
930
  instruction: input.instruction,
842
931
  threadId: input.threadId,
843
- createdAt: now ?? new Date().toISOString(),
932
+ createdAt,
933
+ status: "working",
934
+ summary: null,
935
+ turnId: null,
936
+ updatedAt: createdAt,
937
+ updatedBy: "dispatcher",
844
938
  });
845
939
  const existing = await readDispatchesUnlocked(root);
846
940
  if (existing.some((item) => item.id === dispatch.id)) {
@@ -857,7 +951,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
857
951
  });
858
952
  }
859
953
 
860
- export async function resolveTask(workspaceRoot, taskId, threadId) {
954
+ export async function resolveTask(workspaceRoot, taskId, threadId, { now } = {}) {
861
955
  const id = requireSafeId(taskId, "taskId");
862
956
  const durableThreadId = normalizeDurableThreadId(threadId);
863
957
  const root = await realpath(path.resolve(workspaceRoot));
@@ -877,15 +971,137 @@ export async function resolveTask(workspaceRoot, taskId, threadId) {
877
971
  if (dispatches.some((item) => item.threadId === durableThreadId)) {
878
972
  throw new Error(`threadId is already recorded: ${durableThreadId}`);
879
973
  }
880
- const resolved = { ...dispatch, threadId: durableThreadId };
974
+ const currentRecord = records[index];
975
+ const resolved = currentRecord.raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
976
+ ? await validateDispatchShape({
977
+ ...dispatch,
978
+ threadId: durableThreadId,
979
+ updatedAt: now ?? new Date().toISOString(),
980
+ updatedBy: "dispatcher",
981
+ })
982
+ : { ...dispatch, threadId: durableThreadId };
881
983
  const lines = records.map((record, recordIndex) => recordIndex === index
882
- ? JSON.stringify({ ...record.raw, threadId: durableThreadId })
984
+ ? currentRecord.raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
985
+ ? dispatchLineWithState(resolved, {})
986
+ : JSON.stringify({ ...record.raw, threadId: durableThreadId })
883
987
  : record.line);
884
988
  await writeDispatchLinesAtomic(root, lines);
885
989
  return resolved;
886
990
  });
887
991
  }
888
992
 
993
+ function dispatchLineWithState(dispatch, patch) {
994
+ return JSON.stringify({
995
+ ...dispatch,
996
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
997
+ ...patch,
998
+ });
999
+ }
1000
+
1001
+ export async function startTaskFromHook(
1002
+ workspaceRoot,
1003
+ taskId,
1004
+ threadId,
1005
+ turnId,
1006
+ { now } = {},
1007
+ ) {
1008
+ const id = requireSafeId(taskId, "taskId");
1009
+ const durableThreadId = normalizeDurableThreadId(threadId);
1010
+ const currentTurnId = optionalString(turnId, "turnId", { maxLength: 256 });
1011
+ const root = await realpath(path.resolve(workspaceRoot));
1012
+ return withWorkspaceLock(root, async () => {
1013
+ const records = await readDispatchRecordsUnlocked(root);
1014
+ const dispatches = records.map((record) => record.normalized);
1015
+ const index = dispatches.findIndex((dispatch) => dispatch.id === id);
1016
+ if (index === -1) throw new Error(`task not found: ${id}`);
1017
+ const dispatch = dispatches[index];
1018
+ if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
1019
+ throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
1020
+ }
1021
+ if (dispatch.threadId !== null && dispatch.threadId !== durableThreadId) {
1022
+ throw new Error(`task already has a different threadId: ${id}`);
1023
+ }
1024
+ if (
1025
+ dispatch.threadId === null
1026
+ && dispatches.some((item) => item.id !== id && item.threadId === durableThreadId)
1027
+ ) {
1028
+ throw new Error(`threadId is already recorded: ${durableThreadId}`);
1029
+ }
1030
+ if (dispatch.threadId === durableThreadId && dispatch.updatedBy === "mcp") {
1031
+ return dispatch;
1032
+ }
1033
+ if (dispatch.threadId === durableThreadId && dispatch.updatedBy === "hook") {
1034
+ return dispatch;
1035
+ }
1036
+ const updatedAt = now ?? new Date().toISOString();
1037
+ const started = await validateDispatchShape({
1038
+ ...dispatch,
1039
+ threadId: durableThreadId,
1040
+ status: "working",
1041
+ summary: null,
1042
+ turnId: currentTurnId,
1043
+ updatedAt,
1044
+ updatedBy: "hook",
1045
+ });
1046
+ const lines = records.map((record, recordIndex) => recordIndex === index
1047
+ ? dispatchLineWithState(started, {})
1048
+ : record.line);
1049
+ await writeDispatchLinesAtomic(root, lines);
1050
+ return started;
1051
+ });
1052
+ }
1053
+
1054
+ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
1055
+ requireExactFields(
1056
+ input,
1057
+ new Set(["taskId", "threadId", "turnId", "status", "summary"]),
1058
+ "task result",
1059
+ );
1060
+ const id = requireSafeId(input.taskId, "taskId");
1061
+ const threadId = input.threadId === null
1062
+ ? null
1063
+ : normalizeDurableThreadId(input.threadId, "threadId");
1064
+ const turnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
1065
+ const status = requireEnum(input.status, RESULT_STATUSES, "status");
1066
+ const summary = optionalString(input.summary, "summary", {
1067
+ maxLength: MAX_RESULT_SUMMARY_LENGTH,
1068
+ });
1069
+ if (summary === null) throw new Error("summary must be a non-empty string");
1070
+ const root = await realpath(path.resolve(workspaceRoot));
1071
+ return withWorkspaceLock(root, async () => {
1072
+ const records = await readDispatchRecordsUnlocked(root);
1073
+ const dispatches = records.map((record) => record.normalized);
1074
+ const index = dispatches.findIndex((dispatch) => dispatch.id === id);
1075
+ if (index === -1) throw new Error(`task not found: ${id}`);
1076
+ const dispatch = dispatches[index];
1077
+ if (dispatch.threadId === null) {
1078
+ if (threadId !== null || turnId !== null || status !== "failed") {
1079
+ throw new Error(`task without a durable threadId accepts only failed with null thread/turn IDs: ${id}`);
1080
+ }
1081
+ } else {
1082
+ if (threadId !== dispatch.threadId) {
1083
+ throw new Error(`task result threadId does not match recorded threadId: ${id}`);
1084
+ }
1085
+ if (turnId === null) {
1086
+ throw new Error(`task result turnId is required for a linked task: ${id}`);
1087
+ }
1088
+ }
1089
+ const updated = await validateDispatchShape({
1090
+ ...dispatch,
1091
+ status,
1092
+ summary,
1093
+ turnId,
1094
+ updatedAt: now ?? new Date().toISOString(),
1095
+ updatedBy: "mcp",
1096
+ });
1097
+ const lines = records.map((record, recordIndex) => recordIndex === index
1098
+ ? dispatchLineWithState(updated, {})
1099
+ : record.line);
1100
+ await writeDispatchLinesAtomic(root, lines);
1101
+ return updated;
1102
+ });
1103
+ }
1104
+
889
1105
  export async function readTask(workspaceRoot, taskId) {
890
1106
  const id = requireSafeId(taskId, "taskId");
891
1107
  const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);