taskchef 3.0.3 → 4.1.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 +1 -1
- package/README.md +42 -9
- package/SPEC.md +36 -14
- package/assets/taskchef-dispatcher-instructions.md +2 -0
- package/index.js +6 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +27 -5
- package/skills/taskchef-delegate/SKILL.md +15 -8
- package/src/cli.js +56 -18
- package/src/github.js +108 -0
- package/src/workspace.js +104 -73
package/README.md
CHANGED
|
@@ -73,11 +73,13 @@ tasks.jsonl
|
|
|
73
73
|
TaskChef scans eligible local Codex projects during setup and adds them to the
|
|
74
74
|
managed project list in `taskchef.json`.
|
|
75
75
|
|
|
76
|
-
`taskchef.json` defines the available routes. A project name or GitHub
|
|
77
|
-
request URL in your request is usually enough for TaskChef to choose the
|
|
78
|
-
project.
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
`taskchef.json` defines the available routes. A project name or GitHub issue or
|
|
77
|
+
pull-request URL in your request is usually enough for TaskChef to choose the
|
|
78
|
+
right project. Each project's `githubRepos` field is a list. A managed
|
|
79
|
+
`*-workspace` project lists all child or sub-repositories there, so links into
|
|
80
|
+
any of them route to the workspace. If a project needs more context, extend its
|
|
81
|
+
optional `description` field with responsibilities and keywords that
|
|
82
|
+
distinguish it from nearby projects.
|
|
81
83
|
|
|
82
84
|
The generated `AGENTS.md` turns ordinary requests in this project into
|
|
83
85
|
delegated work. You do not need to name the delegate skill each time.
|
|
@@ -161,7 +163,8 @@ $taskchef-bootstrap Diagnose this TaskChef workspace and fix any repairable conf
|
|
|
161
163
|
Project paths must exist when you add or dispatch to them. Configure the
|
|
162
164
|
repository root for a Git project. TaskChef also accepts non-Git folders. It
|
|
163
165
|
detects Git status and the canonical GitHub `origin` when adding or importing
|
|
164
|
-
a project.
|
|
166
|
+
a project. Repeat `--github-repo` to configure several repositories, or place a
|
|
167
|
+
`githubRepos` array in an import. URLs are canonicalized and deduplicated.
|
|
165
168
|
|
|
166
169
|
Removing a project does not rewrite old task entries. Each entry keeps the
|
|
167
170
|
project metadata that TaskChef used when it delegated the work.
|
|
@@ -242,12 +245,25 @@ taskchef doctor --workspace <workspace>
|
|
|
242
245
|
taskchef project add /workspace/payments \
|
|
243
246
|
--name payments \
|
|
244
247
|
--description "Owns payment authorization, capture, and refunds." \
|
|
248
|
+
--github-repo https://github.com/example/payments-api \
|
|
249
|
+
--github-repo https://github.com/example/payments-sdk \
|
|
245
250
|
--workspace <workspace>
|
|
246
251
|
|
|
247
252
|
taskchef project list --workspace <workspace>
|
|
248
253
|
taskchef project remove payments --workspace <workspace>
|
|
249
254
|
```
|
|
250
255
|
|
|
256
|
+
Human-readable project listings use one row per GitHub repository. Project
|
|
257
|
+
details repeat on each row so multi-repository projects remain clear, while a
|
|
258
|
+
project without a configured repository has one row containing `-`:
|
|
259
|
+
|
|
260
|
+
```text
|
|
261
|
+
NAME KIND GITHUB REPOSITORY PATH
|
|
262
|
+
payments git https://github.com/example/payments-api /workspace/payments
|
|
263
|
+
payments git https://github.com/example/payments-sdk /workspace/payments
|
|
264
|
+
notes folder - /workspace/notes
|
|
265
|
+
```
|
|
266
|
+
|
|
251
267
|
Import projects as a JSON array from a file or standard input:
|
|
252
268
|
|
|
253
269
|
```sh
|
|
@@ -255,9 +271,15 @@ taskchef project import projects.json --workspace <workspace>
|
|
|
255
271
|
taskchef project import - --workspace <workspace> < projects.json
|
|
256
272
|
```
|
|
257
273
|
|
|
258
|
-
Import merges by canonical path
|
|
259
|
-
when the imported object omits it
|
|
260
|
-
set.
|
|
274
|
+
Import merges by canonical path, preserves an existing name or description
|
|
275
|
+
when the imported object omits it, and unions repository lists without
|
|
276
|
+
duplicates. `--replace` replaces the configured project set.
|
|
277
|
+
|
|
278
|
+
The current configuration schema is version 2. Version 1 remains readable:
|
|
279
|
+
legacy `githubRepo: null` normalizes to `githubRepos: []`, and a legacy string
|
|
280
|
+
normalizes to a one-item `githubRepos` list. `workspace init` persists this
|
|
281
|
+
migration atomically; other configuration writes also emit version 2. Legacy
|
|
282
|
+
task lines remain readable without an eager rewrite of the append-only history.
|
|
261
283
|
|
|
262
284
|
### Task history
|
|
263
285
|
|
|
@@ -285,9 +307,20 @@ Inspect the task history without querying Codex tasks:
|
|
|
285
307
|
taskchef task show t1 --workspace <workspace>
|
|
286
308
|
taskchef task list --workspace <workspace>
|
|
287
309
|
taskchef task list --project payments --workspace <workspace>
|
|
310
|
+
taskchef task list --ascending --workspace <workspace>
|
|
288
311
|
taskchef task summary --workspace <workspace>
|
|
289
312
|
```
|
|
290
313
|
|
|
314
|
+
Human-readable task listings put the scannable fields first and the durable ID
|
|
315
|
+
last. Values are kept in full and aligned in columns. Tasks are newest-first by
|
|
316
|
+
default; pass `--ascending` to list them from oldest to newest. The same order
|
|
317
|
+
applies to the `tasks` array in `--json` output.
|
|
318
|
+
|
|
319
|
+
```text
|
|
320
|
+
TITLE PROJECT CREATED ID
|
|
321
|
+
Add retry logs payments 2026-08-12T10:00:00.000Z c0f010ff-84f2-4838-a69d-0ff1f5d721d7
|
|
322
|
+
```
|
|
323
|
+
|
|
291
324
|
The complete data contract is in [SPEC.md](SPEC.md). Deferred ideas are in
|
|
292
325
|
[BACKLOG.md](BACKLOG.md).
|
|
293
326
|
|
package/SPEC.md
CHANGED
|
@@ -54,20 +54,23 @@ the workspace.
|
|
|
54
54
|
|
|
55
55
|
```json
|
|
56
56
|
{
|
|
57
|
-
"schemaVersion":
|
|
57
|
+
"schemaVersion": 2,
|
|
58
58
|
"projects": [
|
|
59
59
|
{
|
|
60
60
|
"name": "payments-api",
|
|
61
61
|
"path": "/workspace/payments-api",
|
|
62
62
|
"isGitRepository": true,
|
|
63
|
-
"
|
|
63
|
+
"githubRepos": [
|
|
64
|
+
"https://github.com/example/payments-api",
|
|
65
|
+
"https://github.com/example/payments-sdk"
|
|
66
|
+
],
|
|
64
67
|
"description": "Owns payment authorization, capture, refunds, and provider integrations."
|
|
65
68
|
},
|
|
66
69
|
{
|
|
67
70
|
"name": "local-data-tools",
|
|
68
71
|
"path": "/workspace/local-data-tools",
|
|
69
72
|
"isGitRepository": false,
|
|
70
|
-
"
|
|
73
|
+
"githubRepos": []
|
|
71
74
|
}
|
|
72
75
|
]
|
|
73
76
|
}
|
|
@@ -79,18 +82,31 @@ the workspace.
|
|
|
79
82
|
- `path` is the normalized, canonical local directory. A Git project must use
|
|
80
83
|
its repository root.
|
|
81
84
|
- `isGitRepository` identifies Git and non-Git projects.
|
|
82
|
-
- `
|
|
85
|
+
- `githubRepos` is a deduplicated list of canonical GitHub repository URLs. Use
|
|
86
|
+
`[]` when the project advertises no repositories. A managed `*-workspace`
|
|
87
|
+
project lists each child or sub-repository that should route to it.
|
|
83
88
|
- `description` is optional routing context.
|
|
84
89
|
|
|
85
|
-
TaskChef classifies work against `name`, `
|
|
86
|
-
path identifies the checkout but is not a routing hint.
|
|
87
|
-
|
|
90
|
+
TaskChef classifies work against `name`, every URL in `githubRepos`, and
|
|
91
|
+
`description`. The path identifies the checkout but is not a routing hint. For
|
|
92
|
+
a GitHub issue or pull-request URL, TaskChef compares canonical,
|
|
93
|
+
case-insensitive owner/repository identities across every configured list. It
|
|
94
|
+
ignores the issue or PR suffix, `http` versus `https`, optional `www`, trailing
|
|
95
|
+
slashes, and trailing `.git`. It routes only when one configured project
|
|
96
|
+
matches; ambiguous and unmatched URLs are never guessed.
|
|
88
97
|
|
|
89
98
|
`project add` and `project import` detect Git status, exact Git roots, and
|
|
90
|
-
canonical GitHub origins. Import merges by
|
|
91
|
-
existing name or description when omitted
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
canonical GitHub origins. `--github-repo` is repeatable. Import merges by
|
|
100
|
+
canonical path, preserves an existing name or description when omitted, and
|
|
101
|
+
unions existing and imported repository lists without duplicates. `--replace`
|
|
102
|
+
replaces the configured set. Removing or replacing a project does not alter
|
|
103
|
+
historical task entries.
|
|
104
|
+
|
|
105
|
+
Schema version 2 replaces the string-or-null `githubRepo` field with the
|
|
106
|
+
list-valued `githubRepos` field. Schema-version-1 configurations remain
|
|
107
|
+
compatible: reads normalize a legacy string to one canonical list item and
|
|
108
|
+
legacy `null` to `[]`; `workspace init` persists the migration atomically, and
|
|
109
|
+
any later configuration write emits version 2.
|
|
94
110
|
|
|
95
111
|
The configuration does not store dispatcher identity, execution modes,
|
|
96
112
|
schedules, task status, results, host information, or the workspace path.
|
|
@@ -100,7 +116,7 @@ schedules, task status, results, host information, or the workspace path.
|
|
|
100
116
|
`tasks.jsonl` contains one compact JSON object per line, in append order:
|
|
101
117
|
|
|
102
118
|
```json
|
|
103
|
-
{"schemaVersion":
|
|
119
|
+
{"schemaVersion":2,"id":"c0f010ff-84f2-4838-a69d-0ff1f5d721d7","project":{"name":"payments-api","path":"/workspace/payments-api","isGitRepository":true,"githubRepos":["https://github.com/example/payments-api","https://github.com/example/payments-sdk"],"description":"Owns payment authorization, capture, refunds, and provider integrations."},"title":"Add payment retry logs","instruction":"# taskchef_id=c0f010ff-84f2-4838-a69d-0ff1f5d721d7\n\nAdd structured logs for failed payment retries and test them.","threadId":"019f9d46-f42c-7482-9707-3c107bf241ee","createdAt":"2026-08-08T10:00:00.000Z"}
|
|
104
120
|
```
|
|
105
121
|
|
|
106
122
|
- `schemaVersion` identifies the task entry format.
|
|
@@ -129,6 +145,11 @@ The project snapshot preserves the route even if the project is renamed,
|
|
|
129
145
|
moved, or removed later. Entries never contain status, result, transcript,
|
|
130
146
|
hidden reasoning, `hostId`, or update timestamps.
|
|
131
147
|
|
|
148
|
+
New task entries use schema version 2 and list-valued project snapshots.
|
|
149
|
+
Version 1 entries with string or null repository metadata remain readable and
|
|
150
|
+
normalize to version 2 in API and CLI output. TaskChef does not eagerly rewrite
|
|
151
|
+
legacy history solely for this migration.
|
|
152
|
+
|
|
132
153
|
## Dispatch workflow
|
|
133
154
|
|
|
134
155
|
For each assignment, `$taskchef-delegate`:
|
|
@@ -180,8 +201,9 @@ it was not recorded.
|
|
|
180
201
|
The CLI reads persisted history without contacting Codex:
|
|
181
202
|
|
|
182
203
|
- `task show <id>` returns one entry.
|
|
183
|
-
- `task list` returns entries
|
|
184
|
-
historical project name or exact path.
|
|
204
|
+
- `task list` returns entries newest-first by creation time, optionally filtered
|
|
205
|
+
by historical project name or exact path. `--ascending` returns oldest-first.
|
|
206
|
+
The selected order applies to both human rows and the JSON `tasks` array.
|
|
185
207
|
- `task summary` returns the total and per-project counts.
|
|
186
208
|
- `task resolve <id> --thread-id <thread-id>` atomically fills one nullable
|
|
187
209
|
thread ID after Codex verifies the exact structured marker match.
|
|
@@ -9,6 +9,8 @@ This repository is a TaskChef dispatcher workspace.
|
|
|
9
9
|
- For every actionable work request, use `$taskchef-delegate`
|
|
10
10
|
automatically, even when the user does not explicitly say "delegate" or
|
|
11
11
|
mention TaskChef.
|
|
12
|
+
- GitHub issue and pull-request URLs may identify any repository advertised by
|
|
13
|
+
a configured project, including child repositories of managed workspaces.
|
|
12
14
|
- Do not perform delegated work directly in the dispatcher thread.
|
|
13
15
|
- Return immediately after dispatch, as required by `$taskchef-delegate`.
|
|
14
16
|
- Answer directly only when the user explicitly asks about TaskChef itself or
|
package/index.js
CHANGED
|
@@ -20,6 +20,12 @@ export {
|
|
|
20
20
|
validateConfig,
|
|
21
21
|
} from "./src/workspace.js";
|
|
22
22
|
|
|
23
|
+
export {
|
|
24
|
+
canonicalGithubRepository,
|
|
25
|
+
matchProjectForGithubUrl,
|
|
26
|
+
normalizeGithubRepositories,
|
|
27
|
+
} from "./src/github.js";
|
|
28
|
+
|
|
23
29
|
export {
|
|
24
30
|
THREAD_RESOLUTION_CHECKPOINTS_MS,
|
|
25
31
|
THREAD_RESOLUTION_CLOCK_SKEW_MS,
|
package/package.json
CHANGED
|
@@ -39,12 +39,34 @@ all deterministic workspace operations.
|
|
|
39
39
|
projects are outside the v1 contract.
|
|
40
40
|
2. Add one project with `project add <path>`, normally supplying `--name` and a
|
|
41
41
|
curated `--description`. The CLI detects Git status, exact Git root, and a
|
|
42
|
-
canonical GitHub `origin`.
|
|
43
|
-
|
|
42
|
+
canonical GitHub `origin`. Repeat `--github-repo <url>` to advertise several
|
|
43
|
+
repositories, or use `--no-github` for an empty list. A managed
|
|
44
|
+
`*-workspace` project must list all of its child or sub-repositories so issue
|
|
45
|
+
and pull-request URLs route to that workspace.
|
|
44
46
|
3. Bulk import with `project import <file|-> --json`. Input is a JSON array of
|
|
45
47
|
objects containing `path` plus optional `name`, `description`, and
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
`githubRepos`, which is always a JSON array of GitHub repository URLs. Import
|
|
49
|
+
merges by canonical path, preserves an existing name or description when
|
|
50
|
+
omitted, and unions existing and imported repository lists without
|
|
51
|
+
duplicates. Use `--replace` only when the user explicitly requests
|
|
52
|
+
replacement.
|
|
49
53
|
4. Inspect configured projects with `project list --json`. Remove by name with
|
|
50
54
|
`project remove`. Existing task entries keep their project snapshots.
|
|
55
|
+
|
|
56
|
+
Example managed-workspace import entry:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"name": "skills-workspace",
|
|
61
|
+
"path": "/workspace/skills-workspace",
|
|
62
|
+
"githubRepos": [
|
|
63
|
+
"https://github.com/example/skill-one",
|
|
64
|
+
"https://github.com/example/skill-two"
|
|
65
|
+
],
|
|
66
|
+
"description": "Manages the listed child skill repositories."
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`workspace init` safely migrates schema-version-1 configuration: a string
|
|
71
|
+
`githubRepo` becomes a one-item `githubRepos` list and `null` becomes
|
|
72
|
+
`githubRepos: []`. All subsequent configuration writes use schema version 2.
|
|
@@ -32,9 +32,15 @@ for all deterministic workspace and task-record operations.
|
|
|
32
32
|
`$taskchef-bootstrap` if the workspace is missing or unhealthy.
|
|
33
33
|
2. Split the request into the smallest independently useful outcomes. Include
|
|
34
34
|
constraints, expected testing, and reporting in every instruction.
|
|
35
|
-
3. Classify against configured `name`,
|
|
36
|
-
`path` only as checkout identity.
|
|
37
|
-
|
|
35
|
+
3. Classify against configured `name`, every URL in the `githubRepos` list, and
|
|
36
|
+
`description`. Use `path` only as checkout identity. Managed `*-workspace`
|
|
37
|
+
projects advertise their child or sub-repositories in this list.
|
|
38
|
+
When the prompt contains a GitHub issue or pull-request URL, canonicalize
|
|
39
|
+
its case-insensitive owner/repository identity, ignoring `http` versus
|
|
40
|
+
`https`, an optional `www`, a trailing slash or `.git`, and the issue or PR
|
|
41
|
+
suffix. Check that identity against every repository URL of every configured
|
|
42
|
+
project. Route on this evidence only when exactly one configured project
|
|
43
|
+
matches. Ask instead of guessing when no project or several projects match.
|
|
38
44
|
4. Resolve native projects once and require the exact configured path.
|
|
39
45
|
5. Generate a lowercase full UUID task ID before creation. Prefix the complete
|
|
40
46
|
executor instruction with exactly `# taskchef_id=<full UUID>`, followed by a
|
|
@@ -97,11 +103,12 @@ for all deterministic workspace and task-record operations.
|
|
|
97
103
|
not read an executor for progress and never wait for executor work
|
|
98
104
|
completion.
|
|
99
105
|
|
|
100
|
-
The package exports pure
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
The package exports pure repository canonicalization and unique URL matching
|
|
107
|
+
helpers from `src/github.js`, plus marker, candidate-filtering, and
|
|
108
|
+
injected-adapter orchestration helpers from `src/delegation.js`, for
|
|
109
|
+
deterministic tests and hosts that can supply thread-tool callbacks. The
|
|
110
|
+
standalone Node CLI cannot call desktop thread tools; perform the tool calls in
|
|
111
|
+
Codex and use the CLI only for validated workspace data operations.
|
|
105
112
|
|
|
106
113
|
## Later resolution
|
|
107
114
|
|
package/src/cli.js
CHANGED
|
@@ -37,6 +37,14 @@ function option(args, name, fallback) {
|
|
|
37
37
|
return args[index + 1];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function options(args, name) {
|
|
41
|
+
const values = [];
|
|
42
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
43
|
+
if (args[index] === name) values.push(args[index + 1]);
|
|
44
|
+
}
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
function validateCommandArgs(
|
|
41
49
|
args,
|
|
42
50
|
startIndex,
|
|
@@ -80,12 +88,44 @@ function print(value, args, human) {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
function table(headers, rows) {
|
|
91
|
+
const display = (value) => value === null || value === undefined || value === ""
|
|
92
|
+
? "-"
|
|
93
|
+
: String(value);
|
|
83
94
|
const widths = headers.map((header, index) =>
|
|
84
|
-
Math.max(header.length, ...rows.map((row) =>
|
|
85
|
-
const format = (row) => row.map((value, index) =>
|
|
95
|
+
Math.max(header.length, ...rows.map((row) => display(row[index]).length)));
|
|
96
|
+
const format = (row) => row.map((value, index) => index === row.length - 1
|
|
97
|
+
? display(value)
|
|
98
|
+
: display(value).padEnd(widths[index])).join(" ");
|
|
86
99
|
return [format(headers), ...rows.map(format)].join("\n");
|
|
87
100
|
}
|
|
88
101
|
|
|
102
|
+
function projectRows(projects) {
|
|
103
|
+
return projects.flatMap((project) => {
|
|
104
|
+
const repositories = project.githubRepos.length > 0 ? project.githubRepos : [null];
|
|
105
|
+
return repositories.map((repository) => [
|
|
106
|
+
project.name,
|
|
107
|
+
project.isGitRepository ? "git" : "folder",
|
|
108
|
+
repository,
|
|
109
|
+
project.path,
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sortTasksByCreatedAt(tasks, ascending) {
|
|
115
|
+
return tasks
|
|
116
|
+
.map((task, index) => ({ task, index }))
|
|
117
|
+
.sort((left, right) => {
|
|
118
|
+
const leftCreatedAt = left.task.createdAt;
|
|
119
|
+
const rightCreatedAt = right.task.createdAt;
|
|
120
|
+
if (!leftCreatedAt && !rightCreatedAt) return left.index - right.index;
|
|
121
|
+
if (!leftCreatedAt) return 1;
|
|
122
|
+
if (!rightCreatedAt) return -1;
|
|
123
|
+
const chronological = Date.parse(leftCreatedAt) - Date.parse(rightCreatedAt);
|
|
124
|
+
return (ascending ? chronological : -chronological) || left.index - right.index;
|
|
125
|
+
})
|
|
126
|
+
.map(({ task }) => task);
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
async function initialize(args) {
|
|
90
130
|
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
91
131
|
const result = await initializeWorkspace(workspaceRoot(args));
|
|
@@ -113,6 +153,7 @@ async function projectAdd(args) {
|
|
|
113
153
|
validateCommandArgs(args, 3, {
|
|
114
154
|
values: ["--workspace", "--name", "--description", "--github-repo"],
|
|
115
155
|
switches: ["--json", "--no-github"],
|
|
156
|
+
repeatable: ["--github-repo"],
|
|
116
157
|
});
|
|
117
158
|
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
118
159
|
throw new Error("--no-github and --github-repo cannot be used together");
|
|
@@ -122,8 +163,8 @@ async function projectAdd(args) {
|
|
|
122
163
|
const description = option(args, "--description", null);
|
|
123
164
|
if (name !== null) input.name = name;
|
|
124
165
|
if (description !== null) input.description = description;
|
|
125
|
-
if (args.includes("--no-github")) input.
|
|
126
|
-
else if (args.includes("--github-repo")) input.
|
|
166
|
+
if (args.includes("--no-github")) input.githubRepos = [];
|
|
167
|
+
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
127
168
|
const project = await addProject(workspaceRoot(args), input);
|
|
128
169
|
print(project, args, (value) => `Added ${value.name}: ${value.path}`);
|
|
129
170
|
return 0;
|
|
@@ -152,12 +193,8 @@ async function projectList(args) {
|
|
|
152
193
|
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
153
194
|
const projects = await listProjects(workspaceRoot(args));
|
|
154
195
|
print({ projectCount: projects.length, projects }, args, (value) => table(
|
|
155
|
-
["NAME", "KIND", "PATH"],
|
|
156
|
-
value.projects
|
|
157
|
-
project.name,
|
|
158
|
-
project.isGitRepository ? "git" : "folder",
|
|
159
|
-
project.path,
|
|
160
|
-
]),
|
|
196
|
+
["NAME", "KIND", "GITHUB REPOSITORY", "PATH"],
|
|
197
|
+
projectRows(value.projects),
|
|
161
198
|
));
|
|
162
199
|
return 0;
|
|
163
200
|
}
|
|
@@ -205,19 +242,20 @@ async function taskShow(args) {
|
|
|
205
242
|
async function taskList(args) {
|
|
206
243
|
validateCommandArgs(args, 2, {
|
|
207
244
|
values: ["--workspace", "--project"],
|
|
208
|
-
switches: ["--json"],
|
|
245
|
+
switches: ["--ascending", "--json"],
|
|
209
246
|
});
|
|
210
|
-
const
|
|
247
|
+
const filtered = await filterTasks(workspaceRoot(args), {
|
|
211
248
|
project: option(args, "--project", null),
|
|
212
249
|
});
|
|
250
|
+
const dispatches = sortTasksByCreatedAt(filtered, args.includes("--ascending"));
|
|
213
251
|
const result = { taskCount: dispatches.length, tasks: dispatches };
|
|
214
252
|
print(result, args, (value) => table(
|
|
215
|
-
["
|
|
253
|
+
["TITLE", "PROJECT", "CREATED", "ID"],
|
|
216
254
|
value.tasks.map((dispatch) => [
|
|
217
|
-
dispatch.id,
|
|
218
|
-
dispatch.createdAt,
|
|
219
|
-
dispatch.project.name,
|
|
220
255
|
dispatch.title,
|
|
256
|
+
dispatch.project?.name,
|
|
257
|
+
dispatch.createdAt,
|
|
258
|
+
dispatch.id,
|
|
221
259
|
]),
|
|
222
260
|
));
|
|
223
261
|
return 0;
|
|
@@ -240,14 +278,14 @@ Usage:
|
|
|
240
278
|
taskchef help
|
|
241
279
|
taskchef doctor [--json] [--workspace <path>]
|
|
242
280
|
taskchef workspace init [--json] [--workspace <path>]
|
|
243
|
-
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> | --no-github] [--json] [--workspace <path>]
|
|
281
|
+
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
244
282
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
245
283
|
taskchef project list [--json] [--workspace <path>]
|
|
246
284
|
taskchef project remove <name> [--json] [--workspace <path>]
|
|
247
285
|
taskchef task record [--json] [--workspace <path>]
|
|
248
286
|
taskchef task resolve <task-id> --thread-id <thread-id> [--json] [--workspace <path>]
|
|
249
287
|
taskchef task show <task-id> [--json] [--workspace <path>]
|
|
250
|
-
taskchef task list [--project <name-or-path>] [--json] [--workspace <path>]
|
|
288
|
+
taskchef task list [--project <name-or-path>] [--ascending] [--json] [--workspace <path>]
|
|
251
289
|
taskchef task summary [--json] [--workspace <path>]
|
|
252
290
|
|
|
253
291
|
Task record reads JSON from standard input. Project import reads a JSON
|
package/src/github.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
function requireRepositoryString(value, name) {
|
|
2
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
3
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
4
|
+
}
|
|
5
|
+
return value.trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function parseGithubUrl(value, name, { allowIssueOrPull = false } = {}) {
|
|
9
|
+
let remote = requireRepositoryString(value, name);
|
|
10
|
+
const scpMatch = remote.match(/^git@github\.com:([^/]+)\/([^/]+)\/?$/i);
|
|
11
|
+
if (scpMatch) {
|
|
12
|
+
return {
|
|
13
|
+
owner: scpMatch[1],
|
|
14
|
+
repository: scpMatch[2].replace(/\.git$/i, ""),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (/^(?:www\.)?github\.com\//i.test(remote)) remote = `https://${remote}`;
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(remote);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`${name} must be a GitHub repository URL`);
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
!["https:", "http:", "ssh:", "git:"].includes(url.protocol)
|
|
26
|
+
|| !["github.com", "www.github.com"].includes(url.hostname.toLowerCase())
|
|
27
|
+
|| (url.username && !(url.protocol === "ssh:" && url.username === "git"))
|
|
28
|
+
|| url.password
|
|
29
|
+
|| url.port
|
|
30
|
+
|| (!allowIssueOrPull && (url.search || url.hash))
|
|
31
|
+
) {
|
|
32
|
+
throw new Error(`${name} must be a GitHub repository URL`);
|
|
33
|
+
}
|
|
34
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
35
|
+
if (segments.length < 2) throw new Error(`${name} must identify one GitHub repository`);
|
|
36
|
+
const suffix = segments.slice(2);
|
|
37
|
+
if (
|
|
38
|
+
(!allowIssueOrPull && suffix.length > 0)
|
|
39
|
+
|| (
|
|
40
|
+
allowIssueOrPull
|
|
41
|
+
&& suffix.length > 0
|
|
42
|
+
&& !(suffix.length >= 2 && ["issues", "pull"].includes(suffix[0]) && /^\d+$/.test(suffix[1]))
|
|
43
|
+
)
|
|
44
|
+
) {
|
|
45
|
+
throw new Error(`${name} must identify one GitHub repository`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
owner: segments[0],
|
|
49
|
+
repository: segments[1].replace(/\.git$/i, ""),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function canonicalGithubRepository(value, name = "githubRepos") {
|
|
54
|
+
const { owner, repository } = parseGithubUrl(value, name);
|
|
55
|
+
if (repository.length === 0) throw new Error(`${name} must identify one GitHub repository`);
|
|
56
|
+
return `https://github.com/${owner}/${repository}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeGithubRepositories(
|
|
60
|
+
value,
|
|
61
|
+
name = "githubRepos",
|
|
62
|
+
{ allowLegacyScalar = false } = {},
|
|
63
|
+
) {
|
|
64
|
+
if (allowLegacyScalar && (value === null || typeof value === "string")) {
|
|
65
|
+
value = value === null ? [] : [value];
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be an array of GitHub repository URLs`);
|
|
68
|
+
const repositories = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const [index, repository] of value.entries()) {
|
|
71
|
+
const canonical = canonicalGithubRepository(repository, `${name}[${index}]`);
|
|
72
|
+
const key = canonical.toLowerCase();
|
|
73
|
+
if (!seen.has(key)) {
|
|
74
|
+
seen.add(key);
|
|
75
|
+
repositories.push(canonical);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return repositories;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function matchProjectForGithubUrl(value, projects) {
|
|
82
|
+
if (!Array.isArray(projects)) throw new Error("projects must be an array");
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = parseGithubUrl(value, "GitHub URL", { allowIssueOrPull: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return { status: "unmatched", repository: null, projects: [] };
|
|
88
|
+
}
|
|
89
|
+
const repository = `https://github.com/${parsed.owner}/${parsed.repository}`;
|
|
90
|
+
const key = repository.toLowerCase();
|
|
91
|
+
const matches = projects.filter((project) =>
|
|
92
|
+
Array.isArray(project?.githubRepos)
|
|
93
|
+
&& project.githubRepos.some((candidate) => {
|
|
94
|
+
try {
|
|
95
|
+
return canonicalGithubRepository(candidate).toLowerCase() === key;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
if (matches.length === 1) {
|
|
101
|
+
return { status: "matched", repository, project: matches[0], projects: matches };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
status: matches.length > 1 ? "ambiguous" : "unmatched",
|
|
105
|
+
repository,
|
|
106
|
+
projects: matches,
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/workspace.js
CHANGED
|
@@ -19,6 +19,10 @@ import path from "node:path";
|
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
20
|
import lockfile from "proper-lockfile";
|
|
21
21
|
import { normalizeDurableThreadId, parseTaskChefMarker } from "./delegation.js";
|
|
22
|
+
import {
|
|
23
|
+
canonicalGithubRepository,
|
|
24
|
+
normalizeGithubRepositories,
|
|
25
|
+
} from "./github.js";
|
|
22
26
|
|
|
23
27
|
const execFile = promisify(execFileCallback);
|
|
24
28
|
const DISPATCHER_INSTRUCTIONS_URL = new URL(
|
|
@@ -37,15 +41,18 @@ const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url))
|
|
|
37
41
|
const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
38
42
|
const DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
|
|
39
43
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
44
|
+
const CURRENT_SCHEMA_VERSION = 2;
|
|
45
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
40
46
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
41
47
|
const PROJECT_FIELDS = new Set([
|
|
42
48
|
"name",
|
|
43
49
|
"path",
|
|
44
50
|
"isGitRepository",
|
|
45
51
|
"githubRepo",
|
|
52
|
+
"githubRepos",
|
|
46
53
|
"description",
|
|
47
54
|
]);
|
|
48
|
-
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "
|
|
55
|
+
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
|
|
49
56
|
const DISPATCH_FIELDS = new Set([
|
|
50
57
|
"schemaVersion",
|
|
51
58
|
"id",
|
|
@@ -165,9 +172,9 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
|
165
172
|
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
166
173
|
}
|
|
167
174
|
|
|
168
|
-
async function
|
|
175
|
+
async function writeDispatchLinesAtomic(workspaceRoot, lines) {
|
|
169
176
|
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
170
|
-
const content =
|
|
177
|
+
const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
171
178
|
await writeTextAtomic(dispatchPath, content);
|
|
172
179
|
}
|
|
173
180
|
|
|
@@ -369,39 +376,23 @@ export async function canonicalDirectory(projectPath) {
|
|
|
369
376
|
return requested;
|
|
370
377
|
}
|
|
371
378
|
|
|
372
|
-
function
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
url = new URL(value);
|
|
378
|
-
} catch {
|
|
379
|
-
throw new Error(`${name} must be a canonical GitHub repository URL or null`);
|
|
380
|
-
}
|
|
381
|
-
if (
|
|
382
|
-
url.protocol !== "https:" ||
|
|
383
|
-
url.hostname !== "github.com" ||
|
|
384
|
-
url.username ||
|
|
385
|
-
url.password ||
|
|
386
|
-
url.port ||
|
|
387
|
-
url.search ||
|
|
388
|
-
url.hash ||
|
|
389
|
-
!/^\/[^/]+\/[^/]+$/.test(url.pathname) ||
|
|
390
|
-
url.pathname.endsWith(".git")
|
|
391
|
-
) {
|
|
392
|
-
throw new Error(`${name} must be a canonical GitHub repository URL or null`);
|
|
393
|
-
}
|
|
394
|
-
return value;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
379
|
+
async function normalizeProject(
|
|
380
|
+
project,
|
|
381
|
+
index,
|
|
382
|
+
{ checkPath = true, allowLegacyGithubRepo = false } = {},
|
|
383
|
+
) {
|
|
398
384
|
const field = `projects[${index}]`;
|
|
399
385
|
if (!project || typeof project !== "object" || Array.isArray(project)) {
|
|
400
386
|
throw new Error(`${field} must be an object`);
|
|
401
387
|
}
|
|
402
388
|
const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
|
|
403
389
|
if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
|
|
404
|
-
|
|
390
|
+
const repositoryField = allowLegacyGithubRepo ? "githubRepo" : "githubRepos";
|
|
391
|
+
const unsupportedRepositoryField = allowLegacyGithubRepo ? "githubRepos" : "githubRepo";
|
|
392
|
+
if (unsupportedRepositoryField in project) {
|
|
393
|
+
throw new Error(`${field} has unsupported field: ${unsupportedRepositoryField}`);
|
|
394
|
+
}
|
|
395
|
+
for (const required of ["name", "path", "isGitRepository", repositoryField]) {
|
|
405
396
|
if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
|
|
406
397
|
}
|
|
407
398
|
const name = requireString(project.name, `${field}.name`).trim();
|
|
@@ -419,15 +410,14 @@ async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
|
419
410
|
throw new Error(`${field}.path must be a normalized absolute path`);
|
|
420
411
|
}
|
|
421
412
|
}
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
413
|
+
const githubRepos = normalizeGithubRepositories(project[repositoryField], `${field}.${repositoryField}`, {
|
|
414
|
+
allowLegacyScalar: allowLegacyGithubRepo,
|
|
415
|
+
});
|
|
426
416
|
const normalized = {
|
|
427
417
|
name,
|
|
428
418
|
path: projectPath,
|
|
429
419
|
isGitRepository: project.isGitRepository,
|
|
430
|
-
|
|
420
|
+
githubRepos,
|
|
431
421
|
};
|
|
432
422
|
if ("description" in project) {
|
|
433
423
|
normalized.description = requireString(
|
|
@@ -438,11 +428,17 @@ async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
|
438
428
|
return normalized;
|
|
439
429
|
}
|
|
440
430
|
|
|
441
|
-
async function normalizeProjects(
|
|
431
|
+
async function normalizeProjects(
|
|
432
|
+
projects,
|
|
433
|
+
{ checkPaths = true, allowLegacyGithubRepo = false } = {},
|
|
434
|
+
) {
|
|
442
435
|
if (!Array.isArray(projects)) throw new Error("projects must be an array");
|
|
443
436
|
const normalized = [];
|
|
444
437
|
for (const [index, project] of projects.entries()) {
|
|
445
|
-
normalized.push(await normalizeProject(project, index, {
|
|
438
|
+
normalized.push(await normalizeProject(project, index, {
|
|
439
|
+
checkPath: checkPaths,
|
|
440
|
+
allowLegacyGithubRepo,
|
|
441
|
+
}));
|
|
446
442
|
}
|
|
447
443
|
if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
|
|
448
444
|
throw new Error("project paths must not contain duplicates");
|
|
@@ -458,16 +454,8 @@ async function normalizeProjects(projects, { checkPaths = true } = {}) {
|
|
|
458
454
|
|
|
459
455
|
function normalizeGithubRemote(remote) {
|
|
460
456
|
if (typeof remote !== "string" || remote.trim().length === 0) return null;
|
|
461
|
-
const value = remote.trim();
|
|
462
|
-
const scpMatch = value.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
463
|
-
if (scpMatch) return `https://github.com/${scpMatch[1]}/${scpMatch[2]}`;
|
|
464
|
-
const sshMatch = value.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
465
|
-
if (sshMatch) return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
|
|
466
457
|
try {
|
|
467
|
-
|
|
468
|
-
if (url.hostname !== "github.com") return null;
|
|
469
|
-
const match = url.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
470
|
-
return match ? `https://github.com/${match[1]}/${match[2]}` : null;
|
|
458
|
+
return canonicalGithubRepository(remote, "GitHub origin");
|
|
471
459
|
} catch {
|
|
472
460
|
return null;
|
|
473
461
|
}
|
|
@@ -507,10 +495,10 @@ async function inspectProject(input, index = 0) {
|
|
|
507
495
|
if (isGitRepository && gitRoot !== projectPath) {
|
|
508
496
|
throw new Error(`project must be the Git repository root: ${projectPath}`);
|
|
509
497
|
}
|
|
510
|
-
let
|
|
498
|
+
let githubRepos = [];
|
|
511
499
|
if (isGitRepository) {
|
|
512
|
-
if ("
|
|
513
|
-
|
|
500
|
+
if ("githubRepos" in input) {
|
|
501
|
+
githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
|
|
514
502
|
} else {
|
|
515
503
|
const remote = await execFile("git", ["remote", "get-url", "origin"], {
|
|
516
504
|
cwd: projectPath,
|
|
@@ -518,10 +506,11 @@ async function inspectProject(input, index = 0) {
|
|
|
518
506
|
if (error.code === 2 && /No such remote/i.test(error.stderr ?? "")) return null;
|
|
519
507
|
throw gitInspectionError("failed to inspect GitHub origin", error);
|
|
520
508
|
});
|
|
521
|
-
|
|
509
|
+
const detected = normalizeGithubRemote(remote);
|
|
510
|
+
githubRepos = detected === null ? [] : [detected];
|
|
522
511
|
}
|
|
523
|
-
} else if ("
|
|
524
|
-
|
|
512
|
+
} else if ("githubRepos" in input) {
|
|
513
|
+
githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
|
|
525
514
|
}
|
|
526
515
|
const project = {
|
|
527
516
|
name: "name" in input
|
|
@@ -529,7 +518,7 @@ async function inspectProject(input, index = 0) {
|
|
|
529
518
|
: path.basename(projectPath),
|
|
530
519
|
path: projectPath,
|
|
531
520
|
isGitRepository,
|
|
532
|
-
|
|
521
|
+
githubRepos,
|
|
533
522
|
};
|
|
534
523
|
if ("description" in input) {
|
|
535
524
|
project.description = requireString(input.description, `${field}.description`).trim();
|
|
@@ -539,10 +528,15 @@ async function inspectProject(input, index = 0) {
|
|
|
539
528
|
|
|
540
529
|
export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
541
530
|
requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
|
|
542
|
-
if (config.schemaVersion
|
|
531
|
+
if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(config.schemaVersion)) {
|
|
532
|
+
throw new Error("unsupported configuration schemaVersion");
|
|
533
|
+
}
|
|
543
534
|
return {
|
|
544
|
-
schemaVersion:
|
|
545
|
-
projects: await normalizeProjects(config.projects, {
|
|
535
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
536
|
+
projects: await normalizeProjects(config.projects, {
|
|
537
|
+
checkPaths,
|
|
538
|
+
allowLegacyGithubRepo: config.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
539
|
+
}),
|
|
546
540
|
};
|
|
547
541
|
}
|
|
548
542
|
|
|
@@ -561,15 +555,21 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
561
555
|
const root = await realpath(requestedRoot);
|
|
562
556
|
const configPath = path.join(root, "taskchef.json");
|
|
563
557
|
const configExists = await managedRegularFileExists(configPath);
|
|
558
|
+
const storedConfigVersion = configExists
|
|
559
|
+
? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
|
|
560
|
+
: null;
|
|
564
561
|
const config = configExists
|
|
565
562
|
? await readConfig(root, { checkPaths: false })
|
|
566
|
-
: { schemaVersion:
|
|
563
|
+
: { schemaVersion: CURRENT_SCHEMA_VERSION, projects: [] };
|
|
567
564
|
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
568
565
|
if (configExists && (await managedRegularFileExists(dispatchPath))) {
|
|
569
566
|
await readDispatchesUnlocked(root);
|
|
570
567
|
}
|
|
571
568
|
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
572
569
|
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
570
|
+
else if (storedConfigVersion !== CURRENT_SCHEMA_VERSION) {
|
|
571
|
+
await writeJsonAtomic(configPath, config);
|
|
572
|
+
}
|
|
573
573
|
const tasks = await ensureDispatchFile(root);
|
|
574
574
|
const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
|
|
575
575
|
if (!configExists) await unlink(configPath).catch(() => {});
|
|
@@ -577,7 +577,13 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
577
577
|
});
|
|
578
578
|
return {
|
|
579
579
|
workspace: root,
|
|
580
|
-
config: {
|
|
580
|
+
config: {
|
|
581
|
+
path: configPath,
|
|
582
|
+
action: !configExists
|
|
583
|
+
? "created"
|
|
584
|
+
: storedConfigVersion === CURRENT_SCHEMA_VERSION ? "unchanged" : "migrated",
|
|
585
|
+
value: config,
|
|
586
|
+
},
|
|
581
587
|
tasks,
|
|
582
588
|
instructions,
|
|
583
589
|
legacySkills,
|
|
@@ -604,7 +610,7 @@ export async function addProject(workspaceRoot, input) {
|
|
|
604
610
|
const config = await readConfig(root);
|
|
605
611
|
const project = await inspectProject(input);
|
|
606
612
|
const updated = await validateConfig({
|
|
607
|
-
schemaVersion:
|
|
613
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
608
614
|
projects: [...config.projects, project],
|
|
609
615
|
});
|
|
610
616
|
await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
|
|
@@ -624,6 +630,12 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
624
630
|
if (!("description" in mergedInput) && existing?.description) {
|
|
625
631
|
mergedInput.description = existing.description;
|
|
626
632
|
}
|
|
633
|
+
if (existing && !replace) {
|
|
634
|
+
const importedRepositories = "githubRepos" in mergedInput
|
|
635
|
+
? normalizeGithubRepositories(mergedInput.githubRepos, `projects[${index}].githubRepos`)
|
|
636
|
+
: [];
|
|
637
|
+
mergedInput.githubRepos = [...existing.githubRepos, ...importedRepositories];
|
|
638
|
+
}
|
|
627
639
|
imported.push(await inspectProject(mergedInput, index));
|
|
628
640
|
}
|
|
629
641
|
const projects = replace ? [] : [...current.projects];
|
|
@@ -632,7 +644,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
632
644
|
if (index === -1) projects.push(project);
|
|
633
645
|
else projects[index] = project;
|
|
634
646
|
}
|
|
635
|
-
const config = await validateConfig({ schemaVersion:
|
|
647
|
+
const config = await validateConfig({ schemaVersion: CURRENT_SCHEMA_VERSION, projects });
|
|
636
648
|
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
637
649
|
return {
|
|
638
650
|
mode: replace ? "replace" : "merge",
|
|
@@ -651,17 +663,25 @@ export async function removeProject(workspaceRoot, name) {
|
|
|
651
663
|
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
652
664
|
const [project] = config.projects.slice(index, index + 1);
|
|
653
665
|
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
654
|
-
await writeJsonAtomic(path.join(root, "taskchef.json"), {
|
|
666
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), {
|
|
667
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
668
|
+
projects,
|
|
669
|
+
});
|
|
655
670
|
return { project };
|
|
656
671
|
}
|
|
657
672
|
|
|
658
673
|
async function validateDispatchShape(dispatch, name = "task") {
|
|
659
674
|
requireExactFields(dispatch, DISPATCH_FIELDS, name);
|
|
660
|
-
if (dispatch.schemaVersion
|
|
675
|
+
if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(dispatch.schemaVersion)) {
|
|
676
|
+
throw new Error(`unsupported ${name} schemaVersion`);
|
|
677
|
+
}
|
|
661
678
|
const id = requireSafeId(dispatch.id, `${name}.id`);
|
|
662
|
-
const project = await normalizeProject(dispatch.project, 0, {
|
|
679
|
+
const project = await normalizeProject(dispatch.project, 0, {
|
|
680
|
+
checkPath: false,
|
|
681
|
+
allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
682
|
+
});
|
|
663
683
|
const normalized = {
|
|
664
|
-
schemaVersion:
|
|
684
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
665
685
|
id,
|
|
666
686
|
project,
|
|
667
687
|
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
@@ -680,7 +700,7 @@ async function validateDispatchShape(dispatch, name = "task") {
|
|
|
680
700
|
return normalized;
|
|
681
701
|
}
|
|
682
702
|
|
|
683
|
-
async function
|
|
703
|
+
async function readDispatchRecordsUnlocked(root) {
|
|
684
704
|
await readConfig(root, { checkPaths: false });
|
|
685
705
|
const filePath = path.join(root, DISPATCH_FILE_NAME);
|
|
686
706
|
if (!(await managedRegularFileExists(filePath))) {
|
|
@@ -691,7 +711,7 @@ async function readDispatchesUnlocked(root) {
|
|
|
691
711
|
throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
|
|
692
712
|
}
|
|
693
713
|
const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
|
|
694
|
-
const
|
|
714
|
+
const records = [];
|
|
695
715
|
for (const [index, line] of lines.entries()) {
|
|
696
716
|
if (line.trim().length === 0) {
|
|
697
717
|
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
|
|
@@ -702,11 +722,15 @@ async function readDispatchesUnlocked(root) {
|
|
|
702
722
|
} catch (error) {
|
|
703
723
|
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
|
|
704
724
|
}
|
|
705
|
-
|
|
725
|
+
records.push({
|
|
726
|
+
line,
|
|
727
|
+
raw: value,
|
|
728
|
+
normalized: await validateDispatchShape(value, `task line ${index + 1}`),
|
|
729
|
+
});
|
|
706
730
|
}
|
|
707
731
|
const ids = new Set();
|
|
708
732
|
const threadIds = new Set();
|
|
709
|
-
for (const dispatch of
|
|
733
|
+
for (const { normalized: dispatch } of records) {
|
|
710
734
|
if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
|
|
711
735
|
if (dispatch.threadId !== null && threadIds.has(dispatch.threadId)) {
|
|
712
736
|
throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
|
|
@@ -714,7 +738,11 @@ async function readDispatchesUnlocked(root) {
|
|
|
714
738
|
ids.add(dispatch.id);
|
|
715
739
|
if (dispatch.threadId !== null) threadIds.add(dispatch.threadId);
|
|
716
740
|
}
|
|
717
|
-
return
|
|
741
|
+
return records;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function readDispatchesUnlocked(root) {
|
|
745
|
+
return (await readDispatchRecordsUnlocked(root)).map((record) => record.normalized);
|
|
718
746
|
}
|
|
719
747
|
|
|
720
748
|
export async function listTasks(workspaceRoot) {
|
|
@@ -730,7 +758,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
730
758
|
const project = config.projects.find((candidate) => candidate.path === projectPath);
|
|
731
759
|
if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
|
|
732
760
|
const dispatch = await validateDispatchShape({
|
|
733
|
-
schemaVersion:
|
|
761
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
734
762
|
id: input.id,
|
|
735
763
|
project,
|
|
736
764
|
title: input.title,
|
|
@@ -759,7 +787,8 @@ export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
|
759
787
|
const durableThreadId = normalizeDurableThreadId(threadId);
|
|
760
788
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
761
789
|
return withDispatchLock(root, async () => {
|
|
762
|
-
const
|
|
790
|
+
const records = await readDispatchRecordsUnlocked(root);
|
|
791
|
+
const dispatches = records.map((record) => record.normalized);
|
|
763
792
|
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
764
793
|
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
765
794
|
const dispatch = dispatches[index];
|
|
@@ -774,8 +803,10 @@ export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
|
774
803
|
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
775
804
|
}
|
|
776
805
|
const resolved = { ...dispatch, threadId: durableThreadId };
|
|
777
|
-
|
|
778
|
-
|
|
806
|
+
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
807
|
+
? JSON.stringify({ ...record.raw, threadId: durableThreadId })
|
|
808
|
+
: record.line);
|
|
809
|
+
await writeDispatchLinesAtomic(root, lines);
|
|
779
810
|
return resolved;
|
|
780
811
|
});
|
|
781
812
|
}
|