taskchef 0.0.1 → 1.0.1
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/BACKLOG.md +67 -0
- package/README.md +156 -3
- package/SPEC.md +310 -0
- package/assets/AGENTS.md.template +18 -0
- package/bin/taskchef.js +7 -2
- package/index.js +23 -3
- package/package.json +20 -5
- package/skills/taskchef-bootstrap/SKILL.md +50 -0
- package/skills/taskchef-bootstrap/agents/openai.yaml +4 -0
- package/skills/taskchef-delegate/SKILL.md +43 -0
- package/skills/taskchef-delegate/agents/openai.yaml +4 -0
- package/skills/taskchef-reconcile/SKILL.md +35 -0
- package/skills/taskchef-reconcile/agents/openai.yaml +4 -0
- package/src/cli.js +303 -0
- package/src/workspace.js +882 -0
package/src/workspace.js
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readlink,
|
|
8
|
+
readdir,
|
|
9
|
+
realpath,
|
|
10
|
+
link,
|
|
11
|
+
rename,
|
|
12
|
+
stat,
|
|
13
|
+
symlink,
|
|
14
|
+
unlink,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
|
|
22
|
+
const execFile = promisify(execFileCallback);
|
|
23
|
+
const DISPATCHER_INSTRUCTIONS_URL = new URL("../assets/AGENTS.md.template", import.meta.url);
|
|
24
|
+
const DISPATCHER_INSTRUCTIONS_START = "<!-- taskchef:dispatcher-instructions:start -->";
|
|
25
|
+
const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -->";
|
|
26
|
+
const TASKCHEF_SKILL_NAMES = [
|
|
27
|
+
"taskchef-bootstrap",
|
|
28
|
+
"taskchef-delegate",
|
|
29
|
+
"taskchef-reconcile",
|
|
30
|
+
];
|
|
31
|
+
const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
32
|
+
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
33
|
+
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
34
|
+
const PROJECT_FIELDS = new Set([
|
|
35
|
+
"name",
|
|
36
|
+
"path",
|
|
37
|
+
"isGitRepository",
|
|
38
|
+
"githubRepo",
|
|
39
|
+
"description",
|
|
40
|
+
]);
|
|
41
|
+
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepo", "description"]);
|
|
42
|
+
const TASK_FIELDS = new Set([
|
|
43
|
+
"schemaVersion",
|
|
44
|
+
"id",
|
|
45
|
+
"project",
|
|
46
|
+
"title",
|
|
47
|
+
"instruction",
|
|
48
|
+
"status",
|
|
49
|
+
"threadId",
|
|
50
|
+
"result",
|
|
51
|
+
"createdAt",
|
|
52
|
+
"updatedAt",
|
|
53
|
+
]);
|
|
54
|
+
const RESULT_FIELDS = new Set(["message", "githubPRs", "githubIssues"]);
|
|
55
|
+
const CREATE_TASK_FIELDS = new Set(["id", "project", "title", "instruction"]);
|
|
56
|
+
const TASK_STATUSES = new Set(["pending", "running", "blocked", "finished"]);
|
|
57
|
+
const STATUS_TRANSITIONS = {
|
|
58
|
+
pending: new Set(["pending", "running"]),
|
|
59
|
+
running: new Set(["running", "blocked", "finished"]),
|
|
60
|
+
blocked: new Set(["blocked", "running", "finished"]),
|
|
61
|
+
finished: new Set(["finished", "running", "blocked"]),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function requireExactFields(value, fields, name) {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
|
+
throw new Error(`${name} must be an object`);
|
|
67
|
+
}
|
|
68
|
+
const unexpected = Object.keys(value).find((key) => !fields.has(key));
|
|
69
|
+
if (unexpected) throw new Error(`${name} has unsupported field: ${unexpected}`);
|
|
70
|
+
const missing = [...fields].find((key) => !(key in value));
|
|
71
|
+
if (missing) throw new Error(`${name} is missing field: ${missing}`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requireString(value, name) {
|
|
76
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
77
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function requireTimestamp(value, name) {
|
|
83
|
+
requireString(value, name);
|
|
84
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(value) || Number.isNaN(Date.parse(value))) {
|
|
85
|
+
throw new Error(`${name} must be an ISO 8601 timestamp`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function requireSafeId(value, name = "id") {
|
|
91
|
+
requireString(value, name);
|
|
92
|
+
if (!SAFE_ID.test(value)) {
|
|
93
|
+
throw new Error(`${name} contains unsupported characters`);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function pathExists(filePath) {
|
|
99
|
+
try {
|
|
100
|
+
await access(filePath);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensureManagedDirectory(root, ...segments) {
|
|
108
|
+
let current = root;
|
|
109
|
+
for (const segment of segments) {
|
|
110
|
+
current = path.join(current, segment);
|
|
111
|
+
let details = await lstat(current).catch((error) => {
|
|
112
|
+
if (error.code === "ENOENT") return null;
|
|
113
|
+
throw error;
|
|
114
|
+
});
|
|
115
|
+
if (details === null) {
|
|
116
|
+
await mkdir(current);
|
|
117
|
+
details = await lstat(current);
|
|
118
|
+
}
|
|
119
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
120
|
+
throw new Error(`managed workspace path is not a real directory: ${current}`);
|
|
121
|
+
}
|
|
122
|
+
if (await realpath(current) !== current) {
|
|
123
|
+
throw new Error(`managed workspace path escapes the workspace: ${current}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return current;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function managedRegularFileExists(filePath) {
|
|
130
|
+
const details = await lstat(filePath).catch((error) => {
|
|
131
|
+
if (error.code === "ENOENT") return null;
|
|
132
|
+
throw error;
|
|
133
|
+
});
|
|
134
|
+
if (details === null) return false;
|
|
135
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
136
|
+
throw new Error(`managed workspace path is not a regular file: ${filePath}`);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
|
|
142
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
143
|
+
if (exclusive && (await pathExists(filePath))) {
|
|
144
|
+
throw new Error(`file already exists: ${filePath}`);
|
|
145
|
+
}
|
|
146
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
147
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
mode: 0o600,
|
|
150
|
+
flag: "wx",
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
if (exclusive) {
|
|
154
|
+
await link(temporaryPath, filePath).catch((error) => {
|
|
155
|
+
if (error.code === "EEXIST") throw new Error(`file already exists: ${filePath}`);
|
|
156
|
+
throw error;
|
|
157
|
+
});
|
|
158
|
+
await unlink(temporaryPath);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await rename(temporaryPath, filePath);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await unlink(temporaryPath).catch(() => {});
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function writeTextAtomic(filePath, value) {
|
|
169
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
170
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
171
|
+
const mode = await stat(filePath)
|
|
172
|
+
.then((details) => details.mode & 0o777)
|
|
173
|
+
.catch((error) => {
|
|
174
|
+
if (error.code === "ENOENT") return 0o644;
|
|
175
|
+
throw error;
|
|
176
|
+
});
|
|
177
|
+
await writeFile(temporaryPath, value, {
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
mode,
|
|
180
|
+
flag: "wx",
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
await rename(temporaryPath, filePath);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await unlink(temporaryPath).catch(() => {});
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readDispatcherInstructions() {
|
|
191
|
+
const instructions = await readFile(DISPATCHER_INSTRUCTIONS_URL, "utf8");
|
|
192
|
+
const startCount = instructions.split(DISPATCHER_INSTRUCTIONS_START).length - 1;
|
|
193
|
+
const endCount = instructions.split(DISPATCHER_INSTRUCTIONS_END).length - 1;
|
|
194
|
+
if (
|
|
195
|
+
startCount !== 1 ||
|
|
196
|
+
endCount !== 1 ||
|
|
197
|
+
instructions.indexOf(DISPATCHER_INSTRUCTIONS_START) >
|
|
198
|
+
instructions.indexOf(DISPATCHER_INSTRUCTIONS_END)
|
|
199
|
+
) {
|
|
200
|
+
throw new Error("TaskChef dispatcher instructions must contain exactly one managed block");
|
|
201
|
+
}
|
|
202
|
+
return instructions.endsWith("\n") ? instructions : `${instructions}\n`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mergeDispatcherInstructions(existing, managed) {
|
|
206
|
+
const startCount = existing.split(DISPATCHER_INSTRUCTIONS_START).length - 1;
|
|
207
|
+
const endCount = existing.split(DISPATCHER_INSTRUCTIONS_END).length - 1;
|
|
208
|
+
if (startCount !== endCount || startCount > 1) {
|
|
209
|
+
throw new Error("AGENTS.md contains malformed TaskChef managed-block markers");
|
|
210
|
+
}
|
|
211
|
+
if (startCount === 1) {
|
|
212
|
+
const start = existing.indexOf(DISPATCHER_INSTRUCTIONS_START);
|
|
213
|
+
const end = existing.indexOf(DISPATCHER_INSTRUCTIONS_END, start);
|
|
214
|
+
if (end === -1) {
|
|
215
|
+
throw new Error("AGENTS.md contains malformed TaskChef managed-block markers");
|
|
216
|
+
}
|
|
217
|
+
return `${existing.slice(0, start)}${managed.trimEnd()}${existing.slice(end + DISPATCHER_INSTRUCTIONS_END.length)}`;
|
|
218
|
+
}
|
|
219
|
+
if (existing.trim().length === 0) return managed;
|
|
220
|
+
return `${existing.trimEnd()}\n\n${managed}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function ensureWorkspaceInstructions(workspaceRoot) {
|
|
224
|
+
const requestedRoot = path.resolve(workspaceRoot);
|
|
225
|
+
await mkdir(requestedRoot, { recursive: true });
|
|
226
|
+
const root = await realpath(requestedRoot);
|
|
227
|
+
const filePath = path.join(root, "AGENTS.md");
|
|
228
|
+
const managed = await readDispatcherInstructions();
|
|
229
|
+
const existing = await managedRegularFileExists(filePath)
|
|
230
|
+
? await readFile(filePath, "utf8")
|
|
231
|
+
: null;
|
|
232
|
+
const merged = mergeDispatcherInstructions(existing ?? "", managed);
|
|
233
|
+
const action = existing === null
|
|
234
|
+
? "created"
|
|
235
|
+
: existing === merged
|
|
236
|
+
? "unchanged"
|
|
237
|
+
: existing.includes(DISPATCHER_INSTRUCTIONS_START)
|
|
238
|
+
? "updated"
|
|
239
|
+
: "merged";
|
|
240
|
+
if (action !== "unchanged") await writeTextAtomic(filePath, merged);
|
|
241
|
+
return { path: filePath, action };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function ensureSkillLink(skillsDirectory, skillName) {
|
|
245
|
+
const source = path.join(SKILLS_SOURCE_ROOT, skillName);
|
|
246
|
+
const destination = path.join(skillsDirectory, skillName);
|
|
247
|
+
await canonicalDirectory(source);
|
|
248
|
+
const details = await lstat(destination).catch((error) => {
|
|
249
|
+
if (error.code === "ENOENT") return null;
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
if (details === null) {
|
|
253
|
+
await symlink(source, destination, "dir");
|
|
254
|
+
return { name: skillName, path: destination, action: "created" };
|
|
255
|
+
}
|
|
256
|
+
if (!details.isSymbolicLink()) {
|
|
257
|
+
throw new Error(`TaskChef skill path exists and is not a symlink: ${destination}`);
|
|
258
|
+
}
|
|
259
|
+
const linked = path.resolve(path.dirname(destination), await readlink(destination));
|
|
260
|
+
if (linked !== source) {
|
|
261
|
+
await unlink(destination);
|
|
262
|
+
await symlink(source, destination, "dir");
|
|
263
|
+
return { name: skillName, path: destination, action: "updated" };
|
|
264
|
+
}
|
|
265
|
+
return { name: skillName, path: destination, action: "unchanged" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function ensureWorkspaceSkills(workspaceRoot) {
|
|
269
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
270
|
+
const skillsDirectory = await ensureManagedDirectory(root, ".agents", "skills");
|
|
271
|
+
const skills = [];
|
|
272
|
+
for (const skillName of TASKCHEF_SKILL_NAMES) {
|
|
273
|
+
skills.push(await ensureSkillLink(skillsDirectory, skillName));
|
|
274
|
+
}
|
|
275
|
+
return { directory: skillsDirectory, skills };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function canonicalGitRoot(projectPath) {
|
|
279
|
+
const requested = await canonicalDirectory(projectPath);
|
|
280
|
+
const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
|
|
281
|
+
cwd: requested,
|
|
282
|
+
}).catch(() => {
|
|
283
|
+
throw new Error(`project is not a Git repository: ${requested}`);
|
|
284
|
+
});
|
|
285
|
+
const gitRoot = await realpath(stdout.trim());
|
|
286
|
+
if (gitRoot !== requested) {
|
|
287
|
+
throw new Error(`project must be the Git repository root: ${requested}`);
|
|
288
|
+
}
|
|
289
|
+
return requested;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function canonicalDirectory(projectPath) {
|
|
293
|
+
const requested = await realpath(path.resolve(requireString(projectPath, "project")))
|
|
294
|
+
.catch((error) => {
|
|
295
|
+
if (error.code === "ENOENT") throw new Error(`project does not exist: ${projectPath}`);
|
|
296
|
+
throw error;
|
|
297
|
+
});
|
|
298
|
+
if (!(await stat(requested)).isDirectory()) {
|
|
299
|
+
throw new Error(`project must be a directory: ${requested}`);
|
|
300
|
+
}
|
|
301
|
+
return requested;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function validateGithubRepository(value, name) {
|
|
305
|
+
if (value === null) return null;
|
|
306
|
+
requireString(value, name);
|
|
307
|
+
let url;
|
|
308
|
+
try {
|
|
309
|
+
url = new URL(value);
|
|
310
|
+
} catch {
|
|
311
|
+
throw new Error(`${name} must be a canonical GitHub repository URL or null`);
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
url.protocol !== "https:" ||
|
|
315
|
+
url.hostname !== "github.com" ||
|
|
316
|
+
url.username ||
|
|
317
|
+
url.password ||
|
|
318
|
+
url.port ||
|
|
319
|
+
url.search ||
|
|
320
|
+
url.hash ||
|
|
321
|
+
!/^\/[^/]+\/[^/]+$/.test(url.pathname) ||
|
|
322
|
+
url.pathname.endsWith(".git")
|
|
323
|
+
) {
|
|
324
|
+
throw new Error(`${name} must be a canonical GitHub repository URL or null`);
|
|
325
|
+
}
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
330
|
+
const field = `projects[${index}]`;
|
|
331
|
+
if (!project || typeof project !== "object" || Array.isArray(project)) {
|
|
332
|
+
throw new Error(`${field} must be an object`);
|
|
333
|
+
}
|
|
334
|
+
const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
|
|
335
|
+
if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
|
|
336
|
+
for (const required of ["name", "path", "isGitRepository", "githubRepo"]) {
|
|
337
|
+
if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
|
|
338
|
+
}
|
|
339
|
+
const name = requireString(project.name, `${field}.name`).trim();
|
|
340
|
+
if (typeof project.isGitRepository !== "boolean") {
|
|
341
|
+
throw new Error(`${field}.isGitRepository must be a boolean`);
|
|
342
|
+
}
|
|
343
|
+
let projectPath;
|
|
344
|
+
if (checkPath) {
|
|
345
|
+
projectPath = project.isGitRepository
|
|
346
|
+
? await canonicalGitRoot(project.path)
|
|
347
|
+
: await canonicalDirectory(project.path);
|
|
348
|
+
} else {
|
|
349
|
+
projectPath = requireString(project.path, `${field}.path`).trim();
|
|
350
|
+
if (!path.isAbsolute(projectPath) || path.normalize(projectPath) !== projectPath) {
|
|
351
|
+
throw new Error(`${field}.path must be a normalized absolute path`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const githubRepo = validateGithubRepository(project.githubRepo, `${field}.githubRepo`);
|
|
355
|
+
if (!project.isGitRepository && githubRepo !== null) {
|
|
356
|
+
throw new Error(`${field}.githubRepo must be null for a non-Git project`);
|
|
357
|
+
}
|
|
358
|
+
const normalized = {
|
|
359
|
+
name,
|
|
360
|
+
path: projectPath,
|
|
361
|
+
isGitRepository: project.isGitRepository,
|
|
362
|
+
githubRepo,
|
|
363
|
+
};
|
|
364
|
+
if ("description" in project) {
|
|
365
|
+
normalized.description = requireString(
|
|
366
|
+
project.description,
|
|
367
|
+
`${field}.description`,
|
|
368
|
+
).trim();
|
|
369
|
+
}
|
|
370
|
+
return normalized;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function normalizeProjects(projects, { checkPaths = true } = {}) {
|
|
374
|
+
if (!Array.isArray(projects)) throw new Error("projects must be an array");
|
|
375
|
+
const normalized = [];
|
|
376
|
+
for (const [index, project] of projects.entries()) {
|
|
377
|
+
normalized.push(await normalizeProject(project, index, { checkPath: checkPaths }));
|
|
378
|
+
}
|
|
379
|
+
if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
|
|
380
|
+
throw new Error("project paths must not contain duplicates");
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
new Set(normalized.map((project) => project.name.toLowerCase())).size !==
|
|
384
|
+
normalized.length
|
|
385
|
+
) {
|
|
386
|
+
throw new Error("project names must not contain duplicates");
|
|
387
|
+
}
|
|
388
|
+
return normalized;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function normalizeGithubRemote(remote) {
|
|
392
|
+
if (typeof remote !== "string" || remote.trim().length === 0) return null;
|
|
393
|
+
const value = remote.trim();
|
|
394
|
+
const scpMatch = value.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
395
|
+
if (scpMatch) return `https://github.com/${scpMatch[1]}/${scpMatch[2]}`;
|
|
396
|
+
const sshMatch = value.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
397
|
+
if (sshMatch) return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
|
|
398
|
+
try {
|
|
399
|
+
const url = new URL(value);
|
|
400
|
+
if (url.hostname !== "github.com") return null;
|
|
401
|
+
const match = url.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
402
|
+
return match ? `https://github.com/${match[1]}/${match[2]}` : null;
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function gitInspectionError(action, error) {
|
|
409
|
+
const detail = typeof error.stderr === "string" && error.stderr.trim()
|
|
410
|
+
? error.stderr.trim()
|
|
411
|
+
: error.message;
|
|
412
|
+
return new Error(`${action}: ${detail}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function inspectProject(input, index = 0) {
|
|
416
|
+
const field = `projects[${index}]`;
|
|
417
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
418
|
+
throw new Error(`${field} must be an object`);
|
|
419
|
+
}
|
|
420
|
+
const unexpected = Object.keys(input).find((key) => !PROJECT_INPUT_FIELDS.has(key));
|
|
421
|
+
if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
|
|
422
|
+
if (!("path" in input)) throw new Error(`${field} is missing field: path`);
|
|
423
|
+
const projectPath = await canonicalDirectory(input.path);
|
|
424
|
+
let isGitRepository = false;
|
|
425
|
+
let gitRoot = null;
|
|
426
|
+
try {
|
|
427
|
+
const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
|
|
428
|
+
cwd: projectPath,
|
|
429
|
+
});
|
|
430
|
+
gitRoot = await realpath(stdout.trim());
|
|
431
|
+
isGitRepository = true;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (error.code === 128 && /not a git repository/i.test(error.stderr ?? "")) {
|
|
434
|
+
isGitRepository = false;
|
|
435
|
+
} else {
|
|
436
|
+
throw gitInspectionError("failed to inspect Git repository", error);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (isGitRepository && gitRoot !== projectPath) {
|
|
440
|
+
throw new Error(`project must be the Git repository root: ${projectPath}`);
|
|
441
|
+
}
|
|
442
|
+
let githubRepo = null;
|
|
443
|
+
if (isGitRepository) {
|
|
444
|
+
if ("githubRepo" in input) {
|
|
445
|
+
githubRepo = validateGithubRepository(input.githubRepo, `${field}.githubRepo`);
|
|
446
|
+
} else {
|
|
447
|
+
const remote = await execFile("git", ["remote", "get-url", "origin"], {
|
|
448
|
+
cwd: projectPath,
|
|
449
|
+
}).then(({ stdout }) => stdout.trim()).catch((error) => {
|
|
450
|
+
if (error.code === 2 && /No such remote/i.test(error.stderr ?? "")) return null;
|
|
451
|
+
throw gitInspectionError("failed to inspect GitHub origin", error);
|
|
452
|
+
});
|
|
453
|
+
githubRepo = normalizeGithubRemote(remote);
|
|
454
|
+
}
|
|
455
|
+
} else if ("githubRepo" in input && input.githubRepo !== null) {
|
|
456
|
+
throw new Error(`${field}.githubRepo must be null for a non-Git project`);
|
|
457
|
+
}
|
|
458
|
+
const project = {
|
|
459
|
+
name: "name" in input
|
|
460
|
+
? requireString(input.name, `${field}.name`).trim()
|
|
461
|
+
: path.basename(projectPath),
|
|
462
|
+
path: projectPath,
|
|
463
|
+
isGitRepository,
|
|
464
|
+
githubRepo,
|
|
465
|
+
};
|
|
466
|
+
if ("description" in input) {
|
|
467
|
+
project.description = requireString(input.description, `${field}.description`).trim();
|
|
468
|
+
}
|
|
469
|
+
return project;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
473
|
+
requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
|
|
474
|
+
if (config.schemaVersion !== 1) throw new Error("unsupported configuration schemaVersion");
|
|
475
|
+
return {
|
|
476
|
+
schemaVersion: 1,
|
|
477
|
+
projects: await normalizeProjects(config.projects, { checkPaths }),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export async function initializeWorkspace(workspaceRoot) {
|
|
482
|
+
const requestedRoot = path.resolve(workspaceRoot);
|
|
483
|
+
await mkdir(requestedRoot, { recursive: true });
|
|
484
|
+
const root = await realpath(requestedRoot);
|
|
485
|
+
await ensureManagedDirectory(root, "tasks");
|
|
486
|
+
const configPath = path.join(root, "taskchef.json");
|
|
487
|
+
const configExists = await managedRegularFileExists(configPath);
|
|
488
|
+
const config = configExists
|
|
489
|
+
? await readConfig(root, { checkPaths: false })
|
|
490
|
+
: { schemaVersion: 1, projects: [] };
|
|
491
|
+
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
492
|
+
const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
|
|
493
|
+
if (!configExists) await unlink(configPath).catch(() => {});
|
|
494
|
+
throw error;
|
|
495
|
+
});
|
|
496
|
+
const skills = await ensureWorkspaceSkills(root).catch(async (error) => {
|
|
497
|
+
if (!configExists) await unlink(configPath).catch(() => {});
|
|
498
|
+
throw error;
|
|
499
|
+
});
|
|
500
|
+
return {
|
|
501
|
+
workspace: root,
|
|
502
|
+
config: { path: configPath, action: configExists ? "unchanged" : "created", value: config },
|
|
503
|
+
tasks: { path: path.join(root, "tasks"), action: "ready" },
|
|
504
|
+
instructions,
|
|
505
|
+
skills,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function readConfig(workspaceRoot, { checkPaths = true } = {}) {
|
|
510
|
+
const root = path.resolve(workspaceRoot);
|
|
511
|
+
const configPath = path.join(root, "taskchef.json");
|
|
512
|
+
if (!(await managedRegularFileExists(configPath))) {
|
|
513
|
+
throw new Error(`configuration does not exist: ${configPath}`);
|
|
514
|
+
}
|
|
515
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
516
|
+
return validateConfig(config, { checkPaths });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function listProjects(workspaceRoot) {
|
|
520
|
+
const config = await readConfig(workspaceRoot);
|
|
521
|
+
return [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function addProject(workspaceRoot, input) {
|
|
525
|
+
const root = path.resolve(workspaceRoot);
|
|
526
|
+
const config = await readConfig(root);
|
|
527
|
+
const project = await inspectProject(input);
|
|
528
|
+
const updated = await validateConfig({
|
|
529
|
+
schemaVersion: 1,
|
|
530
|
+
projects: [...config.projects, project],
|
|
531
|
+
});
|
|
532
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
|
|
533
|
+
return project;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export async function importProjects(workspaceRoot, inputs, { replace = false } = {}) {
|
|
537
|
+
if (!Array.isArray(inputs)) throw new Error("project import must be a JSON array");
|
|
538
|
+
const root = path.resolve(workspaceRoot);
|
|
539
|
+
const current = await readConfig(root, { checkPaths: !replace });
|
|
540
|
+
const imported = [];
|
|
541
|
+
for (const [index, input] of inputs.entries()) {
|
|
542
|
+
const canonicalPath = await canonicalDirectory(input?.path);
|
|
543
|
+
const existing = current.projects.find((project) => project.path === canonicalPath);
|
|
544
|
+
const mergedInput = { ...input, path: canonicalPath };
|
|
545
|
+
if (!("name" in mergedInput) && existing) mergedInput.name = existing.name;
|
|
546
|
+
if (!("description" in mergedInput) && existing?.description) {
|
|
547
|
+
mergedInput.description = existing.description;
|
|
548
|
+
}
|
|
549
|
+
imported.push(await inspectProject(mergedInput, index));
|
|
550
|
+
}
|
|
551
|
+
const projects = replace ? [] : [...current.projects];
|
|
552
|
+
for (const project of imported) {
|
|
553
|
+
const index = projects.findIndex((existing) => existing.path === project.path);
|
|
554
|
+
if (index === -1) projects.push(project);
|
|
555
|
+
else projects[index] = project;
|
|
556
|
+
}
|
|
557
|
+
const config = await validateConfig({ schemaVersion: 1, projects });
|
|
558
|
+
if (replace) {
|
|
559
|
+
const currentPaths = new Set(current.projects.map((project) => project.path));
|
|
560
|
+
const configuredPaths = new Set(config.projects.map((project) => project.path));
|
|
561
|
+
const removedPaths = new Set(
|
|
562
|
+
[...currentPaths].filter((projectPath) => !configuredPaths.has(projectPath)),
|
|
563
|
+
);
|
|
564
|
+
const newlyOrphaned = (await listTasks(root, { checkProjects: false })).filter(
|
|
565
|
+
(task) => removedPaths.has(task.project),
|
|
566
|
+
);
|
|
567
|
+
if (newlyOrphaned.length > 0) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`replacement would orphan ${newlyOrphaned.length} task record(s); remove referenced projects with --force first`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
574
|
+
return {
|
|
575
|
+
mode: replace ? "replace" : "merge",
|
|
576
|
+
importedCount: imported.length,
|
|
577
|
+
projectCount: config.projects.length,
|
|
578
|
+
projects: imported,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export async function removeProject(workspaceRoot, name, { force = false } = {}) {
|
|
583
|
+
const root = path.resolve(workspaceRoot);
|
|
584
|
+
const config = await readConfig(root, { checkPaths: false });
|
|
585
|
+
const index = config.projects.findIndex(
|
|
586
|
+
(project) => project.name.toLowerCase() === requireString(name, "project name").toLowerCase(),
|
|
587
|
+
);
|
|
588
|
+
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
589
|
+
const [project] = config.projects.slice(index, index + 1);
|
|
590
|
+
const referenced = (await listTasks(root, { checkProjects: false })).filter(
|
|
591
|
+
(task) => task.project === project.path,
|
|
592
|
+
);
|
|
593
|
+
if (referenced.length > 0 && !force) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`project is referenced by ${referenced.length} task record(s); pass --force to remove it`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
599
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), { schemaVersion: 1, projects });
|
|
600
|
+
return { project, referencedTaskCount: referenced.length };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function validateGithubUrl(value, kind, name) {
|
|
604
|
+
requireString(value, name);
|
|
605
|
+
let url;
|
|
606
|
+
try {
|
|
607
|
+
url = new URL(value);
|
|
608
|
+
} catch {
|
|
609
|
+
throw new Error(`${name} must be a canonical GitHub URL`);
|
|
610
|
+
}
|
|
611
|
+
const segment = kind === "pull" ? "pull" : "issues";
|
|
612
|
+
const pattern = new RegExp(`^/[^/]+/[^/]+/${segment}/[1-9]\\d*$`);
|
|
613
|
+
if (
|
|
614
|
+
url.protocol !== "https:" ||
|
|
615
|
+
url.hostname !== "github.com" ||
|
|
616
|
+
url.username ||
|
|
617
|
+
url.password ||
|
|
618
|
+
url.port ||
|
|
619
|
+
url.search ||
|
|
620
|
+
url.hash ||
|
|
621
|
+
!pattern.test(url.pathname)
|
|
622
|
+
) {
|
|
623
|
+
throw new Error(`${name} must be a canonical GitHub ${kind} URL`);
|
|
624
|
+
}
|
|
625
|
+
return value;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function validateResult(result) {
|
|
629
|
+
if (result === null) return null;
|
|
630
|
+
requireExactFields(result, RESULT_FIELDS, "result");
|
|
631
|
+
requireString(result.message, "result.message");
|
|
632
|
+
for (const field of ["githubPRs", "githubIssues"]) {
|
|
633
|
+
if (!Array.isArray(result[field])) throw new Error(`result.${field} must be an array`);
|
|
634
|
+
}
|
|
635
|
+
result.githubPRs.forEach((value, index) =>
|
|
636
|
+
validateGithubUrl(value, "pull", `result.githubPRs[${index}]`));
|
|
637
|
+
result.githubIssues.forEach((value, index) =>
|
|
638
|
+
validateGithubUrl(value, "issue", `result.githubIssues[${index}]`));
|
|
639
|
+
return {
|
|
640
|
+
message: result.message,
|
|
641
|
+
githubPRs: [...result.githubPRs],
|
|
642
|
+
githubIssues: [...result.githubIssues],
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function validateTaskShape(task) {
|
|
647
|
+
requireExactFields(task, TASK_FIELDS, "task");
|
|
648
|
+
if (task.schemaVersion !== 1) throw new Error("unsupported task schemaVersion");
|
|
649
|
+
requireSafeId(task.id);
|
|
650
|
+
requireString(task.project, "project");
|
|
651
|
+
requireString(task.title, "title");
|
|
652
|
+
requireString(task.instruction, "instruction");
|
|
653
|
+
if (!TASK_STATUSES.has(task.status)) throw new Error(`unsupported task status: ${task.status}`);
|
|
654
|
+
if (task.threadId !== null) requireString(task.threadId, "threadId");
|
|
655
|
+
if (task.status === "pending" && task.threadId !== null) {
|
|
656
|
+
throw new Error("a pending task must not have a threadId");
|
|
657
|
+
}
|
|
658
|
+
if (task.status !== "pending" && task.threadId === null) {
|
|
659
|
+
throw new Error(`a ${task.status} task requires a threadId`);
|
|
660
|
+
}
|
|
661
|
+
validateResult(task.result);
|
|
662
|
+
requireTimestamp(task.createdAt, "createdAt");
|
|
663
|
+
requireTimestamp(task.updatedAt, "updatedAt");
|
|
664
|
+
if (Date.parse(task.updatedAt) < Date.parse(task.createdAt)) {
|
|
665
|
+
throw new Error("updatedAt must not be earlier than createdAt");
|
|
666
|
+
}
|
|
667
|
+
return task;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export async function createTask(workspaceRoot, input, { now } = {}) {
|
|
671
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
672
|
+
requireExactFields(input, CREATE_TASK_FIELDS, "task creation input");
|
|
673
|
+
const config = await readConfig(root);
|
|
674
|
+
const id = requireSafeId(input.id);
|
|
675
|
+
const project = await canonicalDirectory(input.project);
|
|
676
|
+
if (!config.projects.some((configuredProject) => configuredProject.path === project)) {
|
|
677
|
+
throw new Error(`project is not configured in taskchef.json: ${project}`);
|
|
678
|
+
}
|
|
679
|
+
const createdAt = now ?? new Date().toISOString();
|
|
680
|
+
requireTimestamp(createdAt, "createdAt");
|
|
681
|
+
const task = {
|
|
682
|
+
schemaVersion: 1,
|
|
683
|
+
id,
|
|
684
|
+
project,
|
|
685
|
+
title: requireString(input.title, "title"),
|
|
686
|
+
instruction: requireString(input.instruction, "instruction"),
|
|
687
|
+
status: "pending",
|
|
688
|
+
threadId: null,
|
|
689
|
+
result: null,
|
|
690
|
+
createdAt,
|
|
691
|
+
updatedAt: createdAt,
|
|
692
|
+
};
|
|
693
|
+
const tasksDirectory = await ensureManagedDirectory(root, "tasks");
|
|
694
|
+
const taskDirectory = path.join(tasksDirectory, id);
|
|
695
|
+
if (await lstat(taskDirectory).catch((error) => {
|
|
696
|
+
if (error.code === "ENOENT") return null;
|
|
697
|
+
throw error;
|
|
698
|
+
})) throw new Error(`task already exists: ${id}`);
|
|
699
|
+
await mkdir(taskDirectory, { recursive: false });
|
|
700
|
+
await writeJsonAtomic(path.join(taskDirectory, "task.json"), task, { exclusive: true });
|
|
701
|
+
return task;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export async function readTask(workspaceRoot, taskId) {
|
|
705
|
+
const id = requireSafeId(taskId, "taskId");
|
|
706
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
707
|
+
const tasksDirectory = await ensureManagedDirectory(root, "tasks");
|
|
708
|
+
const taskDirectory = path.join(tasksDirectory, id);
|
|
709
|
+
const taskDirectoryDetails = await lstat(taskDirectory);
|
|
710
|
+
if (taskDirectoryDetails.isSymbolicLink() || !taskDirectoryDetails.isDirectory()) {
|
|
711
|
+
throw new Error(`task path is not a real directory: ${taskDirectory}`);
|
|
712
|
+
}
|
|
713
|
+
const filePath = path.join(taskDirectory, "task.json");
|
|
714
|
+
const fileDetails = await lstat(filePath);
|
|
715
|
+
if (fileDetails.isSymbolicLink() || !fileDetails.isFile()) {
|
|
716
|
+
throw new Error(`task record is not a regular file: ${filePath}`);
|
|
717
|
+
}
|
|
718
|
+
const task = validateTaskShape(JSON.parse(await readFile(filePath, "utf8")));
|
|
719
|
+
if (task.id !== id) throw new Error(`task ID does not match directory: ${id}`);
|
|
720
|
+
return task;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export async function updateTask(workspaceRoot, taskId, patch, { now } = {}) {
|
|
724
|
+
const allowed = new Set(["status", "threadId", "result"]);
|
|
725
|
+
const unexpected = Object.keys(patch).find((key) => !allowed.has(key));
|
|
726
|
+
if (unexpected) throw new Error(`unsupported task update field: ${unexpected}`);
|
|
727
|
+
if (Object.keys(patch).length === 0) throw new Error("task update must not be empty");
|
|
728
|
+
const current = await readTask(workspaceRoot, taskId);
|
|
729
|
+
const status = patch.status ?? current.status;
|
|
730
|
+
if (!TASK_STATUSES.has(status)) throw new Error(`unsupported task status: ${status}`);
|
|
731
|
+
if (!STATUS_TRANSITIONS[current.status].has(status)) {
|
|
732
|
+
throw new Error(`unsupported task transition: ${current.status} -> ${status}`);
|
|
733
|
+
}
|
|
734
|
+
const threadId = patch.threadId === undefined ? current.threadId : patch.threadId;
|
|
735
|
+
if (threadId !== null) requireString(threadId, "threadId");
|
|
736
|
+
if (current.threadId && threadId !== current.threadId) {
|
|
737
|
+
throw new Error("threadId cannot be replaced once recorded");
|
|
738
|
+
}
|
|
739
|
+
const updated = {
|
|
740
|
+
...current,
|
|
741
|
+
status,
|
|
742
|
+
threadId,
|
|
743
|
+
result: patch.result === undefined ? current.result : validateResult(patch.result),
|
|
744
|
+
updatedAt: now ?? new Date().toISOString(),
|
|
745
|
+
};
|
|
746
|
+
requireTimestamp(updated.updatedAt, "updatedAt");
|
|
747
|
+
if (Date.parse(updated.updatedAt) < Date.parse(current.updatedAt)) {
|
|
748
|
+
throw new Error("updatedAt must not move backwards");
|
|
749
|
+
}
|
|
750
|
+
validateTaskShape(updated);
|
|
751
|
+
const filePath = path.join(path.resolve(workspaceRoot), "tasks", current.id, "task.json");
|
|
752
|
+
await writeJsonAtomic(filePath, updated);
|
|
753
|
+
return updated;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export async function listTasks(workspaceRoot, { checkProjects = true } = {}) {
|
|
757
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
758
|
+
await readConfig(root, { checkPaths: checkProjects });
|
|
759
|
+
const tasksDirectory = await ensureManagedDirectory(root, "tasks");
|
|
760
|
+
const entries = await readdir(tasksDirectory, { withFileTypes: true });
|
|
761
|
+
const tasks = [];
|
|
762
|
+
for (const entry of entries) {
|
|
763
|
+
if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
|
|
764
|
+
if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
|
|
765
|
+
tasks.push(await readTask(root, entry.name));
|
|
766
|
+
}
|
|
767
|
+
return tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export async function filterTasks(workspaceRoot, { statuses = [], project = null } = {}) {
|
|
771
|
+
const config = await readConfig(workspaceRoot);
|
|
772
|
+
const statusSet = new Set(statuses);
|
|
773
|
+
for (const status of statusSet) {
|
|
774
|
+
if (!TASK_STATUSES.has(status)) throw new Error(`unsupported task status: ${status}`);
|
|
775
|
+
}
|
|
776
|
+
let projectPath = null;
|
|
777
|
+
if (project !== null) {
|
|
778
|
+
const configured = config.projects.find(
|
|
779
|
+
(candidate) => candidate.name.toLowerCase() === project.toLowerCase() || candidate.path === project,
|
|
780
|
+
);
|
|
781
|
+
if (!configured) throw new Error(`configured project not found: ${project}`);
|
|
782
|
+
projectPath = configured.path;
|
|
783
|
+
}
|
|
784
|
+
return (await listTasks(workspaceRoot)).filter(
|
|
785
|
+
(task) =>
|
|
786
|
+
(statusSet.size === 0 || statusSet.has(task.status)) &&
|
|
787
|
+
(projectPath === null || task.project === projectPath),
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export async function buildTaskSummary(workspaceRoot) {
|
|
792
|
+
const tasks = await listTasks(workspaceRoot);
|
|
793
|
+
return {
|
|
794
|
+
schemaVersion: 1,
|
|
795
|
+
taskCount: tasks.length,
|
|
796
|
+
statusCounts: Object.fromEntries(
|
|
797
|
+
[...TASK_STATUSES].map((status) => [status, tasks.filter((task) => task.status === status).length]),
|
|
798
|
+
),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export async function doctorWorkspace(workspaceRoot) {
|
|
803
|
+
const root = path.resolve(workspaceRoot);
|
|
804
|
+
const checks = [];
|
|
805
|
+
const check = async (name, operation) => {
|
|
806
|
+
try {
|
|
807
|
+
const message = await operation();
|
|
808
|
+
checks.push({ name, status: "pass", message });
|
|
809
|
+
} catch (error) {
|
|
810
|
+
checks.push({ name, status: "fail", message: error.message });
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
let config = null;
|
|
814
|
+
await check("configuration", async () => {
|
|
815
|
+
config = await readConfig(root);
|
|
816
|
+
return `${config.projects.length} configured project(s) valid`;
|
|
817
|
+
});
|
|
818
|
+
await check("tasks-directory", async () => {
|
|
819
|
+
const details = await lstat(path.join(root, "tasks"));
|
|
820
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
821
|
+
throw new Error("tasks path is not a real directory");
|
|
822
|
+
}
|
|
823
|
+
return "tasks directory ready";
|
|
824
|
+
});
|
|
825
|
+
await check("instructions", async () => {
|
|
826
|
+
const filePath = path.join(root, "AGENTS.md");
|
|
827
|
+
const existing = await readFile(filePath, "utf8");
|
|
828
|
+
const managed = await readDispatcherInstructions();
|
|
829
|
+
if (mergeDispatcherInstructions(existing, managed) !== existing) {
|
|
830
|
+
throw new Error("managed AGENTS.md instructions are missing or stale");
|
|
831
|
+
}
|
|
832
|
+
return "managed AGENTS.md instructions current";
|
|
833
|
+
});
|
|
834
|
+
for (const skillName of TASKCHEF_SKILL_NAMES) {
|
|
835
|
+
await check(`skill:${skillName}`, async () => {
|
|
836
|
+
const destination = path.join(root, ".agents", "skills", skillName);
|
|
837
|
+
const details = await lstat(destination);
|
|
838
|
+
if (!details.isSymbolicLink()) throw new Error("skill path is not a symlink");
|
|
839
|
+
const linked = path.resolve(path.dirname(destination), await readlink(destination));
|
|
840
|
+
const expected = path.join(SKILLS_SOURCE_ROOT, skillName);
|
|
841
|
+
if (linked !== expected) throw new Error(`unexpected target: ${linked}`);
|
|
842
|
+
await canonicalDirectory(expected);
|
|
843
|
+
return "skill link valid";
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
await check("task-records", async () => {
|
|
847
|
+
const taskRoot = path.join(root, "tasks");
|
|
848
|
+
const entries = await readdir(taskRoot, { withFileTypes: true });
|
|
849
|
+
let count = 0;
|
|
850
|
+
for (const entry of entries) {
|
|
851
|
+
if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
|
|
852
|
+
if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
|
|
853
|
+
const task = await readTask(root, entry.name);
|
|
854
|
+
if (task.id !== entry.name) throw new Error(`task ID does not match directory: ${entry.name}`);
|
|
855
|
+
if (config && !config.projects.some((project) => project.path === task.project)) {
|
|
856
|
+
throw new Error(`task ${task.id} references an unconfigured project`);
|
|
857
|
+
}
|
|
858
|
+
count += 1;
|
|
859
|
+
}
|
|
860
|
+
return `${count} task record(s) valid`;
|
|
861
|
+
});
|
|
862
|
+
return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export async function buildReconciliationCandidates(
|
|
866
|
+
workspaceRoot,
|
|
867
|
+
{ includeFinished = false } = {},
|
|
868
|
+
) {
|
|
869
|
+
const includedStatuses = includeFinished
|
|
870
|
+
? ["running", "blocked", "finished"]
|
|
871
|
+
: ["running", "blocked"];
|
|
872
|
+
const includedStatusSet = new Set(includedStatuses);
|
|
873
|
+
const tasks = (await listTasks(workspaceRoot)).filter(
|
|
874
|
+
(task) => task.threadId !== null && includedStatusSet.has(task.status),
|
|
875
|
+
);
|
|
876
|
+
return {
|
|
877
|
+
schemaVersion: 1,
|
|
878
|
+
candidateCount: tasks.length,
|
|
879
|
+
includedStatuses,
|
|
880
|
+
tasks,
|
|
881
|
+
};
|
|
882
|
+
}
|