taskchef 3.0.3 → 4.0.0

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