taskchef 1.0.2 → 3.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/.codex-plugin/plugin.json +37 -0
- package/BACKLOG.md +35 -82
- package/README.md +220 -89
- package/SPEC.md +114 -247
- package/assets/taskchef-dispatcher-instructions.md +4 -3
- package/index.js +2 -5
- package/package.json +10 -2
- package/skills/taskchef-bootstrap/SKILL.md +13 -12
- package/skills/taskchef-delegate/SKILL.md +18 -20
- package/skills/taskchef-report/SKILL.md +38 -0
- package/skills/taskchef-report/agents/openai.yaml +4 -0
- package/src/cli.js +27 -71
- package/src/workspace.js +380 -290
- package/skills/taskchef-reconcile/SKILL.md +0 -35
- package/skills/taskchef-reconcile/agents/openai.yaml +0 -4
package/src/workspace.js
CHANGED
|
@@ -4,13 +4,12 @@ import {
|
|
|
4
4
|
lstat,
|
|
5
5
|
mkdir,
|
|
6
6
|
readFile,
|
|
7
|
-
readlink,
|
|
8
7
|
readdir,
|
|
9
8
|
realpath,
|
|
10
9
|
link,
|
|
11
10
|
rename,
|
|
11
|
+
rmdir,
|
|
12
12
|
stat,
|
|
13
|
-
symlink,
|
|
14
13
|
unlink,
|
|
15
14
|
writeFile,
|
|
16
15
|
} from "node:fs/promises";
|
|
@@ -18,6 +17,7 @@ import { randomUUID } from "node:crypto";
|
|
|
18
17
|
import { fileURLToPath } from "node:url";
|
|
19
18
|
import path from "node:path";
|
|
20
19
|
import { promisify } from "node:util";
|
|
20
|
+
import lockfile from "proper-lockfile";
|
|
21
21
|
|
|
22
22
|
const execFile = promisify(execFileCallback);
|
|
23
23
|
const DISPATCHER_INSTRUCTIONS_URL = new URL(
|
|
@@ -29,9 +29,12 @@ const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -
|
|
|
29
29
|
const TASKCHEF_SKILL_NAMES = [
|
|
30
30
|
"taskchef-bootstrap",
|
|
31
31
|
"taskchef-delegate",
|
|
32
|
-
"taskchef-
|
|
32
|
+
"taskchef-report",
|
|
33
33
|
];
|
|
34
|
+
const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
|
|
34
35
|
const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
36
|
+
const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
37
|
+
const DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
|
|
35
38
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
36
39
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
37
40
|
const PROJECT_FIELDS = new Set([
|
|
@@ -42,27 +45,22 @@ const PROJECT_FIELDS = new Set([
|
|
|
42
45
|
"description",
|
|
43
46
|
]);
|
|
44
47
|
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepo", "description"]);
|
|
45
|
-
const
|
|
48
|
+
const DISPATCH_FIELDS = new Set([
|
|
46
49
|
"schemaVersion",
|
|
47
50
|
"id",
|
|
48
51
|
"project",
|
|
49
52
|
"title",
|
|
50
53
|
"instruction",
|
|
51
|
-
"status",
|
|
52
54
|
"threadId",
|
|
53
|
-
"result",
|
|
54
55
|
"createdAt",
|
|
55
|
-
"updatedAt",
|
|
56
56
|
]);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
finished: new Set(["finished", "running", "blocked"]),
|
|
65
|
-
};
|
|
57
|
+
const RECORD_DISPATCH_FIELDS = new Set([
|
|
58
|
+
"id",
|
|
59
|
+
"project",
|
|
60
|
+
"title",
|
|
61
|
+
"instruction",
|
|
62
|
+
"threadId",
|
|
63
|
+
]);
|
|
66
64
|
|
|
67
65
|
function requireExactFields(value, fields, name) {
|
|
68
66
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -141,6 +139,31 @@ async function managedRegularFileExists(filePath) {
|
|
|
141
139
|
return true;
|
|
142
140
|
}
|
|
143
141
|
|
|
142
|
+
async function withDispatchLock(workspaceRoot, operation) {
|
|
143
|
+
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
144
|
+
const lockPath = path.join(workspaceRoot, DISPATCH_LOCK_NAME);
|
|
145
|
+
const release = await lockfile.lock(dispatchPath, {
|
|
146
|
+
realpath: false,
|
|
147
|
+
lockfilePath: lockPath,
|
|
148
|
+
stale: 5_000,
|
|
149
|
+
update: 1_000,
|
|
150
|
+
retries: { retries: 70, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
return await operation();
|
|
154
|
+
} finally {
|
|
155
|
+
await release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
160
|
+
if (dispatches.length === 0) return;
|
|
161
|
+
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
162
|
+
const content = await readFile(dispatchPath, "utf8");
|
|
163
|
+
const appended = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
|
|
164
|
+
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
144
167
|
async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
|
|
145
168
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
146
169
|
if (exclusive && (await pathExists(filePath))) {
|
|
@@ -244,38 +267,73 @@ export async function ensureWorkspaceInstructions(workspaceRoot) {
|
|
|
244
267
|
return { path: filePath, action };
|
|
245
268
|
}
|
|
246
269
|
|
|
247
|
-
async function
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
await canonicalDirectory(source);
|
|
251
|
-
const details = await lstat(destination).catch((error) => {
|
|
270
|
+
async function findLegacySkillLinks(workspaceRoot) {
|
|
271
|
+
const agentsDirectory = path.join(workspaceRoot, ".agents");
|
|
272
|
+
const agentsDetails = await lstat(agentsDirectory).catch((error) => {
|
|
252
273
|
if (error.code === "ENOENT") return null;
|
|
253
274
|
throw error;
|
|
254
275
|
});
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
return {
|
|
276
|
+
if (agentsDetails === null) return { agentsDirectory: null, skillsDirectory: null, links: [] };
|
|
277
|
+
if (agentsDetails.isSymbolicLink() || !agentsDetails.isDirectory()) {
|
|
278
|
+
return { agentsDirectory: null, skillsDirectory: null, links: [] };
|
|
258
279
|
}
|
|
259
|
-
|
|
260
|
-
|
|
280
|
+
|
|
281
|
+
const skillsDirectory = path.join(agentsDirectory, "skills");
|
|
282
|
+
const skillsDetails = await lstat(skillsDirectory).catch((error) => {
|
|
283
|
+
if (error.code === "ENOENT") return null;
|
|
284
|
+
throw error;
|
|
285
|
+
});
|
|
286
|
+
if (skillsDetails === null) return { agentsDirectory, skillsDirectory, links: [] };
|
|
287
|
+
if (skillsDetails.isSymbolicLink() || !skillsDetails.isDirectory()) {
|
|
288
|
+
return { agentsDirectory, skillsDirectory: null, links: [] };
|
|
261
289
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
290
|
+
|
|
291
|
+
const links = [];
|
|
292
|
+
for (const skillName of LEGACY_TASKCHEF_SKILL_NAMES) {
|
|
293
|
+
const skillPath = path.join(skillsDirectory, skillName);
|
|
294
|
+
const details = await lstat(skillPath).catch((error) => {
|
|
295
|
+
if (error.code === "ENOENT") return null;
|
|
296
|
+
throw error;
|
|
297
|
+
});
|
|
298
|
+
if (details === null) continue;
|
|
299
|
+
if (!details.isSymbolicLink()) {
|
|
300
|
+
throw new Error(`legacy TaskChef skill path is not a symlink: ${skillPath}`);
|
|
301
|
+
}
|
|
302
|
+
links.push({ name: skillName, path: skillPath });
|
|
267
303
|
}
|
|
268
|
-
return {
|
|
304
|
+
return { agentsDirectory, skillsDirectory, links };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function removeLegacySkillLinks(workspaceRoot) {
|
|
308
|
+
const legacy = await findLegacySkillLinks(workspaceRoot);
|
|
309
|
+
for (const link of legacy.links) await unlink(link.path);
|
|
310
|
+
const removedDirectories = [];
|
|
311
|
+
if (legacy.skillsDirectory && (await readdir(legacy.skillsDirectory)).length === 0) {
|
|
312
|
+
await rmdir(legacy.skillsDirectory);
|
|
313
|
+
removedDirectories.push(legacy.skillsDirectory);
|
|
314
|
+
}
|
|
315
|
+
if (legacy.agentsDirectory && (await readdir(legacy.agentsDirectory).catch((error) => {
|
|
316
|
+
if (error.code === "ENOENT") return null;
|
|
317
|
+
throw error;
|
|
318
|
+
}))?.length === 0) {
|
|
319
|
+
await rmdir(legacy.agentsDirectory);
|
|
320
|
+
removedDirectories.push(legacy.agentsDirectory);
|
|
321
|
+
}
|
|
322
|
+
return { removed: legacy.links, removedDirectories };
|
|
269
323
|
}
|
|
270
324
|
|
|
271
325
|
export async function ensureWorkspaceSkills(workspaceRoot) {
|
|
272
326
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
skills.
|
|
277
|
-
|
|
278
|
-
|
|
327
|
+
const legacySkills = await removeLegacySkillLinks(root);
|
|
328
|
+
return {
|
|
329
|
+
directory: null,
|
|
330
|
+
skills: TASKCHEF_SKILL_NAMES.map((name) => ({
|
|
331
|
+
name,
|
|
332
|
+
path: path.join(SKILLS_SOURCE_ROOT, name),
|
|
333
|
+
action: "provided-by-plugin",
|
|
334
|
+
})),
|
|
335
|
+
legacySkills,
|
|
336
|
+
};
|
|
279
337
|
}
|
|
280
338
|
|
|
281
339
|
export async function canonicalGitRoot(projectPath) {
|
|
@@ -481,31 +539,44 @@ export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
|
481
539
|
};
|
|
482
540
|
}
|
|
483
541
|
|
|
542
|
+
async function ensureDispatchFile(workspaceRoot) {
|
|
543
|
+
const filePath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
544
|
+
const exists = await managedRegularFileExists(filePath);
|
|
545
|
+
if (!exists) {
|
|
546
|
+
await writeFile(filePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
547
|
+
}
|
|
548
|
+
return { path: filePath, action: exists ? "unchanged" : "created" };
|
|
549
|
+
}
|
|
550
|
+
|
|
484
551
|
export async function initializeWorkspace(workspaceRoot) {
|
|
485
552
|
const requestedRoot = path.resolve(workspaceRoot);
|
|
486
553
|
await mkdir(requestedRoot, { recursive: true });
|
|
487
554
|
const root = await realpath(requestedRoot);
|
|
488
|
-
await ensureManagedDirectory(root, "tasks");
|
|
489
555
|
const configPath = path.join(root, "taskchef.json");
|
|
490
556
|
const configExists = await managedRegularFileExists(configPath);
|
|
491
557
|
const config = configExists
|
|
492
558
|
? await readConfig(root, { checkPaths: false })
|
|
493
559
|
: { schemaVersion: 1, projects: [] };
|
|
560
|
+
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
561
|
+
const existingDispatches = configExists && (await managedRegularFileExists(dispatchPath))
|
|
562
|
+
? await readDispatchesUnlocked(root)
|
|
563
|
+
: [];
|
|
564
|
+
const legacyTaskRecords = await inspectLegacyTaskRecords(root, config, existingDispatches);
|
|
565
|
+
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
494
566
|
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
567
|
+
const tasks = await ensureDispatchFile(root);
|
|
568
|
+
const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
|
|
495
569
|
const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
|
|
496
570
|
if (!configExists) await unlink(configPath).catch(() => {});
|
|
497
571
|
throw error;
|
|
498
572
|
});
|
|
499
|
-
const skills = await ensureWorkspaceSkills(root).catch(async (error) => {
|
|
500
|
-
if (!configExists) await unlink(configPath).catch(() => {});
|
|
501
|
-
throw error;
|
|
502
|
-
});
|
|
503
573
|
return {
|
|
504
574
|
workspace: root,
|
|
505
575
|
config: { path: configPath, action: configExists ? "unchanged" : "created", value: config },
|
|
506
|
-
tasks
|
|
576
|
+
tasks,
|
|
507
577
|
instructions,
|
|
508
|
-
|
|
578
|
+
legacySkills,
|
|
579
|
+
legacyTasks,
|
|
509
580
|
};
|
|
510
581
|
}
|
|
511
582
|
|
|
@@ -558,21 +629,6 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
558
629
|
else projects[index] = project;
|
|
559
630
|
}
|
|
560
631
|
const config = await validateConfig({ schemaVersion: 1, projects });
|
|
561
|
-
if (replace) {
|
|
562
|
-
const currentPaths = new Set(current.projects.map((project) => project.path));
|
|
563
|
-
const configuredPaths = new Set(config.projects.map((project) => project.path));
|
|
564
|
-
const removedPaths = new Set(
|
|
565
|
-
[...currentPaths].filter((projectPath) => !configuredPaths.has(projectPath)),
|
|
566
|
-
);
|
|
567
|
-
const newlyOrphaned = (await listTasks(root, { checkProjects: false })).filter(
|
|
568
|
-
(task) => removedPaths.has(task.project),
|
|
569
|
-
);
|
|
570
|
-
if (newlyOrphaned.length > 0) {
|
|
571
|
-
throw new Error(
|
|
572
|
-
`replacement would orphan ${newlyOrphaned.length} task record(s); remove referenced projects with --force first`,
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
632
|
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
577
633
|
return {
|
|
578
634
|
mode: replace ? "replace" : "merge",
|
|
@@ -582,7 +638,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
582
638
|
};
|
|
583
639
|
}
|
|
584
640
|
|
|
585
|
-
export async function removeProject(workspaceRoot, name
|
|
641
|
+
export async function removeProject(workspaceRoot, name) {
|
|
586
642
|
const root = path.resolve(workspaceRoot);
|
|
587
643
|
const config = await readConfig(root, { checkPaths: false });
|
|
588
644
|
const index = config.projects.findIndex(
|
|
@@ -590,215 +646,287 @@ export async function removeProject(workspaceRoot, name, { force = false } = {})
|
|
|
590
646
|
);
|
|
591
647
|
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
592
648
|
const [project] = config.projects.slice(index, index + 1);
|
|
593
|
-
const referenced = (await listTasks(root, { checkProjects: false })).filter(
|
|
594
|
-
(task) => task.project === project.path,
|
|
595
|
-
);
|
|
596
|
-
if (referenced.length > 0 && !force) {
|
|
597
|
-
throw new Error(
|
|
598
|
-
`project is referenced by ${referenced.length} task record(s); pass --force to remove it`,
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
649
|
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
602
650
|
await writeJsonAtomic(path.join(root, "taskchef.json"), { schemaVersion: 1, projects });
|
|
603
|
-
return { project
|
|
651
|
+
return { project };
|
|
604
652
|
}
|
|
605
653
|
|
|
606
|
-
function
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
} catch {
|
|
612
|
-
throw new Error(`${name} must be a canonical GitHub URL`);
|
|
613
|
-
}
|
|
614
|
-
const segment = kind === "pull" ? "pull" : "issues";
|
|
615
|
-
const pattern = new RegExp(`^/[^/]+/[^/]+/${segment}/[1-9]\\d*$`);
|
|
616
|
-
if (
|
|
617
|
-
url.protocol !== "https:" ||
|
|
618
|
-
url.hostname !== "github.com" ||
|
|
619
|
-
url.username ||
|
|
620
|
-
url.password ||
|
|
621
|
-
url.port ||
|
|
622
|
-
url.search ||
|
|
623
|
-
url.hash ||
|
|
624
|
-
!pattern.test(url.pathname)
|
|
625
|
-
) {
|
|
626
|
-
throw new Error(`${name} must be a canonical GitHub ${kind} URL`);
|
|
627
|
-
}
|
|
628
|
-
return value;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
export function validateResult(result) {
|
|
632
|
-
if (result === null) return null;
|
|
633
|
-
requireExactFields(result, RESULT_FIELDS, "result");
|
|
634
|
-
requireString(result.message, "result.message");
|
|
635
|
-
for (const field of ["githubPRs", "githubIssues"]) {
|
|
636
|
-
if (!Array.isArray(result[field])) throw new Error(`result.${field} must be an array`);
|
|
637
|
-
}
|
|
638
|
-
result.githubPRs.forEach((value, index) =>
|
|
639
|
-
validateGithubUrl(value, "pull", `result.githubPRs[${index}]`));
|
|
640
|
-
result.githubIssues.forEach((value, index) =>
|
|
641
|
-
validateGithubUrl(value, "issue", `result.githubIssues[${index}]`));
|
|
654
|
+
async function validateDispatchShape(dispatch, name = "task") {
|
|
655
|
+
requireExactFields(dispatch, DISPATCH_FIELDS, name);
|
|
656
|
+
if (dispatch.schemaVersion !== 1) throw new Error(`unsupported ${name} schemaVersion`);
|
|
657
|
+
const id = requireSafeId(dispatch.id, `${name}.id`);
|
|
658
|
+
const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
|
|
642
659
|
return {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
660
|
+
schemaVersion: 1,
|
|
661
|
+
id,
|
|
662
|
+
project,
|
|
663
|
+
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
664
|
+
instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
|
|
665
|
+
threadId: requireString(dispatch.threadId, `${name}.threadId`).trim(),
|
|
666
|
+
createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
|
|
646
667
|
};
|
|
647
668
|
}
|
|
648
669
|
|
|
649
|
-
function
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
requireString(task.title, "title");
|
|
655
|
-
requireString(task.instruction, "instruction");
|
|
656
|
-
if (!TASK_STATUSES.has(task.status)) throw new Error(`unsupported task status: ${task.status}`);
|
|
657
|
-
if (task.threadId !== null) requireString(task.threadId, "threadId");
|
|
658
|
-
if (task.status === "pending" && task.threadId !== null) {
|
|
659
|
-
throw new Error("a pending task must not have a threadId");
|
|
670
|
+
async function readDispatchesUnlocked(root) {
|
|
671
|
+
await readConfig(root, { checkPaths: false });
|
|
672
|
+
const filePath = path.join(root, DISPATCH_FILE_NAME);
|
|
673
|
+
if (!(await managedRegularFileExists(filePath))) {
|
|
674
|
+
throw new Error(`task log does not exist: ${filePath}`);
|
|
660
675
|
}
|
|
661
|
-
|
|
662
|
-
|
|
676
|
+
const content = await readFile(filePath, "utf8");
|
|
677
|
+
if (content.length > 0 && !content.endsWith("\n")) {
|
|
678
|
+
throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
|
|
663
679
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
680
|
+
const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
|
|
681
|
+
const dispatches = [];
|
|
682
|
+
for (const [index, line] of lines.entries()) {
|
|
683
|
+
if (line.trim().length === 0) {
|
|
684
|
+
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
|
|
685
|
+
}
|
|
686
|
+
let value;
|
|
687
|
+
try {
|
|
688
|
+
value = JSON.parse(line);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
|
|
691
|
+
}
|
|
692
|
+
dispatches.push(await validateDispatchShape(value, `task line ${index + 1}`));
|
|
693
|
+
}
|
|
694
|
+
const ids = new Set();
|
|
695
|
+
const threadIds = new Set();
|
|
696
|
+
for (const dispatch of dispatches) {
|
|
697
|
+
if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
|
|
698
|
+
if (threadIds.has(dispatch.threadId)) {
|
|
699
|
+
throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
|
|
700
|
+
}
|
|
701
|
+
ids.add(dispatch.id);
|
|
702
|
+
threadIds.add(dispatch.threadId);
|
|
669
703
|
}
|
|
670
|
-
return
|
|
704
|
+
return dispatches;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export async function listTasks(workspaceRoot) {
|
|
708
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
709
|
+
return readDispatchesUnlocked(root);
|
|
671
710
|
}
|
|
672
711
|
|
|
673
|
-
export async function
|
|
712
|
+
export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
713
|
+
requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
|
|
674
714
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
675
|
-
requireExactFields(input, CREATE_TASK_FIELDS, "task creation input");
|
|
676
715
|
const config = await readConfig(root);
|
|
677
|
-
const
|
|
678
|
-
const project =
|
|
679
|
-
if (!
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
const createdAt = now ?? new Date().toISOString();
|
|
683
|
-
requireTimestamp(createdAt, "createdAt");
|
|
684
|
-
const task = {
|
|
716
|
+
const projectPath = await canonicalDirectory(input.project);
|
|
717
|
+
const project = config.projects.find((candidate) => candidate.path === projectPath);
|
|
718
|
+
if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
|
|
719
|
+
const dispatch = await validateDispatchShape({
|
|
685
720
|
schemaVersion: 1,
|
|
686
|
-
id,
|
|
721
|
+
id: input.id,
|
|
687
722
|
project,
|
|
688
|
-
title:
|
|
689
|
-
instruction:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
return task;
|
|
723
|
+
title: input.title,
|
|
724
|
+
instruction: input.instruction,
|
|
725
|
+
threadId: input.threadId,
|
|
726
|
+
createdAt: now ?? new Date().toISOString(),
|
|
727
|
+
});
|
|
728
|
+
await withDispatchLock(root, async () => {
|
|
729
|
+
const existing = await readDispatchesUnlocked(root);
|
|
730
|
+
if (existing.some((item) => item.id === dispatch.id)) {
|
|
731
|
+
throw new Error(`task already exists: ${dispatch.id}`);
|
|
732
|
+
}
|
|
733
|
+
if (existing.some((item) => item.threadId === dispatch.threadId)) {
|
|
734
|
+
throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
|
|
735
|
+
}
|
|
736
|
+
await appendDispatchesAtomic(root, [dispatch]);
|
|
737
|
+
});
|
|
738
|
+
return dispatch;
|
|
705
739
|
}
|
|
706
740
|
|
|
707
741
|
export async function readTask(workspaceRoot, taskId) {
|
|
708
742
|
const id = requireSafeId(taskId, "taskId");
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
return
|
|
743
|
+
const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
|
|
744
|
+
if (!dispatch) throw new Error(`task not found: ${id}`);
|
|
745
|
+
return dispatch;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function filterTasks(workspaceRoot, { project = null } = {}) {
|
|
749
|
+
const dispatches = await listTasks(workspaceRoot);
|
|
750
|
+
if (project === null) return dispatches;
|
|
751
|
+
const value = requireString(project, "project");
|
|
752
|
+
const filtered = dispatches.filter(
|
|
753
|
+
(dispatch) =>
|
|
754
|
+
dispatch.project.name.toLowerCase() === value.toLowerCase() ||
|
|
755
|
+
dispatch.project.path === value,
|
|
756
|
+
);
|
|
757
|
+
return filtered;
|
|
724
758
|
}
|
|
725
759
|
|
|
726
|
-
export async function
|
|
727
|
-
const
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (!STATUS_TRANSITIONS[current.status].has(status)) {
|
|
735
|
-
throw new Error(`unsupported task transition: ${current.status} -> ${status}`);
|
|
736
|
-
}
|
|
737
|
-
const threadId = patch.threadId === undefined ? current.threadId : patch.threadId;
|
|
738
|
-
if (threadId !== null) requireString(threadId, "threadId");
|
|
739
|
-
if (current.threadId && threadId !== current.threadId) {
|
|
740
|
-
throw new Error("threadId cannot be replaced once recorded");
|
|
741
|
-
}
|
|
742
|
-
const updated = {
|
|
743
|
-
...current,
|
|
744
|
-
status,
|
|
745
|
-
threadId,
|
|
746
|
-
result: patch.result === undefined ? current.result : validateResult(patch.result),
|
|
747
|
-
updatedAt: now ?? new Date().toISOString(),
|
|
748
|
-
};
|
|
749
|
-
requireTimestamp(updated.updatedAt, "updatedAt");
|
|
750
|
-
if (Date.parse(updated.updatedAt) < Date.parse(current.updatedAt)) {
|
|
751
|
-
throw new Error("updatedAt must not move backwards");
|
|
760
|
+
export async function buildTaskSummary(workspaceRoot) {
|
|
761
|
+
const dispatches = await listTasks(workspaceRoot);
|
|
762
|
+
const projectCounts = new Map();
|
|
763
|
+
for (const dispatch of dispatches) {
|
|
764
|
+
projectCounts.set(
|
|
765
|
+
dispatch.project.name,
|
|
766
|
+
(projectCounts.get(dispatch.project.name) ?? 0) + 1,
|
|
767
|
+
);
|
|
752
768
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
769
|
+
return {
|
|
770
|
+
schemaVersion: 1,
|
|
771
|
+
taskCount: dispatches.length,
|
|
772
|
+
projectCounts: Object.fromEntries(
|
|
773
|
+
[...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right)),
|
|
774
|
+
),
|
|
775
|
+
};
|
|
757
776
|
}
|
|
758
777
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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();
|
|
765
820
|
for (const entry of entries) {
|
|
766
|
-
if (!entry.isDirectory()
|
|
767
|
-
|
|
768
|
-
|
|
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 });
|
|
769
877
|
}
|
|
770
|
-
return
|
|
878
|
+
return {
|
|
879
|
+
tasksPath,
|
|
880
|
+
entries,
|
|
881
|
+
records,
|
|
882
|
+
action: entries.length === 0 ? "removed-empty" : "migrated",
|
|
883
|
+
};
|
|
771
884
|
}
|
|
772
885
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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),
|
|
783
919
|
);
|
|
784
|
-
|
|
785
|
-
|
|
920
|
+
return pending;
|
|
921
|
+
});
|
|
922
|
+
for (const migration of migrations) {
|
|
923
|
+
if (migration.taskPath !== null) await unlink(migration.taskPath);
|
|
924
|
+
await rmdir(migration.taskDirectory);
|
|
786
925
|
}
|
|
787
|
-
|
|
788
|
-
(task) =>
|
|
789
|
-
(statusSet.size === 0 || statusSet.has(task.status)) &&
|
|
790
|
-
(projectPath === null || task.project === projectPath),
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
export async function buildTaskSummary(workspaceRoot) {
|
|
795
|
-
const tasks = await listTasks(workspaceRoot);
|
|
926
|
+
await rmdir(inspection.tasksPath);
|
|
796
927
|
return {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
statusCounts: Object.fromEntries(
|
|
800
|
-
[...TASK_STATUSES].map((status) => [status, tasks.filter((task) => task.status === status).length]),
|
|
801
|
-
),
|
|
928
|
+
action: inspection.action,
|
|
929
|
+
migratedCount: migrations.filter((item) => item.dispatch).length,
|
|
802
930
|
};
|
|
803
931
|
}
|
|
804
932
|
|
|
@@ -813,17 +941,13 @@ export async function doctorWorkspace(workspaceRoot) {
|
|
|
813
941
|
checks.push({ name, status: "fail", message: error.message });
|
|
814
942
|
}
|
|
815
943
|
};
|
|
816
|
-
let config = null;
|
|
817
944
|
await check("configuration", async () => {
|
|
818
|
-
config = await readConfig(root);
|
|
945
|
+
const config = await readConfig(root);
|
|
819
946
|
return `${config.projects.length} configured project(s) valid`;
|
|
820
947
|
});
|
|
821
|
-
await check("
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
throw new Error("tasks path is not a real directory");
|
|
825
|
-
}
|
|
826
|
-
return "tasks directory ready";
|
|
948
|
+
await check("task-log", async () => {
|
|
949
|
+
const dispatches = await listTasks(root);
|
|
950
|
+
return `${dispatches.length} task record(s) valid`;
|
|
827
951
|
});
|
|
828
952
|
await check("instructions", async () => {
|
|
829
953
|
const filePath = path.join(root, "AGENTS.md");
|
|
@@ -834,52 +958,18 @@ export async function doctorWorkspace(workspaceRoot) {
|
|
|
834
958
|
}
|
|
835
959
|
return "managed AGENTS.md instructions current";
|
|
836
960
|
});
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
if (!details.isSymbolicLink()) throw new Error("skill path is not a symlink");
|
|
842
|
-
const linked = path.resolve(path.dirname(destination), await readlink(destination));
|
|
843
|
-
const expected = path.join(SKILLS_SOURCE_ROOT, skillName);
|
|
844
|
-
if (linked !== expected) throw new Error(`unexpected target: ${linked}`);
|
|
845
|
-
await canonicalDirectory(expected);
|
|
846
|
-
return "skill link valid";
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
await check("task-records", async () => {
|
|
850
|
-
const taskRoot = path.join(root, "tasks");
|
|
851
|
-
const entries = await readdir(taskRoot, { withFileTypes: true });
|
|
852
|
-
let count = 0;
|
|
853
|
-
for (const entry of entries) {
|
|
854
|
-
if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
|
|
855
|
-
if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
|
|
856
|
-
const task = await readTask(root, entry.name);
|
|
857
|
-
if (task.id !== entry.name) throw new Error(`task ID does not match directory: ${entry.name}`);
|
|
858
|
-
if (config && !config.projects.some((project) => project.path === task.project)) {
|
|
859
|
-
throw new Error(`task ${task.id} references an unconfigured project`);
|
|
860
|
-
}
|
|
861
|
-
count += 1;
|
|
961
|
+
await check("legacy-skill-links", async () => {
|
|
962
|
+
const legacy = await findLegacySkillLinks(root);
|
|
963
|
+
if (legacy.links.length > 0) {
|
|
964
|
+
throw new Error("legacy TaskChef skill links remain; run workspace init to remove them");
|
|
862
965
|
}
|
|
863
|
-
return
|
|
966
|
+
return "no legacy TaskChef skill links";
|
|
967
|
+
});
|
|
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";
|
|
864
973
|
});
|
|
865
974
|
return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
|
|
866
975
|
}
|
|
867
|
-
|
|
868
|
-
export async function buildReconciliationCandidates(
|
|
869
|
-
workspaceRoot,
|
|
870
|
-
{ includeFinished = false } = {},
|
|
871
|
-
) {
|
|
872
|
-
const includedStatuses = includeFinished
|
|
873
|
-
? ["running", "blocked", "finished"]
|
|
874
|
-
: ["running", "blocked"];
|
|
875
|
-
const includedStatusSet = new Set(includedStatuses);
|
|
876
|
-
const tasks = (await listTasks(workspaceRoot)).filter(
|
|
877
|
-
(task) => task.threadId !== null && includedStatusSet.has(task.status),
|
|
878
|
-
);
|
|
879
|
-
return {
|
|
880
|
-
schemaVersion: 1,
|
|
881
|
-
candidateCount: tasks.length,
|
|
882
|
-
includedStatuses,
|
|
883
|
-
tasks,
|
|
884
|
-
};
|
|
885
|
-
}
|