@pruddiman/dispatch 0.0.1 → 1.2.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.
Files changed (4) hide show
  1. package/README.md +178 -225
  2. package/dist/cli.js +812 -569
  3. package/dist/cli.js.map +1 -1
  4. package/package.json +10 -4
package/dist/cli.js CHANGED
@@ -11,39 +11,64 @@ var __export = (target, all) => {
11
11
 
12
12
  // src/helpers/logger.ts
13
13
  import chalk from "chalk";
14
- var MAX_CAUSE_CHAIN_DEPTH, log;
14
+ function resolveLogLevel() {
15
+ const envLevel = process.env.LOG_LEVEL?.toLowerCase();
16
+ if (envLevel && Object.hasOwn(LOG_LEVEL_SEVERITY, envLevel)) {
17
+ return envLevel;
18
+ }
19
+ if (process.env.DEBUG) {
20
+ return "debug";
21
+ }
22
+ return "info";
23
+ }
24
+ function shouldLog(level) {
25
+ return LOG_LEVEL_SEVERITY[level] >= LOG_LEVEL_SEVERITY[currentLevel];
26
+ }
27
+ var LOG_LEVEL_SEVERITY, currentLevel, MAX_CAUSE_CHAIN_DEPTH, log;
15
28
  var init_logger = __esm({
16
29
  "src/helpers/logger.ts"() {
17
30
  "use strict";
31
+ LOG_LEVEL_SEVERITY = {
32
+ debug: 0,
33
+ info: 1,
34
+ warn: 2,
35
+ error: 3
36
+ };
37
+ currentLevel = resolveLogLevel();
18
38
  MAX_CAUSE_CHAIN_DEPTH = 5;
19
39
  log = {
20
- /** When true, `debug()` messages are printed. Set by `--verbose`. */
21
40
  verbose: false,
22
41
  info(msg) {
42
+ if (!shouldLog("info")) return;
23
43
  console.log(chalk.blue("\u2139"), msg);
24
44
  },
25
45
  success(msg) {
46
+ if (!shouldLog("info")) return;
26
47
  console.log(chalk.green("\u2714"), msg);
27
48
  },
28
49
  warn(msg) {
29
- console.log(chalk.yellow("\u26A0"), msg);
50
+ if (!shouldLog("warn")) return;
51
+ console.error(chalk.yellow("\u26A0"), msg);
30
52
  },
31
53
  error(msg) {
54
+ if (!shouldLog("error")) return;
32
55
  console.error(chalk.red("\u2716"), msg);
33
56
  },
34
57
  task(index, total, msg) {
58
+ if (!shouldLog("info")) return;
35
59
  console.log(chalk.cyan(`[${index + 1}/${total}]`), msg);
36
60
  },
37
61
  dim(msg) {
62
+ if (!shouldLog("info")) return;
38
63
  console.log(chalk.dim(msg));
39
64
  },
40
65
  /**
41
- * Print a debug/verbose message. Only visible when `log.verbose` is true.
42
- * Messages are prefixed with a dim arrow to visually nest them under the
43
- * preceding info/error line.
66
+ * Print a debug/verbose message. Only visible when the log level is
67
+ * `"debug"`. Messages are prefixed with a dim arrow to visually nest
68
+ * them under the preceding info/error line.
44
69
  */
45
70
  debug(msg) {
46
- if (!this.verbose) return;
71
+ if (!shouldLog("debug")) return;
47
72
  console.log(chalk.dim(` \u2937 ${msg}`));
48
73
  },
49
74
  /**
@@ -83,6 +108,26 @@ var init_logger = __esm({
83
108
  return "";
84
109
  }
85
110
  };
111
+ Object.defineProperty(log, "verbose", {
112
+ get() {
113
+ return currentLevel === "debug";
114
+ },
115
+ set(value) {
116
+ currentLevel = value ? "debug" : "info";
117
+ },
118
+ enumerable: true,
119
+ configurable: true
120
+ });
121
+ }
122
+ });
123
+
124
+ // src/helpers/guards.ts
125
+ function hasProperty(value, key) {
126
+ return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
127
+ }
128
+ var init_guards = __esm({
129
+ "src/helpers/guards.ts"() {
130
+ "use strict";
86
131
  }
87
132
  });
88
133
 
@@ -183,6 +228,7 @@ async function boot(opts) {
183
228
  },
184
229
  async prompt(sessionId, text) {
185
230
  log.debug(`Sending async prompt to session ${sessionId} (${text.length} chars)...`);
231
+ let controller;
186
232
  try {
187
233
  const { error: promptError } = await client.session.promptAsync({
188
234
  path: { id: sessionId },
@@ -195,11 +241,11 @@ async function boot(opts) {
195
241
  throw new Error(`OpenCode promptAsync failed: ${JSON.stringify(promptError)}`);
196
242
  }
197
243
  log.debug("Async prompt accepted, subscribing to events...");
198
- const controller = new AbortController();
199
- const { stream } = await client.event.subscribe({
200
- signal: controller.signal
201
- });
244
+ controller = new AbortController();
202
245
  try {
246
+ const { stream } = await client.event.subscribe({
247
+ signal: controller.signal
248
+ });
203
249
  for await (const event of stream) {
204
250
  if (!isSessionEvent(event, sessionId)) continue;
205
251
  if (event.type === "message.part.updated" && event.properties.part.type === "text") {
@@ -221,7 +267,7 @@ async function boot(opts) {
221
267
  }
222
268
  }
223
269
  } finally {
224
- controller.abort();
270
+ if (controller && !controller.signal.aborted) controller.abort();
225
271
  }
226
272
  const { data: messages } = await client.session.messages({
227
273
  path: { id: sessionId }
@@ -235,7 +281,7 @@ async function boot(opts) {
235
281
  log.debug("No assistant message found in session");
236
282
  return null;
237
283
  }
238
- if (lastAssistant.info.role === "assistant" && "error" in lastAssistant.info && lastAssistant.info.error) {
284
+ if (hasProperty(lastAssistant.info, "error") && lastAssistant.info.error) {
239
285
  throw new Error(
240
286
  `OpenCode assistant error: ${JSON.stringify(lastAssistant.info.error)}`
241
287
  );
@@ -265,11 +311,14 @@ async function boot(opts) {
265
311
  }
266
312
  function isSessionEvent(event, sessionId) {
267
313
  const props = event.properties;
268
- if (props.sessionID === sessionId) return true;
269
- if (props.info && typeof props.info === "object" && props.info.sessionID === sessionId) {
314
+ if (!hasProperty(props, "sessionID") && !hasProperty(props, "info") && !hasProperty(props, "part")) {
315
+ return false;
316
+ }
317
+ if (hasProperty(props, "sessionID") && props.sessionID === sessionId) return true;
318
+ if (hasProperty(props, "info") && hasProperty(props.info, "sessionID") && props.info.sessionID === sessionId) {
270
319
  return true;
271
320
  }
272
- if (props.part && typeof props.part === "object" && props.part.sessionID === sessionId) {
321
+ if (hasProperty(props, "part") && hasProperty(props.part, "sessionID") && props.part.sessionID === sessionId) {
273
322
  return true;
274
323
  }
275
324
  return false;
@@ -278,6 +327,52 @@ var init_opencode = __esm({
278
327
  "src/providers/opencode.ts"() {
279
328
  "use strict";
280
329
  init_logger();
330
+ init_guards();
331
+ }
332
+ });
333
+
334
+ // src/helpers/timeout.ts
335
+ function withTimeout(promise, ms, label) {
336
+ const p = new Promise((resolve4, reject) => {
337
+ let settled = false;
338
+ const timer = setTimeout(() => {
339
+ if (settled) return;
340
+ settled = true;
341
+ reject(new TimeoutError(ms, label));
342
+ }, ms);
343
+ promise.then(
344
+ (value) => {
345
+ if (settled) return;
346
+ settled = true;
347
+ clearTimeout(timer);
348
+ resolve4(value);
349
+ },
350
+ (err) => {
351
+ if (settled) return;
352
+ settled = true;
353
+ clearTimeout(timer);
354
+ reject(err);
355
+ }
356
+ );
357
+ });
358
+ p.catch(() => {
359
+ });
360
+ return p;
361
+ }
362
+ var TimeoutError;
363
+ var init_timeout = __esm({
364
+ "src/helpers/timeout.ts"() {
365
+ "use strict";
366
+ TimeoutError = class extends Error {
367
+ /** Optional label identifying the operation that timed out. */
368
+ label;
369
+ constructor(ms, label) {
370
+ const suffix = label ? ` [${label}]` : "";
371
+ super(`Timed out after ${ms}ms${suffix}`);
372
+ this.name = "TimeoutError";
373
+ this.label = label;
374
+ }
375
+ };
281
376
  }
282
377
  });
283
378
 
@@ -354,18 +449,25 @@ async function boot2(opts) {
354
449
  try {
355
450
  await session.send({ prompt: text });
356
451
  log.debug("Async prompt accepted, waiting for session to become idle...");
357
- await new Promise((resolve2, reject) => {
358
- const unsubIdle = session.on("session.idle", () => {
359
- unsubIdle();
360
- unsubErr();
361
- resolve2();
362
- });
363
- const unsubErr = session.on("session.error", (event) => {
364
- unsubIdle();
365
- unsubErr();
366
- reject(new Error(`Copilot session error: ${event.data.message}`));
367
- });
368
- });
452
+ let unsubIdle;
453
+ let unsubErr;
454
+ try {
455
+ await withTimeout(
456
+ new Promise((resolve4, reject) => {
457
+ unsubIdle = session.on("session.idle", () => {
458
+ resolve4();
459
+ });
460
+ unsubErr = session.on("session.error", (event) => {
461
+ reject(new Error(`Copilot session error: ${event.data.message}`));
462
+ });
463
+ }),
464
+ 3e5,
465
+ "copilot session ready"
466
+ );
467
+ } finally {
468
+ unsubIdle?.();
469
+ unsubErr?.();
470
+ }
369
471
  log.debug("Session went idle, fetching result...");
370
472
  const events = await session.getMessages();
371
473
  const last = [...events].reverse().find((e) => e.type === "assistant.message");
@@ -396,6 +498,7 @@ var init_copilot = __esm({
396
498
  "src/providers/copilot.ts"() {
397
499
  "use strict";
398
500
  init_logger();
501
+ init_timeout();
399
502
  }
400
503
  });
401
504
 
@@ -502,7 +605,8 @@ async function boot4(opts) {
502
605
  model,
503
606
  config: { model, instructions: "" },
504
607
  approvalPolicy: "full-auto",
505
- additionalWritableRoots: opts?.cwd ? [opts.cwd] : [],
608
+ ...opts?.cwd ? { rootDir: opts.cwd } : {},
609
+ additionalWritableRoots: [],
506
610
  getCommandConfirmation: async () => ({ approved: true }),
507
611
  onItem: () => {
508
612
  },
@@ -685,7 +789,7 @@ async function detectTestCommand(cwd) {
685
789
  }
686
790
  }
687
791
  function runTestCommand(command, cwd) {
688
- return new Promise((resolve2) => {
792
+ return new Promise((resolve4) => {
689
793
  const [cmd, ...args] = command.split(" ");
690
794
  execFileCb(
691
795
  cmd,
@@ -693,7 +797,7 @@ function runTestCommand(command, cwd) {
693
797
  { cwd, maxBuffer: 10 * 1024 * 1024 },
694
798
  (error, stdout, stderr) => {
695
799
  const exitCode = error && "code" in error ? error.code ?? 1 : error ? 1 : 0;
696
- resolve2({ exitCode, stdout, stderr, command });
800
+ resolve4({ exitCode, stdout, stderr, command });
697
801
  }
698
802
  );
699
803
  });
@@ -777,7 +881,6 @@ async function runFixTestsPipeline(opts) {
777
881
  } catch (err) {
778
882
  const message = log.extractMessage(err);
779
883
  log.error(`Fix-tests pipeline failed: ${log.formatErrorChain(err)}`);
780
- log.debug(log.formatErrorChain(err));
781
884
  return { mode: "fix-tests", success: false, error: message };
782
885
  }
783
886
  }
@@ -791,7 +894,7 @@ var init_fix_tests_pipeline = __esm({
791
894
  });
792
895
 
793
896
  // src/cli.ts
794
- import { resolve, join as join11 } from "path";
897
+ import { resolve as resolve3, join as join11 } from "path";
795
898
 
796
899
  // src/spec-generator.ts
797
900
  import { cpus, freemem } from "os";
@@ -814,6 +917,13 @@ function slugify(input2, maxLength) {
814
917
  // src/datasources/github.ts
815
918
  init_logger();
816
919
  var exec = promisify(execFile);
920
+ var InvalidBranchNameError = class extends Error {
921
+ constructor(branch, reason) {
922
+ const detail = reason ? ` (${reason})` : "";
923
+ super(`Invalid branch name: "${branch}"${detail}`);
924
+ this.name = "InvalidBranchNameError";
925
+ }
926
+ };
817
927
  async function git(args, cwd) {
818
928
  const { stdout } = await exec("git", args, { cwd });
819
929
  return stdout;
@@ -822,16 +932,35 @@ async function gh(args, cwd) {
822
932
  const { stdout } = await exec("gh", args, { cwd });
823
933
  return stdout;
824
934
  }
935
+ var VALID_BRANCH_NAME_RE = /^[a-zA-Z0-9._\-/]+$/;
936
+ function isValidBranchName(name) {
937
+ if (name.length === 0 || name.length > 255) return false;
938
+ if (!VALID_BRANCH_NAME_RE.test(name)) return false;
939
+ if (name.startsWith("/") || name.endsWith("/")) return false;
940
+ if (name.includes("..")) return false;
941
+ if (name.endsWith(".lock")) return false;
942
+ if (name.includes("@{")) return false;
943
+ if (name.includes("//")) return false;
944
+ return true;
945
+ }
825
946
  function buildBranchName(issueNumber, title, username = "unknown") {
826
947
  const slug = slugify(title, 50);
827
948
  return `${username}/dispatch/${issueNumber}-${slug}`;
828
949
  }
829
950
  async function getDefaultBranch(cwd) {
951
+ const PREFIX = "refs/remotes/origin/";
830
952
  try {
831
953
  const ref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
832
- const parts = ref.trim().split("/");
833
- return parts[parts.length - 1];
834
- } catch {
954
+ const trimmed = ref.trim();
955
+ const branch = trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : trimmed;
956
+ if (!isValidBranchName(branch)) {
957
+ throw new InvalidBranchNameError(branch, "from symbolic-ref output");
958
+ }
959
+ return branch;
960
+ } catch (err) {
961
+ if (err instanceof InvalidBranchNameError) {
962
+ throw err;
963
+ }
835
964
  try {
836
965
  await git(["rev-parse", "--verify", "main"], cwd);
837
966
  return "main";
@@ -842,6 +971,9 @@ async function getDefaultBranch(cwd) {
842
971
  }
843
972
  var datasource = {
844
973
  name: "github",
974
+ supportsGit() {
975
+ return true;
976
+ },
845
977
  async list(opts = {}) {
846
978
  const cwd = opts.cwd || process.cwd();
847
979
  const { stdout } = await exec(
@@ -1043,6 +1175,9 @@ async function detectWorkItemType(opts = {}) {
1043
1175
  }
1044
1176
  var datasource2 = {
1045
1177
  name: "azdevops",
1178
+ supportsGit() {
1179
+ return true;
1180
+ },
1046
1181
  async list(opts = {}) {
1047
1182
  const wiql = "SELECT [System.Id] FROM workitems WHERE [System.State] <> 'Closed' AND [System.State] <> 'Removed' ORDER BY [System.CreatedDate] DESC";
1048
1183
  const args = ["boards", "query", "--wiql", wiql, "--output", "json"];
@@ -1336,6 +1471,20 @@ import { execFile as execFile3 } from "child_process";
1336
1471
  import { readFile, writeFile, readdir, mkdir, rename } from "fs/promises";
1337
1472
  import { join, parse as parsePath } from "path";
1338
1473
  import { promisify as promisify3 } from "util";
1474
+
1475
+ // src/helpers/errors.ts
1476
+ var UnsupportedOperationError = class extends Error {
1477
+ /** The name of the operation that is not supported. */
1478
+ operation;
1479
+ constructor(operation, message) {
1480
+ const msg = message ?? `Operation not supported: ${operation}`;
1481
+ super(msg);
1482
+ this.name = "UnsupportedOperationError";
1483
+ this.operation = operation;
1484
+ }
1485
+ };
1486
+
1487
+ // src/datasources/md.ts
1339
1488
  var exec3 = promisify3(execFile3);
1340
1489
  var DEFAULT_DIR = ".dispatch/specs";
1341
1490
  function resolveDir(opts) {
@@ -1372,6 +1521,9 @@ function toIssueDetails(filename, content, dir) {
1372
1521
  }
1373
1522
  var datasource3 = {
1374
1523
  name: "md",
1524
+ supportsGit() {
1525
+ return false;
1526
+ },
1375
1527
  async list(opts) {
1376
1528
  const dir = resolveDir(opts);
1377
1529
  let entries;
@@ -1436,15 +1588,19 @@ var datasource3 = {
1436
1588
  return `${username}/dispatch/${issueNumber}-${slug}`;
1437
1589
  },
1438
1590
  async createAndSwitchBranch(_branchName, _opts) {
1591
+ throw new UnsupportedOperationError("createAndSwitchBranch");
1439
1592
  },
1440
1593
  async switchBranch(_branchName, _opts) {
1594
+ throw new UnsupportedOperationError("switchBranch");
1441
1595
  },
1442
1596
  async pushBranch(_branchName, _opts) {
1597
+ throw new UnsupportedOperationError("pushBranch");
1443
1598
  },
1444
1599
  async commitAllChanges(_message, _opts) {
1600
+ throw new UnsupportedOperationError("commitAllChanges");
1445
1601
  },
1446
1602
  async createPullRequest(_branchName, _issueNumber, _title, _body, _opts) {
1447
- return "";
1603
+ throw new UnsupportedOperationError("createPullRequest");
1448
1604
  }
1449
1605
  };
1450
1606
 
@@ -1824,7 +1980,12 @@ async function runInteractiveConfigWizard(configDir) {
1824
1980
  }
1825
1981
 
1826
1982
  // src/config.ts
1827
- var CONFIG_KEYS = ["provider", "model", "source", "testTimeout"];
1983
+ var CONFIG_BOUNDS = {
1984
+ testTimeout: { min: 1, max: 120 },
1985
+ planTimeout: { min: 1, max: 120 },
1986
+ concurrency: { min: 1, max: 64 }
1987
+ };
1988
+ var CONFIG_KEYS = ["provider", "model", "source", "testTimeout", "planTimeout", "concurrency"];
1828
1989
  function getConfigPath(configDir) {
1829
1990
  const dir = configDir ?? join3(process.cwd(), ".dispatch");
1830
1991
  return join3(dir, "config.json");
@@ -1852,7 +2013,9 @@ var CONFIG_TO_CLI = {
1852
2013
  provider: "provider",
1853
2014
  model: "model",
1854
2015
  source: "issueSource",
1855
- testTimeout: "testTimeout"
2016
+ testTimeout: "testTimeout",
2017
+ planTimeout: "planTimeout",
2018
+ concurrency: "concurrency"
1856
2019
  };
1857
2020
  function setCliField(target, key, value) {
1858
2021
  target[key] = value;
@@ -1913,7 +2076,7 @@ init_providers();
1913
2076
 
1914
2077
  // src/agents/spec.ts
1915
2078
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4, unlink } from "fs/promises";
1916
- import { join as join5 } from "path";
2079
+ import { join as join5, resolve, sep } from "path";
1917
2080
  import { randomUUID as randomUUID3 } from "crypto";
1918
2081
  init_logger();
1919
2082
  async function boot5(opts) {
@@ -1925,8 +2088,19 @@ async function boot5(opts) {
1925
2088
  name: "spec",
1926
2089
  async generate(genOpts) {
1927
2090
  const { issue, filePath, fileContent, inlineText, cwd: workingDir, outputPath } = genOpts;
2091
+ const startTime = Date.now();
1928
2092
  try {
1929
- const tmpDir = join5(workingDir, ".dispatch", "tmp");
2093
+ const resolvedCwd = resolve(workingDir);
2094
+ const resolvedOutput = resolve(outputPath);
2095
+ if (resolvedOutput !== resolvedCwd && !resolvedOutput.startsWith(resolvedCwd + sep)) {
2096
+ return {
2097
+ data: null,
2098
+ success: false,
2099
+ error: `Output path "${outputPath}" escapes the working directory "${workingDir}"`,
2100
+ durationMs: Date.now() - startTime
2101
+ };
2102
+ }
2103
+ const tmpDir = join5(resolvedCwd, ".dispatch", "tmp");
1930
2104
  await mkdir3(tmpDir, { recursive: true });
1931
2105
  const tmpFilename = `spec-${randomUUID3()}.md`;
1932
2106
  const tmpPath = join5(tmpDir, tmpFilename);
@@ -1939,10 +2113,10 @@ async function boot5(opts) {
1939
2113
  prompt = buildFileSpecPrompt(filePath, fileContent, workingDir, tmpPath);
1940
2114
  } else {
1941
2115
  return {
1942
- content: "",
2116
+ data: null,
1943
2117
  success: false,
1944
2118
  error: "Either issue, inlineText, or filePath+fileContent must be provided",
1945
- valid: false
2119
+ durationMs: Date.now() - startTime
1946
2120
  };
1947
2121
  }
1948
2122
  const sessionId = await provider.createSession();
@@ -1950,10 +2124,10 @@ async function boot5(opts) {
1950
2124
  const response = await provider.prompt(sessionId, prompt);
1951
2125
  if (response === null) {
1952
2126
  return {
1953
- content: "",
2127
+ data: null,
1954
2128
  success: false,
1955
2129
  error: "AI agent returned no response",
1956
- valid: false
2130
+ durationMs: Date.now() - startTime
1957
2131
  };
1958
2132
  }
1959
2133
  log.debug(`Spec agent response (${response.length} chars)`);
@@ -1962,10 +2136,10 @@ async function boot5(opts) {
1962
2136
  rawContent = await readFile4(tmpPath, "utf-8");
1963
2137
  } catch {
1964
2138
  return {
1965
- content: "",
2139
+ data: null,
1966
2140
  success: false,
1967
2141
  error: `Spec agent did not write the file to ${tmpPath}. Agent response: ${response.slice(0, 300)}`,
1968
- valid: false
2142
+ durationMs: Date.now() - startTime
1969
2143
  };
1970
2144
  }
1971
2145
  const cleanedContent = extractSpecContent(rawContent);
@@ -1974,25 +2148,28 @@ async function boot5(opts) {
1974
2148
  if (!validation.valid) {
1975
2149
  log.warn(`Spec validation warning for ${outputPath}: ${validation.reason}`);
1976
2150
  }
1977
- await writeFile4(outputPath, cleanedContent, "utf-8");
1978
- log.debug(`Wrote cleaned spec to ${outputPath}`);
2151
+ await writeFile4(resolvedOutput, cleanedContent, "utf-8");
2152
+ log.debug(`Wrote cleaned spec to ${resolvedOutput}`);
1979
2153
  try {
1980
2154
  await unlink(tmpPath);
1981
2155
  } catch {
1982
2156
  }
1983
2157
  return {
1984
- content: cleanedContent,
2158
+ data: {
2159
+ content: cleanedContent,
2160
+ valid: validation.valid,
2161
+ validationReason: validation.reason
2162
+ },
1985
2163
  success: true,
1986
- valid: validation.valid,
1987
- validationReason: validation.reason
2164
+ durationMs: Date.now() - startTime
1988
2165
  };
1989
2166
  } catch (err) {
1990
2167
  const message = log.extractMessage(err);
1991
2168
  return {
1992
- content: "",
2169
+ data: null,
1993
2170
  success: false,
1994
2171
  error: message,
1995
- valid: false
2172
+ durationMs: Date.now() - startTime
1996
2173
  };
1997
2174
  }
1998
2175
  },
@@ -2000,24 +2177,8 @@ async function boot5(opts) {
2000
2177
  }
2001
2178
  };
2002
2179
  }
2003
- function buildSpecPrompt(issue, cwd, outputPath) {
2004
- const sections = [
2005
- `You are a **spec agent**. Your job is to explore the codebase, understand the issue below, and write a high-level **markdown spec file** to disk that will drive an automated implementation pipeline.`,
2006
- ``,
2007
- `**Important:** This file will be consumed by a two-stage pipeline:`,
2008
- `1. A **planner agent** reads each task together with the prose context in this file, then explores the codebase to produce a detailed, line-level implementation plan.`,
2009
- `2. A **coder agent** follows that detailed plan to make the actual code changes.`,
2010
- ``,
2011
- `Because the planner agent handles low-level details, your spec must stay **high-level and strategic**. Focus on the WHAT, WHY, and HOW \u2014 not exact code or line numbers.`,
2012
- ``,
2013
- `**CRITICAL \u2014 Output constraints (read carefully):**`,
2014
- `The file you write must contain ONLY the structured spec content described below. You MUST NOT include:`,
2015
- `- **No preamble:** Do not add any text before the H1 heading (e.g., "Here's the spec:", "I've written the spec file to...")`,
2016
- `- **No postamble:** Do not add any text after the last spec section (e.g., "Let me know if you'd like changes", "Here's a summary of...")`,
2017
- `- **No summaries:** Do not append a summary or recap of what you wrote`,
2018
- `- **No code fences:** Do not wrap the spec content in \`\`\`markdown ... \`\`\` or any other code fence`,
2019
- `- **No conversational text:** Do not include any explanations, commentary, or dialogue \u2014 the file is consumed by an automated pipeline, not a human`,
2020
- `The file content must start with \`# \` (the H1 heading) and contain nothing before or after the structured spec sections.`,
2180
+ function buildIssueSourceSection(issue) {
2181
+ const lines = [
2021
2182
  ``,
2022
2183
  `## Issue Details`,
2023
2184
  ``,
@@ -2027,120 +2188,60 @@ function buildSpecPrompt(issue, cwd, outputPath) {
2027
2188
  `- **URL:** ${issue.url}`
2028
2189
  ];
2029
2190
  if (issue.labels.length > 0) {
2030
- sections.push(`- **Labels:** ${issue.labels.join(", ")}`);
2191
+ lines.push(`- **Labels:** ${issue.labels.join(", ")}`);
2031
2192
  }
2032
2193
  if (issue.body) {
2033
- sections.push(``, `### Description`, ``, issue.body);
2194
+ lines.push(``, `### Description`, ``, issue.body);
2034
2195
  }
2035
2196
  if (issue.acceptanceCriteria) {
2036
- sections.push(``, `### Acceptance Criteria`, ``, issue.acceptanceCriteria);
2197
+ lines.push(``, `### Acceptance Criteria`, ``, issue.acceptanceCriteria);
2037
2198
  }
2038
2199
  if (issue.comments.length > 0) {
2039
- sections.push(``, `### Discussion`, ``);
2200
+ lines.push(``, `### Discussion`, ``);
2040
2201
  for (const comment of issue.comments) {
2041
- sections.push(comment, ``);
2202
+ lines.push(comment, ``);
2042
2203
  }
2043
2204
  }
2044
- sections.push(
2045
- ``,
2046
- `## Working Directory`,
2047
- ``,
2048
- `\`${cwd}\``,
2049
- ``,
2050
- `## Instructions`,
2051
- ``,
2052
- `1. **Explore the codebase** \u2014 read relevant files, search for symbols, understand the project structure, language, frameworks, conventions, and patterns. Identify the tech stack (languages, package managers, frameworks, test runners) so your spec aligns with the project's actual standards.`,
2053
- ``,
2054
- `2. **Understand the issue** \u2014 analyze the issue description, acceptance criteria, and discussion comments to fully understand what needs to be done and why.`,
2055
- ``,
2056
- `3. **Research the approach** \u2014 look up relevant documentation, libraries, and patterns. Consider how the change integrates with the existing architecture, standards, and technologies already in use. For example, if the project is TypeScript, do not propose a Python solution; if it uses Vitest, do not suggest Jest.`,
2057
- ``,
2058
- `4. **Identify integration points** \u2014 determine which existing modules, interfaces, patterns, and conventions the implementation must align with. Note the key files and modules involved, but do NOT prescribe exact code changes \u2014 the planner agent will handle that.`,
2059
- ``,
2060
- `5. **DO NOT make any code changes** \u2014 you are only producing a spec, not implementing.`,
2061
- ``,
2062
- `## Output`,
2063
- ``,
2064
- `Write the complete spec as a markdown file to this exact path:`,
2065
- ``,
2066
- `\`${outputPath}\``,
2067
- ``,
2068
- `Use your Write tool to save the file. The file content MUST begin with the H1 heading \u2014 no preamble, no code fences, no conversational text before it. Do not add any text after the final spec section \u2014 no postamble, no summary, no commentary. The file must follow this structure exactly:`,
2069
- ``,
2070
- `# <Issue title> (#<number>)`,
2071
- ``,
2072
- `> <One-line summary: what this issue achieves and why it matters>`,
2073
- ``,
2074
- `## Context`,
2075
- ``,
2076
- `<Describe the relevant parts of the codebase: key modules, directory structure,`,
2077
- `language/framework, and architectural patterns. Name specific files and modules`,
2078
- `that are involved so the planner agent knows where to look, but do not include`,
2079
- `code snippets or line-level details.>`,
2080
- ``,
2081
- `## Why`,
2082
- ``,
2083
- `<Explain the motivation \u2014 why this change is needed, what problem it solves,`,
2084
- `what user or system benefit it provides. Pull from the issue description,`,
2085
- `acceptance criteria, and discussion.>`,
2086
- ``,
2087
- `## Approach`,
2088
- ``,
2089
- `<High-level description of the implementation strategy. Explain the overall`,
2090
- `approach, which patterns to follow, what to extend vs. create new, and how`,
2091
- `the change fits into the existing architecture. Mention relevant standards,`,
2092
- `technologies, and conventions the implementation MUST align with.>`,
2093
- ``,
2094
- `## Integration Points`,
2095
- ``,
2096
- `<List the specific modules, interfaces, configurations, and conventions that`,
2097
- `the implementation must integrate with. For example: existing provider`,
2098
- `interfaces to implement, CLI argument patterns to follow, test framework`,
2099
- `and conventions to match, build system requirements, etc.>`,
2100
- ``,
2101
- `## Tasks`,
2102
- ``,
2103
- `Each task MUST be prefixed with an execution-mode tag:`,
2104
- ``,
2105
- `- \`(P)\` \u2014 **Parallel-safe.** This task has no dependency on the output of a prior task and can run concurrently with other \`(P)\` tasks.`,
2106
- `- \`(S)\` \u2014 **Serial / dependent.** This task depends on a prior task's output or modifies shared state that conflicts with concurrent work. It acts as a barrier: all preceding tasks complete before it starts, and it completes before subsequent tasks begin.`,
2107
- `- \`(I)\` \u2014 **Isolated / barrier.** This task must run alone after all preceding tasks complete and before any subsequent tasks begin. Use for validation tasks like running tests, linting, or builds that read the output of prior tasks.`,
2108
- ``,
2109
- `**Default to \`(P)\`.** Most tasks are independent (e.g., adding a function in one module, writing tests in another). Only use \`(S)\` when a task genuinely depends on the result of a prior task (e.g., "refactor module X" followed by "update callers of module X"). Use \`(I)\` for validation or barrier tasks that must run alone after all prior work completes (e.g., "run tests", "run linting", "build the project").`,
2110
- ``,
2111
- `If a task has no \`(P)\`, \`(S)\`, or \`(I)\` prefix, the system treats it as serial, so always tag explicitly.`,
2112
- ``,
2113
- `Example:`,
2205
+ return lines;
2206
+ }
2207
+ function buildFileSourceSection(filePath, content, title) {
2208
+ const lines = [
2114
2209
  ``,
2115
- `- [ ] (P) Add validation helper to the form utils module`,
2116
- `- [ ] (P) Add unit tests for the new validation helper`,
2117
- `- [ ] (S) Refactor the form component to use the new validation helper`,
2118
- `- [ ] (P) Update documentation for the form utils module`,
2119
- `- [ ] (I) Run the full test suite to verify all changes pass`,
2210
+ `## File Details`,
2120
2211
  ``,
2212
+ `- **Title:** ${title}`,
2213
+ `- **Source file:** ${filePath}`
2214
+ ];
2215
+ if (content) {
2216
+ lines.push(``, `### Content`, ``, content);
2217
+ }
2218
+ return lines;
2219
+ }
2220
+ function buildInlineTextSourceSection(title, text) {
2221
+ return [
2121
2222
  ``,
2122
- `## References`,
2223
+ `## Inline Text`,
2123
2224
  ``,
2124
- `- <Links to relevant docs, related issues, or external resources>`,
2225
+ `- **Title:** ${title}`,
2125
2226
  ``,
2126
- `## Key Guidelines`,
2227
+ `### Description`,
2127
2228
  ``,
2128
- `- **Stay high-level.** Do NOT include code snippets, exact line numbers, diffs, or step-by-step coding instructions. A dedicated planner agent will produce those details for each task at execution time.`,
2129
- `- **Respect the project's stack.** Your spec must align with the languages, frameworks, libraries, test tools, and conventions already in use. Never suggest technologies that conflict with the existing project.`,
2130
- `- **Explain WHAT, WHY, and HOW (strategically).** Each task should say what needs to happen, why it's needed, and which part of the codebase it touches \u2014 but leave the tactical "how" to the planner agent.`,
2131
- `- **Detail integration points.** The prose sections (Context, Approach, Integration Points) are critical \u2014 they tell the planner agent where to look and what constraints to respect.`,
2132
- `- **Keep tasks atomic and ordered.** Each \`- [ ]\` task must be a single, clear unit of work. Order them so dependencies come first.`,
2133
- `- **Tag every task with \`(P)\`, \`(S)\`, or \`(I)\`.** Default to \`(P)\` (parallel) unless the task depends on a prior task's output. Use \`(I)\` for validation/barrier tasks. Group related serial dependencies together and prefer parallelism to maximize throughput.`,
2134
- `- **Embed commit instructions within task descriptions.** You control when commits happen. Instead of creating standalone commit tasks (which would fail \u2014 each task runs in an isolated agent session), include commit instructions at the end of implementation task descriptions at logical boundaries. For example: "Implement the validation helper and commit with a conventional commit message." Group related changes into a single commit where it makes logical sense, and use the project's conventional commit types: \`feat\`, \`fix\`, \`docs\`, \`refactor\`, \`test\`, \`chore\`, \`style\`, \`perf\`, \`ci\`. Not every task needs a commit instruction \u2014 use your judgment to place them at logical boundaries.`,
2135
- `- **Keep the markdown clean** \u2014 it will be parsed by an automated tool.`
2136
- );
2137
- return sections.join("\n");
2229
+ text
2230
+ ];
2138
2231
  }
2139
- function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2140
- const title = extractTitle(content, filePath);
2141
- const writePath = outputPath ?? filePath;
2142
- const sections = [
2143
- `You are a **spec agent**. Your job is to explore the codebase, understand the content below, and write a high-level **markdown spec file** to disk that will drive an automated implementation pipeline.`,
2232
+ function buildCommonSpecInstructions(params) {
2233
+ const {
2234
+ subject,
2235
+ sourceSection,
2236
+ cwd,
2237
+ outputPath,
2238
+ understandStep,
2239
+ titleTemplate,
2240
+ summaryTemplate,
2241
+ whyLines
2242
+ } = params;
2243
+ return [
2244
+ `You are a **spec agent**. Your job is to explore the codebase, understand ${subject}, and write a high-level **markdown spec file** to disk that will drive an automated implementation pipeline.`,
2144
2245
  ``,
2145
2246
  `**Important:** This file will be consumed by a two-stage pipeline:`,
2146
2247
  `1. A **planner agent** reads each task together with the prose context in this file, then explores the codebase to produce a detailed, line-level implementation plan.`,
@@ -2156,16 +2257,7 @@ function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2156
2257
  `- **No code fences:** Do not wrap the spec content in \`\`\`markdown ... \`\`\` or any other code fence`,
2157
2258
  `- **No conversational text:** Do not include any explanations, commentary, or dialogue \u2014 the file is consumed by an automated pipeline, not a human`,
2158
2259
  `The file content must start with \`# \` (the H1 heading) and contain nothing before or after the structured spec sections.`,
2159
- ``,
2160
- `## File Details`,
2161
- ``,
2162
- `- **Title:** ${title}`,
2163
- `- **Source file:** ${filePath}`
2164
- ];
2165
- if (content) {
2166
- sections.push(``, `### Content`, ``, content);
2167
- }
2168
- sections.push(
2260
+ ...sourceSection,
2169
2261
  ``,
2170
2262
  `## Working Directory`,
2171
2263
  ``,
@@ -2175,7 +2267,7 @@ function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2175
2267
  ``,
2176
2268
  `1. **Explore the codebase** \u2014 read relevant files, search for symbols, understand the project structure, language, frameworks, conventions, and patterns. Identify the tech stack (languages, package managers, frameworks, test runners) so your spec aligns with the project's actual standards.`,
2177
2269
  ``,
2178
- `2. **Understand the content** \u2014 analyze the file content to fully understand what needs to be done and why.`,
2270
+ understandStep,
2179
2271
  ``,
2180
2272
  `3. **Research the approach** \u2014 look up relevant documentation, libraries, and patterns. Consider how the change integrates with the existing architecture, standards, and technologies already in use. For example, if the project is TypeScript, do not propose a Python solution; if it uses Vitest, do not suggest Jest.`,
2181
2273
  ``,
@@ -2187,13 +2279,13 @@ function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2187
2279
  ``,
2188
2280
  `Write the complete spec as a markdown file to this exact path:`,
2189
2281
  ``,
2190
- `\`${writePath}\``,
2282
+ `\`${outputPath}\``,
2191
2283
  ``,
2192
2284
  `Use your Write tool to save the file. The file content MUST begin with the H1 heading \u2014 no preamble, no code fences, no conversational text before it. Do not add any text after the final spec section \u2014 no postamble, no summary, no commentary. The file must follow this structure exactly:`,
2193
2285
  ``,
2194
- `# <Title>`,
2286
+ titleTemplate,
2195
2287
  ``,
2196
- `> <One-line summary: what this achieves and why it matters>`,
2288
+ summaryTemplate,
2197
2289
  ``,
2198
2290
  `## Context`,
2199
2291
  ``,
@@ -2205,7 +2297,7 @@ function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2205
2297
  `## Why`,
2206
2298
  ``,
2207
2299
  `<Explain the motivation \u2014 why this change is needed, what problem it solves,`,
2208
- `what user or system benefit it provides. Pull from the file content.>`,
2300
+ ...whyLines,
2209
2301
  ``,
2210
2302
  `## Approach`,
2211
2303
  ``,
@@ -2256,130 +2348,53 @@ function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2256
2348
  `- **Tag every task with \`(P)\`, \`(S)\`, or \`(I)\`.** Default to \`(P)\` (parallel) unless the task depends on a prior task's output. Use \`(I)\` for validation/barrier tasks. Group related serial dependencies together and prefer parallelism to maximize throughput.`,
2257
2349
  `- **Embed commit instructions within task descriptions.** You control when commits happen. Instead of creating standalone commit tasks (which would fail \u2014 each task runs in an isolated agent session), include commit instructions at the end of implementation task descriptions at logical boundaries. For example: "Implement the validation helper and commit with a conventional commit message." Group related changes into a single commit where it makes logical sense, and use the project's conventional commit types: \`feat\`, \`fix\`, \`docs\`, \`refactor\`, \`test\`, \`chore\`, \`style\`, \`perf\`, \`ci\`. Not every task needs a commit instruction \u2014 use your judgment to place them at logical boundaries.`,
2258
2350
  `- **Keep the markdown clean** \u2014 it will be parsed by an automated tool.`
2259
- );
2260
- return sections.join("\n");
2351
+ ];
2352
+ }
2353
+ function buildSpecPrompt(issue, cwd, outputPath) {
2354
+ return buildCommonSpecInstructions({
2355
+ subject: "the issue below",
2356
+ sourceSection: buildIssueSourceSection(issue),
2357
+ cwd,
2358
+ outputPath,
2359
+ understandStep: `2. **Understand the issue** \u2014 analyze the issue description, acceptance criteria, and discussion comments to fully understand what needs to be done and why.`,
2360
+ titleTemplate: `# <Issue title> (#<number>)`,
2361
+ summaryTemplate: `> <One-line summary: what this issue achieves and why it matters>`,
2362
+ whyLines: [
2363
+ `what user or system benefit it provides. Pull from the issue description,`,
2364
+ `acceptance criteria, and discussion.>`
2365
+ ]
2366
+ }).join("\n");
2367
+ }
2368
+ function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2369
+ const title = extractTitle(content, filePath);
2370
+ const writePath = outputPath ?? filePath;
2371
+ return buildCommonSpecInstructions({
2372
+ subject: "the content below",
2373
+ sourceSection: buildFileSourceSection(filePath, content, title),
2374
+ cwd,
2375
+ outputPath: writePath,
2376
+ understandStep: `2. **Understand the content** \u2014 analyze the file content to fully understand what needs to be done and why.`,
2377
+ titleTemplate: `# <Title>`,
2378
+ summaryTemplate: `> <One-line summary: what this achieves and why it matters>`,
2379
+ whyLines: [
2380
+ `what user or system benefit it provides. Pull from the file content.>`
2381
+ ]
2382
+ }).join("\n");
2261
2383
  }
2262
2384
  function buildInlineTextSpecPrompt(text, cwd, outputPath) {
2263
2385
  const title = text.length > 80 ? text.slice(0, 80).trimEnd() + "\u2026" : text;
2264
- const sections = [
2265
- `You are a **spec agent**. Your job is to explore the codebase, understand the request below, and write a high-level **markdown spec file** to disk that will drive an automated implementation pipeline.`,
2266
- ``,
2267
- `**Important:** This file will be consumed by a two-stage pipeline:`,
2268
- `1. A **planner agent** reads each task together with the prose context in this file, then explores the codebase to produce a detailed, line-level implementation plan.`,
2269
- `2. A **coder agent** follows that detailed plan to make the actual code changes.`,
2270
- ``,
2271
- `Because the planner agent handles low-level details, your spec must stay **high-level and strategic**. Focus on the WHAT, WHY, and HOW \u2014 not exact code or line numbers.`,
2272
- ``,
2273
- `**CRITICAL \u2014 Output constraints (read carefully):**`,
2274
- `The file you write must contain ONLY the structured spec content described below. You MUST NOT include:`,
2275
- `- **No preamble:** Do not add any text before the H1 heading (e.g., "Here's the spec:", "I've written the spec file to...")`,
2276
- `- **No postamble:** Do not add any text after the last spec section (e.g., "Let me know if you'd like changes", "Here's a summary of...")`,
2277
- `- **No summaries:** Do not append a summary or recap of what you wrote`,
2278
- `- **No code fences:** Do not wrap the spec content in \`\`\`markdown ... \`\`\` or any other code fence`,
2279
- `- **No conversational text:** Do not include any explanations, commentary, or dialogue \u2014 the file is consumed by an automated pipeline, not a human`,
2280
- `The file content must start with \`# \` (the H1 heading) and contain nothing before or after the structured spec sections.`,
2281
- ``,
2282
- `## Inline Text`,
2283
- ``,
2284
- `- **Title:** ${title}`,
2285
- ``,
2286
- `### Description`,
2287
- ``,
2288
- text
2289
- ];
2290
- sections.push(
2291
- ``,
2292
- `## Working Directory`,
2293
- ``,
2294
- `\`${cwd}\``,
2295
- ``,
2296
- `## Instructions`,
2297
- ``,
2298
- `1. **Explore the codebase** \u2014 read relevant files, search for symbols, understand the project structure, language, frameworks, conventions, and patterns. Identify the tech stack (languages, package managers, frameworks, test runners) so your spec aligns with the project's actual standards.`,
2299
- ``,
2300
- `2. **Understand the request** \u2014 analyze the inline text to fully understand what needs to be done and why. Since this is a brief description rather than a detailed issue or document, you may need to infer details from the codebase.`,
2301
- ``,
2302
- `3. **Research the approach** \u2014 look up relevant documentation, libraries, and patterns. Consider how the change integrates with the existing architecture, standards, and technologies already in use. For example, if the project is TypeScript, do not propose a Python solution; if it uses Vitest, do not suggest Jest.`,
2303
- ``,
2304
- `4. **Identify integration points** \u2014 determine which existing modules, interfaces, patterns, and conventions the implementation must align with. Note the key files and modules involved, but do NOT prescribe exact code changes \u2014 the planner agent will handle that.`,
2305
- ``,
2306
- `5. **DO NOT make any code changes** \u2014 you are only producing a spec, not implementing.`,
2307
- ``,
2308
- `## Output`,
2309
- ``,
2310
- `Write the complete spec as a markdown file to this exact path:`,
2311
- ``,
2312
- `\`${outputPath}\``,
2313
- ``,
2314
- `Use your Write tool to save the file. The file content MUST begin with the H1 heading \u2014 no preamble, no code fences, no conversational text before it. Do not add any text after the final spec section \u2014 no postamble, no summary, no commentary. The file must follow this structure exactly:`,
2315
- ``,
2316
- `# <Title>`,
2317
- ``,
2318
- `> <One-line summary: what this achieves and why it matters>`,
2319
- ``,
2320
- `## Context`,
2321
- ``,
2322
- `<Describe the relevant parts of the codebase: key modules, directory structure,`,
2323
- `language/framework, and architectural patterns. Name specific files and modules`,
2324
- `that are involved so the planner agent knows where to look, but do not include`,
2325
- `code snippets or line-level details.>`,
2326
- ``,
2327
- `## Why`,
2328
- ``,
2329
- `<Explain the motivation \u2014 why this change is needed, what problem it solves,`,
2330
- `what user or system benefit it provides. Pull from the inline text description.>`,
2331
- ``,
2332
- `## Approach`,
2333
- ``,
2334
- `<High-level description of the implementation strategy. Explain the overall`,
2335
- `approach, which patterns to follow, what to extend vs. create new, and how`,
2336
- `the change fits into the existing architecture. Mention relevant standards,`,
2337
- `technologies, and conventions the implementation MUST align with.>`,
2338
- ``,
2339
- `## Integration Points`,
2340
- ``,
2341
- `<List the specific modules, interfaces, configurations, and conventions that`,
2342
- `the implementation must integrate with. For example: existing provider`,
2343
- `interfaces to implement, CLI argument patterns to follow, test framework`,
2344
- `and conventions to match, build system requirements, etc.>`,
2345
- ``,
2346
- `## Tasks`,
2347
- ``,
2348
- `Each task MUST be prefixed with an execution-mode tag:`,
2349
- ``,
2350
- `- \`(P)\` \u2014 **Parallel-safe.** This task has no dependency on the output of a prior task and can run concurrently with other \`(P)\` tasks.`,
2351
- `- \`(S)\` \u2014 **Serial / dependent.** This task depends on a prior task's output or modifies shared state that conflicts with concurrent work. It acts as a barrier: all preceding tasks complete before it starts, and it completes before subsequent tasks begin.`,
2352
- `- \`(I)\` \u2014 **Isolated / barrier.** This task must run alone after all preceding tasks complete and before any subsequent tasks begin. Use for validation tasks like running tests, linting, or builds that read the output of prior tasks.`,
2353
- ``,
2354
- `**Default to \`(P)\`.** Most tasks are independent (e.g., adding a function in one module, writing tests in another). Only use \`(S)\` when a task genuinely depends on the result of a prior task (e.g., "refactor module X" followed by "update callers of module X"). Use \`(I)\` for validation or barrier tasks that must run alone after all prior work completes (e.g., "run tests", "run linting", "build the project").`,
2355
- ``,
2356
- `If a task has no \`(P)\`, \`(S)\`, or \`(I)\` prefix, the system treats it as serial, so always tag explicitly.`,
2357
- ``,
2358
- `Example:`,
2359
- ``,
2360
- `- [ ] (P) Add validation helper to the form utils module`,
2361
- `- [ ] (P) Add unit tests for the new validation helper`,
2362
- `- [ ] (S) Refactor the form component to use the new validation helper`,
2363
- `- [ ] (P) Update documentation for the form utils module`,
2364
- `- [ ] (I) Run the full test suite to verify all changes pass`,
2365
- ``,
2366
- ``,
2367
- `## References`,
2368
- ``,
2369
- `- <Links to relevant docs, related issues, or external resources>`,
2370
- ``,
2371
- `## Key Guidelines`,
2372
- ``,
2373
- `- **Stay high-level.** Do NOT include code snippets, exact line numbers, diffs, or step-by-step coding instructions. A dedicated planner agent will produce those details for each task at execution time.`,
2374
- `- **Respect the project's stack.** Your spec must align with the languages, frameworks, libraries, test tools, and conventions already in use. Never suggest technologies that conflict with the existing project.`,
2375
- `- **Explain WHAT, WHY, and HOW (strategically).** Each task should say what needs to happen, why it's needed, and which part of the codebase it touches \u2014 but leave the tactical "how" to the planner agent.`,
2376
- `- **Detail integration points.** The prose sections (Context, Approach, Integration Points) are critical \u2014 they tell the planner agent where to look and what constraints to respect.`,
2377
- `- **Keep tasks atomic and ordered.** Each \`- [ ]\` task must be a single, clear unit of work. Order them so dependencies come first.`,
2378
- `- **Tag every task with \`(P)\`, \`(S)\`, or \`(I)\`.** Default to \`(P)\` (parallel) unless the task depends on a prior task's output. Use \`(I)\` for validation/barrier tasks. Group related serial dependencies together and prefer parallelism to maximize throughput.`,
2379
- `- **Embed commit instructions within task descriptions.** You control when commits happen. Instead of creating standalone commit tasks (which would fail \u2014 each task runs in an isolated agent session), include commit instructions at the end of implementation task descriptions at logical boundaries. For example: "Implement the validation helper and commit with a conventional commit message." Group related changes into a single commit where it makes logical sense, and use the project's conventional commit types: \`feat\`, \`fix\`, \`docs\`, \`refactor\`, \`test\`, \`chore\`, \`style\`, \`perf\`, \`ci\`. Not every task needs a commit instruction \u2014 use your judgment to place them at logical boundaries.`,
2380
- `- **Keep the markdown clean** \u2014 it will be parsed by an automated tool.`
2381
- );
2382
- return sections.join("\n");
2386
+ return buildCommonSpecInstructions({
2387
+ subject: "the request below",
2388
+ sourceSection: buildInlineTextSourceSection(title, text),
2389
+ cwd,
2390
+ outputPath,
2391
+ understandStep: `2. **Understand the request** \u2014 analyze the inline text to fully understand what needs to be done and why. Since this is a brief description rather than a detailed issue or document, you may need to infer details from the codebase.`,
2392
+ titleTemplate: `# <Title>`,
2393
+ summaryTemplate: `> <One-line summary: what this achieves and why it matters>`,
2394
+ whyLines: [
2395
+ `what user or system benefit it provides. Pull from the inline text description.>`
2396
+ ]
2397
+ }).join("\n");
2383
2398
  }
2384
2399
 
2385
2400
  // src/orchestrator/spec-pipeline.ts
@@ -2435,146 +2450,134 @@ async function withRetry(fn, maxRetries, options) {
2435
2450
  }
2436
2451
 
2437
2452
  // src/orchestrator/spec-pipeline.ts
2438
- async function runSpecPipeline(opts) {
2439
- const {
2440
- issues,
2441
- provider,
2442
- model,
2443
- serverUrl,
2444
- cwd: specCwd,
2445
- outputDir = join6(specCwd, ".dispatch", "specs"),
2446
- org,
2447
- project,
2448
- workItemType,
2449
- concurrency = defaultConcurrency(),
2450
- dryRun,
2451
- retries = 2
2452
- } = opts;
2453
- const pipelineStart = Date.now();
2454
- const source = await resolveSource(issues, opts.issueSource, specCwd);
2455
- if (!source) {
2456
- return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2457
- }
2453
+ init_timeout();
2454
+ var FETCH_TIMEOUT_MS = 3e4;
2455
+ async function resolveDatasource(issues, issueSource, specCwd, org, project, workItemType) {
2456
+ const source = await resolveSource(issues, issueSource, specCwd);
2457
+ if (!source) return null;
2458
2458
  const datasource4 = getDatasource(source);
2459
2459
  const fetchOpts = { cwd: specCwd, org, project, workItemType };
2460
- const isTrackerMode = isIssueNumbers(issues);
2461
- const isInlineText = !isTrackerMode && !isGlobOrFilePath(issues);
2462
- let items;
2463
- if (isTrackerMode) {
2464
- const issueNumbers2 = issues.split(",").map((s) => s.trim()).filter(Boolean);
2465
- if (issueNumbers2.length === 0) {
2466
- log.error("No issue numbers provided. Use --spec 1,2,3");
2467
- return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: 0, fileDurationsMs: {} };
2468
- }
2469
- const fetchStart = Date.now();
2470
- log.info(`Fetching ${issueNumbers2.length} issue(s) from ${source} (concurrency: ${concurrency})...`);
2471
- items = [];
2472
- const fetchQueue = [...issueNumbers2];
2473
- while (fetchQueue.length > 0) {
2474
- const batch = fetchQueue.splice(0, concurrency);
2475
- log.debug(`Fetching batch of ${batch.length}: #${batch.join(", #")}`);
2476
- const batchResults = await Promise.all(
2477
- batch.map(async (id) => {
2478
- try {
2479
- const details = await datasource4.fetch(id, fetchOpts);
2480
- log.success(`Fetched #${id}: ${details.title}`);
2481
- log.debug(`Body: ${details.body?.length ?? 0} chars, Labels: ${details.labels.length}, Comments: ${details.comments.length}`);
2482
- return { id, details };
2483
- } catch (err) {
2484
- const message = log.extractMessage(err);
2485
- log.error(`Failed to fetch #${id}: ${log.formatErrorChain(err)}`);
2486
- log.debug(log.formatErrorChain(err));
2487
- return { id, details: null, error: message };
2488
- }
2489
- })
2490
- );
2491
- items.push(...batchResults);
2492
- }
2493
- log.debug(`Issue fetching completed in ${elapsed(Date.now() - fetchStart)}`);
2494
- } else if (isInlineText) {
2495
- const text = Array.isArray(issues) ? issues.join(" ") : issues;
2496
- const title = text.length > 80 ? text.slice(0, 80).trimEnd() + "\u2026" : text;
2497
- const slug = slugify(text, MAX_SLUG_LENGTH);
2498
- const filename = `${slug}.md`;
2499
- const filepath = join6(outputDir, filename);
2500
- const details = {
2501
- number: filepath,
2502
- title,
2503
- body: text,
2504
- labels: [],
2505
- state: "open",
2506
- url: filepath,
2507
- comments: [],
2508
- acceptanceCriteria: ""
2509
- };
2510
- log.info(`Inline text spec: "${title}"`);
2511
- items = [{ id: filepath, details }];
2512
- } else {
2513
- const files = await glob(issues, { cwd: specCwd, absolute: true });
2514
- if (files.length === 0) {
2515
- log.error(`No files matched the pattern "${Array.isArray(issues) ? issues.join(", ") : issues}".`);
2516
- return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: 0, fileDurationsMs: {} };
2517
- }
2518
- log.info(`Matched ${files.length} file(s) for spec generation (concurrency: ${concurrency})...`);
2519
- items = [];
2520
- for (const filePath of files) {
2521
- try {
2522
- const content = await readFile5(filePath, "utf-8");
2523
- const title = extractTitle(content, filePath);
2524
- const details = {
2525
- number: filePath,
2526
- title,
2527
- body: content,
2528
- labels: [],
2529
- state: "open",
2530
- url: filePath,
2531
- comments: [],
2532
- acceptanceCriteria: ""
2533
- };
2534
- items.push({ id: filePath, details });
2535
- } catch (err) {
2536
- items.push({ id: filePath, details: null, error: log.extractMessage(err) });
2537
- }
2460
+ return { source, datasource: datasource4, fetchOpts };
2461
+ }
2462
+ async function fetchTrackerItems(issues, datasource4, fetchOpts, concurrency, source) {
2463
+ const issueNumbers = issues.split(",").map((s) => s.trim()).filter(Boolean);
2464
+ if (issueNumbers.length === 0) {
2465
+ log.error("No issue numbers provided. Use --spec 1,2,3");
2466
+ return [];
2467
+ }
2468
+ const fetchStart = Date.now();
2469
+ log.info(`Fetching ${issueNumbers.length} issue(s) from ${source} (concurrency: ${concurrency})...`);
2470
+ const items = [];
2471
+ const fetchQueue = [...issueNumbers];
2472
+ while (fetchQueue.length > 0) {
2473
+ const batch = fetchQueue.splice(0, concurrency);
2474
+ log.debug(`Fetching batch of ${batch.length}: #${batch.join(", #")}`);
2475
+ const batchResults = await Promise.all(
2476
+ batch.map(async (id) => {
2477
+ try {
2478
+ const details = await withTimeout(datasource4.fetch(id, fetchOpts), FETCH_TIMEOUT_MS, "datasource fetch");
2479
+ log.success(`Fetched #${id}: ${details.title}`);
2480
+ log.debug(`Body: ${details.body?.length ?? 0} chars, Labels: ${details.labels.length}, Comments: ${details.comments.length}`);
2481
+ return { id, details };
2482
+ } catch (err) {
2483
+ const message = log.extractMessage(err);
2484
+ log.error(`Failed to fetch #${id}: ${log.formatErrorChain(err)}`);
2485
+ log.debug(log.formatErrorChain(err));
2486
+ return { id, details: null, error: message };
2487
+ }
2488
+ })
2489
+ );
2490
+ items.push(...batchResults);
2491
+ }
2492
+ log.debug(`Issue fetching completed in ${elapsed(Date.now() - fetchStart)}`);
2493
+ return items;
2494
+ }
2495
+ function buildInlineTextItem(issues, outputDir) {
2496
+ const text = Array.isArray(issues) ? issues.join(" ") : issues;
2497
+ const title = text.length > 80 ? text.slice(0, 80).trimEnd() + "\u2026" : text;
2498
+ const slug = slugify(text, MAX_SLUG_LENGTH);
2499
+ const filename = `${slug}.md`;
2500
+ const filepath = join6(outputDir, filename);
2501
+ const details = {
2502
+ number: filepath,
2503
+ title,
2504
+ body: text,
2505
+ labels: [],
2506
+ state: "open",
2507
+ url: filepath,
2508
+ comments: [],
2509
+ acceptanceCriteria: ""
2510
+ };
2511
+ log.info(`Inline text spec: "${title}"`);
2512
+ return [{ id: filepath, details }];
2513
+ }
2514
+ async function resolveFileItems(issues, specCwd, concurrency) {
2515
+ const files = await glob(issues, { cwd: specCwd, absolute: true });
2516
+ if (files.length === 0) {
2517
+ log.error(`No files matched the pattern "${Array.isArray(issues) ? issues.join(", ") : issues}".`);
2518
+ return null;
2519
+ }
2520
+ log.info(`Matched ${files.length} file(s) for spec generation (concurrency: ${concurrency})...`);
2521
+ const items = [];
2522
+ for (const filePath of files) {
2523
+ try {
2524
+ const content = await readFile5(filePath, "utf-8");
2525
+ const title = extractTitle(content, filePath);
2526
+ const details = {
2527
+ number: filePath,
2528
+ title,
2529
+ body: content,
2530
+ labels: [],
2531
+ state: "open",
2532
+ url: filePath,
2533
+ comments: [],
2534
+ acceptanceCriteria: ""
2535
+ };
2536
+ items.push({ id: filePath, details });
2537
+ } catch (err) {
2538
+ items.push({ id: filePath, details: null, error: log.extractMessage(err) });
2538
2539
  }
2539
2540
  }
2541
+ return items;
2542
+ }
2543
+ function filterValidItems(items, isTrackerMode, isInlineText) {
2540
2544
  const validItems = items.filter(
2541
2545
  (i) => i.details !== null
2542
2546
  );
2543
2547
  if (validItems.length === 0) {
2544
2548
  const noun = isTrackerMode ? "issues" : isInlineText ? "inline specs" : "files";
2545
2549
  log.error(`No ${noun} could be loaded. Aborting spec generation.`);
2546
- return { total: items.length, generated: 0, failed: items.length, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2550
+ return null;
2547
2551
  }
2548
- if (dryRun) {
2549
- const mode = isTrackerMode ? "tracker" : isInlineText ? "inline" : "file";
2550
- log.info(`[DRY RUN] Would generate ${validItems.length} spec(s) (mode: ${mode}):
2552
+ return validItems;
2553
+ }
2554
+ function previewDryRun(validItems, items, isTrackerMode, isInlineText, outputDir, pipelineStart) {
2555
+ const mode = isTrackerMode ? "tracker" : isInlineText ? "inline" : "file";
2556
+ log.info(`[DRY RUN] Would generate ${validItems.length} spec(s) (mode: ${mode}):
2551
2557
  `);
2552
- for (const { id, details } of validItems) {
2553
- let filepath;
2554
- if (isTrackerMode) {
2555
- const slug = slugify(details.title, 60);
2556
- filepath = join6(outputDir, `${id}-${slug}.md`);
2557
- } else {
2558
- filepath = id;
2559
- }
2560
- const label = isTrackerMode ? `#${id}` : filepath;
2561
- log.info(`[DRY RUN] Would generate spec for ${label}: "${details.title}"`);
2562
- log.dim(` \u2192 ${filepath}`);
2558
+ for (const { id, details } of validItems) {
2559
+ let filepath;
2560
+ if (isTrackerMode) {
2561
+ const slug = slugify(details.title, 60);
2562
+ filepath = join6(outputDir, `${id}-${slug}.md`);
2563
+ } else {
2564
+ filepath = id;
2563
2565
  }
2564
- return {
2565
- total: items.length,
2566
- generated: 0,
2567
- failed: items.filter((i) => i.details === null).length,
2568
- files: [],
2569
- issueNumbers: [],
2570
- durationMs: Date.now() - pipelineStart,
2571
- fileDurationsMs: {}
2572
- };
2573
- }
2574
- const confirmed = await confirmLargeBatch(validItems.length);
2575
- if (!confirmed) {
2576
- return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2566
+ const label = isTrackerMode ? `#${id}` : filepath;
2567
+ log.info(`[DRY RUN] Would generate spec for ${label}: "${details.title}"`);
2568
+ log.dim(` \u2192 ${filepath}`);
2577
2569
  }
2570
+ return {
2571
+ total: items.length,
2572
+ generated: 0,
2573
+ failed: items.filter((i) => i.details === null).length,
2574
+ files: [],
2575
+ issueNumbers: [],
2576
+ durationMs: Date.now() - pipelineStart,
2577
+ fileDurationsMs: {}
2578
+ };
2579
+ }
2580
+ async function bootPipeline(provider, serverUrl, specCwd, model, source) {
2578
2581
  const bootStart = Date.now();
2579
2582
  log.info(`Booting ${provider} provider...`);
2580
2583
  log.debug(serverUrl ? `Using server URL: ${serverUrl}` : "No --server-url, will spawn local server");
@@ -2593,6 +2596,9 @@ async function runSpecPipeline(opts) {
2593
2596
  console.log(chalk5.dim(" \u2500".repeat(24)));
2594
2597
  console.log("");
2595
2598
  const specAgent = await boot5({ provider: instance, cwd: specCwd });
2599
+ return { specAgent, instance };
2600
+ }
2601
+ async function generateSpecsBatch(validItems, items, specAgent, instance, isTrackerMode, isInlineText, datasource4, fetchOpts, outputDir, specCwd, concurrency, retries) {
2596
2602
  await mkdir4(outputDir, { recursive: true });
2597
2603
  const generatedFiles = [];
2598
2604
  const issueNumbers = [];
@@ -2638,7 +2644,7 @@ async function runSpecPipeline(opts) {
2638
2644
  throw new Error(result.error ?? "Spec generation failed");
2639
2645
  }
2640
2646
  if (isTrackerMode || isInlineText) {
2641
- const h1Title = extractTitle(result.content, filepath);
2647
+ const h1Title = extractTitle(result.data.content, filepath);
2642
2648
  const h1Slug = slugify(h1Title, MAX_SLUG_LENGTH);
2643
2649
  const finalFilename = isTrackerMode ? `${id}-${h1Slug}.md` : `${h1Slug}.md`;
2644
2650
  const finalFilepath = join6(outputDir, finalFilename);
@@ -2653,14 +2659,14 @@ async function runSpecPipeline(opts) {
2653
2659
  let identifier = filepath;
2654
2660
  try {
2655
2661
  if (isTrackerMode) {
2656
- await datasource4.update(id, details.title, result.content, fetchOpts);
2662
+ await datasource4.update(id, details.title, result.data.content, fetchOpts);
2657
2663
  log.success(`Updated issue #${id} with spec content`);
2658
2664
  await unlink2(filepath);
2659
2665
  log.success(`Deleted local spec ${filepath} (now tracked as issue #${id})`);
2660
2666
  identifier = id;
2661
2667
  issueNumbers.push(id);
2662
2668
  } else if (datasource4.name !== "md") {
2663
- const created = await datasource4.create(details.title, result.content, fetchOpts);
2669
+ const created = await datasource4.create(details.title, result.data.content, fetchOpts);
2664
2670
  log.success(`Created issue #${created.number} from ${filepath}`);
2665
2671
  await unlink2(filepath);
2666
2672
  log.success(`Deleted local spec ${filepath} (now tracked as issue #${created.number})`);
@@ -2692,6 +2698,9 @@ async function runSpecPipeline(opts) {
2692
2698
  modelLoggedInBanner = true;
2693
2699
  }
2694
2700
  }
2701
+ return { generatedFiles, issueNumbers, dispatchIdentifiers, failed, fileDurationsMs };
2702
+ }
2703
+ async function cleanupPipeline(specAgent, instance) {
2695
2704
  try {
2696
2705
  await specAgent.cleanup();
2697
2706
  } catch (err) {
@@ -2702,7 +2711,8 @@ async function runSpecPipeline(opts) {
2702
2711
  } catch (err) {
2703
2712
  log.warn(`Provider cleanup failed: ${log.formatErrorChain(err)}`);
2704
2713
  }
2705
- const totalDuration = Date.now() - pipelineStart;
2714
+ }
2715
+ function logSummary(generatedFiles, dispatchIdentifiers, failed, totalDuration) {
2706
2716
  log.info(
2707
2717
  `Spec generation complete: ${generatedFiles.length} generated, ${failed} failed in ${elapsed(totalDuration)}`
2708
2718
  );
@@ -2718,19 +2728,89 @@ async function runSpecPipeline(opts) {
2718
2728
  `);
2719
2729
  }
2720
2730
  }
2731
+ }
2732
+ async function runSpecPipeline(opts) {
2733
+ const {
2734
+ issues,
2735
+ provider,
2736
+ model,
2737
+ serverUrl,
2738
+ cwd: specCwd,
2739
+ outputDir = join6(specCwd, ".dispatch", "specs"),
2740
+ org,
2741
+ project,
2742
+ workItemType,
2743
+ concurrency = defaultConcurrency(),
2744
+ dryRun,
2745
+ retries = 2
2746
+ } = opts;
2747
+ const pipelineStart = Date.now();
2748
+ const resolved = await resolveDatasource(issues, opts.issueSource, specCwd, org, project, workItemType);
2749
+ if (!resolved) {
2750
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2751
+ }
2752
+ const { source, datasource: datasource4, fetchOpts } = resolved;
2753
+ const isTrackerMode = isIssueNumbers(issues);
2754
+ const isInlineText = !isTrackerMode && !isGlobOrFilePath(issues);
2755
+ let items;
2756
+ if (isTrackerMode) {
2757
+ items = await fetchTrackerItems(issues, datasource4, fetchOpts, concurrency, source);
2758
+ if (items.length === 0) {
2759
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2760
+ }
2761
+ } else if (isInlineText) {
2762
+ items = buildInlineTextItem(issues, outputDir);
2763
+ } else {
2764
+ const fileItems = await resolveFileItems(issues, specCwd, concurrency);
2765
+ if (!fileItems) {
2766
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2767
+ }
2768
+ items = fileItems;
2769
+ }
2770
+ const validItems = filterValidItems(items, isTrackerMode, isInlineText);
2771
+ if (!validItems) {
2772
+ return { total: items.length, generated: 0, failed: items.length, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2773
+ }
2774
+ if (dryRun) {
2775
+ return previewDryRun(validItems, items, isTrackerMode, isInlineText, outputDir, pipelineStart);
2776
+ }
2777
+ const confirmed = await confirmLargeBatch(validItems.length);
2778
+ if (!confirmed) {
2779
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
2780
+ }
2781
+ const { specAgent, instance } = await bootPipeline(provider, serverUrl, specCwd, model, source);
2782
+ const results = await generateSpecsBatch(
2783
+ validItems,
2784
+ items,
2785
+ specAgent,
2786
+ instance,
2787
+ isTrackerMode,
2788
+ isInlineText,
2789
+ datasource4,
2790
+ fetchOpts,
2791
+ outputDir,
2792
+ specCwd,
2793
+ concurrency,
2794
+ retries
2795
+ );
2796
+ await cleanupPipeline(specAgent, instance);
2797
+ const totalDuration = Date.now() - pipelineStart;
2798
+ logSummary(results.generatedFiles, results.dispatchIdentifiers, results.failed, totalDuration);
2721
2799
  return {
2722
2800
  total: items.length,
2723
- generated: generatedFiles.length,
2724
- failed,
2725
- files: generatedFiles,
2726
- issueNumbers,
2727
- identifiers: dispatchIdentifiers,
2801
+ generated: results.generatedFiles.length,
2802
+ failed: results.failed,
2803
+ files: results.generatedFiles,
2804
+ issueNumbers: results.issueNumbers,
2805
+ identifiers: results.dispatchIdentifiers,
2728
2806
  durationMs: totalDuration,
2729
- fileDurationsMs
2807
+ fileDurationsMs: results.fileDurationsMs
2730
2808
  };
2731
2809
  }
2732
2810
 
2733
2811
  // src/orchestrator/dispatch-pipeline.ts
2812
+ import { execFile as execFile9 } from "child_process";
2813
+ import { promisify as promisify9 } from "util";
2734
2814
  import { readFile as readFile7 } from "fs/promises";
2735
2815
 
2736
2816
  // src/parser.ts
@@ -2837,25 +2917,26 @@ async function boot6(opts) {
2837
2917
  }
2838
2918
  return {
2839
2919
  name: "planner",
2840
- async plan(task, fileContext, cwdOverride) {
2920
+ async plan(task, fileContext, cwdOverride, worktreeRoot) {
2921
+ const startTime = Date.now();
2841
2922
  try {
2842
2923
  const sessionId = await provider.createSession();
2843
- const prompt = buildPlannerPrompt(task, cwdOverride ?? cwd, fileContext);
2924
+ const prompt = buildPlannerPrompt(task, cwdOverride ?? cwd, fileContext, worktreeRoot);
2844
2925
  const plan = await provider.prompt(sessionId, prompt);
2845
2926
  if (!plan?.trim()) {
2846
- return { prompt: "", success: false, error: "Planner returned empty plan" };
2927
+ return { data: null, success: false, error: "Planner returned empty plan", durationMs: Date.now() - startTime };
2847
2928
  }
2848
- return { prompt: plan, success: true };
2929
+ return { data: { prompt: plan }, success: true, durationMs: Date.now() - startTime };
2849
2930
  } catch (err) {
2850
2931
  const message = log.extractMessage(err);
2851
- return { prompt: "", success: false, error: message };
2932
+ return { data: null, success: false, error: message, durationMs: Date.now() - startTime };
2852
2933
  }
2853
2934
  },
2854
2935
  async cleanup() {
2855
2936
  }
2856
2937
  };
2857
2938
  }
2858
- function buildPlannerPrompt(task, cwd, fileContext) {
2939
+ function buildPlannerPrompt(task, cwd, fileContext, worktreeRoot) {
2859
2940
  const sections = [
2860
2941
  `You are a **planning agent**. Your job is to explore the codebase, understand the task below, and produce a detailed execution prompt that another agent will follow to implement the changes.`,
2861
2942
  ``,
@@ -2879,6 +2960,21 @@ function buildPlannerPrompt(task, cwd, fileContext) {
2879
2960
  `\`\`\``
2880
2961
  );
2881
2962
  }
2963
+ if (worktreeRoot) {
2964
+ sections.push(
2965
+ ``,
2966
+ `## Worktree Isolation`,
2967
+ ``,
2968
+ `You are operating inside a git worktree. All file operations MUST be confined`,
2969
+ `to the following directory tree:`,
2970
+ ``,
2971
+ ` ${worktreeRoot}`,
2972
+ ``,
2973
+ `- Do NOT read, write, or execute commands that access files outside this directory.`,
2974
+ `- Do NOT reference or modify files in the main repository working tree or other worktrees.`,
2975
+ `- All relative paths must resolve within the worktree root above.`
2976
+ );
2977
+ }
2882
2978
  sections.push(
2883
2979
  ``,
2884
2980
  `## Instructions`,
@@ -2914,11 +3010,11 @@ function buildPlannerPrompt(task, cwd, fileContext) {
2914
3010
 
2915
3011
  // src/dispatcher.ts
2916
3012
  init_logger();
2917
- async function dispatchTask(instance, task, cwd, plan) {
3013
+ async function dispatchTask(instance, task, cwd, plan, worktreeRoot) {
2918
3014
  try {
2919
3015
  log.debug(`Dispatching task: ${task.file}:${task.line} \u2014 ${task.text.slice(0, 80)}`);
2920
3016
  const sessionId = await instance.createSession();
2921
- const prompt = plan ? buildPlannedPrompt(task, cwd, plan) : buildPrompt(task, cwd);
3017
+ const prompt = plan ? buildPlannedPrompt(task, cwd, plan, worktreeRoot) : buildPrompt(task, cwd, worktreeRoot);
2922
3018
  log.debug(`Prompt built (${prompt.length} chars, ${plan ? "with plan" : "no plan"})`);
2923
3019
  const response = await instance.prompt(sessionId, prompt);
2924
3020
  if (response === null) {
@@ -2933,7 +3029,7 @@ async function dispatchTask(instance, task, cwd, plan) {
2933
3029
  return { task, success: false, error: message };
2934
3030
  }
2935
3031
  }
2936
- function buildPrompt(task, cwd) {
3032
+ function buildPrompt(task, cwd, worktreeRoot) {
2937
3033
  return [
2938
3034
  `You are completing a task from a markdown task file.`,
2939
3035
  ``,
@@ -2945,10 +3041,11 @@ function buildPrompt(task, cwd) {
2945
3041
  `- Complete ONLY this specific task \u2014 do not work on other tasks.`,
2946
3042
  `- Make the minimal, correct changes needed.`,
2947
3043
  buildCommitInstruction(task.text),
3044
+ ...buildWorktreeIsolation(worktreeRoot),
2948
3045
  `- When finished, confirm by saying "Task complete."`
2949
3046
  ].join("\n");
2950
3047
  }
2951
- function buildPlannedPrompt(task, cwd, plan) {
3048
+ function buildPlannedPrompt(task, cwd, plan, worktreeRoot) {
2952
3049
  return [
2953
3050
  `You are an **executor agent** completing a task that has been pre-planned by a planner agent.`,
2954
3051
  `The planner has already explored the codebase and produced detailed instructions below.`,
@@ -2973,6 +3070,7 @@ function buildPlannedPrompt(task, cwd, plan) {
2973
3070
  `- Do NOT re-plan, question, or revise the plan. Trust it as given and execute it faithfully.`,
2974
3071
  `- Do NOT search for additional context using grep, find, or similar tools unless the plan explicitly instructs you to.`,
2975
3072
  buildCommitInstruction(task.text),
3073
+ ...buildWorktreeIsolation(worktreeRoot),
2976
3074
  `- When finished, confirm by saying "Task complete."`
2977
3075
  ].join("\n");
2978
3076
  }
@@ -2985,6 +3083,12 @@ function buildCommitInstruction(taskText) {
2985
3083
  }
2986
3084
  return `- Do NOT commit changes \u2014 the orchestrator handles commits.`;
2987
3085
  }
3086
+ function buildWorktreeIsolation(worktreeRoot) {
3087
+ if (!worktreeRoot) return [];
3088
+ return [
3089
+ `- **Worktree isolation:** You are operating inside a git worktree at \`${worktreeRoot}\`. You MUST NOT read, write, or execute commands that access files outside this directory. All file paths must resolve within \`${worktreeRoot}\`.`
3090
+ ];
3091
+ }
2988
3092
 
2989
3093
  // src/agents/executor.ts
2990
3094
  init_logger();
@@ -2996,27 +3100,18 @@ async function boot7(opts) {
2996
3100
  return {
2997
3101
  name: "executor",
2998
3102
  async execute(input2) {
2999
- const { task, cwd, plan } = input2;
3103
+ const { task, cwd, plan, worktreeRoot } = input2;
3000
3104
  const startTime = Date.now();
3001
3105
  try {
3002
- const result = await dispatchTask(provider, task, cwd, plan ?? void 0);
3106
+ const result = await dispatchTask(provider, task, cwd, plan ?? void 0, worktreeRoot);
3003
3107
  if (result.success) {
3004
3108
  await markTaskComplete(task);
3109
+ return { data: { dispatchResult: result }, success: true, durationMs: Date.now() - startTime };
3005
3110
  }
3006
- return {
3007
- dispatchResult: result,
3008
- success: result.success,
3009
- error: result.error,
3010
- elapsedMs: Date.now() - startTime
3011
- };
3111
+ return { data: null, success: false, error: result.error, durationMs: Date.now() - startTime };
3012
3112
  } catch (err) {
3013
3113
  const message = log.extractMessage(err);
3014
- return {
3015
- dispatchResult: { task, success: false, error: message },
3016
- success: false,
3017
- error: message,
3018
- elapsedMs: Date.now() - startTime
3019
- };
3114
+ return { data: null, success: false, error: message, durationMs: Date.now() - startTime };
3020
3115
  }
3021
3116
  },
3022
3117
  async cleanup() {
@@ -3027,7 +3122,7 @@ async function boot7(opts) {
3027
3122
  // src/agents/commit.ts
3028
3123
  init_logger();
3029
3124
  import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
3030
- import { join as join7 } from "path";
3125
+ import { join as join7, resolve as resolve2 } from "path";
3031
3126
  import { randomUUID as randomUUID4 } from "crypto";
3032
3127
  async function boot8(opts) {
3033
3128
  const { provider } = opts;
@@ -3040,7 +3135,8 @@ async function boot8(opts) {
3040
3135
  name: "commit",
3041
3136
  async generate(genOpts) {
3042
3137
  try {
3043
- const tmpDir = join7(genOpts.cwd, ".dispatch", "tmp");
3138
+ const resolvedCwd = resolve2(genOpts.cwd);
3139
+ const tmpDir = join7(resolvedCwd, ".dispatch", "tmp");
3044
3140
  await mkdir5(tmpDir, { recursive: true });
3045
3141
  const tmpFilename = `commit-${randomUUID4()}.md`;
3046
3142
  const tmpPath = join7(tmpDir, tmpFilename);
@@ -3220,6 +3316,7 @@ init_cleanup();
3220
3316
  import { join as join8, basename } from "path";
3221
3317
  import { execFile as execFile7 } from "child_process";
3222
3318
  import { promisify as promisify7 } from "util";
3319
+ import { randomUUID as randomUUID5 } from "crypto";
3223
3320
  init_logger();
3224
3321
  var exec7 = promisify7(execFile7);
3225
3322
  var WORKTREE_DIR = ".dispatch/worktrees";
@@ -3230,13 +3327,16 @@ async function git2(args, cwd) {
3230
3327
  function worktreeName(issueFilename) {
3231
3328
  const base = basename(issueFilename);
3232
3329
  const withoutExt = base.replace(/\.md$/i, "");
3233
- return slugify(withoutExt);
3330
+ const match = withoutExt.match(/^(\d+)/);
3331
+ return match ? `issue-${match[1]}` : slugify(withoutExt);
3234
3332
  }
3235
- async function createWorktree(repoRoot, issueFilename, branchName) {
3333
+ async function createWorktree(repoRoot, issueFilename, branchName, startPoint) {
3236
3334
  const name = worktreeName(issueFilename);
3237
3335
  const worktreePath = join8(repoRoot, WORKTREE_DIR, name);
3238
3336
  try {
3239
- await git2(["worktree", "add", worktreePath, "-b", branchName], repoRoot);
3337
+ const args = ["worktree", "add", worktreePath, "-b", branchName];
3338
+ if (startPoint) args.push(startPoint);
3339
+ await git2(args, repoRoot);
3240
3340
  log.debug(`Created worktree at ${worktreePath} on branch ${branchName}`);
3241
3341
  } catch (err) {
3242
3342
  const message = log.extractMessage(err);
@@ -3268,6 +3368,11 @@ async function removeWorktree(repoRoot, issueFilename) {
3268
3368
  log.warn(`Could not prune worktrees: ${log.formatErrorChain(err)}`);
3269
3369
  }
3270
3370
  }
3371
+ function generateFeatureBranchName() {
3372
+ const uuid = randomUUID5();
3373
+ const octet = uuid.split("-")[0];
3374
+ return `dispatch/feature-${octet}`;
3375
+ }
3271
3376
 
3272
3377
  // src/tui.ts
3273
3378
  import chalk6 from "chalk";
@@ -3675,48 +3780,50 @@ async function buildPrTitle(issueTitle, defaultBranch, cwd) {
3675
3780
  }
3676
3781
  return `${commits[commits.length - 1]} (+${commits.length - 1} more)`;
3677
3782
  }
3678
-
3679
- // src/helpers/timeout.ts
3680
- var TimeoutError = class extends Error {
3681
- /** Optional label identifying the operation that timed out. */
3682
- label;
3683
- constructor(ms, label) {
3684
- const suffix = label ? ` [${label}]` : "";
3685
- super(`Timed out after ${ms}ms${suffix}`);
3686
- this.name = "TimeoutError";
3687
- this.label = label;
3783
+ function buildFeaturePrTitle(featureBranchName, issues) {
3784
+ if (issues.length === 1) {
3785
+ return issues[0].title;
3688
3786
  }
3689
- };
3690
- function withTimeout(promise, ms, label) {
3691
- const p = new Promise((resolve2, reject) => {
3692
- let settled = false;
3693
- const timer = setTimeout(() => {
3694
- if (settled) return;
3695
- settled = true;
3696
- reject(new TimeoutError(ms, label));
3697
- }, ms);
3698
- promise.then(
3699
- (value) => {
3700
- if (settled) return;
3701
- settled = true;
3702
- clearTimeout(timer);
3703
- resolve2(value);
3704
- },
3705
- (err) => {
3706
- if (settled) return;
3707
- settled = true;
3708
- clearTimeout(timer);
3709
- reject(err);
3710
- }
3711
- );
3712
- });
3713
- p.catch(() => {
3787
+ const issueRefs = issues.map((d) => `#${d.number}`).join(", ");
3788
+ return `feat: ${featureBranchName} (${issueRefs})`;
3789
+ }
3790
+ function buildFeaturePrBody(issues, tasks, results, datasourceName) {
3791
+ const sections = [];
3792
+ sections.push("## Issues\n");
3793
+ for (const issue of issues) {
3794
+ sections.push(`- #${issue.number}: ${issue.title}`);
3795
+ }
3796
+ sections.push("");
3797
+ const taskResults = new Map(results.map((r) => [r.task, r]));
3798
+ const completedTasks = tasks.filter((t) => taskResults.get(t)?.success);
3799
+ const failedTasks = tasks.filter((t) => {
3800
+ const r = taskResults.get(t);
3801
+ return r && !r.success;
3714
3802
  });
3715
- return p;
3803
+ if (completedTasks.length > 0 || failedTasks.length > 0) {
3804
+ sections.push("## Tasks\n");
3805
+ for (const task of completedTasks) {
3806
+ sections.push(`- [x] ${task.text}`);
3807
+ }
3808
+ for (const task of failedTasks) {
3809
+ sections.push(`- [ ] ${task.text}`);
3810
+ }
3811
+ sections.push("");
3812
+ }
3813
+ for (const issue of issues) {
3814
+ if (datasourceName === "github") {
3815
+ sections.push(`Closes #${issue.number}`);
3816
+ } else if (datasourceName === "azdevops") {
3817
+ sections.push(`Resolves AB#${issue.number}`);
3818
+ }
3819
+ }
3820
+ return sections.join("\n");
3716
3821
  }
3717
3822
 
3718
3823
  // src/orchestrator/dispatch-pipeline.ts
3824
+ init_timeout();
3719
3825
  import chalk7 from "chalk";
3826
+ var exec9 = promisify9(execFile9);
3720
3827
  var DEFAULT_PLAN_TIMEOUT_MIN = 10;
3721
3828
  var DEFAULT_PLAN_RETRIES = 1;
3722
3829
  async function runDispatchPipeline(opts, cwd) {
@@ -3728,6 +3835,7 @@ async function runDispatchPipeline(opts, cwd) {
3728
3835
  noPlan,
3729
3836
  noBranch,
3730
3837
  noWorktree,
3838
+ feature,
3731
3839
  provider = "opencode",
3732
3840
  model,
3733
3841
  source,
@@ -3820,7 +3928,7 @@ async function runDispatchPipeline(opts, cwd) {
3820
3928
  list.push(task);
3821
3929
  tasksByFile.set(task.file, list);
3822
3930
  }
3823
- const useWorktrees = !noWorktree && !noBranch && tasksByFile.size > 1;
3931
+ const useWorktrees = !noWorktree && (feature || !noBranch && tasksByFile.size > 1);
3824
3932
  tui.state.phase = "booting";
3825
3933
  if (verbose) log.info(`Booting ${provider} provider...`);
3826
3934
  if (serverUrl) {
@@ -3848,6 +3956,30 @@ async function runDispatchPipeline(opts, cwd) {
3848
3956
  let completed = 0;
3849
3957
  let failed = 0;
3850
3958
  const lifecycleOpts = { cwd };
3959
+ let featureBranchName;
3960
+ let featureDefaultBranch;
3961
+ if (feature) {
3962
+ try {
3963
+ featureDefaultBranch = await datasource4.getDefaultBranch(lifecycleOpts);
3964
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
3965
+ featureBranchName = generateFeatureBranchName();
3966
+ await datasource4.createAndSwitchBranch(featureBranchName, lifecycleOpts);
3967
+ log.debug(`Created feature branch ${featureBranchName} from ${featureDefaultBranch}`);
3968
+ registerCleanup(async () => {
3969
+ try {
3970
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
3971
+ } catch {
3972
+ }
3973
+ });
3974
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
3975
+ log.debug(`Switched back to ${featureDefaultBranch} for worktree creation`);
3976
+ } catch (err) {
3977
+ log.error(`Feature branch creation failed: ${log.extractMessage(err)}`);
3978
+ tui.state.phase = "done";
3979
+ tui.stop();
3980
+ return { total: allTasks.length, completed: 0, failed: allTasks.length, skipped: 0, results: [] };
3981
+ }
3982
+ }
3851
3983
  let username = "";
3852
3984
  try {
3853
3985
  username = await datasource4.getUsername(lifecycleOpts);
@@ -3862,10 +3994,10 @@ async function runDispatchPipeline(opts, cwd) {
3862
3994
  let issueCwd = cwd;
3863
3995
  if (!noBranch && details) {
3864
3996
  try {
3865
- defaultBranch = await datasource4.getDefaultBranch(lifecycleOpts);
3997
+ defaultBranch = feature ? featureBranchName : await datasource4.getDefaultBranch(lifecycleOpts);
3866
3998
  branchName = datasource4.buildBranchName(details.number, details.title, username);
3867
3999
  if (useWorktrees) {
3868
- worktreePath = await createWorktree(cwd, file, branchName);
4000
+ worktreePath = await createWorktree(cwd, file, branchName, ...feature && featureBranchName ? [featureBranchName] : []);
3869
4001
  registerCleanup(async () => {
3870
4002
  await removeWorktree(cwd, file);
3871
4003
  });
@@ -3876,7 +4008,7 @@ async function runDispatchPipeline(opts, cwd) {
3876
4008
  const tuiTask = tui.state.tasks.find((t) => t.task === task);
3877
4009
  if (tuiTask) tuiTask.worktree = wtName;
3878
4010
  }
3879
- } else {
4011
+ } else if (datasource4.supportsGit()) {
3880
4012
  await datasource4.createAndSwitchBranch(branchName, lifecycleOpts);
3881
4013
  log.debug(`Switched to branch ${branchName}`);
3882
4014
  }
@@ -3895,6 +4027,7 @@ async function runDispatchPipeline(opts, cwd) {
3895
4027
  return;
3896
4028
  }
3897
4029
  }
4030
+ const worktreeRoot = useWorktrees ? worktreePath : void 0;
3898
4031
  const issueLifecycleOpts = { cwd: issueCwd };
3899
4032
  let localInstance;
3900
4033
  let localPlanner;
@@ -3937,7 +4070,7 @@ async function runDispatchPipeline(opts, cwd) {
3937
4070
  for (let attempt = 1; attempt <= maxPlanAttempts; attempt++) {
3938
4071
  try {
3939
4072
  planResult = await withTimeout(
3940
- localPlanner.plan(task, fileContext, issueCwd),
4073
+ localPlanner.plan(task, fileContext, issueCwd, worktreeRoot),
3941
4074
  planTimeoutMs,
3942
4075
  "planner.plan()"
3943
4076
  );
@@ -3952,9 +4085,10 @@ async function runDispatchPipeline(opts, cwd) {
3952
4085
  }
3953
4086
  } else {
3954
4087
  planResult = {
3955
- prompt: "",
4088
+ data: null,
3956
4089
  success: false,
3957
- error: log.extractMessage(err)
4090
+ error: log.extractMessage(err),
4091
+ durationMs: 0
3958
4092
  };
3959
4093
  break;
3960
4094
  }
@@ -3963,9 +4097,10 @@ async function runDispatchPipeline(opts, cwd) {
3963
4097
  if (!planResult) {
3964
4098
  const timeoutMin = planTimeout ?? 10;
3965
4099
  planResult = {
3966
- prompt: "",
4100
+ data: null,
3967
4101
  success: false,
3968
- error: `Planning timed out after ${timeoutMin}m (${maxPlanAttempts} attempts)`
4102
+ error: `Planning timed out after ${timeoutMin}m (${maxPlanAttempts} attempts)`,
4103
+ durationMs: 0
3969
4104
  };
3970
4105
  }
3971
4106
  if (!planResult.success) {
@@ -3976,7 +4111,7 @@ async function runDispatchPipeline(opts, cwd) {
3976
4111
  failed++;
3977
4112
  return { task, success: false, error: tuiTask.error };
3978
4113
  }
3979
- plan = planResult.prompt;
4114
+ plan = planResult.data.prompt;
3980
4115
  }
3981
4116
  tuiTask.status = "running";
3982
4117
  if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: executing \u2014 "${task.text}"`);
@@ -3986,7 +4121,8 @@ async function runDispatchPipeline(opts, cwd) {
3986
4121
  const result = await localExecutor.execute({
3987
4122
  task,
3988
4123
  cwd: issueCwd,
3989
- plan: plan ?? null
4124
+ plan: plan ?? null,
4125
+ worktreeRoot
3990
4126
  });
3991
4127
  if (!result.success) {
3992
4128
  throw new Error(result.error ?? "Execution failed");
@@ -3996,10 +4132,10 @@ async function runDispatchPipeline(opts, cwd) {
3996
4132
  execRetries,
3997
4133
  { label: `executor "${task.text}"` }
3998
4134
  ).catch((err) => ({
3999
- dispatchResult: { task, success: false, error: log.extractMessage(err) },
4135
+ data: null,
4000
4136
  success: false,
4001
4137
  error: log.extractMessage(err),
4002
- elapsedMs: 0
4138
+ durationMs: 0
4003
4139
  }));
4004
4140
  if (execResult.success) {
4005
4141
  try {
@@ -4025,7 +4161,12 @@ async function runDispatchPipeline(opts, cwd) {
4025
4161
  if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})${tuiTask.error ? `: ${tuiTask.error}` : ""}`);
4026
4162
  failed++;
4027
4163
  }
4028
- return execResult.dispatchResult;
4164
+ const dispatchResult = execResult.success ? execResult.data.dispatchResult : {
4165
+ task,
4166
+ success: false,
4167
+ error: execResult.error ?? "Executor failed without returning a dispatch result."
4168
+ };
4169
+ return dispatchResult;
4029
4170
  })
4030
4171
  );
4031
4172
  issueResults.push(...batchResults);
@@ -4035,7 +4176,7 @@ async function runDispatchPipeline(opts, cwd) {
4035
4176
  }
4036
4177
  }
4037
4178
  results.push(...issueResults);
4038
- if (!noBranch && branchName && defaultBranch && details) {
4179
+ if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
4039
4180
  try {
4040
4181
  await datasource4.commitAllChanges(
4041
4182
  `chore: stage uncommitted changes for issue #${details.number}`,
@@ -4047,7 +4188,7 @@ async function runDispatchPipeline(opts, cwd) {
4047
4188
  }
4048
4189
  }
4049
4190
  let commitAgentResult;
4050
- if (!noBranch && branchName && defaultBranch && details) {
4191
+ if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
4051
4192
  try {
4052
4193
  const branchDiff = await getBranchDiff(defaultBranch, issueCwd);
4053
4194
  if (branchDiff) {
@@ -4055,7 +4196,8 @@ async function runDispatchPipeline(opts, cwd) {
4055
4196
  branchDiff,
4056
4197
  issue: details,
4057
4198
  taskResults: issueResults,
4058
- cwd: issueCwd
4199
+ cwd: issueCwd,
4200
+ worktreeRoot
4059
4201
  });
4060
4202
  if (result.success) {
4061
4203
  commitAgentResult = result;
@@ -4074,47 +4216,97 @@ async function runDispatchPipeline(opts, cwd) {
4074
4216
  }
4075
4217
  }
4076
4218
  if (!noBranch && branchName && defaultBranch && details) {
4077
- try {
4078
- await datasource4.pushBranch(branchName, issueLifecycleOpts);
4079
- log.debug(`Pushed branch ${branchName}`);
4080
- } catch (err) {
4081
- log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
4082
- }
4083
- try {
4084
- const prTitle = commitAgentResult?.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
4085
- const prBody = commitAgentResult?.prDescription || await buildPrBody(
4086
- details,
4087
- fileTasks,
4088
- issueResults,
4089
- defaultBranch,
4090
- datasource4.name,
4091
- issueLifecycleOpts.cwd
4092
- );
4093
- const prUrl = await datasource4.createPullRequest(
4094
- branchName,
4095
- details.number,
4096
- prTitle,
4097
- prBody,
4098
- issueLifecycleOpts
4099
- );
4100
- if (prUrl) {
4101
- log.success(`Created PR for issue #${details.number}: ${prUrl}`);
4219
+ if (feature && featureBranchName) {
4220
+ if (worktreePath) {
4221
+ try {
4222
+ await removeWorktree(cwd, file);
4223
+ } catch (err) {
4224
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
4225
+ }
4102
4226
  }
4103
- } catch (err) {
4104
- log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
4105
- }
4106
- if (useWorktrees && worktreePath) {
4107
4227
  try {
4108
- await removeWorktree(cwd, file);
4228
+ await datasource4.switchBranch(featureBranchName, lifecycleOpts);
4229
+ await exec9("git", ["merge", branchName, "--no-ff", "-m", `merge: issue #${details.number}`], { cwd });
4230
+ log.debug(`Merged ${branchName} into ${featureBranchName}`);
4109
4231
  } catch (err) {
4110
- log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
4232
+ const mergeError = `Could not merge ${branchName} into feature branch: ${log.formatErrorChain(err)}`;
4233
+ log.warn(mergeError);
4234
+ try {
4235
+ await exec9("git", ["merge", "--abort"], { cwd });
4236
+ } catch {
4237
+ }
4238
+ for (const task of fileTasks) {
4239
+ const tuiTask = tui.state.tasks.find((t) => t.task === task);
4240
+ if (tuiTask) {
4241
+ tuiTask.status = "failed";
4242
+ tuiTask.error = mergeError;
4243
+ }
4244
+ const existingResult = results.find((r) => r.task === task);
4245
+ if (existingResult) {
4246
+ existingResult.success = false;
4247
+ existingResult.error = mergeError;
4248
+ }
4249
+ }
4250
+ return;
4111
4251
  }
4112
- } else if (!useWorktrees) {
4113
4252
  try {
4114
- await datasource4.switchBranch(defaultBranch, lifecycleOpts);
4115
- log.debug(`Switched back to ${defaultBranch}`);
4253
+ await exec9("git", ["branch", "-d", branchName], { cwd });
4254
+ log.debug(`Deleted local branch ${branchName}`);
4116
4255
  } catch (err) {
4117
- log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
4256
+ log.warn(`Could not delete local branch ${branchName}: ${log.formatErrorChain(err)}`);
4257
+ }
4258
+ try {
4259
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4260
+ } catch (err) {
4261
+ log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
4262
+ }
4263
+ } else {
4264
+ if (datasource4.supportsGit()) {
4265
+ try {
4266
+ await datasource4.pushBranch(branchName, issueLifecycleOpts);
4267
+ log.debug(`Pushed branch ${branchName}`);
4268
+ } catch (err) {
4269
+ log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
4270
+ }
4271
+ }
4272
+ if (datasource4.supportsGit()) {
4273
+ try {
4274
+ const prTitle = commitAgentResult?.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
4275
+ const prBody = commitAgentResult?.prDescription || await buildPrBody(
4276
+ details,
4277
+ fileTasks,
4278
+ issueResults,
4279
+ defaultBranch,
4280
+ datasource4.name,
4281
+ issueLifecycleOpts.cwd
4282
+ );
4283
+ const prUrl = await datasource4.createPullRequest(
4284
+ branchName,
4285
+ details.number,
4286
+ prTitle,
4287
+ prBody,
4288
+ issueLifecycleOpts
4289
+ );
4290
+ if (prUrl) {
4291
+ log.success(`Created PR for issue #${details.number}: ${prUrl}`);
4292
+ }
4293
+ } catch (err) {
4294
+ log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
4295
+ }
4296
+ }
4297
+ if (useWorktrees && worktreePath) {
4298
+ try {
4299
+ await removeWorktree(cwd, file);
4300
+ } catch (err) {
4301
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
4302
+ }
4303
+ } else if (!useWorktrees && datasource4.supportsGit()) {
4304
+ try {
4305
+ await datasource4.switchBranch(defaultBranch, lifecycleOpts);
4306
+ log.debug(`Switched back to ${defaultBranch}`);
4307
+ } catch (err) {
4308
+ log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
4309
+ }
4118
4310
  }
4119
4311
  }
4120
4312
  }
@@ -4124,7 +4316,7 @@ async function runDispatchPipeline(opts, cwd) {
4124
4316
  await localInstance.cleanup();
4125
4317
  }
4126
4318
  };
4127
- if (useWorktrees) {
4319
+ if (useWorktrees && !feature) {
4128
4320
  await Promise.all(
4129
4321
  Array.from(tasksByFile).map(
4130
4322
  ([file, fileTasks]) => processIssueFile(file, fileTasks)
@@ -4135,6 +4327,43 @@ async function runDispatchPipeline(opts, cwd) {
4135
4327
  await processIssueFile(file, fileTasks);
4136
4328
  }
4137
4329
  }
4330
+ if (feature && featureBranchName && featureDefaultBranch) {
4331
+ try {
4332
+ await datasource4.switchBranch(featureBranchName, lifecycleOpts);
4333
+ log.debug(`Switched to feature branch ${featureBranchName}`);
4334
+ } catch (err) {
4335
+ log.warn(`Could not switch to feature branch: ${log.formatErrorChain(err)}`);
4336
+ }
4337
+ try {
4338
+ await datasource4.pushBranch(featureBranchName, lifecycleOpts);
4339
+ log.debug(`Pushed feature branch ${featureBranchName}`);
4340
+ } catch (err) {
4341
+ log.warn(`Could not push feature branch: ${log.formatErrorChain(err)}`);
4342
+ }
4343
+ try {
4344
+ const allIssueDetails = Array.from(issueDetailsByFile.values());
4345
+ const prTitle = buildFeaturePrTitle(featureBranchName, allIssueDetails);
4346
+ const prBody = buildFeaturePrBody(allIssueDetails, allTasks, results, source);
4347
+ const primaryIssue = allIssueDetails[0]?.number ?? "";
4348
+ const prUrl = await datasource4.createPullRequest(
4349
+ featureBranchName,
4350
+ primaryIssue,
4351
+ prTitle,
4352
+ prBody,
4353
+ lifecycleOpts
4354
+ );
4355
+ if (prUrl) {
4356
+ log.success(`Created feature PR: ${prUrl}`);
4357
+ }
4358
+ } catch (err) {
4359
+ log.warn(`Could not create feature PR: ${log.formatErrorChain(err)}`);
4360
+ }
4361
+ try {
4362
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4363
+ } catch (err) {
4364
+ log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
4365
+ }
4366
+ }
4138
4367
  try {
4139
4368
  await closeCompletedSpecIssues(taskFiles, results, cwd, source, org, project, workItemType);
4140
4369
  } catch (err) {
@@ -4233,12 +4462,17 @@ async function boot9(opts) {
4233
4462
  const modeFlags = [
4234
4463
  m.spec !== void 0 && "--spec",
4235
4464
  m.respec !== void 0 && "--respec",
4236
- m.fixTests && "--fix-tests"
4465
+ m.fixTests && "--fix-tests",
4466
+ m.feature && "--feature"
4237
4467
  ].filter(Boolean);
4238
4468
  if (modeFlags.length > 1) {
4239
4469
  log.error(`${modeFlags.join(" and ")} are mutually exclusive`);
4240
4470
  process.exit(1);
4241
4471
  }
4472
+ if (m.feature && m.noBranch) {
4473
+ log.error("--feature and --no-branch are mutually exclusive");
4474
+ process.exit(1);
4475
+ }
4242
4476
  if (m.fixTests && m.issueIds.length > 0) {
4243
4477
  log.error("--fix-tests cannot be combined with issue IDs");
4244
4478
  process.exit(1);
@@ -4320,7 +4554,8 @@ async function boot9(opts) {
4320
4554
  planTimeout: m.planTimeout,
4321
4555
  planRetries: m.planRetries,
4322
4556
  retries: m.retries,
4323
- force: m.force
4557
+ force: m.force,
4558
+ feature: m.feature
4324
4559
  });
4325
4560
  }
4326
4561
  };
@@ -4331,7 +4566,7 @@ async function boot9(opts) {
4331
4566
  init_logger();
4332
4567
  init_cleanup();
4333
4568
  init_providers();
4334
- var MAX_CONCURRENCY = 64;
4569
+ var MAX_CONCURRENCY = CONFIG_BOUNDS.concurrency.max;
4335
4570
  var HELP = `
4336
4571
  dispatch \u2014 AI agent orchestration CLI
4337
4572
 
@@ -4350,8 +4585,9 @@ var HELP = `
4350
4585
  --no-plan Skip the planner agent, dispatch directly
4351
4586
  --no-branch Skip branch creation, push, and PR lifecycle
4352
4587
  --no-worktree Skip git worktree isolation for parallel issues
4588
+ --feature Group issues into a single feature branch and PR
4353
4589
  --force Ignore prior run state and re-run all tasks
4354
- --concurrency <n> Max parallel dispatches (default: min(cpus, freeMB/500), max: 64)
4590
+ --concurrency <n> Max parallel dispatches (default: min(cpus, freeMB/500), max: ${MAX_CONCURRENCY})
4355
4591
  --provider <name> Agent backend: ${PROVIDER_NAMES.join(", ")} (default: opencode)
4356
4592
  --server-url <url> URL of a running provider server
4357
4593
  --plan-timeout <min> Planning timeout in minutes (default: 10)
@@ -4433,6 +4669,9 @@ function parseArgs(argv) {
4433
4669
  } else if (arg === "--no-worktree") {
4434
4670
  args.noWorktree = true;
4435
4671
  explicitFlags.add("noWorktree");
4672
+ } else if (arg === "--feature") {
4673
+ args.feature = true;
4674
+ explicitFlags.add("feature");
4436
4675
  } else if (arg === "--force") {
4437
4676
  args.force = true;
4438
4677
  explicitFlags.add("force");
@@ -4483,7 +4722,7 @@ function parseArgs(argv) {
4483
4722
  explicitFlags.add("project");
4484
4723
  } else if (arg === "--output-dir") {
4485
4724
  i++;
4486
- args.outputDir = resolve(argv[i]);
4725
+ args.outputDir = resolve3(argv[i]);
4487
4726
  explicitFlags.add("outputDir");
4488
4727
  } else if (arg === "--concurrency") {
4489
4728
  i++;
@@ -4514,10 +4753,14 @@ function parseArgs(argv) {
4514
4753
  } else if (arg === "--plan-timeout") {
4515
4754
  i++;
4516
4755
  const val = parseFloat(argv[i]);
4517
- if (isNaN(val) || val <= 0) {
4756
+ if (isNaN(val) || val < CONFIG_BOUNDS.planTimeout.min) {
4518
4757
  log.error("--plan-timeout must be a positive number (minutes)");
4519
4758
  process.exit(1);
4520
4759
  }
4760
+ if (val > CONFIG_BOUNDS.planTimeout.max) {
4761
+ log.error(`--plan-timeout must not exceed ${CONFIG_BOUNDS.planTimeout.max}`);
4762
+ process.exit(1);
4763
+ }
4521
4764
  args.planTimeout = val;
4522
4765
  explicitFlags.add("planTimeout");
4523
4766
  } else if (arg === "--retries") {
@@ -4549,7 +4792,7 @@ function parseArgs(argv) {
4549
4792
  explicitFlags.add("testTimeout");
4550
4793
  } else if (arg === "--cwd") {
4551
4794
  i++;
4552
- args.cwd = resolve(argv[i]);
4795
+ args.cwd = resolve3(argv[i]);
4553
4796
  explicitFlags.add("cwd");
4554
4797
  } else if (!arg.startsWith("-")) {
4555
4798
  args.issueIds.push(arg);
@@ -4567,7 +4810,7 @@ async function main() {
4567
4810
  let cwd = process.cwd();
4568
4811
  for (let i = 1; i < rawArgv.length; i++) {
4569
4812
  if (rawArgv[i] === "--cwd" && i + 1 < rawArgv.length) {
4570
- cwd = resolve(rawArgv[i + 1]);
4813
+ cwd = resolve3(rawArgv[i + 1]);
4571
4814
  break;
4572
4815
  }
4573
4816
  }
@@ -4592,7 +4835,7 @@ async function main() {
4592
4835
  process.exit(0);
4593
4836
  }
4594
4837
  if (args.version) {
4595
- console.log(`dispatch v${"0.0.1"}`);
4838
+ console.log(`dispatch v${"1.2.1"}`);
4596
4839
  process.exit(0);
4597
4840
  }
4598
4841
  const orchestrator = await boot9({ cwd: args.cwd });