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.
@@ -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
@@ -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: CURRENT_SCHEMA_VERSION,
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: now ?? new Date().toISOString(),
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);