taskchef 6.1.3 → 7.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
@@ -5,17 +5,14 @@ import {
5
5
  lstat,
6
6
  mkdir,
7
7
  readFile,
8
- readdir,
9
8
  realpath,
10
9
  link,
11
10
  rename,
12
- rmdir,
13
11
  stat,
14
12
  unlink,
15
13
  writeFile,
16
14
  } from "node:fs/promises";
17
15
  import { randomUUID } from "node:crypto";
18
- import { fileURLToPath } from "node:url";
19
16
  import path from "node:path";
20
17
  import { promisify } from "node:util";
21
18
  import lockfile from "proper-lockfile";
@@ -37,27 +34,16 @@ const DISPATCHER_INSTRUCTIONS_URL = new URL(
37
34
  );
38
35
  const DISPATCHER_INSTRUCTIONS_START = "<!-- taskchef:dispatcher-instructions:start -->";
39
36
  const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -->";
40
- const TASKCHEF_SKILL_NAMES = [
41
- "taskchef-bootstrap",
42
- "taskchef-delegate",
43
- "taskchef-report",
44
- ];
45
- const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
46
- const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
47
37
  const DISPATCH_FILE_NAME = "tasks.jsonl";
48
38
  const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
49
39
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
50
40
  const CURRENT_CONFIG_SCHEMA_VERSION = 2;
51
41
  const CURRENT_TASK_SCHEMA_VERSION = 4;
52
- const PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION = 3;
53
- const LEGACY_SCHEMA_VERSION = 1;
54
- const PREVIOUS_SCHEMA_VERSION = 2;
55
42
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
56
43
  const PROJECT_FIELDS = new Set([
57
44
  "name",
58
45
  "path",
59
46
  "isGitRepository",
60
- "githubRepo",
61
47
  "githubRepos",
62
48
  "description",
63
49
  ]);
@@ -76,15 +62,6 @@ const DISPATCH_FIELDS = new Set([
76
62
  "updatedAt",
77
63
  "updatedBy",
78
64
  ]);
79
- const LEGACY_DISPATCH_FIELDS = new Set([
80
- "schemaVersion",
81
- "id",
82
- "project",
83
- "title",
84
- "instruction",
85
- "threadId",
86
- "createdAt",
87
- ]);
88
65
  const RECORD_DISPATCH_FIELDS = new Set([
89
66
  "id",
90
67
  "project",
@@ -94,7 +71,7 @@ const RECORD_DISPATCH_FIELDS = new Set([
94
71
  ]);
95
72
  const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
96
73
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
97
- const TASK_UPDATE_SOURCES = new Set(["dispatcher", "hook", "mcp"]);
74
+ const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
98
75
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
99
76
 
100
77
  function requireExactFields(value, fields, name) {
@@ -347,75 +324,6 @@ export async function ensureWorkspaceInstructions(workspaceRoot) {
347
324
  return { path: filePath, action };
348
325
  }
349
326
 
350
- async function findLegacySkillLinks(workspaceRoot) {
351
- const agentsDirectory = path.join(workspaceRoot, ".agents");
352
- const agentsDetails = await lstat(agentsDirectory).catch((error) => {
353
- if (error.code === "ENOENT") return null;
354
- throw error;
355
- });
356
- if (agentsDetails === null) return { agentsDirectory: null, skillsDirectory: null, links: [] };
357
- if (agentsDetails.isSymbolicLink() || !agentsDetails.isDirectory()) {
358
- return { agentsDirectory: null, skillsDirectory: null, links: [] };
359
- }
360
-
361
- const skillsDirectory = path.join(agentsDirectory, "skills");
362
- const skillsDetails = await lstat(skillsDirectory).catch((error) => {
363
- if (error.code === "ENOENT") return null;
364
- throw error;
365
- });
366
- if (skillsDetails === null) return { agentsDirectory, skillsDirectory, links: [] };
367
- if (skillsDetails.isSymbolicLink() || !skillsDetails.isDirectory()) {
368
- return { agentsDirectory, skillsDirectory: null, links: [] };
369
- }
370
-
371
- const links = [];
372
- for (const skillName of LEGACY_TASKCHEF_SKILL_NAMES) {
373
- const skillPath = path.join(skillsDirectory, skillName);
374
- const details = await lstat(skillPath).catch((error) => {
375
- if (error.code === "ENOENT") return null;
376
- throw error;
377
- });
378
- if (details === null) continue;
379
- if (!details.isSymbolicLink()) {
380
- throw new Error(`legacy TaskChef skill path is not a symlink: ${skillPath}`);
381
- }
382
- links.push({ name: skillName, path: skillPath });
383
- }
384
- return { agentsDirectory, skillsDirectory, links };
385
- }
386
-
387
- async function removeLegacySkillLinks(workspaceRoot) {
388
- const legacy = await findLegacySkillLinks(workspaceRoot);
389
- for (const link of legacy.links) await unlink(link.path);
390
- const removedDirectories = [];
391
- if (legacy.skillsDirectory && (await readdir(legacy.skillsDirectory)).length === 0) {
392
- await rmdir(legacy.skillsDirectory);
393
- removedDirectories.push(legacy.skillsDirectory);
394
- }
395
- if (legacy.agentsDirectory && (await readdir(legacy.agentsDirectory).catch((error) => {
396
- if (error.code === "ENOENT") return null;
397
- throw error;
398
- }))?.length === 0) {
399
- await rmdir(legacy.agentsDirectory);
400
- removedDirectories.push(legacy.agentsDirectory);
401
- }
402
- return { removed: legacy.links, removedDirectories };
403
- }
404
-
405
- export async function ensureWorkspaceSkills(workspaceRoot) {
406
- const root = await realpath(path.resolve(workspaceRoot));
407
- const legacySkills = await removeLegacySkillLinks(root);
408
- return {
409
- directory: null,
410
- skills: TASKCHEF_SKILL_NAMES.map((name) => ({
411
- name,
412
- path: path.join(SKILLS_SOURCE_ROOT, name),
413
- action: "provided-by-plugin",
414
- })),
415
- legacySkills,
416
- };
417
- }
418
-
419
327
  export async function canonicalGitRoot(projectPath) {
420
328
  const requested = await canonicalDirectory(projectPath);
421
329
  const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
@@ -445,7 +353,7 @@ export async function canonicalDirectory(projectPath) {
445
353
  async function normalizeProject(
446
354
  project,
447
355
  index,
448
- { checkPath = true, allowLegacyGithubRepo = false } = {},
356
+ { checkPath = true } = {},
449
357
  ) {
450
358
  const field = `projects[${index}]`;
451
359
  if (!project || typeof project !== "object" || Array.isArray(project)) {
@@ -453,12 +361,7 @@ async function normalizeProject(
453
361
  }
454
362
  const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
455
363
  if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
456
- const repositoryField = allowLegacyGithubRepo ? "githubRepo" : "githubRepos";
457
- const unsupportedRepositoryField = allowLegacyGithubRepo ? "githubRepos" : "githubRepo";
458
- if (unsupportedRepositoryField in project) {
459
- throw new Error(`${field} has unsupported field: ${unsupportedRepositoryField}`);
460
- }
461
- for (const required of ["name", "path", "isGitRepository", repositoryField]) {
364
+ for (const required of ["name", "path", "isGitRepository", "githubRepos"]) {
462
365
  if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
463
366
  }
464
367
  const name = requireString(project.name, `${field}.name`).trim();
@@ -476,9 +379,7 @@ async function normalizeProject(
476
379
  throw new Error(`${field}.path must be a normalized absolute path`);
477
380
  }
478
381
  }
479
- const githubRepos = normalizeGithubRepositories(project[repositoryField], `${field}.${repositoryField}`, {
480
- allowLegacyScalar: allowLegacyGithubRepo,
481
- });
382
+ const githubRepos = normalizeGithubRepositories(project.githubRepos, `${field}.githubRepos`);
482
383
  const normalized = {
483
384
  name,
484
385
  path: projectPath,
@@ -496,14 +397,13 @@ async function normalizeProject(
496
397
 
497
398
  async function normalizeProjects(
498
399
  projects,
499
- { checkPaths = true, allowLegacyGithubRepo = false } = {},
400
+ { checkPaths = true } = {},
500
401
  ) {
501
402
  if (!Array.isArray(projects)) throw new Error("projects must be an array");
502
403
  const normalized = [];
503
404
  for (const [index, project] of projects.entries()) {
504
405
  normalized.push(await normalizeProject(project, index, {
505
406
  checkPath: checkPaths,
506
- allowLegacyGithubRepo,
507
407
  }));
508
408
  }
509
409
  if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
@@ -594,15 +494,12 @@ async function inspectProject(input, index = 0) {
594
494
 
595
495
  export async function validateConfig(config, { checkPaths = true } = {}) {
596
496
  requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
597
- if (![LEGACY_SCHEMA_VERSION, CURRENT_CONFIG_SCHEMA_VERSION].includes(config.schemaVersion)) {
497
+ if (config.schemaVersion !== CURRENT_CONFIG_SCHEMA_VERSION) {
598
498
  throw new Error("unsupported configuration schemaVersion");
599
499
  }
600
500
  return {
601
501
  schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
602
- projects: await normalizeProjects(config.projects, {
603
- checkPaths,
604
- allowLegacyGithubRepo: config.schemaVersion === LEGACY_SCHEMA_VERSION,
605
- }),
502
+ projects: await normalizeProjects(config.projects, { checkPaths }),
606
503
  };
607
504
  }
608
505
 
@@ -630,9 +527,6 @@ export async function initializeWorkspace(workspaceRoot) {
630
527
  return withWorkspaceLock(root, async () => {
631
528
  const configPath = path.join(root, "taskchef.json");
632
529
  const configExists = await managedRegularFileExists(configPath);
633
- const storedConfigVersion = configExists
634
- ? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
635
- : null;
636
530
  const config = configExists
637
531
  ? await readConfig(root, { checkPaths: false })
638
532
  : { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION, projects: [] };
@@ -640,11 +534,7 @@ export async function initializeWorkspace(workspaceRoot) {
640
534
  if (configExists && (await managedRegularFileExists(dispatchPath))) {
641
535
  await readDispatchesUnlocked(root);
642
536
  }
643
- const { legacySkills } = await ensureWorkspaceSkills(root);
644
537
  if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
645
- else if (storedConfigVersion !== CURRENT_CONFIG_SCHEMA_VERSION) {
646
- await writeJsonAtomic(configPath, config);
647
- }
648
538
  const tasks = await ensureDispatchFile(root);
649
539
  const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
650
540
  if (!configExists) await unlink(configPath).catch(() => {});
@@ -656,14 +546,11 @@ export async function initializeWorkspace(workspaceRoot) {
656
546
  workspace: root,
657
547
  config: {
658
548
  path: configPath,
659
- action: !configExists
660
- ? "created"
661
- : storedConfigVersion === CURRENT_CONFIG_SCHEMA_VERSION ? "unchanged" : "migrated",
549
+ action: configExists ? "unchanged" : "created",
662
550
  value: config,
663
551
  },
664
552
  tasks,
665
553
  instructions,
666
- legacySkills,
667
554
  };
668
555
  });
669
556
  }
@@ -780,34 +667,15 @@ export async function removeProject(workspaceRoot, name) {
780
667
  });
781
668
  }
782
669
 
783
- async function validateDispatchShape(
784
- dispatch,
785
- name = "task",
786
- { allowLegacyHeading = false } = {},
787
- ) {
788
- const supportedVersions = [
789
- LEGACY_SCHEMA_VERSION,
790
- PREVIOUS_SCHEMA_VERSION,
791
- PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION,
792
- CURRENT_TASK_SCHEMA_VERSION,
793
- ];
794
- if (!supportedVersions.includes(dispatch?.schemaVersion)) {
670
+ async function validateDispatchShape(dispatch, name = "task") {
671
+ if (dispatch?.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION) {
795
672
  throw new Error(`unsupported ${name} schemaVersion`);
796
673
  }
797
- requireExactFields(
798
- dispatch,
799
- dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
800
- ? DISPATCH_FIELDS
801
- : LEGACY_DISPATCH_FIELDS,
802
- name,
803
- );
674
+ requireExactFields(dispatch, DISPATCH_FIELDS, name);
804
675
  const id = requireSafeId(dispatch.id, `${name}.id`);
805
- const project = await normalizeProject(dispatch.project, 0, {
806
- checkPath: false,
807
- allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
808
- });
676
+ const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
809
677
  const normalized = {
810
- schemaVersion: dispatch.schemaVersion,
678
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
811
679
  id,
812
680
  project,
813
681
  title: requireString(dispatch.title, `${name}.title`).trim(),
@@ -816,23 +684,13 @@ async function validateDispatchShape(
816
684
  ? null
817
685
  : normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
818
686
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
819
- status: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
820
- ? requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`)
821
- : null,
822
- summary: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
823
- ? optionalString(dispatch.summary, `${name}.summary`, {
824
- maxLength: MAX_RESULT_SUMMARY_LENGTH,
825
- })
826
- : null,
827
- turnId: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
828
- ? optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 })
829
- : null,
830
- updatedAt: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
831
- ? requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`)
832
- : null,
833
- updatedBy: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
834
- ? requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`)
835
- : null,
687
+ status: requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`),
688
+ summary: optionalString(dispatch.summary, `${name}.summary`, {
689
+ maxLength: MAX_RESULT_SUMMARY_LENGTH,
690
+ }),
691
+ turnId: optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 }),
692
+ updatedAt: requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`),
693
+ updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
836
694
  };
837
695
  if (normalized.status === "working" && normalized.summary !== null) {
838
696
  throw new Error(`${name}.summary must be null while status is working`);
@@ -841,14 +699,13 @@ async function validateDispatchShape(
841
699
  throw new Error(`${name}.summary is required for status ${normalized.status}`);
842
700
  }
843
701
  if (
844
- normalized.updatedAt !== null
845
- && Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
702
+ Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
846
703
  ) {
847
704
  throw new Error(`${name}.updatedAt must not be earlier than createdAt`);
848
705
  }
849
706
  if (
850
707
  normalized.threadId === null &&
851
- parseTaskChefMarker(normalized.instruction, { allowLegacyHeading }) !== normalized.id
708
+ parseTaskChefMarker(normalized.instruction) !== normalized.id
852
709
  ) {
853
710
  throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
854
711
  }
@@ -875,9 +732,7 @@ async function parseDispatchRecordsUnlocked(root, content) {
875
732
  records.push({
876
733
  line,
877
734
  raw: value,
878
- normalized: await validateDispatchShape(value, `task line ${index + 1}`, {
879
- allowLegacyHeading: true,
880
- }),
735
+ normalized: await validateDispatchShape(value, `task line ${index + 1}`),
881
736
  });
882
737
  }
883
738
  const ids = new Set();
@@ -972,53 +827,6 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
972
827
  });
973
828
  }
974
829
 
975
- export async function resolveTask(workspaceRoot, taskId, threadId, { now } = {}) {
976
- const id = requireSafeId(taskId, "taskId");
977
- const durableThreadId = normalizeDurableThreadId(threadId);
978
- const root = await realpath(path.resolve(workspaceRoot));
979
- return withWorkspaceLock(root, async () => {
980
- const records = await readDispatchRecordsUnlocked(root);
981
- const dispatches = records.map((record) => record.normalized);
982
- const index = dispatches.findIndex((dispatch) => dispatch.id === id);
983
- if (index === -1) throw new Error(`task not found: ${id}`);
984
- const currentRecord = records[index];
985
- if (currentRecord.raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
986
- throw new Error(`task resolve is only available for legacy pre-self-linking records: ${id}`);
987
- }
988
- const dispatch = dispatches[index];
989
- if (dispatch.threadId === durableThreadId) return dispatch;
990
- if (dispatch.threadId !== null) {
991
- throw new Error(`task already has a different threadId: ${id}`);
992
- }
993
- if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
994
- throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
995
- }
996
- if (dispatches.some((item) => (
997
- item.threadId !== null
998
- && threadIdentityKey(item.threadId) === threadIdentityKey(durableThreadId)
999
- ))) {
1000
- throw new Error(`threadId is already recorded: ${durableThreadId}`);
1001
- }
1002
- const statefulLegacy = currentRecord.raw.schemaVersion === PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION;
1003
- const rawResolved = statefulLegacy
1004
- ? {
1005
- ...currentRecord.raw,
1006
- threadId: durableThreadId,
1007
- updatedAt: now ?? new Date().toISOString(),
1008
- updatedBy: "dispatcher",
1009
- }
1010
- : { ...currentRecord.raw, threadId: durableThreadId };
1011
- const resolved = statefulLegacy
1012
- ? await validateDispatchShape(rawResolved)
1013
- : { ...dispatch, threadId: durableThreadId };
1014
- const lines = records.map((record, recordIndex) => recordIndex === index
1015
- ? JSON.stringify(rawResolved)
1016
- : record.line);
1017
- await writeDispatchLinesAtomic(root, lines);
1018
- return resolved;
1019
- });
1020
- }
1021
-
1022
830
  export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1023
831
  const id = requireSafeId(taskId, "taskId");
1024
832
  const durableThreadId = normalizeCodexThreadId(threadId);
@@ -1028,10 +836,6 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1028
836
  const dispatches = records.map((record) => record.normalized);
1029
837
  const index = dispatches.findIndex((dispatch) => dispatch.id === id);
1030
838
  if (index === -1) throw new Error(`task not found: ${id}`);
1031
- const currentRecord = records[index];
1032
- if (currentRecord.raw.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
1033
- throw new Error(`link_task accepts only self-linking task records: ${id}`);
1034
- }
1035
839
  const dispatch = dispatches[index];
1036
840
  const sameIdentity = dispatch.threadId?.toLowerCase() === durableThreadId;
1037
841
  if (sameIdentity && dispatch.updatedBy === "mcp") {
@@ -1115,8 +919,7 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
1115
919
  if (index === -1) throw new Error(`task not found: ${id}`);
1116
920
  const dispatch = dispatches[index];
1117
921
  const isSelfLinkingJourney = (
1118
- records[index].raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
1119
- && dispatch.threadId !== null
922
+ dispatch.threadId !== null
1120
923
  && parseTaskChefMarker(dispatch.instruction) === dispatch.id
1121
924
  );
1122
925
  let resultTurnId = turnId;
@@ -1149,12 +952,9 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
1149
952
  ) {
1150
953
  throw new Error(`task result turnId must be newer than the stored turnId: ${id}`);
1151
954
  }
1152
- const persistedSchemaVersion = records[index].raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
1153
- ? CURRENT_TASK_SCHEMA_VERSION
1154
- : PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION;
1155
955
  const updated = await validateDispatchShape({
1156
956
  ...dispatch,
1157
- schemaVersion: persistedSchemaVersion,
957
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1158
958
  status,
1159
959
  summary,
1160
960
  turnId: resultTurnId,
@@ -1162,7 +962,7 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
1162
962
  updatedBy: "mcp",
1163
963
  });
1164
964
  const lines = records.map((record, recordIndex) => recordIndex === index
1165
- ? dispatchLineWithState(updated, { schemaVersion: persistedSchemaVersion })
965
+ ? dispatchLineWithState(updated, {})
1166
966
  : record.line);
1167
967
  await writeDispatchLinesAtomic(root, lines);
1168
968
  return updated;
@@ -1234,12 +1034,5 @@ export async function doctorWorkspace(workspaceRoot) {
1234
1034
  }
1235
1035
  return "managed AGENTS.md instructions current";
1236
1036
  });
1237
- await check("legacy-skill-links", async () => {
1238
- const legacy = await findLegacySkillLinks(root);
1239
- if (legacy.links.length > 0) {
1240
- throw new Error("legacy TaskChef skill links remain; run workspace init to remove them");
1241
- }
1242
- return "no legacy TaskChef skill links";
1243
- });
1244
1037
  return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
1245
1038
  }
package/SPEC.md DELETED
@@ -1,107 +0,0 @@
1
- # TaskChef specification
2
-
3
- ## Purpose
4
-
5
- TaskChef routes work from a data-only dispatcher workspace to visible Codex
6
- project tasks, stores exact child links, and caches explicit semantic results.
7
-
8
- ## Workspace
9
-
10
- The canonical workspace contains only:
11
-
12
- - `AGENTS.md`: managed dispatcher instructions plus user instructions.
13
- - `taskchef.json`: schema-versioned configured projects.
14
- - `tasks.jsonl`: append-on-create, atomic-rewrite-on-update task snapshots.
15
-
16
- All workspace writes use one lock and atomic replacement. Task IDs and non-null
17
- thread IDs are globally unique.
18
-
19
- ## Task schema
20
-
21
- New records use schema version 4 and contain:
22
-
23
- - immutable `id`, project snapshot, title, instruction, and `createdAt`;
24
- - nullable `threadId` until executor self-linking succeeds;
25
- - `status`, nullable bounded `summary`, nullable `turnId`, `updatedAt`, and
26
- `updatedBy`.
27
-
28
- New unresolved records require the exact first-line HTML marker followed by a
29
- blank line. Schema 1-3 records and historical `updatedBy: hook` values remain
30
- readable without eager migration, and task read/list APIs expose their persisted
31
- schema version so reporting can distinguish legacy recovery from schema 4
32
- executor link-pending state.
33
-
34
- ## Dispatch workflow
35
-
36
- 1. `prepare_dispatch` validates the workspace and returns one fresh lowercase
37
- UUID, exact marker, timestamp, and routing targets.
38
- 2. The marked instruction contains the executor ownership paragraph, mandatory
39
- first-action `link_task` paragraph, result callback paragraph, and task body.
40
- 3. `record_task` writes schema 4 with `threadId: null` before creation.
41
- 4. Native task creation runs once.
42
- 5. The dispatcher returns immediately, including for a provisional worktree
43
- client ID. It does not list or read tasks, search markers, wait, poll, or
44
- link the record.
45
- 6. Creation failure writes a terminal `failed` result with null identities and
46
- a concise bounded summary.
47
-
48
- ## Executor self-linking
49
-
50
- The executor obtains its own durable native thread ID and calls
51
- `link_task(taskId, threadId)` before substantive work. The operation:
52
-
53
- - accepts only schema 4 records;
54
- - rejects unknown task IDs, malformed/provisional IDs, exact-marker mismatch,
55
- thread reuse, and conflicting retries;
56
- - atomically changes only `null` to one durable ID under the workspace lock;
57
- - is idempotent for the same task and ID;
58
- - leaves rejected, unavailable, interrupted, or cancelled links visibly
59
- pending and retryable.
60
-
61
- The executor must never use a parent/delegator ID, inherited session identity,
62
- title match, or provisional client ID. This is a cooperative local assertion,
63
- not transport-authenticated caller identity.
64
-
65
- ## Semantic results
66
-
67
- `report_result` accepts `completed`, `needs_input`, or `failed` with a concise
68
- summary. Linked tasks require an exact stored thread-ID match and a non-null
69
- current turn ID. Self-linked schema 4 journeys require canonical, time-ordered
70
- Codex UUID turn IDs; each changed semantic result must use an ID newer than the
71
- stored turn, while an identical same-turn retry is idempotent. Creation failure
72
- is the sole null-identity result. Follow-up turns must self-read the exact
73
- thread and submit the new turn ID. Atomic writes preserve one JSONL line per
74
- task.
75
-
76
- ## Legacy resolver
77
-
78
- The CLI-only `taskchef task resolve` command accepts unresolved schema 1-3
79
- records after an operator establishes one exact marker match. It rejects schema
80
- 4 records. There is no public `resolve_task` MCP tool.
81
-
82
- ## Dashboard and reports
83
-
84
- The existing file watcher observes `link_task` and result rewrites in real
85
- time. "Open task in Codex" uses only the self-linked child ID. Reports treat
86
- `updatedBy: mcp` terminal or needs-input snapshots as semantic cache and may
87
- compare them with one native metadata snapshot. Reporting never mutates task
88
- history or infers completion from hooks.
89
-
90
- ## Packaging
91
-
92
- The plugin ships its skills, MCP server, CLI, dashboard, and source modules. It
93
- contains no hooks configuration, hook executable, hook runtime, or hook trust
94
- requirement.
95
-
96
- ## Acceptance
97
-
98
- - Record-before-create and exact-marker guarantees remain mandatory.
99
- - Creation returns without post-create waiting, polling, task search, or reads.
100
- - A provisional-path executor self-links its exact durable child ID before
101
- substantive work.
102
- - Parent/delegator and provisional IDs cannot be accidentally substituted.
103
- - Link and result retries are idempotent; conflicts cannot corrupt JSONL.
104
- - Link failures remain visible and retryable; creation failures are terminal.
105
- - Current-turn freshness is retained across needs-input, follow-up, and final
106
- completion.
107
- - The plugin installs and runs without hook configuration or approval.