taskchef 3.0.3 → 4.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 +1 -1
- package/README.md +20 -9
- package/SPEC.md +33 -12
- 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 +14 -4
- 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,6 +245,8 @@ 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>
|
|
@@ -255,9 +260,15 @@ taskchef project import projects.json --workspace <workspace>
|
|
|
255
260
|
taskchef project import - --workspace <workspace> < projects.json
|
|
256
261
|
```
|
|
257
262
|
|
|
258
|
-
Import merges by canonical path
|
|
259
|
-
when the imported object omits it
|
|
260
|
-
set.
|
|
263
|
+
Import merges by canonical path, preserves an existing name or description
|
|
264
|
+
when the imported object omits it, and unions repository lists without
|
|
265
|
+
duplicates. `--replace` replaces the configured project set.
|
|
266
|
+
|
|
267
|
+
The current configuration schema is version 2. Version 1 remains readable:
|
|
268
|
+
legacy `githubRepo: null` normalizes to `githubRepos: []`, and a legacy string
|
|
269
|
+
normalizes to a one-item `githubRepos` list. `workspace init` persists this
|
|
270
|
+
migration atomically; other configuration writes also emit version 2. Legacy
|
|
271
|
+
task lines remain readable without an eager rewrite of the append-only history.
|
|
261
272
|
|
|
262
273
|
### Task history
|
|
263
274
|
|
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`:
|
|
@@ -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,
|
|
@@ -113,6 +121,7 @@ async function projectAdd(args) {
|
|
|
113
121
|
validateCommandArgs(args, 3, {
|
|
114
122
|
values: ["--workspace", "--name", "--description", "--github-repo"],
|
|
115
123
|
switches: ["--json", "--no-github"],
|
|
124
|
+
repeatable: ["--github-repo"],
|
|
116
125
|
});
|
|
117
126
|
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
118
127
|
throw new Error("--no-github and --github-repo cannot be used together");
|
|
@@ -122,8 +131,8 @@ async function projectAdd(args) {
|
|
|
122
131
|
const description = option(args, "--description", null);
|
|
123
132
|
if (name !== null) input.name = name;
|
|
124
133
|
if (description !== null) input.description = description;
|
|
125
|
-
if (args.includes("--no-github")) input.
|
|
126
|
-
else if (args.includes("--github-repo")) input.
|
|
134
|
+
if (args.includes("--no-github")) input.githubRepos = [];
|
|
135
|
+
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
127
136
|
const project = await addProject(workspaceRoot(args), input);
|
|
128
137
|
print(project, args, (value) => `Added ${value.name}: ${value.path}`);
|
|
129
138
|
return 0;
|
|
@@ -152,10 +161,11 @@ async function projectList(args) {
|
|
|
152
161
|
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
153
162
|
const projects = await listProjects(workspaceRoot(args));
|
|
154
163
|
print({ projectCount: projects.length, projects }, args, (value) => table(
|
|
155
|
-
["NAME", "KIND", "PATH"],
|
|
164
|
+
["NAME", "KIND", "GITHUB REPOSITORIES", "PATH"],
|
|
156
165
|
value.projects.map((project) => [
|
|
157
166
|
project.name,
|
|
158
167
|
project.isGitRepository ? "git" : "folder",
|
|
168
|
+
project.githubRepos.join(", ") || "-",
|
|
159
169
|
project.path,
|
|
160
170
|
]),
|
|
161
171
|
));
|
|
@@ -240,7 +250,7 @@ Usage:
|
|
|
240
250
|
taskchef help
|
|
241
251
|
taskchef doctor [--json] [--workspace <path>]
|
|
242
252
|
taskchef workspace init [--json] [--workspace <path>]
|
|
243
|
-
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> | --no-github] [--json] [--workspace <path>]
|
|
253
|
+
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
244
254
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
245
255
|
taskchef project list [--json] [--workspace <path>]
|
|
246
256
|
taskchef project remove <name> [--json] [--workspace <path>]
|
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
|
}
|