taskchef 4.1.2 → 5.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "4.1.2",
3
+ "version": "5.0.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
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 a small folder that stores project routes and
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. Create the dispatcher workspace
49
+ ### 2. Bootstrap the dispatcher workspace
50
50
 
51
- Create a folder wherever you keep local projects:
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 in this folder.
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 this project into
85
- delegated work. You do not need to name the delegate skill each time.
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,45 +227,77 @@ taskchef task list
232
227
  taskchef task summary
233
228
  ```
234
229
 
235
- Workspace and data commands accept `--workspace <path>` and default to the
236
- current directory. Data commands accept `--json` for machine-readable output.
237
- Run `taskchef help` for every option.
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 --workspace <workspace>
243
- taskchef doctor --workspace <workspace>
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 --workspace <workspace>
253
- taskchef project remove payments --workspace <workspace>
280
+ taskchef project list
281
+ taskchef project remove payments
254
282
  ```
255
283
 
256
- Human-readable project listings show one parent row per project. The first
257
- configured GitHub repository appears on that row; additional repositories
258
- appear beneath it as indented tree rows, aligned with the repository column and
259
- with repeated kind and path cells left blank. A project without a configured
260
- repository has only its parent row, containing `-` in the repository column:
284
+ Human-readable project listings show one row per configured GitHub repository.
285
+ Project details repeat on each row so multi-repository projects remain clear.
286
+ A project without a configured repository has one row containing `-` in the
287
+ repository column:
261
288
 
262
289
  ```text
263
290
  NAME KIND GITHUB REPOSITORY PATH
264
291
  notes folder - /workspace/notes
265
292
  payments git https://github.com/example/payments-api /workspace/payments
266
- └─ https://github.com/example/payments-sdk
293
+ payments git https://github.com/example/payments-sdk /workspace/payments
267
294
  ```
268
295
 
269
296
  Import projects as a JSON array from a file or standard input:
270
297
 
271
298
  ```sh
272
- taskchef project import projects.json --workspace <workspace>
273
- taskchef project import - --workspace <workspace> < projects.json
299
+ taskchef project import projects.json
300
+ taskchef project import - < projects.json
274
301
  ```
275
302
 
276
303
  Import merges by canonical path, preserves an existing name or description
@@ -290,7 +317,7 @@ task lines remain readable without an eager rewrite of the append-only history.
290
317
 
291
318
  ```sh
292
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..."}' |
293
- taskchef task record --json --workspace <workspace>
320
+ taskchef task record --json
294
321
  ```
295
322
 
296
323
  If a task has `threadId: null`, Codex can later find its exact marker and pass
@@ -299,18 +326,17 @@ one-way transition from null to one unique thread ID:
299
326
 
300
327
  ```sh
301
328
  taskchef task resolve c0f010ff-84f2-4838-a69d-0ff1f5d721d7 \
302
- --thread-id 019f9d46-f42c-7482-9707-3c107bf241ee \
303
- --workspace <workspace>
329
+ --thread-id 019f9d46-f42c-7482-9707-3c107bf241ee
304
330
  ```
305
331
 
306
332
  Inspect the task history without querying Codex tasks:
307
333
 
308
334
  ```sh
309
- taskchef task show t1 --workspace <workspace>
310
- taskchef task list --workspace <workspace>
311
- taskchef task list --project payments --workspace <workspace>
312
- taskchef task list --ascending --workspace <workspace>
313
- taskchef task summary --workspace <workspace>
335
+ taskchef task show t1
336
+ taskchef task list
337
+ taskchef task list --project payments
338
+ taskchef task list --ascending
339
+ taskchef task summary
314
340
  ```
315
341
 
316
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 repository is a TaskChef dispatcher workspace.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "4.1.2",
3
+ "version": "5.0.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -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 a data-only TaskChef dispatcher workspace.
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 init --json`. It takes no stdin, creates an empty
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
- 2. Run `doctor --json` after setup or when the user asks to diagnose the
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
- 3. Report the actions or failed checks. End without dispatching unless the user
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. List native Codex projects once when discovery is necessary. Configure only
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 dispatcher workspaces"
4
- default_prompt: "Use $taskchef-bootstrap to initialize or refresh this TaskChef dispatcher workspace."
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 from an initialized TaskChef workspace into independently openable Codex project tasks. Use for ordinary work requests in a TaskChef workspace, explicit delegation, 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."
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 from a TaskChef data workspace and return immediately.
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 --workspace <workspace>`
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 --workspace <workspace>`.
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 --workspace <workspace>`.
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 of its Codex
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. Select only the tasks the user asked about:
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 --workspace <workspace>`.
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 --workspace <workspace>`.
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 --workspace <workspace>`
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 --workspace <workspace>`.
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,8 +18,6 @@ import {
15
18
  resolveTask,
16
19
  } from "./workspace.js";
17
20
 
18
- const BLANK_TABLE_CELL = Symbol("blank table cell");
19
-
20
21
  async function readStdin() {
21
22
  let input = "";
22
23
  process.stdin.setEncoding("utf8");
@@ -77,8 +78,14 @@ function validateCommandArgs(
77
78
  }
78
79
  }
79
80
 
81
+ function workspaceSelection(args) {
82
+ return resolveWorkspacePath({
83
+ explicit: args.includes("--workspace") ? option(args, "--workspace") : null,
84
+ });
85
+ }
86
+
80
87
  function workspaceRoot(args) {
81
- return path.resolve(option(args, "--workspace", process.cwd()));
88
+ return workspaceSelection(args).workspace;
82
89
  }
83
90
 
84
91
  function print(value, args, human) {
@@ -90,11 +97,9 @@ function print(value, args, human) {
90
97
  }
91
98
 
92
99
  function table(headers, rows) {
93
- const display = (value) => {
94
- if (value === BLANK_TABLE_CELL) return "";
95
- if (value === null || value === undefined || value === "") return "-";
96
- return String(value);
97
- };
100
+ const display = (value) => value === null || value === undefined || value === ""
101
+ ? "-"
102
+ : String(value);
98
103
  const widths = headers.map((header, index) =>
99
104
  Math.max(header.length, ...rows.map((row) => display(row[index]).length)));
100
105
  const format = (row) => row.map((value, index) => index === row.length - 1
@@ -105,18 +110,13 @@ function table(headers, rows) {
105
110
 
106
111
  function projectRows(projects) {
107
112
  return projects.flatMap((project) => {
108
- const [primaryRepository = null, ...additionalRepositories] = project.githubRepos;
109
- return [[
113
+ const repositories = project.githubRepos.length > 0 ? project.githubRepos : [null];
114
+ return repositories.map((repository) => [
110
115
  project.name,
111
116
  project.isGitRepository ? "git" : "folder",
112
- primaryRepository,
113
- project.path,
114
- ], ...additionalRepositories.map((repository, index) => [
115
- ` ${index === additionalRepositories.length - 1 ? "└─" : "├─"}`,
116
- BLANK_TABLE_CELL,
117
117
  repository,
118
- BLANK_TABLE_CELL,
119
- ])];
118
+ project.path,
119
+ ]);
120
120
  });
121
121
  }
122
122
 
@@ -136,15 +136,46 @@ function sortTasksByCreatedAt(tasks, ascending) {
136
136
  }
137
137
 
138
138
  async function initialize(args) {
139
- validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
140
- const result = await initializeWorkspace(workspaceRoot(args));
139
+ validateCommandArgs(args, 2, {
140
+ values: ["--workspace", "--codex-cli"],
141
+ switches: ["--json", "--register-codex"],
142
+ });
143
+ const resolution = workspaceSelection(args);
144
+ const result = await initializeWorkspace(resolution.workspace);
145
+ result.resolutionSource = resolution.source;
146
+ let registrationFailed = false;
147
+ if (args.includes("--register-codex")) {
148
+ try {
149
+ result.registration = await openWorkspaceInCodex(result.workspace, {
150
+ explicit: args.includes("--codex-cli") ? option(args, "--codex-cli") : null,
151
+ });
152
+ } catch (error) {
153
+ registrationFailed = true;
154
+ result.registration = { status: "failed", reason: error.message };
155
+ }
156
+ }
141
157
  print(result, args, (value) => [
142
158
  `Workspace: ${value.workspace}`,
143
159
  `Configuration: ${value.config.action}`,
144
160
  `Task log: ${value.tasks.action}`,
145
161
  `Instructions: ${value.instructions.action}`,
146
162
  `Legacy skill links removed: ${value.legacySkills.removed.length}`,
163
+ ...(value.registration ? [`Codex opening: ${value.registration.status}`] : []),
147
164
  ].join("\n"));
165
+ return registrationFailed ? 5 : 0;
166
+ }
167
+
168
+ async function workspacePath(args) {
169
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
170
+ const resolution = workspaceSelection(args);
171
+ const exists = await access(resolution.workspace).then(() => true).catch(() => false);
172
+ const workspace = exists ? await realpath(resolution.workspace) : resolution.workspace;
173
+ print({
174
+ schemaVersion: 1,
175
+ workspace,
176
+ source: resolution.source,
177
+ exists,
178
+ }, args, (value) => value.workspace);
148
179
  return 0;
149
180
  }
150
181
 
@@ -286,7 +317,8 @@ function usage() {
286
317
  Usage:
287
318
  taskchef help
288
319
  taskchef doctor [--json] [--workspace <path>]
289
- taskchef workspace init [--json] [--workspace <path>]
320
+ taskchef workspace path [--json] [--workspace <path>]
321
+ taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
290
322
  taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
291
323
  taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
292
324
  taskchef project list [--json] [--workspace <path>]
@@ -299,6 +331,8 @@ Usage:
299
331
 
300
332
  Task record reads JSON from standard input. Project import reads a JSON
301
333
  array from a file, or from standard input when the source is '-' or omitted.
334
+ Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
335
+ ~/.agents/taskchef.
302
336
  `);
303
337
  }
304
338
 
@@ -308,6 +342,7 @@ export async function runCli(args) {
308
342
  return 0;
309
343
  }
310
344
  if (args[0] === "doctor") return doctor(args);
345
+ if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
311
346
  if (args[0] === "workspace" && args[1] === "init") return initialize(args);
312
347
  if (args[0] === "project" && args[1] === "add") return projectAdd(args);
313
348
  if (args[0] === "project" && args[1] === "import") return projectImport(args);
@@ -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 DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
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
- async function withDispatchLock(workspaceRoot, operation) {
151
- const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
152
- const lockPath = path.join(workspaceRoot, DISPATCH_LOCK_NAME);
153
- const release = await lockfile.lock(dispatchPath, {
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: 5_000,
157
- update: 1_000,
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 0o644;
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
- const configPath = path.join(root, "taskchef.json");
557
- const configExists = await managedRegularFileExists(configPath);
558
- const storedConfigVersion = configExists
559
- ? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
560
- : null;
561
- const config = configExists
562
- ? await readConfig(root, { checkPaths: false })
563
- : { schemaVersion: CURRENT_SCHEMA_VERSION, projects: [] };
564
- const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
565
- if (configExists && (await managedRegularFileExists(dispatchPath))) {
566
- await readDispatchesUnlocked(root);
567
- }
568
- const { legacySkills } = await ensureWorkspaceSkills(root);
569
- if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
570
- else if (storedConfigVersion !== CURRENT_SCHEMA_VERSION) {
571
- await writeJsonAtomic(configPath, config);
572
- }
573
- const tasks = await ensureDispatchFile(root);
574
- const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
575
- if (!configExists) await unlink(configPath).catch(() => {});
576
- throw error;
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
- return validateConfig(config, { checkPaths });
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
- const config = await readConfig(root);
611
- const project = await inspectProject(input);
612
- const updated = await validateConfig({
613
- schemaVersion: CURRENT_SCHEMA_VERSION,
614
- projects: [...config.projects, project],
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
- const current = await readConfig(root, { checkPaths: !replace });
624
- const imported = [];
625
- for (const [index, input] of inputs.entries()) {
626
- const canonicalPath = await canonicalDirectory(input?.path);
627
- const existing = current.projects.find((project) => project.path === canonicalPath);
628
- const mergedInput = { ...input, path: canonicalPath };
629
- if (!("name" in mergedInput) && existing) mergedInput.name = existing.name;
630
- if (!("description" in mergedInput) && existing?.description) {
631
- mergedInput.description = existing.description;
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
- if (existing && !replace) {
634
- const importedRepositories = "githubRepos" in mergedInput
635
- ? normalizeGithubRepositories(mergedInput.githubRepos, `projects[${index}].githubRepos`)
636
- : [];
637
- mergedInput.githubRepos = [...existing.githubRepos, ...importedRepositories];
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
- imported.push(await inspectProject(mergedInput, index));
640
- }
641
- const projects = replace ? [] : [...current.projects];
642
- for (const project of imported) {
643
- const index = projects.findIndex((existing) => existing.path === project.path);
644
- if (index === -1) projects.push(project);
645
- else projects[index] = project;
646
- }
647
- const config = await validateConfig({ schemaVersion: CURRENT_SCHEMA_VERSION, projects });
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
- const config = await readConfig(root, { checkPaths: false });
660
- const index = config.projects.findIndex(
661
- (project) => project.name.toLowerCase() === requireString(name, "project name").toLowerCase(),
662
- );
663
- if (index === -1) throw new Error(`configured project not found: ${name}`);
664
- const [project] = config.projects.slice(index, index + 1);
665
- const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
666
- await writeJsonAtomic(path.join(root, "taskchef.json"), {
667
- schemaVersion: CURRENT_SCHEMA_VERSION,
668
- projects,
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
- const config = await readConfig(root);
757
- const projectPath = await canonicalDirectory(input.project);
758
- const project = config.projects.find((candidate) => candidate.path === projectPath);
759
- if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
760
- const dispatch = await validateDispatchShape({
761
- schemaVersion: CURRENT_SCHEMA_VERSION,
762
- id: input.id,
763
- project,
764
- title: input.title,
765
- instruction: input.instruction,
766
- threadId: input.threadId,
767
- createdAt: now ?? new Date().toISOString(),
768
- });
769
- await withDispatchLock(root, async () => {
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 withDispatchLock(root, async () => {
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);