taskchef 4.1.3 → 5.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/.codex-plugin/plugin.json +1 -1
- package/README.md +67 -40
- package/SPEC.md +20 -2
- package/assets/taskchef-dispatcher-instructions.md +3 -1
- package/index.js +11 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +22 -5
- package/skills/taskchef-bootstrap/agents/openai.yaml +2 -2
- package/skills/taskchef-delegate/SKILL.md +10 -6
- package/skills/taskchef-report/SKILL.md +10 -7
- package/src/cli.js +65 -12
- package/src/codex-app.js +77 -0
- package/src/workspace-path.js +44 -0
- package/src/workspace.js +143 -112
package/README.md
CHANGED
|
@@ -11,8 +11,8 @@ next request or open any executor and work with it directly.
|
|
|
11
11
|
|
|
12
12
|
## The mental model
|
|
13
13
|
|
|
14
|
-
- The **dispatcher workspace** is
|
|
15
|
-
task history. A Codex task opened in this folder is the
|
|
14
|
+
- The **dispatcher workspace** is the per-user `~/.agents/taskchef` folder that
|
|
15
|
+
stores project routes and task history. A Codex task opened in this folder is the
|
|
16
16
|
**dispatcher**.
|
|
17
17
|
- A **configured project** is a local repository or folder where TaskChef may
|
|
18
18
|
send work.
|
|
@@ -46,23 +46,16 @@ codex plugin marketplace add favoyang/codex-plugins
|
|
|
46
46
|
codex plugin add taskchef@favoyang-plugins
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
### 2.
|
|
49
|
+
### 2. Bootstrap the dispatcher workspace
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
```sh
|
|
54
|
-
mkdir -p ~/taskchef
|
|
55
|
-
cd ~/taskchef
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Add `~/taskchef` as a project in Codex. Start a new task in that project and
|
|
59
|
-
run the bootstrap skill:
|
|
51
|
+
Invoke the bootstrap skill from any Codex project:
|
|
60
52
|
|
|
61
53
|
```text
|
|
62
|
-
$taskchef-bootstrap Set up TaskChef
|
|
54
|
+
$taskchef-bootstrap Set up TaskChef.
|
|
63
55
|
```
|
|
64
56
|
|
|
65
|
-
Bootstrap creates
|
|
57
|
+
Bootstrap creates `~/.agents/taskchef`, opens it in the current Codex desktop
|
|
58
|
+
app when it is not already a saved local project, and creates:
|
|
66
59
|
|
|
67
60
|
```text
|
|
68
61
|
AGENTS.md
|
|
@@ -81,8 +74,9 @@ any of them route to the workspace. If a project needs more context, extend its
|
|
|
81
74
|
optional `description` field with responsibilities and keywords that
|
|
82
75
|
distinguish it from nearby projects.
|
|
83
76
|
|
|
84
|
-
The generated `AGENTS.md` turns ordinary requests in
|
|
85
|
-
delegated work.
|
|
77
|
+
The generated `AGENTS.md` turns ordinary requests in the TaskChef project into
|
|
78
|
+
delegated work. From any other project, explicitly invoke `$taskchef-delegate`;
|
|
79
|
+
it uses the same configuration and task history.
|
|
86
80
|
|
|
87
81
|
### 3. Delegate the first task
|
|
88
82
|
|
|
@@ -220,6 +214,7 @@ that create and inspect executor tasks. From a source checkout, use
|
|
|
220
214
|
```text
|
|
221
215
|
taskchef help
|
|
222
216
|
taskchef doctor
|
|
217
|
+
taskchef workspace path
|
|
223
218
|
taskchef workspace init
|
|
224
219
|
taskchef project add <path>
|
|
225
220
|
taskchef project import [<file> | -]
|
|
@@ -232,44 +227,77 @@ taskchef task list
|
|
|
232
227
|
taskchef task summary
|
|
233
228
|
```
|
|
234
229
|
|
|
235
|
-
Workspace
|
|
236
|
-
|
|
237
|
-
|
|
230
|
+
Workspace resolution is deterministic: `--workspace <path>`, then the
|
|
231
|
+
`TASKCHEF_WORKSPACE` environment variable, then `~/.agents/taskchef`. The
|
|
232
|
+
current directory is never an implicit workspace. Data commands accept
|
|
233
|
+
`--json` for machine-readable output. Run `taskchef help` for every option.
|
|
234
|
+
|
|
235
|
+
### One-time upgrade from an older workspace
|
|
236
|
+
|
|
237
|
+
TaskChef 5 does not include a general migration command. For a one-time upgrade,
|
|
238
|
+
stop delegating and validate the old workspace. Then perform this one-time copy
|
|
239
|
+
only when the destination does not already exist:
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
set -eu
|
|
243
|
+
old_workspace=/path/to/old-taskchef-workspace
|
|
244
|
+
new_workspace="$HOME/.agents/taskchef"
|
|
245
|
+
backup_workspace="$old_workspace.pre-taskchef-5-backup"
|
|
246
|
+
if [ -e "$new_workspace" ]; then
|
|
247
|
+
printf '%s\n' "Refusing to overwrite existing destination: $new_workspace" >&2
|
|
248
|
+
exit 1
|
|
249
|
+
fi
|
|
250
|
+
if [ -e "$backup_workspace" ]; then
|
|
251
|
+
printf '%s\n' "Refusing to overwrite existing backup: $backup_workspace" >&2
|
|
252
|
+
exit 1
|
|
253
|
+
fi
|
|
254
|
+
taskchef doctor --workspace "$old_workspace"
|
|
255
|
+
cp -pR "$old_workspace" "$backup_workspace"
|
|
256
|
+
install -d -m 700 "$new_workspace"
|
|
257
|
+
install -m 600 "$old_workspace/AGENTS.md" "$new_workspace/AGENTS.md"
|
|
258
|
+
install -m 600 "$old_workspace/taskchef.json" "$new_workspace/taskchef.json"
|
|
259
|
+
install -m 600 "$old_workspace/tasks.jsonl" "$new_workspace/tasks.jsonl"
|
|
260
|
+
taskchef workspace init --workspace "$new_workspace" --register-codex
|
|
261
|
+
taskchef doctor --workspace "$new_workspace"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Keep the backup and old saved Codex project until `project list`, `task list`,
|
|
265
|
+
and `doctor` confirm the expected project and task counts. Do not merge several
|
|
266
|
+
histories by hand; conflicting task or thread IDs require case-by-case review.
|
|
238
267
|
|
|
239
268
|
### Project administration
|
|
240
269
|
|
|
241
270
|
```sh
|
|
242
|
-
taskchef workspace init
|
|
243
|
-
taskchef doctor
|
|
271
|
+
taskchef workspace init
|
|
272
|
+
taskchef doctor
|
|
244
273
|
|
|
245
274
|
taskchef project add /workspace/payments \
|
|
246
275
|
--name payments \
|
|
247
276
|
--description "Owns payment authorization, capture, and refunds." \
|
|
248
277
|
--github-repo https://github.com/example/payments-api \
|
|
249
|
-
--github-repo https://github.com/example/payments-sdk
|
|
250
|
-
--workspace <workspace>
|
|
278
|
+
--github-repo https://github.com/example/payments-sdk
|
|
251
279
|
|
|
252
|
-
taskchef project list
|
|
253
|
-
taskchef project remove payments
|
|
280
|
+
taskchef project list
|
|
281
|
+
taskchef project remove payments
|
|
254
282
|
```
|
|
255
283
|
|
|
256
|
-
Human-readable project listings
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
repository column:
|
|
284
|
+
Human-readable project listings group configured GitHub repositories by
|
|
285
|
+
project. The first row shows the project details; additional repository rows
|
|
286
|
+
leave the repeated name, kind, and path columns blank. A project without a
|
|
287
|
+
configured repository has one row containing `-` in the repository column:
|
|
260
288
|
|
|
261
289
|
```text
|
|
262
290
|
NAME KIND GITHUB REPOSITORY PATH
|
|
263
291
|
notes folder - /workspace/notes
|
|
264
292
|
payments git https://github.com/example/payments-api /workspace/payments
|
|
265
|
-
|
|
293
|
+
https://github.com/example/payments-sdk
|
|
266
294
|
```
|
|
267
295
|
|
|
268
296
|
Import projects as a JSON array from a file or standard input:
|
|
269
297
|
|
|
270
298
|
```sh
|
|
271
|
-
taskchef project import projects.json
|
|
272
|
-
taskchef project import -
|
|
299
|
+
taskchef project import projects.json
|
|
300
|
+
taskchef project import - < projects.json
|
|
273
301
|
```
|
|
274
302
|
|
|
275
303
|
Import merges by canonical path, preserves an existing name or description
|
|
@@ -289,7 +317,7 @@ task lines remain readable without an eager rewrite of the append-only history.
|
|
|
289
317
|
|
|
290
318
|
```sh
|
|
291
319
|
printf '%s\n' '{"id":"c0f010ff-84f2-4838-a69d-0ff1f5d721d7","project":"/workspace/payments","title":"Add retry logs","instruction":"# taskchef_id=c0f010ff-84f2-4838-a69d-0ff1f5d721d7\n\nAdd structured logs for failed retries and test them.","threadId":"019f..."}' |
|
|
292
|
-
taskchef task record --json
|
|
320
|
+
taskchef task record --json
|
|
293
321
|
```
|
|
294
322
|
|
|
295
323
|
If a task has `threadId: null`, Codex can later find its exact marker and pass
|
|
@@ -298,18 +326,17 @@ one-way transition from null to one unique thread ID:
|
|
|
298
326
|
|
|
299
327
|
```sh
|
|
300
328
|
taskchef task resolve c0f010ff-84f2-4838-a69d-0ff1f5d721d7 \
|
|
301
|
-
--thread-id 019f9d46-f42c-7482-9707-3c107bf241ee
|
|
302
|
-
--workspace <workspace>
|
|
329
|
+
--thread-id 019f9d46-f42c-7482-9707-3c107bf241ee
|
|
303
330
|
```
|
|
304
331
|
|
|
305
332
|
Inspect the task history without querying Codex tasks:
|
|
306
333
|
|
|
307
334
|
```sh
|
|
308
|
-
taskchef task show t1
|
|
309
|
-
taskchef task list
|
|
310
|
-
taskchef task list --project payments
|
|
311
|
-
taskchef task list --ascending
|
|
312
|
-
taskchef task summary
|
|
335
|
+
taskchef task show t1
|
|
336
|
+
taskchef task list
|
|
337
|
+
taskchef task list --project payments
|
|
338
|
+
taskchef task list --ascending
|
|
339
|
+
taskchef task summary
|
|
313
340
|
```
|
|
314
341
|
|
|
315
342
|
Human-readable task listings put the scannable fields first and the durable ID
|
package/SPEC.md
CHANGED
|
@@ -12,7 +12,8 @@ not maintain a second lifecycle database.
|
|
|
12
12
|
|
|
13
13
|
## Core behavior
|
|
14
14
|
|
|
15
|
-
1. The user submits a request in the dispatcher workspace
|
|
15
|
+
1. The user submits a request in the dispatcher workspace or explicitly invokes
|
|
16
|
+
the delegation skill from another Codex project.
|
|
16
17
|
2. TaskChef separates only outcomes that can proceed independently.
|
|
17
18
|
3. It selects each target using configured project metadata and validates the
|
|
18
19
|
selected local path.
|
|
@@ -29,7 +30,7 @@ Several active executors may target the same project.
|
|
|
29
30
|
## Workspace layout
|
|
30
31
|
|
|
31
32
|
```text
|
|
32
|
-
taskchef/
|
|
33
|
+
~/.agents/taskchef/
|
|
33
34
|
├── AGENTS.md
|
|
34
35
|
├── taskchef.json
|
|
35
36
|
└── tasks.jsonl
|
|
@@ -44,10 +45,27 @@ instructions and refreshes only the managed block.
|
|
|
44
45
|
log when missing, refreshes managed instructions, and removes legacy TaskChef
|
|
45
46
|
skill symlinks.
|
|
46
47
|
|
|
48
|
+
Every command resolves one workspace in this precedence order:
|
|
49
|
+
|
|
50
|
+
1. explicit `--workspace <path>`
|
|
51
|
+
2. `TASKCHEF_WORKSPACE`
|
|
52
|
+
3. `~/.agents/taskchef`
|
|
53
|
+
|
|
54
|
+
The current directory is never an implicit workspace. `workspace path` exposes
|
|
55
|
+
the resolved absolute path and its source. Bootstrap compares this canonical
|
|
56
|
+
path with native Codex projects, and when absent invokes the validated
|
|
57
|
+
`codex app <path>` command before verifying the native list again. It never
|
|
58
|
+
uses `codex add` or a hard-coded application bundle path.
|
|
59
|
+
|
|
47
60
|
`doctor` validates configuration, project paths, the JSONL log, managed
|
|
48
61
|
instructions, and the absence of legacy TaskChef skill links without modifying
|
|
49
62
|
the workspace.
|
|
50
63
|
|
|
64
|
+
Neither the workspace nor any directory containing it can be configured as a
|
|
65
|
+
delegation project. All project configuration and task-history mutations share
|
|
66
|
+
one cross-process workspace lock. Writers reread and validate state while
|
|
67
|
+
holding that lock and publish complete files by atomic replacement.
|
|
68
|
+
|
|
51
69
|
## Project configuration
|
|
52
70
|
|
|
53
71
|
`taskchef.json` is the user-facing routing configuration:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- taskchef:dispatcher-instructions:start -->
|
|
2
2
|
# TaskChef Dispatcher Instructions
|
|
3
3
|
|
|
4
|
-
This
|
|
4
|
+
This folder is the canonical per-user TaskChef dispatcher workspace.
|
|
5
5
|
|
|
6
6
|
- Use `$taskchef-bootstrap` when initializing or refreshing this workspace,
|
|
7
7
|
changing or listing its configured projects, running TaskChef doctor,
|
|
@@ -18,4 +18,6 @@ This repository is a TaskChef dispatcher workspace.
|
|
|
18
18
|
- Use `$taskchef-report` only when the user asks for a live report about
|
|
19
19
|
delegated work. Reports query the recorded Codex tasks once and do not
|
|
20
20
|
write status or results to this workspace.
|
|
21
|
+
- Explicit invocations of TaskChef skills from other Codex projects use this
|
|
22
|
+
same workspace and task history through TaskChef's global resolution rules.
|
|
21
23
|
<!-- taskchef:dispatcher-instructions:end -->
|
package/index.js
CHANGED
|
@@ -42,3 +42,14 @@ export {
|
|
|
42
42
|
structuredDelegatedInputs,
|
|
43
43
|
taskChefMarker,
|
|
44
44
|
} from "./src/delegation.js";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
TASKCHEF_WORKSPACE_ENV,
|
|
48
|
+
defaultWorkspacePath,
|
|
49
|
+
resolveWorkspacePath,
|
|
50
|
+
} from "./src/workspace-path.js";
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
discoverCodexCli,
|
|
54
|
+
openWorkspaceInCodex,
|
|
55
|
+
} from "./src/codex-app.js";
|
package/package.json
CHANGED
|
@@ -5,7 +5,8 @@ description: "Initialize, diagnose, or refresh TaskChef dispatcher workspaces, p
|
|
|
5
5
|
|
|
6
6
|
# TaskChef Bootstrap
|
|
7
7
|
|
|
8
|
-
Initialize or refresh
|
|
8
|
+
Initialize or refresh the per-user TaskChef dispatcher workspace at
|
|
9
|
+
`~/.agents/taskchef`.
|
|
9
10
|
|
|
10
11
|
Resolve this skill directory with `realpath`. The TaskChef plugin root is two
|
|
11
12
|
parents above the skill directory. Invoke `<plugin-root>/bin/taskchef.js` for
|
|
@@ -22,21 +23,37 @@ all deterministic workspace operations.
|
|
|
22
23
|
|
|
23
24
|
## Initialize and repair
|
|
24
25
|
|
|
25
|
-
1. Run `workspace
|
|
26
|
+
1. Run `workspace path --json` and use its returned canonical path for native
|
|
27
|
+
project comparisons. The CLI resolves `--workspace`, then
|
|
28
|
+
`TASKCHEF_WORKSPACE`, then `~/.agents/taskchef`; do not infer a workspace
|
|
29
|
+
from the current project.
|
|
30
|
+
2. List native Codex projects once. If an exact canonical-path local project
|
|
31
|
+
already exists, run `workspace init --json`. Otherwise run
|
|
32
|
+
`workspace init --register-codex --json`, then list native projects once more
|
|
33
|
+
and require one exact canonical-path local project. `--register-codex`
|
|
34
|
+
invokes the supported `codex app <path>` command through a validated Codex
|
|
35
|
+
CLI discovered from the current desktop environment; never invoke
|
|
36
|
+
`codex add` or hard-code an application bundle path.
|
|
37
|
+
3. `workspace init` takes no stdin, creates an empty
|
|
26
38
|
configuration when missing, creates the append-only task log, refreshes
|
|
27
39
|
managed instructions, and removes legacy TaskChef skill links. The installed
|
|
28
40
|
plugin provides all three TaskChef skills outside the dispatcher workspace.
|
|
29
|
-
|
|
41
|
+
4. Run `doctor --json` after setup or when the user asks to diagnose the
|
|
30
42
|
workspace. Doctor is read-only. Rerun `workspace init --json` to repair the
|
|
31
43
|
managed scaffold.
|
|
32
|
-
|
|
44
|
+
5. Report the actions or failed checks. A successful initialization with failed
|
|
45
|
+
Codex opening remains initialized but not verified as a saved local project.
|
|
46
|
+
End without dispatching unless the user
|
|
33
47
|
separately requested work.
|
|
34
48
|
|
|
35
49
|
## Configure projects
|
|
36
50
|
|
|
37
|
-
1.
|
|
51
|
+
1. Reuse the native Codex project list from initialization when available.
|
|
52
|
+
Configure only
|
|
38
53
|
projects local to the TaskChef workspace's execution host. Remote connection
|
|
39
54
|
projects are outside the v1 contract.
|
|
55
|
+
Never configure the TaskChef dispatcher workspace or a directory containing
|
|
56
|
+
it as a routing target.
|
|
40
57
|
2. Add one project with `project add <path>`, normally supplying `--name` and a
|
|
41
58
|
curated `--description`. The CLI detects Git status, exact Git root, and a
|
|
42
59
|
canonical GitHub `origin`. Repeat `--github-repo <url>` to advertise several
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "TaskChef Bootstrap"
|
|
3
|
-
short_description: "Bootstrap TaskChef
|
|
4
|
-
default_prompt: "Use $taskchef-bootstrap to initialize or refresh
|
|
3
|
+
short_description: "Bootstrap the per-user TaskChef workspace"
|
|
4
|
+
default_prompt: "Use $taskchef-bootstrap to initialize or refresh my per-user TaskChef dispatcher workspace."
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: taskchef-delegate
|
|
3
|
-
description: "Dispatch actionable requests
|
|
3
|
+
description: "Dispatch actionable requests through the per-user TaskChef workspace into independently openable Codex project tasks. Use for ordinary work requests in the TaskChef project, explicit delegation from any project, or splitting independent work across projects. Preserve unresolved delegations for later marker-based recovery, and never use subagents, hooks, schedules, daemons, or executor-completion waiting."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# TaskChef Delegate
|
|
7
7
|
|
|
8
|
-
Create real Codex tasks
|
|
8
|
+
Create real Codex tasks through the canonical per-user TaskChef data workspace
|
|
9
|
+
and return immediately.
|
|
9
10
|
|
|
10
11
|
Resolve this skill directory with `realpath`. The TaskChef plugin root is two
|
|
11
12
|
parents above the skill directory. Use the TaskChef executable under that root
|
|
@@ -26,10 +27,13 @@ for all deterministic workspace and task-record operations.
|
|
|
26
27
|
|
|
27
28
|
## Dispatch
|
|
28
29
|
|
|
29
|
-
1. Run
|
|
30
|
-
`<plugin-root>/bin/taskchef.js project list --json
|
|
30
|
+
1. Run `<plugin-root>/bin/taskchef.js workspace path --json`, then run
|
|
31
|
+
`<plugin-root>/bin/taskchef.js project list --json`
|
|
31
32
|
to load and validate the configured routing targets. Use
|
|
32
33
|
`$taskchef-bootstrap` if the workspace is missing or unhealthy.
|
|
34
|
+
The CLI resolves `--workspace`, then `TASKCHEF_WORKSPACE`, then
|
|
35
|
+
`~/.agents/taskchef`; do not substitute the current project. Reject the
|
|
36
|
+
dispatcher workspace itself as a target.
|
|
33
37
|
2. Split the request into the smallest independently useful outcomes. Include
|
|
34
38
|
constraints, expected testing, and reporting in every instruction.
|
|
35
39
|
3. Classify against configured `name`, every URL in the `githubRepos` list, and
|
|
@@ -50,7 +54,7 @@ for all deterministic workspace and task-record operations.
|
|
|
50
54
|
6. Create one real Codex task using the exact configured project, a local
|
|
51
55
|
environment on its executor host, the marked instruction, and a short title.
|
|
52
56
|
7. When `create_thread` returns a durable `threadId`, immediately run
|
|
53
|
-
`<plugin-root>/bin/taskchef.js task record --json
|
|
57
|
+
`<plugin-root>/bin/taskchef.js task record --json`.
|
|
54
58
|
Send exactly `id`, `project`, `title`, `instruction`, and `threadId` as JSON
|
|
55
59
|
on stdin. Use the configured project path for `project`, and send the marked
|
|
56
60
|
instruction unchanged. Never persist a provisional `clientThreadId` or
|
|
@@ -114,6 +118,6 @@ Codex and use the CLI only for validated workspace data operations.
|
|
|
114
118
|
|
|
115
119
|
When a later Codex workflow finds exactly one durable thread whose structured
|
|
116
120
|
delegated input contains an unresolved task's exact marker, run
|
|
117
|
-
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json
|
|
121
|
+
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json`.
|
|
118
122
|
Never edit `tasks.jsonl` directly. The CLI permits only an idempotent one-way
|
|
119
123
|
transition from `threadId: null` to one unique durable thread ID.
|
|
@@ -5,8 +5,8 @@ description: "Report the live state of Codex tasks recorded in a TaskChef task h
|
|
|
5
5
|
|
|
6
6
|
# TaskChef Report
|
|
7
7
|
|
|
8
|
-
Read the TaskChef task history and report the current state
|
|
9
|
-
tasks once.
|
|
8
|
+
Read the canonical per-user TaskChef task history and report the current state
|
|
9
|
+
of its Codex tasks once.
|
|
10
10
|
|
|
11
11
|
Resolve this skill directory with `realpath`. The TaskChef plugin root is two
|
|
12
12
|
parents above the skill directory. Invoke `<plugin-root>/bin/taskchef.js` for
|
|
@@ -14,13 +14,16 @@ all deterministic task-log operations.
|
|
|
14
14
|
|
|
15
15
|
## Report
|
|
16
16
|
|
|
17
|
-
1.
|
|
17
|
+
1. Run `<plugin-root>/bin/taskchef.js workspace path --json`. The CLI resolves
|
|
18
|
+
`--workspace`, then `TASKCHEF_WORKSPACE`, then `~/.agents/taskchef`; never
|
|
19
|
+
infer the history from the current project. Select only the tasks the user
|
|
20
|
+
asked about:
|
|
18
21
|
- For an exact task ID, run
|
|
19
|
-
`<plugin-root>/bin/taskchef.js task show <task-id> --json
|
|
22
|
+
`<plugin-root>/bin/taskchef.js task show <task-id> --json`.
|
|
20
23
|
- For a project, run
|
|
21
|
-
`<plugin-root>/bin/taskchef.js task list --project <name-or-path> --json
|
|
24
|
+
`<plugin-root>/bin/taskchef.js task list --project <name-or-path> --json`.
|
|
22
25
|
- For a title or other description, run
|
|
23
|
-
`<plugin-root>/bin/taskchef.js task list --json
|
|
26
|
+
`<plugin-root>/bin/taskchef.js task list --json`
|
|
24
27
|
once, then select matching entries. Ask the user if the match is ambiguous.
|
|
25
28
|
- Use the full list only when the user asks for an overview of the task history.
|
|
26
29
|
2. Separate entries whose `threadId` is `null`. For those entries, take one
|
|
@@ -28,7 +31,7 @@ all deterministic task-log operations.
|
|
|
28
31
|
and inspect candidate structured delegated inputs. Use title only to
|
|
29
32
|
prioritize candidates, never to exclude them. When exactly one candidate
|
|
30
33
|
starts with the task's exact marker, run
|
|
31
|
-
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json
|
|
34
|
+
`<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json`.
|
|
32
35
|
Do not resolve zero or multiple matches. Report unmatched entries as
|
|
33
36
|
recorded but unresolved and do not pass them to native thread tools.
|
|
34
37
|
3. Query every resolved or previously durable thread exactly once using
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { access, readFile, realpath } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { openWorkspaceInCodex } from "./codex-app.js";
|
|
5
|
+
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
6
|
+
|
|
4
7
|
import {
|
|
5
8
|
addProject,
|
|
6
9
|
buildTaskSummary,
|
|
@@ -15,6 +18,8 @@ import {
|
|
|
15
18
|
resolveTask,
|
|
16
19
|
} from "./workspace.js";
|
|
17
20
|
|
|
21
|
+
const BLANK_TABLE_CELL = Symbol("blank table cell");
|
|
22
|
+
|
|
18
23
|
async function readStdin() {
|
|
19
24
|
let input = "";
|
|
20
25
|
process.stdin.setEncoding("utf8");
|
|
@@ -75,8 +80,14 @@ function validateCommandArgs(
|
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
function workspaceSelection(args) {
|
|
84
|
+
return resolveWorkspacePath({
|
|
85
|
+
explicit: args.includes("--workspace") ? option(args, "--workspace") : null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
function workspaceRoot(args) {
|
|
79
|
-
return
|
|
90
|
+
return workspaceSelection(args).workspace;
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
function print(value, args, human) {
|
|
@@ -88,9 +99,11 @@ function print(value, args, human) {
|
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
function table(headers, rows) {
|
|
91
|
-
const display = (value) =>
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
const display = (value) => {
|
|
103
|
+
if (value === BLANK_TABLE_CELL) return "";
|
|
104
|
+
if (value === null || value === undefined || value === "") return "-";
|
|
105
|
+
return String(value);
|
|
106
|
+
};
|
|
94
107
|
const widths = headers.map((header, index) =>
|
|
95
108
|
Math.max(header.length, ...rows.map((row) => display(row[index]).length)));
|
|
96
109
|
const format = (row) => row.map((value, index) => index === row.length - 1
|
|
@@ -101,13 +114,18 @@ function table(headers, rows) {
|
|
|
101
114
|
|
|
102
115
|
function projectRows(projects) {
|
|
103
116
|
return projects.flatMap((project) => {
|
|
104
|
-
const
|
|
105
|
-
return
|
|
117
|
+
const [primaryRepository = null, ...additionalRepositories] = project.githubRepos;
|
|
118
|
+
return [[
|
|
106
119
|
project.name,
|
|
107
120
|
project.isGitRepository ? "git" : "folder",
|
|
108
|
-
|
|
121
|
+
primaryRepository,
|
|
109
122
|
project.path,
|
|
110
|
-
])
|
|
123
|
+
], ...additionalRepositories.map((repository) => [
|
|
124
|
+
BLANK_TABLE_CELL,
|
|
125
|
+
BLANK_TABLE_CELL,
|
|
126
|
+
repository,
|
|
127
|
+
BLANK_TABLE_CELL,
|
|
128
|
+
])];
|
|
111
129
|
});
|
|
112
130
|
}
|
|
113
131
|
|
|
@@ -127,15 +145,46 @@ function sortTasksByCreatedAt(tasks, ascending) {
|
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
async function initialize(args) {
|
|
130
|
-
validateCommandArgs(args, 2, {
|
|
131
|
-
|
|
148
|
+
validateCommandArgs(args, 2, {
|
|
149
|
+
values: ["--workspace", "--codex-cli"],
|
|
150
|
+
switches: ["--json", "--register-codex"],
|
|
151
|
+
});
|
|
152
|
+
const resolution = workspaceSelection(args);
|
|
153
|
+
const result = await initializeWorkspace(resolution.workspace);
|
|
154
|
+
result.resolutionSource = resolution.source;
|
|
155
|
+
let registrationFailed = false;
|
|
156
|
+
if (args.includes("--register-codex")) {
|
|
157
|
+
try {
|
|
158
|
+
result.registration = await openWorkspaceInCodex(result.workspace, {
|
|
159
|
+
explicit: args.includes("--codex-cli") ? option(args, "--codex-cli") : null,
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
registrationFailed = true;
|
|
163
|
+
result.registration = { status: "failed", reason: error.message };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
132
166
|
print(result, args, (value) => [
|
|
133
167
|
`Workspace: ${value.workspace}`,
|
|
134
168
|
`Configuration: ${value.config.action}`,
|
|
135
169
|
`Task log: ${value.tasks.action}`,
|
|
136
170
|
`Instructions: ${value.instructions.action}`,
|
|
137
171
|
`Legacy skill links removed: ${value.legacySkills.removed.length}`,
|
|
172
|
+
...(value.registration ? [`Codex opening: ${value.registration.status}`] : []),
|
|
138
173
|
].join("\n"));
|
|
174
|
+
return registrationFailed ? 5 : 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function workspacePath(args) {
|
|
178
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
179
|
+
const resolution = workspaceSelection(args);
|
|
180
|
+
const exists = await access(resolution.workspace).then(() => true).catch(() => false);
|
|
181
|
+
const workspace = exists ? await realpath(resolution.workspace) : resolution.workspace;
|
|
182
|
+
print({
|
|
183
|
+
schemaVersion: 1,
|
|
184
|
+
workspace,
|
|
185
|
+
source: resolution.source,
|
|
186
|
+
exists,
|
|
187
|
+
}, args, (value) => value.workspace);
|
|
139
188
|
return 0;
|
|
140
189
|
}
|
|
141
190
|
|
|
@@ -277,7 +326,8 @@ function usage() {
|
|
|
277
326
|
Usage:
|
|
278
327
|
taskchef help
|
|
279
328
|
taskchef doctor [--json] [--workspace <path>]
|
|
280
|
-
taskchef workspace
|
|
329
|
+
taskchef workspace path [--json] [--workspace <path>]
|
|
330
|
+
taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
|
|
281
331
|
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
282
332
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
283
333
|
taskchef project list [--json] [--workspace <path>]
|
|
@@ -290,6 +340,8 @@ Usage:
|
|
|
290
340
|
|
|
291
341
|
Task record reads JSON from standard input. Project import reads a JSON
|
|
292
342
|
array from a file, or from standard input when the source is '-' or omitted.
|
|
343
|
+
Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
|
|
344
|
+
~/.agents/taskchef.
|
|
293
345
|
`);
|
|
294
346
|
}
|
|
295
347
|
|
|
@@ -299,6 +351,7 @@ export async function runCli(args) {
|
|
|
299
351
|
return 0;
|
|
300
352
|
}
|
|
301
353
|
if (args[0] === "doctor") return doctor(args);
|
|
354
|
+
if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
|
|
302
355
|
if (args[0] === "workspace" && args[1] === "init") return initialize(args);
|
|
303
356
|
if (args[0] === "project" && args[1] === "add") return projectAdd(args);
|
|
304
357
|
if (args[0] === "project" && args[1] === "import") return projectImport(args);
|
package/src/codex-app.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCallback);
|
|
7
|
+
const CODEX_COMMAND_TIMEOUT_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
function runCodex(run, filePath, args) {
|
|
10
|
+
return run(filePath, args, { timeout: CODEX_COMMAND_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function executable(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
await access(filePath, 1);
|
|
16
|
+
return path.resolve(filePath);
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pathCandidates(env) {
|
|
23
|
+
return (env.PATH ?? "")
|
|
24
|
+
.split(path.delimiter)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((directory) => path.join(directory, process.platform === "win32" ? "codex.exe" : "codex"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function supportsAppCommand(filePath, run) {
|
|
30
|
+
try {
|
|
31
|
+
const { stdout, stderr } = await runCodex(run, filePath, ["app", "--help"]);
|
|
32
|
+
return /(?:^|\n)Usage:\s+codex\s+app(?:\s|$)/.test(`${stdout}\n${stderr}`);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function discoverCodexCli({
|
|
39
|
+
explicit = null,
|
|
40
|
+
env = process.env,
|
|
41
|
+
run = execFile,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const override = explicit ?? env.TASKCHEF_CODEX_CLI ?? null;
|
|
44
|
+
if (override !== null) {
|
|
45
|
+
const candidate = await executable(path.resolve(override));
|
|
46
|
+
if (!candidate) throw new Error(`Codex CLI is not executable: ${override}`);
|
|
47
|
+
if (!(await supportsAppCommand(candidate, run))) {
|
|
48
|
+
throw new Error(`Codex CLI does not support the app command: ${candidate}`);
|
|
49
|
+
}
|
|
50
|
+
return { path: candidate, source: explicit !== null ? "explicit" : "environment" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let candidate = null;
|
|
54
|
+
for (const pathCandidate of pathCandidates(env)) {
|
|
55
|
+
candidate = await executable(pathCandidate);
|
|
56
|
+
if (candidate) break;
|
|
57
|
+
}
|
|
58
|
+
if (!candidate) throw new Error("Codex CLI was not found in PATH; pass --codex-cli");
|
|
59
|
+
if (!(await supportsAppCommand(candidate, run))) {
|
|
60
|
+
throw new Error(`Codex CLI does not support the app command: ${candidate}`);
|
|
61
|
+
}
|
|
62
|
+
const bundled = candidate.includes(`${path.sep}Contents${path.sep}Resources${path.sep}`);
|
|
63
|
+
return { path: candidate, source: bundled ? "desktop-path" : "path" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function openWorkspaceInCodex(workspace, options = {}) {
|
|
67
|
+
const run = options.run ?? execFile;
|
|
68
|
+
const cli = await discoverCodexCli({ ...options, run });
|
|
69
|
+
await runCodex(run, cli.path, ["app", workspace]);
|
|
70
|
+
return {
|
|
71
|
+
status: "requested",
|
|
72
|
+
mechanism: "codex-app",
|
|
73
|
+
codexCli: cli.path,
|
|
74
|
+
codexCliSource: cli.source,
|
|
75
|
+
workspace,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TASKCHEF_WORKSPACE_ENV = "TASKCHEF_WORKSPACE";
|
|
5
|
+
|
|
6
|
+
export function defaultWorkspacePath({ homedir = os.homedir() } = {}) {
|
|
7
|
+
return path.join(homedir, ".agents", "taskchef");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function expandHome(value, homedir) {
|
|
11
|
+
if (value === "~") return homedir;
|
|
12
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
13
|
+
return path.join(homedir, value.slice(2));
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveWorkspacePath({
|
|
19
|
+
explicit = null,
|
|
20
|
+
env = process.env,
|
|
21
|
+
homedir = os.homedir(),
|
|
22
|
+
cwd = process.cwd(),
|
|
23
|
+
} = {}) {
|
|
24
|
+
let source = "default";
|
|
25
|
+
let value = defaultWorkspacePath({ homedir });
|
|
26
|
+
if (env[TASKCHEF_WORKSPACE_ENV] !== undefined) {
|
|
27
|
+
if (typeof env[TASKCHEF_WORKSPACE_ENV] !== "string" || env[TASKCHEF_WORKSPACE_ENV].trim() === "") {
|
|
28
|
+
throw new Error(`${TASKCHEF_WORKSPACE_ENV} must be a non-empty path`);
|
|
29
|
+
}
|
|
30
|
+
source = "environment";
|
|
31
|
+
value = env[TASKCHEF_WORKSPACE_ENV];
|
|
32
|
+
}
|
|
33
|
+
if (explicit !== null) {
|
|
34
|
+
if (typeof explicit !== "string" || explicit.trim() === "") {
|
|
35
|
+
throw new Error("--workspace must be a non-empty path");
|
|
36
|
+
}
|
|
37
|
+
source = "explicit";
|
|
38
|
+
value = explicit;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
workspace: path.resolve(cwd, expandHome(value, homedir)),
|
|
42
|
+
source,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile as execFileCallback } from "node:child_process";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
|
+
chmod,
|
|
4
5
|
lstat,
|
|
5
6
|
mkdir,
|
|
6
7
|
readFile,
|
|
@@ -39,7 +40,7 @@ const TASKCHEF_SKILL_NAMES = [
|
|
|
39
40
|
const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
|
|
40
41
|
const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
41
42
|
const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
42
|
-
const
|
|
43
|
+
const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
|
|
43
44
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
44
45
|
const CURRENT_SCHEMA_VERSION = 2;
|
|
45
46
|
const LEGACY_SCHEMA_VERSION = 1;
|
|
@@ -147,14 +148,22 @@ async function managedRegularFileExists(filePath) {
|
|
|
147
148
|
return true;
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
function assertWorkspaceOutsideProject(workspaceRoot, projectPath) {
|
|
152
|
+
const relative = path.relative(projectPath, workspaceRoot);
|
|
153
|
+
if (relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"TaskChef workspace cannot be configured as its own delegation project or inside a delegation project",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function withWorkspaceLock(workspaceRoot, operation) {
|
|
161
|
+
const lockPath = path.join(workspaceRoot, WORKSPACE_LOCK_NAME);
|
|
162
|
+
const release = await lockfile.lock(workspaceRoot, {
|
|
154
163
|
realpath: false,
|
|
155
164
|
lockfilePath: lockPath,
|
|
156
|
-
stale:
|
|
157
|
-
update:
|
|
165
|
+
stale: 600_000,
|
|
166
|
+
update: 10_000,
|
|
158
167
|
retries: { retries: 70, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
|
159
168
|
});
|
|
160
169
|
try {
|
|
@@ -211,7 +220,7 @@ async function writeTextAtomic(filePath, value) {
|
|
|
211
220
|
const mode = await stat(filePath)
|
|
212
221
|
.then((details) => details.mode & 0o777)
|
|
213
222
|
.catch((error) => {
|
|
214
|
-
if (error.code === "ENOENT") return
|
|
223
|
+
if (error.code === "ENOENT") return 0o600;
|
|
215
224
|
throw error;
|
|
216
225
|
});
|
|
217
226
|
await writeFile(temporaryPath, value, {
|
|
@@ -551,43 +560,55 @@ async function ensureDispatchFile(workspaceRoot) {
|
|
|
551
560
|
|
|
552
561
|
export async function initializeWorkspace(workspaceRoot) {
|
|
553
562
|
const requestedRoot = path.resolve(workspaceRoot);
|
|
554
|
-
await mkdir(requestedRoot, { recursive: true });
|
|
563
|
+
await mkdir(requestedRoot, { recursive: true, mode: 0o700 });
|
|
564
|
+
const requestedDetails = await lstat(requestedRoot);
|
|
565
|
+
if (requestedDetails.isSymbolicLink() || !requestedDetails.isDirectory()) {
|
|
566
|
+
throw new Error(`workspace path is not a real directory: ${requestedRoot}`);
|
|
567
|
+
}
|
|
568
|
+
if (typeof process.getuid === "function" && requestedDetails.uid !== process.getuid()) {
|
|
569
|
+
throw new Error(`workspace is not owned by the current user: ${requestedRoot}`);
|
|
570
|
+
}
|
|
555
571
|
const root = await realpath(requestedRoot);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
await writeJsonAtomic(configPath, config);
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
572
|
+
await chmod(root, 0o700);
|
|
573
|
+
return withWorkspaceLock(root, async () => {
|
|
574
|
+
const configPath = path.join(root, "taskchef.json");
|
|
575
|
+
const configExists = await managedRegularFileExists(configPath);
|
|
576
|
+
const storedConfigVersion = configExists
|
|
577
|
+
? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
|
|
578
|
+
: null;
|
|
579
|
+
const config = configExists
|
|
580
|
+
? await readConfig(root, { checkPaths: false })
|
|
581
|
+
: { schemaVersion: CURRENT_SCHEMA_VERSION, projects: [] };
|
|
582
|
+
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
583
|
+
if (configExists && (await managedRegularFileExists(dispatchPath))) {
|
|
584
|
+
await readDispatchesUnlocked(root);
|
|
585
|
+
}
|
|
586
|
+
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
587
|
+
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
588
|
+
else if (storedConfigVersion !== CURRENT_SCHEMA_VERSION) {
|
|
589
|
+
await writeJsonAtomic(configPath, config);
|
|
590
|
+
}
|
|
591
|
+
const tasks = await ensureDispatchFile(root);
|
|
592
|
+
const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
|
|
593
|
+
if (!configExists) await unlink(configPath).catch(() => {});
|
|
594
|
+
throw error;
|
|
595
|
+
});
|
|
596
|
+
await Promise.all([configPath, tasks.path, instructions.path]
|
|
597
|
+
.map((filePath) => chmod(filePath, 0o600)));
|
|
598
|
+
return {
|
|
599
|
+
workspace: root,
|
|
600
|
+
config: {
|
|
601
|
+
path: configPath,
|
|
602
|
+
action: !configExists
|
|
603
|
+
? "created"
|
|
604
|
+
: storedConfigVersion === CURRENT_SCHEMA_VERSION ? "unchanged" : "migrated",
|
|
605
|
+
value: config,
|
|
606
|
+
},
|
|
607
|
+
tasks,
|
|
608
|
+
instructions,
|
|
609
|
+
legacySkills,
|
|
610
|
+
};
|
|
577
611
|
});
|
|
578
|
-
return {
|
|
579
|
-
workspace: root,
|
|
580
|
-
config: {
|
|
581
|
-
path: configPath,
|
|
582
|
-
action: !configExists
|
|
583
|
-
? "created"
|
|
584
|
-
: storedConfigVersion === CURRENT_SCHEMA_VERSION ? "unchanged" : "migrated",
|
|
585
|
-
value: config,
|
|
586
|
-
},
|
|
587
|
-
tasks,
|
|
588
|
-
instructions,
|
|
589
|
-
legacySkills,
|
|
590
|
-
};
|
|
591
612
|
}
|
|
592
613
|
|
|
593
614
|
export async function readConfig(workspaceRoot, { checkPaths = true } = {}) {
|
|
@@ -596,8 +617,10 @@ export async function readConfig(workspaceRoot, { checkPaths = true } = {}) {
|
|
|
596
617
|
if (!(await managedRegularFileExists(configPath))) {
|
|
597
618
|
throw new Error(`configuration does not exist: ${configPath}`);
|
|
598
619
|
}
|
|
599
|
-
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
600
|
-
|
|
620
|
+
const config = await validateConfig(JSON.parse(await readFile(configPath, "utf8")), { checkPaths });
|
|
621
|
+
const canonicalRoot = await realpath(root);
|
|
622
|
+
for (const project of config.projects) assertWorkspaceOutsideProject(canonicalRoot, project.path);
|
|
623
|
+
return config;
|
|
601
624
|
}
|
|
602
625
|
|
|
603
626
|
export async function listProjects(workspaceRoot) {
|
|
@@ -606,68 +629,76 @@ export async function listProjects(workspaceRoot) {
|
|
|
606
629
|
}
|
|
607
630
|
|
|
608
631
|
export async function addProject(workspaceRoot, input) {
|
|
609
|
-
const root = path.resolve(workspaceRoot);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
632
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
633
|
+
return withWorkspaceLock(root, async () => {
|
|
634
|
+
const config = await readConfig(root);
|
|
635
|
+
const project = await inspectProject(input);
|
|
636
|
+
assertWorkspaceOutsideProject(root, project.path);
|
|
637
|
+
const updated = await validateConfig({
|
|
638
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
639
|
+
projects: [...config.projects, project],
|
|
640
|
+
});
|
|
641
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
|
|
642
|
+
return project;
|
|
615
643
|
});
|
|
616
|
-
await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
|
|
617
|
-
return project;
|
|
618
644
|
}
|
|
619
645
|
|
|
620
646
|
export async function importProjects(workspaceRoot, inputs, { replace = false } = {}) {
|
|
621
647
|
if (!Array.isArray(inputs)) throw new Error("project import must be a JSON array");
|
|
622
|
-
const root = path.resolve(workspaceRoot);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
mergedInput.
|
|
648
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
649
|
+
return withWorkspaceLock(root, async () => {
|
|
650
|
+
const current = await readConfig(root, { checkPaths: !replace });
|
|
651
|
+
const imported = [];
|
|
652
|
+
for (const [index, input] of inputs.entries()) {
|
|
653
|
+
const canonicalPath = await canonicalDirectory(input?.path);
|
|
654
|
+
assertWorkspaceOutsideProject(root, canonicalPath);
|
|
655
|
+
const existing = current.projects.find((project) => project.path === canonicalPath);
|
|
656
|
+
const mergedInput = { ...input, path: canonicalPath };
|
|
657
|
+
if (!("name" in mergedInput) && existing) mergedInput.name = existing.name;
|
|
658
|
+
if (!("description" in mergedInput) && existing?.description) {
|
|
659
|
+
mergedInput.description = existing.description;
|
|
660
|
+
}
|
|
661
|
+
if (existing && !replace) {
|
|
662
|
+
const importedRepositories = "githubRepos" in mergedInput
|
|
663
|
+
? normalizeGithubRepositories(mergedInput.githubRepos, `projects[${index}].githubRepos`)
|
|
664
|
+
: [];
|
|
665
|
+
mergedInput.githubRepos = [...existing.githubRepos, ...importedRepositories];
|
|
666
|
+
}
|
|
667
|
+
imported.push(await inspectProject(mergedInput, index));
|
|
632
668
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
669
|
+
const projects = replace ? [] : [...current.projects];
|
|
670
|
+
for (const project of imported) {
|
|
671
|
+
const index = projects.findIndex((existing) => existing.path === project.path);
|
|
672
|
+
if (index === -1) projects.push(project);
|
|
673
|
+
else projects[index] = project;
|
|
638
674
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
649
|
-
return {
|
|
650
|
-
mode: replace ? "replace" : "merge",
|
|
651
|
-
importedCount: imported.length,
|
|
652
|
-
projectCount: config.projects.length,
|
|
653
|
-
projects: imported,
|
|
654
|
-
};
|
|
675
|
+
const config = await validateConfig({ schemaVersion: CURRENT_SCHEMA_VERSION, projects });
|
|
676
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
677
|
+
return {
|
|
678
|
+
mode: replace ? "replace" : "merge",
|
|
679
|
+
importedCount: imported.length,
|
|
680
|
+
projectCount: config.projects.length,
|
|
681
|
+
projects: imported,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
655
684
|
}
|
|
656
685
|
|
|
657
686
|
export async function removeProject(workspaceRoot, name) {
|
|
658
|
-
const root = path.resolve(workspaceRoot);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
687
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
688
|
+
return withWorkspaceLock(root, async () => {
|
|
689
|
+
const config = await readConfig(root, { checkPaths: false });
|
|
690
|
+
const index = config.projects.findIndex(
|
|
691
|
+
(project) => project.name.toLowerCase() === requireString(name, "project name").toLowerCase(),
|
|
692
|
+
);
|
|
693
|
+
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
694
|
+
const [project] = config.projects.slice(index, index + 1);
|
|
695
|
+
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
696
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), {
|
|
697
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
698
|
+
projects,
|
|
699
|
+
});
|
|
700
|
+
return { project };
|
|
669
701
|
});
|
|
670
|
-
return { project };
|
|
671
702
|
}
|
|
672
703
|
|
|
673
704
|
async function validateDispatchShape(dispatch, name = "task") {
|
|
@@ -753,20 +784,20 @@ export async function listTasks(workspaceRoot) {
|
|
|
753
784
|
export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
754
785
|
requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
|
|
755
786
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
787
|
+
return withWorkspaceLock(root, async () => {
|
|
788
|
+
const config = await readConfig(root);
|
|
789
|
+
const projectPath = await canonicalDirectory(input.project);
|
|
790
|
+
const project = config.projects.find((candidate) => candidate.path === projectPath);
|
|
791
|
+
if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
|
|
792
|
+
const dispatch = await validateDispatchShape({
|
|
793
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
794
|
+
id: input.id,
|
|
795
|
+
project,
|
|
796
|
+
title: input.title,
|
|
797
|
+
instruction: input.instruction,
|
|
798
|
+
threadId: input.threadId,
|
|
799
|
+
createdAt: now ?? new Date().toISOString(),
|
|
800
|
+
});
|
|
770
801
|
const existing = await readDispatchesUnlocked(root);
|
|
771
802
|
if (existing.some((item) => item.id === dispatch.id)) {
|
|
772
803
|
throw new Error(`task already exists: ${dispatch.id}`);
|
|
@@ -778,15 +809,15 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
778
809
|
throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
|
|
779
810
|
}
|
|
780
811
|
await appendDispatchesAtomic(root, [dispatch]);
|
|
812
|
+
return dispatch;
|
|
781
813
|
});
|
|
782
|
-
return dispatch;
|
|
783
814
|
}
|
|
784
815
|
|
|
785
816
|
export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
786
817
|
const id = requireSafeId(taskId, "taskId");
|
|
787
818
|
const durableThreadId = normalizeDurableThreadId(threadId);
|
|
788
819
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
789
|
-
return
|
|
820
|
+
return withWorkspaceLock(root, async () => {
|
|
790
821
|
const records = await readDispatchRecordsUnlocked(root);
|
|
791
822
|
const dispatches = records.map((record) => record.normalized);
|
|
792
823
|
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|