@pruddiman/dispatch 0.0.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +180 -225
  2. package/dist/cli.js +1666 -1122
  3. package/dist/cli.js.map +1 -1
  4. package/package.json +11 -4
package/dist/cli.js CHANGED
@@ -9,42 +9,156 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/helpers/file-logger.ts
13
+ import { mkdirSync, writeFileSync, appendFileSync } from "fs";
14
+ import { join, dirname } from "path";
15
+ import { AsyncLocalStorage } from "async_hooks";
16
+ var fileLoggerStorage, FileLogger;
17
+ var init_file_logger = __esm({
18
+ "src/helpers/file-logger.ts"() {
19
+ "use strict";
20
+ fileLoggerStorage = new AsyncLocalStorage();
21
+ FileLogger = class _FileLogger {
22
+ filePath;
23
+ static sanitizeIssueId(issueId) {
24
+ const raw = String(issueId);
25
+ return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
26
+ }
27
+ constructor(issueId, cwd) {
28
+ const safeIssueId = _FileLogger.sanitizeIssueId(issueId);
29
+ this.filePath = join(cwd, ".dispatch", "logs", `issue-${safeIssueId}.log`);
30
+ mkdirSync(dirname(this.filePath), { recursive: true });
31
+ writeFileSync(this.filePath, "", "utf-8");
32
+ }
33
+ write(level, message) {
34
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
35
+ const line = `[${timestamp}] [${level}] ${message}
36
+ `;
37
+ appendFileSync(this.filePath, line, "utf-8");
38
+ }
39
+ info(message) {
40
+ this.write("INFO", message);
41
+ }
42
+ debug(message) {
43
+ this.write("DEBUG", message);
44
+ }
45
+ warn(message) {
46
+ this.write("WARN", message);
47
+ }
48
+ error(message) {
49
+ this.write("ERROR", message);
50
+ }
51
+ success(message) {
52
+ this.write("SUCCESS", message);
53
+ }
54
+ task(message) {
55
+ this.write("TASK", message);
56
+ }
57
+ dim(message) {
58
+ this.write("DIM", message);
59
+ }
60
+ prompt(label, content) {
61
+ const separator = "\u2500".repeat(40);
62
+ this.write("PROMPT", `${label}
63
+ ${separator}
64
+ ${content}
65
+ ${separator}`);
66
+ }
67
+ response(label, content) {
68
+ const separator = "\u2500".repeat(40);
69
+ this.write("RESPONSE", `${label}
70
+ ${separator}
71
+ ${content}
72
+ ${separator}`);
73
+ }
74
+ phase(name) {
75
+ const banner = "\u2550".repeat(40);
76
+ this.write("PHASE", `${banner}
77
+ ${name}
78
+ ${banner}`);
79
+ }
80
+ agentEvent(agent, event, detail) {
81
+ const msg = detail ? `[${agent}] ${event}: ${detail}` : `[${agent}] ${event}`;
82
+ this.write("AGENT", msg);
83
+ }
84
+ close() {
85
+ }
86
+ };
87
+ }
88
+ });
89
+
12
90
  // src/helpers/logger.ts
13
91
  import chalk from "chalk";
14
- var MAX_CAUSE_CHAIN_DEPTH, log;
92
+ function resolveLogLevel() {
93
+ const envLevel = process.env.LOG_LEVEL?.toLowerCase();
94
+ if (envLevel && Object.hasOwn(LOG_LEVEL_SEVERITY, envLevel)) {
95
+ return envLevel;
96
+ }
97
+ if (process.env.DEBUG) {
98
+ return "debug";
99
+ }
100
+ return "info";
101
+ }
102
+ function shouldLog(level) {
103
+ return LOG_LEVEL_SEVERITY[level] >= LOG_LEVEL_SEVERITY[currentLevel];
104
+ }
105
+ function stripAnsi(str) {
106
+ return str.replace(/\x1B\[[0-9;]*m/g, "");
107
+ }
108
+ var LOG_LEVEL_SEVERITY, currentLevel, MAX_CAUSE_CHAIN_DEPTH, log;
15
109
  var init_logger = __esm({
16
110
  "src/helpers/logger.ts"() {
17
111
  "use strict";
112
+ init_file_logger();
113
+ LOG_LEVEL_SEVERITY = {
114
+ debug: 0,
115
+ info: 1,
116
+ warn: 2,
117
+ error: 3
118
+ };
119
+ currentLevel = resolveLogLevel();
18
120
  MAX_CAUSE_CHAIN_DEPTH = 5;
19
121
  log = {
20
- /** When true, `debug()` messages are printed. Set by `--verbose`. */
21
122
  verbose: false,
22
123
  info(msg) {
124
+ if (!shouldLog("info")) return;
23
125
  console.log(chalk.blue("\u2139"), msg);
126
+ fileLoggerStorage.getStore()?.info(stripAnsi(msg));
24
127
  },
25
128
  success(msg) {
129
+ if (!shouldLog("info")) return;
26
130
  console.log(chalk.green("\u2714"), msg);
131
+ fileLoggerStorage.getStore()?.success(stripAnsi(msg));
27
132
  },
28
133
  warn(msg) {
29
- console.log(chalk.yellow("\u26A0"), msg);
134
+ if (!shouldLog("warn")) return;
135
+ console.error(chalk.yellow("\u26A0"), msg);
136
+ fileLoggerStorage.getStore()?.warn(stripAnsi(msg));
30
137
  },
31
138
  error(msg) {
139
+ if (!shouldLog("error")) return;
32
140
  console.error(chalk.red("\u2716"), msg);
141
+ fileLoggerStorage.getStore()?.error(stripAnsi(msg));
33
142
  },
34
143
  task(index, total, msg) {
144
+ if (!shouldLog("info")) return;
35
145
  console.log(chalk.cyan(`[${index + 1}/${total}]`), msg);
146
+ fileLoggerStorage.getStore()?.task(stripAnsi(`[${index + 1}/${total}] ${msg}`));
36
147
  },
37
148
  dim(msg) {
149
+ if (!shouldLog("info")) return;
38
150
  console.log(chalk.dim(msg));
151
+ fileLoggerStorage.getStore()?.dim(stripAnsi(msg));
39
152
  },
40
153
  /**
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.
154
+ * Print a debug/verbose message. Only visible when the log level is
155
+ * `"debug"`. Messages are prefixed with a dim arrow to visually nest
156
+ * them under the preceding info/error line.
44
157
  */
45
158
  debug(msg) {
46
- if (!this.verbose) return;
159
+ if (!shouldLog("debug")) return;
47
160
  console.log(chalk.dim(` \u2937 ${msg}`));
161
+ fileLoggerStorage.getStore()?.debug(stripAnsi(msg));
48
162
  },
49
163
  /**
50
164
  * Extract and format the full error cause chain. Node.js network errors
@@ -83,6 +197,26 @@ var init_logger = __esm({
83
197
  return "";
84
198
  }
85
199
  };
200
+ Object.defineProperty(log, "verbose", {
201
+ get() {
202
+ return currentLevel === "debug";
203
+ },
204
+ set(value) {
205
+ currentLevel = value ? "debug" : "info";
206
+ },
207
+ enumerable: true,
208
+ configurable: true
209
+ });
210
+ }
211
+ });
212
+
213
+ // src/helpers/guards.ts
214
+ function hasProperty(value, key) {
215
+ return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
216
+ }
217
+ var init_guards = __esm({
218
+ "src/helpers/guards.ts"() {
219
+ "use strict";
86
220
  }
87
221
  });
88
222
 
@@ -183,6 +317,7 @@ async function boot(opts) {
183
317
  },
184
318
  async prompt(sessionId, text) {
185
319
  log.debug(`Sending async prompt to session ${sessionId} (${text.length} chars)...`);
320
+ let controller;
186
321
  try {
187
322
  const { error: promptError } = await client.session.promptAsync({
188
323
  path: { id: sessionId },
@@ -195,11 +330,11 @@ async function boot(opts) {
195
330
  throw new Error(`OpenCode promptAsync failed: ${JSON.stringify(promptError)}`);
196
331
  }
197
332
  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
- });
333
+ controller = new AbortController();
202
334
  try {
335
+ const { stream } = await client.event.subscribe({
336
+ signal: controller.signal
337
+ });
203
338
  for await (const event of stream) {
204
339
  if (!isSessionEvent(event, sessionId)) continue;
205
340
  if (event.type === "message.part.updated" && event.properties.part.type === "text") {
@@ -221,7 +356,7 @@ async function boot(opts) {
221
356
  }
222
357
  }
223
358
  } finally {
224
- controller.abort();
359
+ if (controller && !controller.signal.aborted) controller.abort();
225
360
  }
226
361
  const { data: messages } = await client.session.messages({
227
362
  path: { id: sessionId }
@@ -235,7 +370,7 @@ async function boot(opts) {
235
370
  log.debug("No assistant message found in session");
236
371
  return null;
237
372
  }
238
- if (lastAssistant.info.role === "assistant" && "error" in lastAssistant.info && lastAssistant.info.error) {
373
+ if (hasProperty(lastAssistant.info, "error") && lastAssistant.info.error) {
239
374
  throw new Error(
240
375
  `OpenCode assistant error: ${JSON.stringify(lastAssistant.info.error)}`
241
376
  );
@@ -265,11 +400,14 @@ async function boot(opts) {
265
400
  }
266
401
  function isSessionEvent(event, sessionId) {
267
402
  const props = event.properties;
268
- if (props.sessionID === sessionId) return true;
269
- if (props.info && typeof props.info === "object" && props.info.sessionID === sessionId) {
403
+ if (!hasProperty(props, "sessionID") && !hasProperty(props, "info") && !hasProperty(props, "part")) {
404
+ return false;
405
+ }
406
+ if (hasProperty(props, "sessionID") && props.sessionID === sessionId) return true;
407
+ if (hasProperty(props, "info") && hasProperty(props.info, "sessionID") && props.info.sessionID === sessionId) {
270
408
  return true;
271
409
  }
272
- if (props.part && typeof props.part === "object" && props.part.sessionID === sessionId) {
410
+ if (hasProperty(props, "part") && hasProperty(props.part, "sessionID") && props.part.sessionID === sessionId) {
273
411
  return true;
274
412
  }
275
413
  return false;
@@ -278,6 +416,52 @@ var init_opencode = __esm({
278
416
  "src/providers/opencode.ts"() {
279
417
  "use strict";
280
418
  init_logger();
419
+ init_guards();
420
+ }
421
+ });
422
+
423
+ // src/helpers/timeout.ts
424
+ function withTimeout(promise, ms, label) {
425
+ const p = new Promise((resolve4, reject) => {
426
+ let settled = false;
427
+ const timer = setTimeout(() => {
428
+ if (settled) return;
429
+ settled = true;
430
+ reject(new TimeoutError(ms, label));
431
+ }, ms);
432
+ promise.then(
433
+ (value) => {
434
+ if (settled) return;
435
+ settled = true;
436
+ clearTimeout(timer);
437
+ resolve4(value);
438
+ },
439
+ (err) => {
440
+ if (settled) return;
441
+ settled = true;
442
+ clearTimeout(timer);
443
+ reject(err);
444
+ }
445
+ );
446
+ });
447
+ p.catch(() => {
448
+ });
449
+ return p;
450
+ }
451
+ var TimeoutError;
452
+ var init_timeout = __esm({
453
+ "src/helpers/timeout.ts"() {
454
+ "use strict";
455
+ TimeoutError = class extends Error {
456
+ /** Optional label identifying the operation that timed out. */
457
+ label;
458
+ constructor(ms, label) {
459
+ const suffix = label ? ` [${label}]` : "";
460
+ super(`Timed out after ${ms}ms${suffix}`);
461
+ this.name = "TimeoutError";
462
+ this.label = label;
463
+ }
464
+ };
281
465
  }
282
466
  });
283
467
 
@@ -354,18 +538,25 @@ async function boot2(opts) {
354
538
  try {
355
539
  await session.send({ prompt: text });
356
540
  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
- });
541
+ let unsubIdle;
542
+ let unsubErr;
543
+ try {
544
+ await withTimeout(
545
+ new Promise((resolve4, reject) => {
546
+ unsubIdle = session.on("session.idle", () => {
547
+ resolve4();
548
+ });
549
+ unsubErr = session.on("session.error", (event) => {
550
+ reject(new Error(`Copilot session error: ${event.data.message}`));
551
+ });
552
+ }),
553
+ 3e5,
554
+ "copilot session ready"
555
+ );
556
+ } finally {
557
+ unsubIdle?.();
558
+ unsubErr?.();
559
+ }
369
560
  log.debug("Session went idle, fetching result...");
370
561
  const events = await session.getMessages();
371
562
  const last = [...events].reverse().find((e) => e.type === "assistant.message");
@@ -396,6 +587,7 @@ var init_copilot = __esm({
396
587
  "src/providers/copilot.ts"() {
397
588
  "use strict";
398
589
  init_logger();
590
+ init_timeout();
399
591
  }
400
592
  });
401
593
 
@@ -502,7 +694,8 @@ async function boot4(opts) {
502
694
  model,
503
695
  config: { model, instructions: "" },
504
696
  approvalPolicy: "full-auto",
505
- additionalWritableRoots: opts?.cwd ? [opts.cwd] : [],
697
+ ...opts?.cwd ? { rootDir: opts.cwd } : {},
698
+ additionalWritableRoots: [],
506
699
  getCommandConfirmation: async () => ({ approved: true }),
507
700
  onItem: () => {
508
701
  },
@@ -567,7 +760,9 @@ import { execFile as execFile6 } from "child_process";
567
760
  import { promisify as promisify6 } from "util";
568
761
  async function checkProviderInstalled(name) {
569
762
  try {
570
- await exec6(PROVIDER_BINARIES[name], ["--version"]);
763
+ await exec6(PROVIDER_BINARIES[name], ["--version"], {
764
+ shell: process.platform === "win32"
765
+ });
571
766
  return true;
572
767
  } catch {
573
768
  return false;
@@ -661,11 +856,11 @@ __export(fix_tests_pipeline_exports, {
661
856
  runTestCommand: () => runTestCommand
662
857
  });
663
858
  import { readFile as readFile8 } from "fs/promises";
664
- import { join as join10 } from "path";
859
+ import { join as join11 } from "path";
665
860
  import { execFile as execFileCb } from "child_process";
666
861
  async function detectTestCommand(cwd) {
667
862
  try {
668
- const raw = await readFile8(join10(cwd, "package.json"), "utf-8");
863
+ const raw = await readFile8(join11(cwd, "package.json"), "utf-8");
669
864
  let pkg;
670
865
  try {
671
866
  pkg = JSON.parse(raw);
@@ -685,7 +880,7 @@ async function detectTestCommand(cwd) {
685
880
  }
686
881
  }
687
882
  function runTestCommand(command, cwd) {
688
- return new Promise((resolve2) => {
883
+ return new Promise((resolve4) => {
689
884
  const [cmd, ...args] = command.split(" ");
690
885
  execFileCb(
691
886
  cmd,
@@ -693,7 +888,7 @@ function runTestCommand(command, cwd) {
693
888
  { cwd, maxBuffer: 10 * 1024 * 1024 },
694
889
  (error, stdout, stderr) => {
695
890
  const exitCode = error && "code" in error ? error.code ?? 1 : error ? 1 : 0;
696
- resolve2({ exitCode, stdout, stderr, command });
891
+ resolve4({ exitCode, stdout, stderr, command });
697
892
  }
698
893
  );
699
894
  });
@@ -740,46 +935,66 @@ async function runFixTestsPipeline(opts) {
740
935
  log.dim(` Working directory: ${cwd}`);
741
936
  return { mode: "fix-tests", success: false };
742
937
  }
743
- try {
744
- log.info("Running test suite...");
745
- const testResult = await runTestCommand(testCommand, cwd);
746
- if (testResult.exitCode === 0) {
747
- log.success("All tests pass \u2014 nothing to fix.");
748
- return { mode: "fix-tests", success: true };
749
- }
750
- log.warn(
751
- `Tests failed (exit code ${testResult.exitCode}). Dispatching AI to fix...`
752
- );
753
- const provider = opts.provider ?? "opencode";
754
- const instance = await bootProvider(provider, { url: opts.serverUrl, cwd });
755
- registerCleanup(() => instance.cleanup());
756
- const prompt = buildFixTestsPrompt(testResult, cwd);
757
- log.debug(`Prompt built (${prompt.length} chars)`);
758
- const sessionId = await instance.createSession();
759
- const response = await instance.prompt(sessionId, prompt);
760
- if (response === null) {
761
- log.error("No response from AI agent.");
938
+ const fileLogger = opts.verbose ? new FileLogger("fix-tests", cwd) : null;
939
+ const pipelineBody = async () => {
940
+ try {
941
+ log.info("Running test suite...");
942
+ const testResult = await runTestCommand(testCommand, cwd);
943
+ fileLoggerStorage.getStore()?.info(`Test run complete (exit code: ${testResult.exitCode})`);
944
+ if (testResult.exitCode === 0) {
945
+ log.success("All tests pass \u2014 nothing to fix.");
946
+ return { mode: "fix-tests", success: true };
947
+ }
948
+ log.warn(
949
+ `Tests failed (exit code ${testResult.exitCode}). Dispatching AI to fix...`
950
+ );
951
+ const provider = opts.provider ?? "opencode";
952
+ const instance = await bootProvider(provider, { url: opts.serverUrl, cwd });
953
+ registerCleanup(() => instance.cleanup());
954
+ const prompt = buildFixTestsPrompt(testResult, cwd);
955
+ log.debug(`Prompt built (${prompt.length} chars)`);
956
+ fileLoggerStorage.getStore()?.prompt("fix-tests", prompt);
957
+ const sessionId = await instance.createSession();
958
+ const response = await instance.prompt(sessionId, prompt);
959
+ if (response === null) {
960
+ fileLoggerStorage.getStore()?.error("No response from AI agent.");
961
+ log.error("No response from AI agent.");
962
+ await instance.cleanup();
963
+ return { mode: "fix-tests", success: false, error: "No response from agent" };
964
+ }
965
+ if (response) fileLoggerStorage.getStore()?.response("fix-tests", response);
966
+ log.success("AI agent completed fixes.");
967
+ fileLoggerStorage.getStore()?.phase("Verification");
968
+ log.info("Re-running tests to verify fixes...");
969
+ const verifyResult = await runTestCommand(testCommand, cwd);
762
970
  await instance.cleanup();
763
- return { mode: "fix-tests", success: false, error: "No response from agent" };
764
- }
765
- log.success("AI agent completed fixes.");
766
- log.info("Re-running tests to verify fixes...");
767
- const verifyResult = await runTestCommand(testCommand, cwd);
768
- await instance.cleanup();
769
- if (verifyResult.exitCode === 0) {
770
- log.success("All tests pass after fixes!");
771
- return { mode: "fix-tests", success: true };
971
+ fileLoggerStorage.getStore()?.info(`Verification result: exit code ${verifyResult.exitCode}`);
972
+ if (verifyResult.exitCode === 0) {
973
+ log.success("All tests pass after fixes!");
974
+ return { mode: "fix-tests", success: true };
975
+ }
976
+ log.warn(
977
+ `Tests still failing after fix attempt (exit code ${verifyResult.exitCode}).`
978
+ );
979
+ return { mode: "fix-tests", success: false, error: "Tests still failing after fix attempt" };
980
+ } catch (err) {
981
+ const message = log.extractMessage(err);
982
+ fileLoggerStorage.getStore()?.error(`Fix-tests pipeline failed: ${message}${err instanceof Error && err.stack ? `
983
+ ${err.stack}` : ""}`);
984
+ log.error(`Fix-tests pipeline failed: ${log.formatErrorChain(err)}`);
985
+ return { mode: "fix-tests", success: false, error: message };
772
986
  }
773
- log.warn(
774
- `Tests still failing after fix attempt (exit code ${verifyResult.exitCode}).`
775
- );
776
- return { mode: "fix-tests", success: false, error: "Tests still failing after fix attempt" };
777
- } catch (err) {
778
- const message = log.extractMessage(err);
779
- log.error(`Fix-tests pipeline failed: ${log.formatErrorChain(err)}`);
780
- log.debug(log.formatErrorChain(err));
781
- return { mode: "fix-tests", success: false, error: message };
987
+ };
988
+ if (fileLogger) {
989
+ return fileLoggerStorage.run(fileLogger, async () => {
990
+ try {
991
+ return await pipelineBody();
992
+ } finally {
993
+ fileLogger.close();
994
+ }
995
+ });
782
996
  }
997
+ return pipelineBody();
783
998
  }
784
999
  var init_fix_tests_pipeline = __esm({
785
1000
  "src/orchestrator/fix-tests-pipeline.ts"() {
@@ -787,11 +1002,13 @@ var init_fix_tests_pipeline = __esm({
787
1002
  init_providers();
788
1003
  init_cleanup();
789
1004
  init_logger();
1005
+ init_file_logger();
790
1006
  }
791
1007
  });
792
1008
 
793
1009
  // src/cli.ts
794
- import { resolve, join as join11 } from "path";
1010
+ import { resolve as resolve3, join as join12 } from "path";
1011
+ import { Command, Option, CommanderError } from "commander";
795
1012
 
796
1013
  // src/spec-generator.ts
797
1014
  import { cpus, freemem } from "os";
@@ -806,14 +1023,21 @@ import { promisify } from "util";
806
1023
 
807
1024
  // src/helpers/slugify.ts
808
1025
  var MAX_SLUG_LENGTH = 60;
809
- function slugify(input2, maxLength) {
810
- const slug = input2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1026
+ function slugify(input3, maxLength) {
1027
+ const slug = input3.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
811
1028
  return maxLength != null ? slug.slice(0, maxLength) : slug;
812
1029
  }
813
1030
 
814
1031
  // src/datasources/github.ts
815
1032
  init_logger();
816
1033
  var exec = promisify(execFile);
1034
+ var InvalidBranchNameError = class extends Error {
1035
+ constructor(branch, reason) {
1036
+ const detail = reason ? ` (${reason})` : "";
1037
+ super(`Invalid branch name: "${branch}"${detail}`);
1038
+ this.name = "InvalidBranchNameError";
1039
+ }
1040
+ };
817
1041
  async function git(args, cwd) {
818
1042
  const { stdout } = await exec("git", args, { cwd });
819
1043
  return stdout;
@@ -822,16 +1046,35 @@ async function gh(args, cwd) {
822
1046
  const { stdout } = await exec("gh", args, { cwd });
823
1047
  return stdout;
824
1048
  }
1049
+ var VALID_BRANCH_NAME_RE = /^[a-zA-Z0-9._\-/]+$/;
1050
+ function isValidBranchName(name) {
1051
+ if (name.length === 0 || name.length > 255) return false;
1052
+ if (!VALID_BRANCH_NAME_RE.test(name)) return false;
1053
+ if (name.startsWith("/") || name.endsWith("/")) return false;
1054
+ if (name.includes("..")) return false;
1055
+ if (name.endsWith(".lock")) return false;
1056
+ if (name.includes("@{")) return false;
1057
+ if (name.includes("//")) return false;
1058
+ return true;
1059
+ }
825
1060
  function buildBranchName(issueNumber, title, username = "unknown") {
826
1061
  const slug = slugify(title, 50);
827
1062
  return `${username}/dispatch/${issueNumber}-${slug}`;
828
1063
  }
829
1064
  async function getDefaultBranch(cwd) {
1065
+ const PREFIX = "refs/remotes/origin/";
830
1066
  try {
831
1067
  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 {
1068
+ const trimmed = ref.trim();
1069
+ const branch = trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : trimmed;
1070
+ if (!isValidBranchName(branch)) {
1071
+ throw new InvalidBranchNameError(branch, "from symbolic-ref output");
1072
+ }
1073
+ return branch;
1074
+ } catch (err) {
1075
+ if (err instanceof InvalidBranchNameError) {
1076
+ throw err;
1077
+ }
835
1078
  try {
836
1079
  await git(["rev-parse", "--verify", "main"], cwd);
837
1080
  return "main";
@@ -842,6 +1085,9 @@ async function getDefaultBranch(cwd) {
842
1085
  }
843
1086
  var datasource = {
844
1087
  name: "github",
1088
+ supportsGit() {
1089
+ return true;
1090
+ },
845
1091
  async list(opts = {}) {
846
1092
  const cwd = opts.cwd || process.cwd();
847
1093
  const { stdout } = await exec(
@@ -1043,8 +1289,30 @@ async function detectWorkItemType(opts = {}) {
1043
1289
  }
1044
1290
  var datasource2 = {
1045
1291
  name: "azdevops",
1292
+ supportsGit() {
1293
+ return true;
1294
+ },
1046
1295
  async list(opts = {}) {
1047
- const wiql = "SELECT [System.Id] FROM workitems WHERE [System.State] <> 'Closed' AND [System.State] <> 'Removed' ORDER BY [System.CreatedDate] DESC";
1296
+ const conditions = [
1297
+ "[System.State] <> 'Closed'",
1298
+ "[System.State] <> 'Removed'"
1299
+ ];
1300
+ if (opts.iteration) {
1301
+ const iterValue = String(opts.iteration).trim();
1302
+ if (iterValue === "@CurrentIteration") {
1303
+ conditions.push(`[System.IterationPath] UNDER @CurrentIteration`);
1304
+ } else {
1305
+ const escaped = iterValue.replace(/'/g, "''");
1306
+ if (escaped) conditions.push(`[System.IterationPath] UNDER '${escaped}'`);
1307
+ }
1308
+ }
1309
+ if (opts.area) {
1310
+ const area = String(opts.area).trim().replace(/'/g, "''");
1311
+ if (area) {
1312
+ conditions.push(`[System.AreaPath] UNDER '${area}'`);
1313
+ }
1314
+ }
1315
+ const wiql = `SELECT [System.Id] FROM workitems WHERE ${conditions.join(" AND ")} ORDER BY [System.CreatedDate] DESC`;
1048
1316
  const args = ["boards", "query", "--wiql", wiql, "--output", "json"];
1049
1317
  if (opts.org) args.push("--org", opts.org);
1050
1318
  if (opts.project) args.push("--project", opts.project);
@@ -1104,7 +1372,13 @@ var datasource2 = {
1104
1372
  state: fields["System.State"] ?? "",
1105
1373
  url: item._links?.html?.href ?? item.url ?? "",
1106
1374
  comments,
1107
- acceptanceCriteria: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? ""
1375
+ acceptanceCriteria: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? "",
1376
+ iterationPath: fields["System.IterationPath"] || void 0,
1377
+ areaPath: fields["System.AreaPath"] || void 0,
1378
+ assignee: fields["System.AssignedTo"]?.displayName || void 0,
1379
+ priority: fields["Microsoft.VSTS.Common.Priority"] ?? void 0,
1380
+ storyPoints: fields["Microsoft.VSTS.Scheduling.StoryPoints"] ?? fields["Microsoft.VSTS.Scheduling.Effort"] ?? fields["Microsoft.VSTS.Scheduling.Size"] ?? void 0,
1381
+ workItemType: fields["System.WorkItemType"] || void 0
1108
1382
  };
1109
1383
  },
1110
1384
  async update(issueId, title, body, opts = {}) {
@@ -1177,7 +1451,13 @@ var datasource2 = {
1177
1451
  state: fields["System.State"] ?? "New",
1178
1452
  url: item._links?.html?.href ?? item.url ?? "",
1179
1453
  comments: [],
1180
- acceptanceCriteria: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? ""
1454
+ acceptanceCriteria: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? "",
1455
+ iterationPath: fields["System.IterationPath"] || void 0,
1456
+ areaPath: fields["System.AreaPath"] || void 0,
1457
+ assignee: fields["System.AssignedTo"]?.displayName || void 0,
1458
+ priority: fields["Microsoft.VSTS.Common.Priority"] ?? void 0,
1459
+ storyPoints: fields["Microsoft.VSTS.Scheduling.StoryPoints"] ?? fields["Microsoft.VSTS.Scheduling.Effort"] ?? fields["Microsoft.VSTS.Scheduling.Size"] ?? void 0,
1460
+ workItemType: fields["System.WorkItemType"] || workItemType
1181
1461
  };
1182
1462
  },
1183
1463
  async getDefaultBranch(opts) {
@@ -1197,12 +1477,25 @@ var datasource2 = {
1197
1477
  async getUsername(opts) {
1198
1478
  try {
1199
1479
  const { stdout } = await exec2("git", ["config", "user.name"], { cwd: opts.cwd });
1200
- const name = stdout.trim();
1201
- if (!name) return "unknown";
1202
- return slugify(name);
1480
+ const name = slugify(stdout.trim());
1481
+ if (name) return name;
1482
+ } catch {
1483
+ }
1484
+ try {
1485
+ const { stdout } = await exec2("az", ["account", "show", "--query", "user.name", "-o", "tsv"], { cwd: opts.cwd });
1486
+ const name = slugify(stdout.trim());
1487
+ if (name) return name;
1488
+ } catch {
1489
+ }
1490
+ try {
1491
+ const { stdout } = await exec2("az", ["account", "show", "--query", "user.principalName", "-o", "tsv"], { cwd: opts.cwd });
1492
+ const principal = stdout.trim();
1493
+ const prefix = principal.split("@")[0];
1494
+ const name = slugify(prefix);
1495
+ if (name) return name;
1203
1496
  } catch {
1204
- return "unknown";
1205
1497
  }
1498
+ return "unknown";
1206
1499
  },
1207
1500
  buildBranchName(issueNumber, title, username) {
1208
1501
  const slug = slugify(title, 50);
@@ -1334,13 +1627,27 @@ async function fetchComments(workItemId, opts) {
1334
1627
  // src/datasources/md.ts
1335
1628
  import { execFile as execFile3 } from "child_process";
1336
1629
  import { readFile, writeFile, readdir, mkdir, rename } from "fs/promises";
1337
- import { join, parse as parsePath } from "path";
1630
+ import { join as join2, parse as parsePath } from "path";
1338
1631
  import { promisify as promisify3 } from "util";
1632
+
1633
+ // src/helpers/errors.ts
1634
+ var UnsupportedOperationError = class extends Error {
1635
+ /** The name of the operation that is not supported. */
1636
+ operation;
1637
+ constructor(operation, message) {
1638
+ const msg = message ?? `Operation not supported: ${operation}`;
1639
+ super(msg);
1640
+ this.name = "UnsupportedOperationError";
1641
+ this.operation = operation;
1642
+ }
1643
+ };
1644
+
1645
+ // src/datasources/md.ts
1339
1646
  var exec3 = promisify3(execFile3);
1340
1647
  var DEFAULT_DIR = ".dispatch/specs";
1341
1648
  function resolveDir(opts) {
1342
1649
  const cwd = opts?.cwd ?? process.cwd();
1343
- return join(cwd, DEFAULT_DIR);
1650
+ return join2(cwd, DEFAULT_DIR);
1344
1651
  }
1345
1652
  function extractTitle(content, filename) {
1346
1653
  const match = content.match(/^#\s+(.+)$/m);
@@ -1365,13 +1672,16 @@ function toIssueDetails(filename, content, dir) {
1365
1672
  body: content,
1366
1673
  labels: [],
1367
1674
  state: "open",
1368
- url: join(dir, filename),
1675
+ url: join2(dir, filename),
1369
1676
  comments: [],
1370
1677
  acceptanceCriteria: ""
1371
1678
  };
1372
1679
  }
1373
1680
  var datasource3 = {
1374
1681
  name: "md",
1682
+ supportsGit() {
1683
+ return false;
1684
+ },
1375
1685
  async list(opts) {
1376
1686
  const dir = resolveDir(opts);
1377
1687
  let entries;
@@ -1383,7 +1693,7 @@ var datasource3 = {
1383
1693
  const mdFiles = entries.filter((f) => f.endsWith(".md")).sort();
1384
1694
  const results = [];
1385
1695
  for (const filename of mdFiles) {
1386
- const filePath = join(dir, filename);
1696
+ const filePath = join2(dir, filename);
1387
1697
  const content = await readFile(filePath, "utf-8");
1388
1698
  results.push(toIssueDetails(filename, content, dir));
1389
1699
  }
@@ -1392,29 +1702,29 @@ var datasource3 = {
1392
1702
  async fetch(issueId, opts) {
1393
1703
  const dir = resolveDir(opts);
1394
1704
  const filename = issueId.endsWith(".md") ? issueId : `${issueId}.md`;
1395
- const filePath = join(dir, filename);
1705
+ const filePath = join2(dir, filename);
1396
1706
  const content = await readFile(filePath, "utf-8");
1397
1707
  return toIssueDetails(filename, content, dir);
1398
1708
  },
1399
1709
  async update(issueId, _title, body, opts) {
1400
1710
  const dir = resolveDir(opts);
1401
1711
  const filename = issueId.endsWith(".md") ? issueId : `${issueId}.md`;
1402
- const filePath = join(dir, filename);
1712
+ const filePath = join2(dir, filename);
1403
1713
  await writeFile(filePath, body, "utf-8");
1404
1714
  },
1405
1715
  async close(issueId, opts) {
1406
1716
  const dir = resolveDir(opts);
1407
1717
  const filename = issueId.endsWith(".md") ? issueId : `${issueId}.md`;
1408
- const filePath = join(dir, filename);
1409
- const archiveDir = join(dir, "archive");
1718
+ const filePath = join2(dir, filename);
1719
+ const archiveDir = join2(dir, "archive");
1410
1720
  await mkdir(archiveDir, { recursive: true });
1411
- await rename(filePath, join(archiveDir, filename));
1721
+ await rename(filePath, join2(archiveDir, filename));
1412
1722
  },
1413
1723
  async create(title, body, opts) {
1414
1724
  const dir = resolveDir(opts);
1415
1725
  await mkdir(dir, { recursive: true });
1416
1726
  const filename = `${slugify(title)}.md`;
1417
- const filePath = join(dir, filename);
1727
+ const filePath = join2(dir, filename);
1418
1728
  await writeFile(filePath, body, "utf-8");
1419
1729
  return toIssueDetails(filename, body, dir);
1420
1730
  },
@@ -1436,15 +1746,19 @@ var datasource3 = {
1436
1746
  return `${username}/dispatch/${issueNumber}-${slug}`;
1437
1747
  },
1438
1748
  async createAndSwitchBranch(_branchName, _opts) {
1749
+ throw new UnsupportedOperationError("createAndSwitchBranch");
1439
1750
  },
1440
1751
  async switchBranch(_branchName, _opts) {
1752
+ throw new UnsupportedOperationError("switchBranch");
1441
1753
  },
1442
1754
  async pushBranch(_branchName, _opts) {
1755
+ throw new UnsupportedOperationError("pushBranch");
1443
1756
  },
1444
1757
  async commitAllChanges(_message, _opts) {
1758
+ throw new UnsupportedOperationError("commitAllChanges");
1445
1759
  },
1446
1760
  async createPullRequest(_branchName, _issueNumber, _title, _body, _opts) {
1447
- return "";
1761
+ throw new UnsupportedOperationError("createPullRequest");
1448
1762
  }
1449
1763
  };
1450
1764
 
@@ -1490,6 +1804,36 @@ async function detectDatasource(cwd) {
1490
1804
  }
1491
1805
  return null;
1492
1806
  }
1807
+ function parseAzDevOpsRemoteUrl(url) {
1808
+ const httpsMatch = url.match(
1809
+ /^https?:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\//i
1810
+ );
1811
+ if (httpsMatch) {
1812
+ return {
1813
+ orgUrl: `https://dev.azure.com/${decodeURIComponent(httpsMatch[1])}`,
1814
+ project: decodeURIComponent(httpsMatch[2])
1815
+ };
1816
+ }
1817
+ const sshMatch = url.match(
1818
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\//i
1819
+ );
1820
+ if (sshMatch) {
1821
+ return {
1822
+ orgUrl: `https://dev.azure.com/${decodeURIComponent(sshMatch[1])}`,
1823
+ project: decodeURIComponent(sshMatch[2])
1824
+ };
1825
+ }
1826
+ const legacyMatch = url.match(
1827
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\//i
1828
+ );
1829
+ if (legacyMatch) {
1830
+ return {
1831
+ orgUrl: `https://dev.azure.com/${decodeURIComponent(legacyMatch[1])}`,
1832
+ project: decodeURIComponent(legacyMatch[2])
1833
+ };
1834
+ }
1835
+ return null;
1836
+ }
1493
1837
 
1494
1838
  // src/spec-generator.ts
1495
1839
  init_logger();
@@ -1506,16 +1850,16 @@ var RECOGNIZED_H2 = /* @__PURE__ */ new Set([
1506
1850
  function defaultConcurrency() {
1507
1851
  return Math.max(1, Math.min(cpus().length, Math.floor(freemem() / 1024 / 1024 / MB_PER_CONCURRENT_TASK)));
1508
1852
  }
1509
- function isIssueNumbers(input2) {
1510
- if (Array.isArray(input2)) return false;
1511
- return /^\d+(,\s*\d+)*$/.test(input2);
1853
+ function isIssueNumbers(input3) {
1854
+ if (Array.isArray(input3)) return false;
1855
+ return /^\d+(,\s*\d+)*$/.test(input3);
1512
1856
  }
1513
- function isGlobOrFilePath(input2) {
1514
- if (Array.isArray(input2)) return true;
1515
- if (/[*?\[{]/.test(input2)) return true;
1516
- if (/[/\\]/.test(input2)) return true;
1517
- if (/^\.\.?\//.test(input2)) return true;
1518
- if (/\.(md|txt|yaml|yml|json|ts|js|tsx|jsx)$/i.test(input2)) return true;
1857
+ function isGlobOrFilePath(input3) {
1858
+ if (Array.isArray(input3)) return true;
1859
+ if (/[*?\[{]/.test(input3)) return true;
1860
+ if (/[/\\]/.test(input3)) return true;
1861
+ if (/^\.\.?[\/\\]/.test(input3)) return true;
1862
+ if (/\.(md|txt|yaml|yml|json|ts|js|tsx|jsx)$/i.test(input3)) return true;
1519
1863
  return false;
1520
1864
  }
1521
1865
  function extractSpecContent(raw) {
@@ -1641,7 +1985,7 @@ function semverGte(current, minimum) {
1641
1985
  async function checkPrereqs(context) {
1642
1986
  const failures = [];
1643
1987
  try {
1644
- await exec5("git", ["--version"]);
1988
+ await exec5("git", ["--version"], { shell: process.platform === "win32" });
1645
1989
  } catch {
1646
1990
  failures.push("git is required but was not found on PATH. Install it from https://git-scm.com");
1647
1991
  }
@@ -1653,7 +1997,7 @@ async function checkPrereqs(context) {
1653
1997
  }
1654
1998
  if (context?.datasource === "github") {
1655
1999
  try {
1656
- await exec5("gh", ["--version"]);
2000
+ await exec5("gh", ["--version"], { shell: process.platform === "win32" });
1657
2001
  } catch {
1658
2002
  failures.push(
1659
2003
  "gh (GitHub CLI) is required for the github datasource but was not found on PATH. Install it from https://cli.github.com/"
@@ -1662,7 +2006,7 @@ async function checkPrereqs(context) {
1662
2006
  }
1663
2007
  if (context?.datasource === "azdevops") {
1664
2008
  try {
1665
- await exec5("az", ["--version"]);
2009
+ await exec5("az", ["--version"], { shell: process.platform === "win32" });
1666
2010
  } catch {
1667
2011
  failures.push(
1668
2012
  "az (Azure CLI) is required for the azdevops datasource but was not found on PATH. Install it from https://learn.microsoft.com/en-us/cli/azure/"
@@ -1675,17 +2019,23 @@ async function checkPrereqs(context) {
1675
2019
  // src/helpers/gitignore.ts
1676
2020
  init_logger();
1677
2021
  import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1678
- import { join as join2 } from "path";
2022
+ import { join as join3 } from "path";
1679
2023
  async function ensureGitignoreEntry(repoRoot, entry) {
1680
- const gitignorePath = join2(repoRoot, ".gitignore");
2024
+ const gitignorePath = join3(repoRoot, ".gitignore");
1681
2025
  let contents = "";
1682
2026
  try {
1683
2027
  contents = await readFile2(gitignorePath, "utf8");
1684
- } catch {
2028
+ } catch (err) {
2029
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
2030
+ } else {
2031
+ log.warn(`Could not read .gitignore: ${String(err)}`);
2032
+ return;
2033
+ }
1685
2034
  }
1686
- const lines = contents.split("\n").map((l) => l.trim());
2035
+ const lines = contents.split(/\r?\n/);
1687
2036
  const bare = entry.replace(/\/$/, "");
1688
- if (lines.includes(entry) || lines.includes(bare)) {
2037
+ const withSlash = bare + "/";
2038
+ if (lines.includes(entry) || lines.includes(bare) || lines.includes(withSlash)) {
1689
2039
  return;
1690
2040
  }
1691
2041
  try {
@@ -1700,18 +2050,18 @@ async function ensureGitignoreEntry(repoRoot, entry) {
1700
2050
 
1701
2051
  // src/orchestrator/cli-config.ts
1702
2052
  init_logger();
1703
- import { join as join4 } from "path";
2053
+ import { join as join5 } from "path";
1704
2054
  import { access } from "fs/promises";
1705
2055
  import { constants } from "fs";
1706
2056
 
1707
2057
  // src/config.ts
1708
2058
  init_providers();
1709
2059
  import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
1710
- import { join as join3, dirname } from "path";
2060
+ import { join as join4, dirname as dirname2 } from "path";
1711
2061
 
1712
2062
  // src/config-prompts.ts
1713
2063
  init_logger();
1714
- import { select, confirm } from "@inquirer/prompts";
2064
+ import { select, confirm, input as input2 } from "@inquirer/prompts";
1715
2065
  import chalk3 from "chalk";
1716
2066
  init_providers();
1717
2067
  async function runInteractiveConfigWizard(configDir) {
@@ -1791,6 +2141,54 @@ async function runInteractiveConfigWizard(configDir) {
1791
2141
  default: datasourceDefault
1792
2142
  });
1793
2143
  const source = selectedSource === "auto" ? void 0 : selectedSource;
2144
+ let org;
2145
+ let project;
2146
+ let workItemType;
2147
+ let iteration;
2148
+ let area;
2149
+ const effectiveSource = source ?? detectedSource;
2150
+ if (effectiveSource === "azdevops") {
2151
+ let defaultOrg = existing.org ?? "";
2152
+ let defaultProject = existing.project ?? "";
2153
+ try {
2154
+ const remoteUrl = await getGitRemoteUrl(process.cwd());
2155
+ if (remoteUrl) {
2156
+ const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
2157
+ if (parsed) {
2158
+ if (!defaultOrg) defaultOrg = parsed.orgUrl;
2159
+ if (!defaultProject) defaultProject = parsed.project;
2160
+ }
2161
+ }
2162
+ } catch {
2163
+ }
2164
+ console.log();
2165
+ log.info(chalk3.bold("Azure DevOps settings") + chalk3.dim(" (leave empty to skip):"));
2166
+ const orgInput = await input2({
2167
+ message: "Organization URL:",
2168
+ default: defaultOrg || void 0
2169
+ });
2170
+ if (orgInput.trim()) org = orgInput.trim();
2171
+ const projectInput = await input2({
2172
+ message: "Project name:",
2173
+ default: defaultProject || void 0
2174
+ });
2175
+ if (projectInput.trim()) project = projectInput.trim();
2176
+ const workItemTypeInput = await input2({
2177
+ message: "Work item type (e.g. User Story, Bug):",
2178
+ default: existing.workItemType ?? void 0
2179
+ });
2180
+ if (workItemTypeInput.trim()) workItemType = workItemTypeInput.trim();
2181
+ const iterationInput = await input2({
2182
+ message: "Iteration path (e.g. MyProject\\Sprint 1, or @CurrentIteration):",
2183
+ default: existing.iteration ?? void 0
2184
+ });
2185
+ if (iterationInput.trim()) iteration = iterationInput.trim();
2186
+ const areaInput = await input2({
2187
+ message: "Area path (e.g. MyProject\\Team A):",
2188
+ default: existing.area ?? void 0
2189
+ });
2190
+ if (areaInput.trim()) area = areaInput.trim();
2191
+ }
1794
2192
  const newConfig = {
1795
2193
  provider,
1796
2194
  source
@@ -1798,6 +2196,11 @@ async function runInteractiveConfigWizard(configDir) {
1798
2196
  if (selectedModel !== void 0) {
1799
2197
  newConfig.model = selectedModel;
1800
2198
  }
2199
+ if (org !== void 0) newConfig.org = org;
2200
+ if (project !== void 0) newConfig.project = project;
2201
+ if (workItemType !== void 0) newConfig.workItemType = workItemType;
2202
+ if (iteration !== void 0) newConfig.iteration = iteration;
2203
+ if (area !== void 0) newConfig.area = area;
1801
2204
  console.log();
1802
2205
  log.info(chalk3.bold("Configuration summary:"));
1803
2206
  for (const [key, value] of Object.entries(newConfig)) {
@@ -1824,10 +2227,15 @@ async function runInteractiveConfigWizard(configDir) {
1824
2227
  }
1825
2228
 
1826
2229
  // src/config.ts
1827
- var CONFIG_KEYS = ["provider", "model", "source", "testTimeout"];
2230
+ var CONFIG_BOUNDS = {
2231
+ testTimeout: { min: 1, max: 120 },
2232
+ planTimeout: { min: 1, max: 120 },
2233
+ concurrency: { min: 1, max: 64 }
2234
+ };
2235
+ var CONFIG_KEYS = ["provider", "model", "source", "testTimeout", "planTimeout", "concurrency", "org", "project", "workItemType", "iteration", "area"];
1828
2236
  function getConfigPath(configDir) {
1829
- const dir = configDir ?? join3(process.cwd(), ".dispatch");
1830
- return join3(dir, "config.json");
2237
+ const dir = configDir ?? join4(process.cwd(), ".dispatch");
2238
+ return join4(dir, "config.json");
1831
2239
  }
1832
2240
  async function loadConfig(configDir) {
1833
2241
  const configPath = getConfigPath(configDir);
@@ -1840,7 +2248,7 @@ async function loadConfig(configDir) {
1840
2248
  }
1841
2249
  async function saveConfig(config, configDir) {
1842
2250
  const configPath = getConfigPath(configDir);
1843
- await mkdir2(dirname(configPath), { recursive: true });
2251
+ await mkdir2(dirname2(configPath), { recursive: true });
1844
2252
  await writeFile3(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1845
2253
  }
1846
2254
  async function handleConfigCommand(_argv, configDir) {
@@ -1852,14 +2260,21 @@ var CONFIG_TO_CLI = {
1852
2260
  provider: "provider",
1853
2261
  model: "model",
1854
2262
  source: "issueSource",
1855
- testTimeout: "testTimeout"
2263
+ testTimeout: "testTimeout",
2264
+ planTimeout: "planTimeout",
2265
+ concurrency: "concurrency",
2266
+ org: "org",
2267
+ project: "project",
2268
+ workItemType: "workItemType",
2269
+ iteration: "iteration",
2270
+ area: "area"
1856
2271
  };
1857
2272
  function setCliField(target, key, value) {
1858
2273
  target[key] = value;
1859
2274
  }
1860
2275
  async function resolveCliConfig(args) {
1861
2276
  const { explicitFlags } = args;
1862
- const configDir = join4(args.cwd, ".dispatch");
2277
+ const configDir = join5(args.cwd, ".dispatch");
1863
2278
  const config = await loadConfig(configDir);
1864
2279
  const merged = { ...args };
1865
2280
  for (const configKey of CONFIG_KEYS) {
@@ -1906,16 +2321,17 @@ async function resolveCliConfig(args) {
1906
2321
  }
1907
2322
 
1908
2323
  // src/orchestrator/spec-pipeline.ts
1909
- import { join as join6 } from "path";
2324
+ import { join as join7 } from "path";
1910
2325
  import { mkdir as mkdir4, readFile as readFile5, rename as rename2, unlink as unlink2 } from "fs/promises";
1911
2326
  import { glob } from "glob";
1912
2327
  init_providers();
1913
2328
 
1914
2329
  // src/agents/spec.ts
1915
2330
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4, unlink } from "fs/promises";
1916
- import { join as join5 } from "path";
2331
+ import { join as join6, resolve, sep } from "path";
1917
2332
  import { randomUUID as randomUUID3 } from "crypto";
1918
2333
  init_logger();
2334
+ init_file_logger();
1919
2335
  async function boot5(opts) {
1920
2336
  const { provider } = opts;
1921
2337
  if (!provider) {
@@ -1925,11 +2341,22 @@ async function boot5(opts) {
1925
2341
  name: "spec",
1926
2342
  async generate(genOpts) {
1927
2343
  const { issue, filePath, fileContent, inlineText, cwd: workingDir, outputPath } = genOpts;
2344
+ const startTime = Date.now();
1928
2345
  try {
1929
- const tmpDir = join5(workingDir, ".dispatch", "tmp");
2346
+ const resolvedCwd = resolve(workingDir);
2347
+ const resolvedOutput = resolve(outputPath);
2348
+ if (resolvedOutput !== resolvedCwd && !resolvedOutput.startsWith(resolvedCwd + sep)) {
2349
+ return {
2350
+ data: null,
2351
+ success: false,
2352
+ error: `Output path "${outputPath}" escapes the working directory "${workingDir}"`,
2353
+ durationMs: Date.now() - startTime
2354
+ };
2355
+ }
2356
+ const tmpDir = join6(resolvedCwd, ".dispatch", "tmp");
1930
2357
  await mkdir3(tmpDir, { recursive: true });
1931
2358
  const tmpFilename = `spec-${randomUUID3()}.md`;
1932
- const tmpPath = join5(tmpDir, tmpFilename);
2359
+ const tmpPath = join6(tmpDir, tmpFilename);
1933
2360
  let prompt;
1934
2361
  if (issue) {
1935
2362
  prompt = buildSpecPrompt(issue, workingDir, tmpPath);
@@ -1939,33 +2366,35 @@ async function boot5(opts) {
1939
2366
  prompt = buildFileSpecPrompt(filePath, fileContent, workingDir, tmpPath);
1940
2367
  } else {
1941
2368
  return {
1942
- content: "",
2369
+ data: null,
1943
2370
  success: false,
1944
2371
  error: "Either issue, inlineText, or filePath+fileContent must be provided",
1945
- valid: false
2372
+ durationMs: Date.now() - startTime
1946
2373
  };
1947
2374
  }
2375
+ fileLoggerStorage.getStore()?.prompt("spec", prompt);
1948
2376
  const sessionId = await provider.createSession();
1949
2377
  log.debug(`Spec prompt built (${prompt.length} chars)`);
1950
2378
  const response = await provider.prompt(sessionId, prompt);
1951
2379
  if (response === null) {
1952
2380
  return {
1953
- content: "",
2381
+ data: null,
1954
2382
  success: false,
1955
2383
  error: "AI agent returned no response",
1956
- valid: false
2384
+ durationMs: Date.now() - startTime
1957
2385
  };
1958
2386
  }
1959
2387
  log.debug(`Spec agent response (${response.length} chars)`);
2388
+ fileLoggerStorage.getStore()?.response("spec", response);
1960
2389
  let rawContent;
1961
2390
  try {
1962
2391
  rawContent = await readFile4(tmpPath, "utf-8");
1963
2392
  } catch {
1964
2393
  return {
1965
- content: "",
2394
+ data: null,
1966
2395
  success: false,
1967
2396
  error: `Spec agent did not write the file to ${tmpPath}. Agent response: ${response.slice(0, 300)}`,
1968
- valid: false
2397
+ durationMs: Date.now() - startTime
1969
2398
  };
1970
2399
  }
1971
2400
  const cleanedContent = extractSpecContent(rawContent);
@@ -1974,25 +2403,31 @@ async function boot5(opts) {
1974
2403
  if (!validation.valid) {
1975
2404
  log.warn(`Spec validation warning for ${outputPath}: ${validation.reason}`);
1976
2405
  }
1977
- await writeFile4(outputPath, cleanedContent, "utf-8");
1978
- log.debug(`Wrote cleaned spec to ${outputPath}`);
2406
+ await writeFile4(resolvedOutput, cleanedContent, "utf-8");
2407
+ log.debug(`Wrote cleaned spec to ${resolvedOutput}`);
1979
2408
  try {
1980
2409
  await unlink(tmpPath);
1981
2410
  } catch {
1982
2411
  }
2412
+ fileLoggerStorage.getStore()?.agentEvent("spec", "completed", `${Date.now() - startTime}ms`);
1983
2413
  return {
1984
- content: cleanedContent,
2414
+ data: {
2415
+ content: cleanedContent,
2416
+ valid: validation.valid,
2417
+ validationReason: validation.reason
2418
+ },
1985
2419
  success: true,
1986
- valid: validation.valid,
1987
- validationReason: validation.reason
2420
+ durationMs: Date.now() - startTime
1988
2421
  };
1989
2422
  } catch (err) {
1990
2423
  const message = log.extractMessage(err);
2424
+ fileLoggerStorage.getStore()?.error(`spec error: ${message}${err instanceof Error && err.stack ? `
2425
+ ${err.stack}` : ""}`);
1991
2426
  return {
1992
- content: "",
2427
+ data: null,
1993
2428
  success: false,
1994
2429
  error: message,
1995
- valid: false
2430
+ durationMs: Date.now() - startTime
1996
2431
  };
1997
2432
  }
1998
2433
  },
@@ -2000,24 +2435,8 @@ async function boot5(opts) {
2000
2435
  }
2001
2436
  };
2002
2437
  }
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.`,
2438
+ function buildIssueSourceSection(issue) {
2439
+ const lines = [
2021
2440
  ``,
2022
2441
  `## Issue Details`,
2023
2442
  ``,
@@ -2027,21 +2446,76 @@ function buildSpecPrompt(issue, cwd, outputPath) {
2027
2446
  `- **URL:** ${issue.url}`
2028
2447
  ];
2029
2448
  if (issue.labels.length > 0) {
2030
- sections.push(`- **Labels:** ${issue.labels.join(", ")}`);
2449
+ lines.push(`- **Labels:** ${issue.labels.join(", ")}`);
2031
2450
  }
2032
2451
  if (issue.body) {
2033
- sections.push(``, `### Description`, ``, issue.body);
2452
+ lines.push(``, `### Description`, ``, issue.body);
2034
2453
  }
2035
2454
  if (issue.acceptanceCriteria) {
2036
- sections.push(``, `### Acceptance Criteria`, ``, issue.acceptanceCriteria);
2455
+ lines.push(``, `### Acceptance Criteria`, ``, issue.acceptanceCriteria);
2037
2456
  }
2038
2457
  if (issue.comments.length > 0) {
2039
- sections.push(``, `### Discussion`, ``);
2458
+ lines.push(``, `### Discussion`, ``);
2040
2459
  for (const comment of issue.comments) {
2041
- sections.push(comment, ``);
2460
+ lines.push(comment, ``);
2042
2461
  }
2043
2462
  }
2044
- sections.push(
2463
+ return lines;
2464
+ }
2465
+ function buildFileSourceSection(filePath, content, title) {
2466
+ const lines = [
2467
+ ``,
2468
+ `## File Details`,
2469
+ ``,
2470
+ `- **Title:** ${title}`,
2471
+ `- **Source file:** ${filePath}`
2472
+ ];
2473
+ if (content) {
2474
+ lines.push(``, `### Content`, ``, content);
2475
+ }
2476
+ return lines;
2477
+ }
2478
+ function buildInlineTextSourceSection(title, text) {
2479
+ return [
2480
+ ``,
2481
+ `## Inline Text`,
2482
+ ``,
2483
+ `- **Title:** ${title}`,
2484
+ ``,
2485
+ `### Description`,
2486
+ ``,
2487
+ text
2488
+ ];
2489
+ }
2490
+ function buildCommonSpecInstructions(params) {
2491
+ const {
2492
+ subject,
2493
+ sourceSection,
2494
+ cwd,
2495
+ outputPath,
2496
+ understandStep,
2497
+ titleTemplate,
2498
+ summaryTemplate,
2499
+ whyLines
2500
+ } = params;
2501
+ return [
2502
+ `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.`,
2503
+ ``,
2504
+ `**Important:** This file will be consumed by a two-stage pipeline:`,
2505
+ `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.`,
2506
+ `2. A **coder agent** follows that detailed plan to make the actual code changes.`,
2507
+ ``,
2508
+ `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.`,
2509
+ ``,
2510
+ `**CRITICAL \u2014 Output constraints (read carefully):**`,
2511
+ `The file you write must contain ONLY the structured spec content described below. You MUST NOT include:`,
2512
+ `- **No preamble:** Do not add any text before the H1 heading (e.g., "Here's the spec:", "I've written the spec file to...")`,
2513
+ `- **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...")`,
2514
+ `- **No summaries:** Do not append a summary or recap of what you wrote`,
2515
+ `- **No code fences:** Do not wrap the spec content in \`\`\`markdown ... \`\`\` or any other code fence`,
2516
+ `- **No conversational text:** Do not include any explanations, commentary, or dialogue \u2014 the file is consumed by an automated pipeline, not a human`,
2517
+ `The file content must start with \`# \` (the H1 heading) and contain nothing before or after the structured spec sections.`,
2518
+ ...sourceSection,
2045
2519
  ``,
2046
2520
  `## Working Directory`,
2047
2521
  ``,
@@ -2051,7 +2525,7 @@ function buildSpecPrompt(issue, cwd, outputPath) {
2051
2525
  ``,
2052
2526
  `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
2527
  ``,
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.`,
2528
+ understandStep,
2055
2529
  ``,
2056
2530
  `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
2531
  ``,
@@ -2067,9 +2541,9 @@ function buildSpecPrompt(issue, cwd, outputPath) {
2067
2541
  ``,
2068
2542
  `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
2543
  ``,
2070
- `# <Issue title> (#<number>)`,
2544
+ titleTemplate,
2071
2545
  ``,
2072
- `> <One-line summary: what this issue achieves and why it matters>`,
2546
+ summaryTemplate,
2073
2547
  ``,
2074
2548
  `## Context`,
2075
2549
  ``,
@@ -2081,8 +2555,7 @@ function buildSpecPrompt(issue, cwd, outputPath) {
2081
2555
  `## Why`,
2082
2556
  ``,
2083
2557
  `<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.>`,
2558
+ ...whyLines,
2086
2559
  ``,
2087
2560
  `## Approach`,
2088
2561
  ``,
@@ -2133,258 +2606,59 @@ function buildSpecPrompt(issue, cwd, outputPath) {
2133
2606
  `- **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
2607
  `- **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
2608
  `- **Keep the markdown clean** \u2014 it will be parsed by an automated tool.`
2136
- );
2137
- return sections.join("\n");
2609
+ ];
2610
+ }
2611
+ function buildSpecPrompt(issue, cwd, outputPath) {
2612
+ return buildCommonSpecInstructions({
2613
+ subject: "the issue below",
2614
+ sourceSection: buildIssueSourceSection(issue),
2615
+ cwd,
2616
+ outputPath,
2617
+ 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.`,
2618
+ titleTemplate: `# <Issue title> (#<number>)`,
2619
+ summaryTemplate: `> <One-line summary: what this issue achieves and why it matters>`,
2620
+ whyLines: [
2621
+ `what user or system benefit it provides. Pull from the issue description,`,
2622
+ `acceptance criteria, and discussion.>`
2623
+ ]
2624
+ }).join("\n");
2138
2625
  }
2139
2626
  function buildFileSpecPrompt(filePath, content, cwd, outputPath) {
2140
2627
  const title = extractTitle(content, filePath);
2141
2628
  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.`,
2144
- ``,
2145
- `**Important:** This file will be consumed by a two-stage pipeline:`,
2146
- `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.`,
2147
- `2. A **coder agent** follows that detailed plan to make the actual code changes.`,
2148
- ``,
2149
- `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.`,
2150
- ``,
2151
- `**CRITICAL \u2014 Output constraints (read carefully):**`,
2152
- `The file you write must contain ONLY the structured spec content described below. You MUST NOT include:`,
2153
- `- **No preamble:** Do not add any text before the H1 heading (e.g., "Here's the spec:", "I've written the spec file to...")`,
2154
- `- **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...")`,
2155
- `- **No summaries:** Do not append a summary or recap of what you wrote`,
2156
- `- **No code fences:** Do not wrap the spec content in \`\`\`markdown ... \`\`\` or any other code fence`,
2157
- `- **No conversational text:** Do not include any explanations, commentary, or dialogue \u2014 the file is consumed by an automated pipeline, not a human`,
2158
- `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(
2169
- ``,
2170
- `## Working Directory`,
2171
- ``,
2172
- `\`${cwd}\``,
2173
- ``,
2174
- `## Instructions`,
2175
- ``,
2176
- `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
- ``,
2178
- `2. **Understand the content** \u2014 analyze the file content to fully understand what needs to be done and why.`,
2179
- ``,
2180
- `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
- ``,
2182
- `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.`,
2183
- ``,
2184
- `5. **DO NOT make any code changes** \u2014 you are only producing a spec, not implementing.`,
2185
- ``,
2186
- `## Output`,
2187
- ``,
2188
- `Write the complete spec as a markdown file to this exact path:`,
2189
- ``,
2190
- `\`${writePath}\``,
2191
- ``,
2192
- `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
- ``,
2194
- `# <Title>`,
2195
- ``,
2196
- `> <One-line summary: what this achieves and why it matters>`,
2197
- ``,
2198
- `## Context`,
2199
- ``,
2200
- `<Describe the relevant parts of the codebase: key modules, directory structure,`,
2201
- `language/framework, and architectural patterns. Name specific files and modules`,
2202
- `that are involved so the planner agent knows where to look, but do not include`,
2203
- `code snippets or line-level details.>`,
2204
- ``,
2205
- `## Why`,
2206
- ``,
2207
- `<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.>`,
2209
- ``,
2210
- `## Approach`,
2211
- ``,
2212
- `<High-level description of the implementation strategy. Explain the overall`,
2213
- `approach, which patterns to follow, what to extend vs. create new, and how`,
2214
- `the change fits into the existing architecture. Mention relevant standards,`,
2215
- `technologies, and conventions the implementation MUST align with.>`,
2216
- ``,
2217
- `## Integration Points`,
2218
- ``,
2219
- `<List the specific modules, interfaces, configurations, and conventions that`,
2220
- `the implementation must integrate with. For example: existing provider`,
2221
- `interfaces to implement, CLI argument patterns to follow, test framework`,
2222
- `and conventions to match, build system requirements, etc.>`,
2223
- ``,
2224
- `## Tasks`,
2225
- ``,
2226
- `Each task MUST be prefixed with an execution-mode tag:`,
2227
- ``,
2228
- `- \`(P)\` \u2014 **Parallel-safe.** This task has no dependency on the output of a prior task and can run concurrently with other \`(P)\` tasks.`,
2229
- `- \`(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.`,
2230
- `- \`(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.`,
2231
- ``,
2232
- `**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").`,
2233
- ``,
2234
- `If a task has no \`(P)\`, \`(S)\`, or \`(I)\` prefix, the system treats it as serial, so always tag explicitly.`,
2235
- ``,
2236
- `Example:`,
2237
- ``,
2238
- `- [ ] (P) Add validation helper to the form utils module`,
2239
- `- [ ] (P) Add unit tests for the new validation helper`,
2240
- `- [ ] (S) Refactor the form component to use the new validation helper`,
2241
- `- [ ] (P) Update documentation for the form utils module`,
2242
- `- [ ] (I) Run the full test suite to verify all changes pass`,
2243
- ``,
2244
- ``,
2245
- `## References`,
2246
- ``,
2247
- `- <Links to relevant docs, related issues, or external resources>`,
2248
- ``,
2249
- `## Key Guidelines`,
2250
- ``,
2251
- `- **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.`,
2252
- `- **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.`,
2253
- `- **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.`,
2254
- `- **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.`,
2255
- `- **Keep tasks atomic and ordered.** Each \`- [ ]\` task must be a single, clear unit of work. Order them so dependencies come first.`,
2256
- `- **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
- `- **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
- `- **Keep the markdown clean** \u2014 it will be parsed by an automated tool.`
2259
- );
2260
- return sections.join("\n");
2629
+ return buildCommonSpecInstructions({
2630
+ subject: "the content below",
2631
+ sourceSection: buildFileSourceSection(filePath, content, title),
2632
+ cwd,
2633
+ outputPath: writePath,
2634
+ understandStep: `2. **Understand the content** \u2014 analyze the file content to fully understand what needs to be done and why.`,
2635
+ titleTemplate: `# <Title>`,
2636
+ summaryTemplate: `> <One-line summary: what this achieves and why it matters>`,
2637
+ whyLines: [
2638
+ `what user or system benefit it provides. Pull from the file content.>`
2639
+ ]
2640
+ }).join("\n");
2261
2641
  }
2262
2642
  function buildInlineTextSpecPrompt(text, cwd, outputPath) {
2263
2643
  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");
2644
+ return buildCommonSpecInstructions({
2645
+ subject: "the request below",
2646
+ sourceSection: buildInlineTextSourceSection(title, text),
2647
+ cwd,
2648
+ outputPath,
2649
+ 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.`,
2650
+ titleTemplate: `# <Title>`,
2651
+ summaryTemplate: `> <One-line summary: what this achieves and why it matters>`,
2652
+ whyLines: [
2653
+ `what user or system benefit it provides. Pull from the inline text description.>`
2654
+ ]
2655
+ }).join("\n");
2383
2656
  }
2384
2657
 
2385
2658
  // src/orchestrator/spec-pipeline.ts
2386
2659
  init_cleanup();
2387
2660
  init_logger();
2661
+ init_file_logger();
2388
2662
  import chalk5 from "chalk";
2389
2663
 
2390
2664
  // src/helpers/format.ts
@@ -2435,146 +2709,134 @@ async function withRetry(fn, maxRetries, options) {
2435
2709
  }
2436
2710
 
2437
2711
  // 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
- }
2712
+ init_timeout();
2713
+ var FETCH_TIMEOUT_MS = 3e4;
2714
+ async function resolveDatasource(issues, issueSource, specCwd, org, project, workItemType, iteration, area) {
2715
+ const source = await resolveSource(issues, issueSource, specCwd);
2716
+ if (!source) return null;
2458
2717
  const datasource4 = getDatasource(source);
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
- }
2718
+ const fetchOpts = { cwd: specCwd, org, project, workItemType, iteration, area };
2719
+ return { source, datasource: datasource4, fetchOpts };
2720
+ }
2721
+ async function fetchTrackerItems(issues, datasource4, fetchOpts, concurrency, source) {
2722
+ const issueNumbers = issues.split(",").map((s) => s.trim()).filter(Boolean);
2723
+ if (issueNumbers.length === 0) {
2724
+ log.error("No issue numbers provided. Use --spec 1,2,3");
2725
+ return [];
2726
+ }
2727
+ const fetchStart = Date.now();
2728
+ log.info(`Fetching ${issueNumbers.length} issue(s) from ${source} (concurrency: ${concurrency})...`);
2729
+ const items = [];
2730
+ const fetchQueue = [...issueNumbers];
2731
+ while (fetchQueue.length > 0) {
2732
+ const batch = fetchQueue.splice(0, concurrency);
2733
+ log.debug(`Fetching batch of ${batch.length}: #${batch.join(", #")}`);
2734
+ const batchResults = await Promise.all(
2735
+ batch.map(async (id) => {
2736
+ try {
2737
+ const details = await withTimeout(datasource4.fetch(id, fetchOpts), FETCH_TIMEOUT_MS, "datasource fetch");
2738
+ log.success(`Fetched #${id}: ${details.title}`);
2739
+ log.debug(`Body: ${details.body?.length ?? 0} chars, Labels: ${details.labels.length}, Comments: ${details.comments.length}`);
2740
+ return { id, details };
2741
+ } catch (err) {
2742
+ const message = log.extractMessage(err);
2743
+ log.error(`Failed to fetch #${id}: ${log.formatErrorChain(err)}`);
2744
+ log.debug(log.formatErrorChain(err));
2745
+ return { id, details: null, error: message };
2746
+ }
2747
+ })
2748
+ );
2749
+ items.push(...batchResults);
2750
+ }
2751
+ log.debug(`Issue fetching completed in ${elapsed(Date.now() - fetchStart)}`);
2752
+ return items;
2753
+ }
2754
+ function buildInlineTextItem(issues, outputDir) {
2755
+ const text = Array.isArray(issues) ? issues.join(" ") : issues;
2756
+ const title = text.length > 80 ? text.slice(0, 80).trimEnd() + "\u2026" : text;
2757
+ const slug = slugify(text, MAX_SLUG_LENGTH);
2758
+ const filename = `${slug}.md`;
2759
+ const filepath = join7(outputDir, filename);
2760
+ const details = {
2761
+ number: filepath,
2762
+ title,
2763
+ body: text,
2764
+ labels: [],
2765
+ state: "open",
2766
+ url: filepath,
2767
+ comments: [],
2768
+ acceptanceCriteria: ""
2769
+ };
2770
+ log.info(`Inline text spec: "${title}"`);
2771
+ return [{ id: filepath, details }];
2772
+ }
2773
+ async function resolveFileItems(issues, specCwd, concurrency) {
2774
+ const files = await glob(issues, { cwd: specCwd, absolute: true });
2775
+ if (files.length === 0) {
2776
+ log.error(`No files matched the pattern "${Array.isArray(issues) ? issues.join(", ") : issues}".`);
2777
+ return null;
2778
+ }
2779
+ log.info(`Matched ${files.length} file(s) for spec generation (concurrency: ${concurrency})...`);
2780
+ const items = [];
2781
+ for (const filePath of files) {
2782
+ try {
2783
+ const content = await readFile5(filePath, "utf-8");
2784
+ const title = extractTitle(content, filePath);
2785
+ const details = {
2786
+ number: filePath,
2787
+ title,
2788
+ body: content,
2789
+ labels: [],
2790
+ state: "open",
2791
+ url: filePath,
2792
+ comments: [],
2793
+ acceptanceCriteria: ""
2794
+ };
2795
+ items.push({ id: filePath, details });
2796
+ } catch (err) {
2797
+ items.push({ id: filePath, details: null, error: log.extractMessage(err) });
2538
2798
  }
2539
2799
  }
2800
+ return items;
2801
+ }
2802
+ function filterValidItems(items, isTrackerMode, isInlineText) {
2540
2803
  const validItems = items.filter(
2541
2804
  (i) => i.details !== null
2542
2805
  );
2543
2806
  if (validItems.length === 0) {
2544
2807
  const noun = isTrackerMode ? "issues" : isInlineText ? "inline specs" : "files";
2545
2808
  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: {} };
2809
+ return null;
2547
2810
  }
2548
- if (dryRun) {
2549
- const mode = isTrackerMode ? "tracker" : isInlineText ? "inline" : "file";
2550
- log.info(`[DRY RUN] Would generate ${validItems.length} spec(s) (mode: ${mode}):
2811
+ return validItems;
2812
+ }
2813
+ function previewDryRun(validItems, items, isTrackerMode, isInlineText, outputDir, pipelineStart) {
2814
+ const mode = isTrackerMode ? "tracker" : isInlineText ? "inline" : "file";
2815
+ log.info(`[DRY RUN] Would generate ${validItems.length} spec(s) (mode: ${mode}):
2551
2816
  `);
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}`);
2817
+ for (const { id, details } of validItems) {
2818
+ let filepath;
2819
+ if (isTrackerMode) {
2820
+ const slug = slugify(details.title, 60);
2821
+ filepath = join7(outputDir, `${id}-${slug}.md`);
2822
+ } else {
2823
+ filepath = id;
2563
2824
  }
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: {} };
2825
+ const label = isTrackerMode ? `#${id}` : filepath;
2826
+ log.info(`[DRY RUN] Would generate spec for ${label}: "${details.title}"`);
2827
+ log.dim(` \u2192 ${filepath}`);
2577
2828
  }
2829
+ return {
2830
+ total: items.length,
2831
+ generated: 0,
2832
+ failed: items.filter((i) => i.details === null).length,
2833
+ files: [],
2834
+ issueNumbers: [],
2835
+ durationMs: Date.now() - pipelineStart,
2836
+ fileDurationsMs: {}
2837
+ };
2838
+ }
2839
+ async function bootPipeline(provider, serverUrl, specCwd, model, source) {
2578
2840
  const bootStart = Date.now();
2579
2841
  log.info(`Booting ${provider} provider...`);
2580
2842
  log.debug(serverUrl ? `Using server URL: ${serverUrl}` : "No --server-url, will spawn local server");
@@ -2593,6 +2855,9 @@ async function runSpecPipeline(opts) {
2593
2855
  console.log(chalk5.dim(" \u2500".repeat(24)));
2594
2856
  console.log("");
2595
2857
  const specAgent = await boot5({ provider: instance, cwd: specCwd });
2858
+ return { specAgent, instance };
2859
+ }
2860
+ async function generateSpecsBatch(validItems, items, specAgent, instance, isTrackerMode, isInlineText, datasource4, fetchOpts, outputDir, specCwd, concurrency, retries) {
2596
2861
  await mkdir4(outputDir, { recursive: true });
2597
2862
  const generatedFiles = [];
2598
2863
  const issueNumbers = [];
@@ -2611,72 +2876,92 @@ async function runSpecPipeline(opts) {
2611
2876
  log.error(`Skipping item ${id}: missing issue details`);
2612
2877
  return null;
2613
2878
  }
2614
- let filepath;
2615
- if (isTrackerMode) {
2616
- const slug = slugify(details.title, MAX_SLUG_LENGTH);
2617
- const filename = `${id}-${slug}.md`;
2618
- filepath = join6(outputDir, filename);
2619
- } else if (isInlineText) {
2620
- filepath = id;
2621
- } else {
2622
- filepath = id;
2623
- }
2624
- try {
2625
- log.info(`Generating spec for ${isTrackerMode ? `#${id}` : filepath}: ${details.title}...`);
2626
- const result = await withRetry(
2627
- () => specAgent.generate({
2628
- issue: isTrackerMode ? details : void 0,
2629
- filePath: isTrackerMode ? void 0 : id,
2630
- fileContent: isTrackerMode ? void 0 : details.body,
2631
- cwd: specCwd,
2632
- outputPath: filepath
2633
- }),
2634
- retries,
2635
- { label: `specAgent.generate(${isTrackerMode ? `#${id}` : filepath})` }
2636
- );
2637
- if (!result.success) {
2638
- throw new Error(result.error ?? "Spec generation failed");
2639
- }
2640
- if (isTrackerMode || isInlineText) {
2641
- const h1Title = extractTitle(result.content, filepath);
2642
- const h1Slug = slugify(h1Title, MAX_SLUG_LENGTH);
2643
- const finalFilename = isTrackerMode ? `${id}-${h1Slug}.md` : `${h1Slug}.md`;
2644
- const finalFilepath = join6(outputDir, finalFilename);
2645
- if (finalFilepath !== filepath) {
2646
- await rename2(filepath, finalFilepath);
2647
- filepath = finalFilepath;
2648
- }
2879
+ const itemBody = async () => {
2880
+ let filepath;
2881
+ if (isTrackerMode) {
2882
+ const slug = slugify(details.title, MAX_SLUG_LENGTH);
2883
+ const filename = `${id}-${slug}.md`;
2884
+ filepath = join7(outputDir, filename);
2885
+ } else if (isInlineText) {
2886
+ filepath = id;
2887
+ } else {
2888
+ filepath = id;
2649
2889
  }
2650
- const specDuration = Date.now() - specStart;
2651
- fileDurationsMs[filepath] = specDuration;
2652
- log.success(`Spec written: ${filepath} (${elapsed(specDuration)})`);
2653
- let identifier = filepath;
2890
+ fileLoggerStorage.getStore()?.info(`Output path: ${filepath}`);
2654
2891
  try {
2655
- if (isTrackerMode) {
2656
- await datasource4.update(id, details.title, result.content, fetchOpts);
2657
- log.success(`Updated issue #${id} with spec content`);
2658
- await unlink2(filepath);
2659
- log.success(`Deleted local spec ${filepath} (now tracked as issue #${id})`);
2660
- identifier = id;
2661
- issueNumbers.push(id);
2662
- } else if (datasource4.name !== "md") {
2663
- const created = await datasource4.create(details.title, result.content, fetchOpts);
2664
- log.success(`Created issue #${created.number} from ${filepath}`);
2665
- await unlink2(filepath);
2666
- log.success(`Deleted local spec ${filepath} (now tracked as issue #${created.number})`);
2667
- identifier = created.number;
2668
- issueNumbers.push(created.number);
2892
+ fileLoggerStorage.getStore()?.info(`Starting spec generation for ${isTrackerMode ? `#${id}` : filepath}`);
2893
+ log.info(`Generating spec for ${isTrackerMode ? `#${id}` : filepath}: ${details.title}...`);
2894
+ const result = await withRetry(
2895
+ () => specAgent.generate({
2896
+ issue: isTrackerMode ? details : void 0,
2897
+ filePath: isTrackerMode ? void 0 : id,
2898
+ fileContent: isTrackerMode ? void 0 : details.body,
2899
+ cwd: specCwd,
2900
+ outputPath: filepath
2901
+ }),
2902
+ retries,
2903
+ { label: `specAgent.generate(${isTrackerMode ? `#${id}` : filepath})` }
2904
+ );
2905
+ if (!result.success) {
2906
+ throw new Error(result.error ?? "Spec generation failed");
2907
+ }
2908
+ fileLoggerStorage.getStore()?.info(`Spec generated successfully`);
2909
+ if (isTrackerMode || isInlineText) {
2910
+ const h1Title = extractTitle(result.data.content, filepath);
2911
+ const h1Slug = slugify(h1Title, MAX_SLUG_LENGTH);
2912
+ const finalFilename = isTrackerMode ? `${id}-${h1Slug}.md` : `${h1Slug}.md`;
2913
+ const finalFilepath = join7(outputDir, finalFilename);
2914
+ if (finalFilepath !== filepath) {
2915
+ await rename2(filepath, finalFilepath);
2916
+ filepath = finalFilepath;
2917
+ }
2918
+ }
2919
+ const specDuration = Date.now() - specStart;
2920
+ fileDurationsMs[filepath] = specDuration;
2921
+ log.success(`Spec written: ${filepath} (${elapsed(specDuration)})`);
2922
+ let identifier = filepath;
2923
+ fileLoggerStorage.getStore()?.phase("Datasource sync");
2924
+ try {
2925
+ if (isTrackerMode) {
2926
+ await datasource4.update(id, details.title, result.data.content, fetchOpts);
2927
+ log.success(`Updated issue #${id} with spec content`);
2928
+ await unlink2(filepath);
2929
+ log.success(`Deleted local spec ${filepath} (now tracked as issue #${id})`);
2930
+ identifier = id;
2931
+ issueNumbers.push(id);
2932
+ } else if (datasource4.name !== "md") {
2933
+ const created = await datasource4.create(details.title, result.data.content, fetchOpts);
2934
+ log.success(`Created issue #${created.number} from ${filepath}`);
2935
+ await unlink2(filepath);
2936
+ log.success(`Deleted local spec ${filepath} (now tracked as issue #${created.number})`);
2937
+ identifier = created.number;
2938
+ issueNumbers.push(created.number);
2939
+ }
2940
+ } catch (err) {
2941
+ const label = isTrackerMode ? `issue #${id}` : filepath;
2942
+ log.warn(`Could not sync ${label} to datasource: ${log.formatErrorChain(err)}`);
2669
2943
  }
2944
+ return { filepath, identifier };
2670
2945
  } catch (err) {
2671
- const label = isTrackerMode ? `issue #${id}` : filepath;
2672
- log.warn(`Could not sync ${label} to datasource: ${log.formatErrorChain(err)}`);
2946
+ fileLoggerStorage.getStore()?.error(`Spec generation failed for ${id}: ${log.extractMessage(err)}${err instanceof Error && err.stack ? `
2947
+ ${err.stack}` : ""}`);
2948
+ log.error(`Failed to generate spec for ${isTrackerMode ? `#${id}` : filepath}: ${log.formatErrorChain(err)}`);
2949
+ log.debug(log.formatErrorChain(err));
2950
+ return null;
2673
2951
  }
2674
- return { filepath, identifier };
2675
- } catch (err) {
2676
- log.error(`Failed to generate spec for ${isTrackerMode ? `#${id}` : filepath}: ${log.formatErrorChain(err)}`);
2677
- log.debug(log.formatErrorChain(err));
2678
- return null;
2952
+ };
2953
+ const fileLogger = log.verbose ? new FileLogger(id, specCwd) : null;
2954
+ if (fileLogger) {
2955
+ return fileLoggerStorage.run(fileLogger, async () => {
2956
+ try {
2957
+ fileLogger.phase(`Spec generation: ${id}`);
2958
+ return await itemBody();
2959
+ } finally {
2960
+ fileLogger.close();
2961
+ }
2962
+ });
2679
2963
  }
2964
+ return itemBody();
2680
2965
  })
2681
2966
  );
2682
2967
  for (const result of batchResults) {
@@ -2692,6 +2977,9 @@ async function runSpecPipeline(opts) {
2692
2977
  modelLoggedInBanner = true;
2693
2978
  }
2694
2979
  }
2980
+ return { generatedFiles, issueNumbers, dispatchIdentifiers, failed, fileDurationsMs };
2981
+ }
2982
+ async function cleanupPipeline(specAgent, instance) {
2695
2983
  try {
2696
2984
  await specAgent.cleanup();
2697
2985
  } catch (err) {
@@ -2702,7 +2990,8 @@ async function runSpecPipeline(opts) {
2702
2990
  } catch (err) {
2703
2991
  log.warn(`Provider cleanup failed: ${log.formatErrorChain(err)}`);
2704
2992
  }
2705
- const totalDuration = Date.now() - pipelineStart;
2993
+ }
2994
+ function logSummary(generatedFiles, dispatchIdentifiers, failed, totalDuration) {
2706
2995
  log.info(
2707
2996
  `Spec generation complete: ${generatedFiles.length} generated, ${failed} failed in ${elapsed(totalDuration)}`
2708
2997
  );
@@ -2718,19 +3007,91 @@ async function runSpecPipeline(opts) {
2718
3007
  `);
2719
3008
  }
2720
3009
  }
3010
+ }
3011
+ async function runSpecPipeline(opts) {
3012
+ const {
3013
+ issues,
3014
+ provider,
3015
+ model,
3016
+ serverUrl,
3017
+ cwd: specCwd,
3018
+ outputDir = join7(specCwd, ".dispatch", "specs"),
3019
+ org,
3020
+ project,
3021
+ workItemType,
3022
+ iteration,
3023
+ area,
3024
+ concurrency = defaultConcurrency(),
3025
+ dryRun,
3026
+ retries = 2
3027
+ } = opts;
3028
+ const pipelineStart = Date.now();
3029
+ const resolved = await resolveDatasource(issues, opts.issueSource, specCwd, org, project, workItemType, iteration, area);
3030
+ if (!resolved) {
3031
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
3032
+ }
3033
+ const { source, datasource: datasource4, fetchOpts } = resolved;
3034
+ const isTrackerMode = isIssueNumbers(issues);
3035
+ const isInlineText = !isTrackerMode && !isGlobOrFilePath(issues);
3036
+ let items;
3037
+ if (isTrackerMode) {
3038
+ items = await fetchTrackerItems(issues, datasource4, fetchOpts, concurrency, source);
3039
+ if (items.length === 0) {
3040
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
3041
+ }
3042
+ } else if (isInlineText) {
3043
+ items = buildInlineTextItem(issues, outputDir);
3044
+ } else {
3045
+ const fileItems = await resolveFileItems(issues, specCwd, concurrency);
3046
+ if (!fileItems) {
3047
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
3048
+ }
3049
+ items = fileItems;
3050
+ }
3051
+ const validItems = filterValidItems(items, isTrackerMode, isInlineText);
3052
+ if (!validItems) {
3053
+ return { total: items.length, generated: 0, failed: items.length, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
3054
+ }
3055
+ if (dryRun) {
3056
+ return previewDryRun(validItems, items, isTrackerMode, isInlineText, outputDir, pipelineStart);
3057
+ }
3058
+ const confirmed = await confirmLargeBatch(validItems.length);
3059
+ if (!confirmed) {
3060
+ return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
3061
+ }
3062
+ const { specAgent, instance } = await bootPipeline(provider, serverUrl, specCwd, model, source);
3063
+ const results = await generateSpecsBatch(
3064
+ validItems,
3065
+ items,
3066
+ specAgent,
3067
+ instance,
3068
+ isTrackerMode,
3069
+ isInlineText,
3070
+ datasource4,
3071
+ fetchOpts,
3072
+ outputDir,
3073
+ specCwd,
3074
+ concurrency,
3075
+ retries
3076
+ );
3077
+ await cleanupPipeline(specAgent, instance);
3078
+ const totalDuration = Date.now() - pipelineStart;
3079
+ logSummary(results.generatedFiles, results.dispatchIdentifiers, results.failed, totalDuration);
2721
3080
  return {
2722
3081
  total: items.length,
2723
- generated: generatedFiles.length,
2724
- failed,
2725
- files: generatedFiles,
2726
- issueNumbers,
2727
- identifiers: dispatchIdentifiers,
3082
+ generated: results.generatedFiles.length,
3083
+ failed: results.failed,
3084
+ files: results.generatedFiles,
3085
+ issueNumbers: results.issueNumbers,
3086
+ identifiers: results.dispatchIdentifiers,
2728
3087
  durationMs: totalDuration,
2729
- fileDurationsMs
3088
+ fileDurationsMs: results.fileDurationsMs
2730
3089
  };
2731
3090
  }
2732
3091
 
2733
3092
  // src/orchestrator/dispatch-pipeline.ts
3093
+ import { execFile as execFile9 } from "child_process";
3094
+ import { promisify as promisify9 } from "util";
2734
3095
  import { readFile as readFile7 } from "fs/promises";
2735
3096
 
2736
3097
  // src/parser.ts
@@ -2785,7 +3146,9 @@ async function parseTaskFile(filePath) {
2785
3146
  }
2786
3147
  async function markTaskComplete(task) {
2787
3148
  const content = await readFile6(task.file, "utf-8");
2788
- const lines = content.split("\n");
3149
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
3150
+ const normalized = content.replace(/\r\n/g, "\n");
3151
+ const lines = normalized.split("\n");
2789
3152
  const lineIndex = task.line - 1;
2790
3153
  if (lineIndex < 0 || lineIndex >= lines.length) {
2791
3154
  throw new Error(
@@ -2800,7 +3163,7 @@ async function markTaskComplete(task) {
2800
3163
  );
2801
3164
  }
2802
3165
  lines[lineIndex] = updated;
2803
- await writeFile5(task.file, lines.join("\n"), "utf-8");
3166
+ await writeFile5(task.file, lines.join(eol), "utf-8");
2804
3167
  }
2805
3168
  function groupTasksByMode(tasks) {
2806
3169
  if (tasks.length === 0) return [];
@@ -2830,6 +3193,7 @@ function groupTasksByMode(tasks) {
2830
3193
 
2831
3194
  // src/agents/planner.ts
2832
3195
  init_logger();
3196
+ init_file_logger();
2833
3197
  async function boot6(opts) {
2834
3198
  const { provider, cwd } = opts;
2835
3199
  if (!provider) {
@@ -2837,25 +3201,31 @@ async function boot6(opts) {
2837
3201
  }
2838
3202
  return {
2839
3203
  name: "planner",
2840
- async plan(task, fileContext, cwdOverride) {
3204
+ async plan(task, fileContext, cwdOverride, worktreeRoot) {
3205
+ const startTime = Date.now();
2841
3206
  try {
2842
3207
  const sessionId = await provider.createSession();
2843
- const prompt = buildPlannerPrompt(task, cwdOverride ?? cwd, fileContext);
3208
+ const prompt = buildPlannerPrompt(task, cwdOverride ?? cwd, fileContext, worktreeRoot);
3209
+ fileLoggerStorage.getStore()?.prompt("planner", prompt);
2844
3210
  const plan = await provider.prompt(sessionId, prompt);
3211
+ if (plan) fileLoggerStorage.getStore()?.response("planner", plan);
2845
3212
  if (!plan?.trim()) {
2846
- return { prompt: "", success: false, error: "Planner returned empty plan" };
3213
+ return { data: null, success: false, error: "Planner returned empty plan", durationMs: Date.now() - startTime };
2847
3214
  }
2848
- return { prompt: plan, success: true };
3215
+ fileLoggerStorage.getStore()?.agentEvent("planner", "completed", `${Date.now() - startTime}ms`);
3216
+ return { data: { prompt: plan }, success: true, durationMs: Date.now() - startTime };
2849
3217
  } catch (err) {
2850
3218
  const message = log.extractMessage(err);
2851
- return { prompt: "", success: false, error: message };
3219
+ fileLoggerStorage.getStore()?.error(`planner error: ${message}${err instanceof Error && err.stack ? `
3220
+ ${err.stack}` : ""}`);
3221
+ return { data: null, success: false, error: message, durationMs: Date.now() - startTime };
2852
3222
  }
2853
3223
  },
2854
3224
  async cleanup() {
2855
3225
  }
2856
3226
  };
2857
3227
  }
2858
- function buildPlannerPrompt(task, cwd, fileContext) {
3228
+ function buildPlannerPrompt(task, cwd, fileContext, worktreeRoot) {
2859
3229
  const sections = [
2860
3230
  `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
3231
  ``,
@@ -2879,6 +3249,21 @@ function buildPlannerPrompt(task, cwd, fileContext) {
2879
3249
  `\`\`\``
2880
3250
  );
2881
3251
  }
3252
+ if (worktreeRoot) {
3253
+ sections.push(
3254
+ ``,
3255
+ `## Worktree Isolation`,
3256
+ ``,
3257
+ `You are operating inside a git worktree. All file operations MUST be confined`,
3258
+ `to the following directory tree:`,
3259
+ ``,
3260
+ ` ${worktreeRoot}`,
3261
+ ``,
3262
+ `- Do NOT read, write, or execute commands that access files outside this directory.`,
3263
+ `- Do NOT reference or modify files in the main repository working tree or other worktrees.`,
3264
+ `- All relative paths must resolve within the worktree root above.`
3265
+ );
3266
+ }
2882
3267
  sections.push(
2883
3268
  ``,
2884
3269
  `## Instructions`,
@@ -2914,26 +3299,32 @@ function buildPlannerPrompt(task, cwd, fileContext) {
2914
3299
 
2915
3300
  // src/dispatcher.ts
2916
3301
  init_logger();
2917
- async function dispatchTask(instance, task, cwd, plan) {
3302
+ init_file_logger();
3303
+ async function dispatchTask(instance, task, cwd, plan, worktreeRoot) {
2918
3304
  try {
2919
3305
  log.debug(`Dispatching task: ${task.file}:${task.line} \u2014 ${task.text.slice(0, 80)}`);
2920
3306
  const sessionId = await instance.createSession();
2921
- const prompt = plan ? buildPlannedPrompt(task, cwd, plan) : buildPrompt(task, cwd);
3307
+ const prompt = plan ? buildPlannedPrompt(task, cwd, plan, worktreeRoot) : buildPrompt(task, cwd, worktreeRoot);
2922
3308
  log.debug(`Prompt built (${prompt.length} chars, ${plan ? "with plan" : "no plan"})`);
3309
+ fileLoggerStorage.getStore()?.prompt("dispatchTask", prompt);
2923
3310
  const response = await instance.prompt(sessionId, prompt);
2924
3311
  if (response === null) {
2925
3312
  log.debug("Task dispatch returned null response");
3313
+ fileLoggerStorage.getStore()?.warn("dispatchTask: null response");
2926
3314
  return { task, success: false, error: "No response from agent" };
2927
3315
  }
2928
3316
  log.debug(`Task dispatch completed (${response.length} chars response)`);
3317
+ fileLoggerStorage.getStore()?.response("dispatchTask", response);
2929
3318
  return { task, success: true };
2930
3319
  } catch (err) {
2931
3320
  const message = log.extractMessage(err);
2932
3321
  log.debug(`Task dispatch failed: ${log.formatErrorChain(err)}`);
3322
+ fileLoggerStorage.getStore()?.error(`dispatchTask error: ${message}${err instanceof Error && err.stack ? `
3323
+ ${err.stack}` : ""}`);
2933
3324
  return { task, success: false, error: message };
2934
3325
  }
2935
3326
  }
2936
- function buildPrompt(task, cwd) {
3327
+ function buildPrompt(task, cwd, worktreeRoot) {
2937
3328
  return [
2938
3329
  `You are completing a task from a markdown task file.`,
2939
3330
  ``,
@@ -2945,10 +3336,11 @@ function buildPrompt(task, cwd) {
2945
3336
  `- Complete ONLY this specific task \u2014 do not work on other tasks.`,
2946
3337
  `- Make the minimal, correct changes needed.`,
2947
3338
  buildCommitInstruction(task.text),
3339
+ ...buildWorktreeIsolation(worktreeRoot),
2948
3340
  `- When finished, confirm by saying "Task complete."`
2949
3341
  ].join("\n");
2950
3342
  }
2951
- function buildPlannedPrompt(task, cwd, plan) {
3343
+ function buildPlannedPrompt(task, cwd, plan, worktreeRoot) {
2952
3344
  return [
2953
3345
  `You are an **executor agent** completing a task that has been pre-planned by a planner agent.`,
2954
3346
  `The planner has already explored the codebase and produced detailed instructions below.`,
@@ -2973,6 +3365,7 @@ function buildPlannedPrompt(task, cwd, plan) {
2973
3365
  `- Do NOT re-plan, question, or revise the plan. Trust it as given and execute it faithfully.`,
2974
3366
  `- Do NOT search for additional context using grep, find, or similar tools unless the plan explicitly instructs you to.`,
2975
3367
  buildCommitInstruction(task.text),
3368
+ ...buildWorktreeIsolation(worktreeRoot),
2976
3369
  `- When finished, confirm by saying "Task complete."`
2977
3370
  ].join("\n");
2978
3371
  }
@@ -2985,9 +3378,16 @@ function buildCommitInstruction(taskText) {
2985
3378
  }
2986
3379
  return `- Do NOT commit changes \u2014 the orchestrator handles commits.`;
2987
3380
  }
3381
+ function buildWorktreeIsolation(worktreeRoot) {
3382
+ if (!worktreeRoot) return [];
3383
+ return [
3384
+ `- **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}\`.`
3385
+ ];
3386
+ }
2988
3387
 
2989
3388
  // src/agents/executor.ts
2990
3389
  init_logger();
3390
+ init_file_logger();
2991
3391
  async function boot7(opts) {
2992
3392
  const { provider } = opts;
2993
3393
  if (!provider) {
@@ -2995,28 +3395,24 @@ async function boot7(opts) {
2995
3395
  }
2996
3396
  return {
2997
3397
  name: "executor",
2998
- async execute(input2) {
2999
- const { task, cwd, plan } = input2;
3398
+ async execute(input3) {
3399
+ const { task, cwd, plan, worktreeRoot } = input3;
3000
3400
  const startTime = Date.now();
3001
3401
  try {
3002
- const result = await dispatchTask(provider, task, cwd, plan ?? void 0);
3402
+ fileLoggerStorage.getStore()?.agentEvent("executor", "started", task.text);
3403
+ const result = await dispatchTask(provider, task, cwd, plan ?? void 0, worktreeRoot);
3003
3404
  if (result.success) {
3004
3405
  await markTaskComplete(task);
3406
+ fileLoggerStorage.getStore()?.agentEvent("executor", "completed", `${Date.now() - startTime}ms`);
3407
+ return { data: { dispatchResult: result }, success: true, durationMs: Date.now() - startTime };
3005
3408
  }
3006
- return {
3007
- dispatchResult: result,
3008
- success: result.success,
3009
- error: result.error,
3010
- elapsedMs: Date.now() - startTime
3011
- };
3409
+ fileLoggerStorage.getStore()?.agentEvent("executor", "failed", result.error ?? "unknown error");
3410
+ return { data: null, success: false, error: result.error, durationMs: Date.now() - startTime };
3012
3411
  } catch (err) {
3013
3412
  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
- };
3413
+ fileLoggerStorage.getStore()?.error(`executor error: ${message}${err instanceof Error && err.stack ? `
3414
+ ${err.stack}` : ""}`);
3415
+ return { data: null, success: false, error: message, durationMs: Date.now() - startTime };
3020
3416
  }
3021
3417
  },
3022
3418
  async cleanup() {
@@ -3026,8 +3422,9 @@ async function boot7(opts) {
3026
3422
 
3027
3423
  // src/agents/commit.ts
3028
3424
  init_logger();
3425
+ init_file_logger();
3029
3426
  import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
3030
- import { join as join7 } from "path";
3427
+ import { join as join8, resolve as resolve2 } from "path";
3031
3428
  import { randomUUID as randomUUID4 } from "crypto";
3032
3429
  async function boot8(opts) {
3033
3430
  const { provider } = opts;
@@ -3040,14 +3437,17 @@ async function boot8(opts) {
3040
3437
  name: "commit",
3041
3438
  async generate(genOpts) {
3042
3439
  try {
3043
- const tmpDir = join7(genOpts.cwd, ".dispatch", "tmp");
3440
+ const resolvedCwd = resolve2(genOpts.cwd);
3441
+ const tmpDir = join8(resolvedCwd, ".dispatch", "tmp");
3044
3442
  await mkdir5(tmpDir, { recursive: true });
3045
3443
  const tmpFilename = `commit-${randomUUID4()}.md`;
3046
- const tmpPath = join7(tmpDir, tmpFilename);
3444
+ const tmpPath = join8(tmpDir, tmpFilename);
3047
3445
  const prompt = buildCommitPrompt(genOpts);
3446
+ fileLoggerStorage.getStore()?.prompt("commit", prompt);
3048
3447
  const sessionId = await provider.createSession();
3049
3448
  log.debug(`Commit prompt built (${prompt.length} chars)`);
3050
3449
  const response = await provider.prompt(sessionId, prompt);
3450
+ if (response) fileLoggerStorage.getStore()?.response("commit", response);
3051
3451
  if (!response?.trim()) {
3052
3452
  return {
3053
3453
  commitMessage: "",
@@ -3071,12 +3471,15 @@ async function boot8(opts) {
3071
3471
  const outputContent = formatOutputFile(parsed);
3072
3472
  await writeFile6(tmpPath, outputContent, "utf-8");
3073
3473
  log.debug(`Wrote commit agent output to ${tmpPath}`);
3474
+ fileLoggerStorage.getStore()?.agentEvent("commit", "completed", `message: ${parsed.commitMessage.slice(0, 80)}`);
3074
3475
  return {
3075
3476
  ...parsed,
3076
3477
  success: true,
3077
3478
  outputPath: tmpPath
3078
3479
  };
3079
3480
  } catch (err) {
3481
+ fileLoggerStorage.getStore()?.error(`commit error: ${log.extractMessage(err)}${err instanceof Error && err.stack ? `
3482
+ ${err.stack}` : ""}`);
3080
3483
  const message = log.extractMessage(err);
3081
3484
  return {
3082
3485
  commitMessage: "",
@@ -3217,9 +3620,10 @@ init_logger();
3217
3620
  init_cleanup();
3218
3621
 
3219
3622
  // src/helpers/worktree.ts
3220
- import { join as join8, basename } from "path";
3623
+ import { join as join9, basename } from "path";
3221
3624
  import { execFile as execFile7 } from "child_process";
3222
3625
  import { promisify as promisify7 } from "util";
3626
+ import { randomUUID as randomUUID5 } from "crypto";
3223
3627
  init_logger();
3224
3628
  var exec7 = promisify7(execFile7);
3225
3629
  var WORKTREE_DIR = ".dispatch/worktrees";
@@ -3230,13 +3634,16 @@ async function git2(args, cwd) {
3230
3634
  function worktreeName(issueFilename) {
3231
3635
  const base = basename(issueFilename);
3232
3636
  const withoutExt = base.replace(/\.md$/i, "");
3233
- return slugify(withoutExt);
3637
+ const match = withoutExt.match(/^(\d+)/);
3638
+ return match ? `issue-${match[1]}` : slugify(withoutExt);
3234
3639
  }
3235
- async function createWorktree(repoRoot, issueFilename, branchName) {
3640
+ async function createWorktree(repoRoot, issueFilename, branchName, startPoint) {
3236
3641
  const name = worktreeName(issueFilename);
3237
- const worktreePath = join8(repoRoot, WORKTREE_DIR, name);
3642
+ const worktreePath = join9(repoRoot, WORKTREE_DIR, name);
3238
3643
  try {
3239
- await git2(["worktree", "add", worktreePath, "-b", branchName], repoRoot);
3644
+ const args = ["worktree", "add", worktreePath, "-b", branchName];
3645
+ if (startPoint) args.push(startPoint);
3646
+ await git2(args, repoRoot);
3240
3647
  log.debug(`Created worktree at ${worktreePath} on branch ${branchName}`);
3241
3648
  } catch (err) {
3242
3649
  const message = log.extractMessage(err);
@@ -3251,7 +3658,7 @@ async function createWorktree(repoRoot, issueFilename, branchName) {
3251
3658
  }
3252
3659
  async function removeWorktree(repoRoot, issueFilename) {
3253
3660
  const name = worktreeName(issueFilename);
3254
- const worktreePath = join8(repoRoot, WORKTREE_DIR, name);
3661
+ const worktreePath = join9(repoRoot, WORKTREE_DIR, name);
3255
3662
  try {
3256
3663
  await git2(["worktree", "remove", worktreePath], repoRoot);
3257
3664
  } catch {
@@ -3268,6 +3675,11 @@ async function removeWorktree(repoRoot, issueFilename) {
3268
3675
  log.warn(`Could not prune worktrees: ${log.formatErrorChain(err)}`);
3269
3676
  }
3270
3677
  }
3678
+ function generateFeatureBranchName() {
3679
+ const uuid = randomUUID5();
3680
+ const octet = uuid.split("-")[0];
3681
+ return `dispatch/feature-${octet}`;
3682
+ }
3271
3683
 
3272
3684
  // src/tui.ts
3273
3685
  import chalk6 from "chalk";
@@ -3481,13 +3893,24 @@ function render(state) {
3481
3893
  return lines.join("\n");
3482
3894
  }
3483
3895
  function draw(state) {
3484
- if (lastLineCount > 0) {
3485
- process.stdout.write(`\x1B[${lastLineCount}A\x1B[0J`);
3486
- }
3487
3896
  const output = render(state);
3488
- process.stdout.write(output);
3489
3897
  const cols = process.stdout.columns || 80;
3490
- lastLineCount = countVisualRows(output, cols);
3898
+ const newLineCount = countVisualRows(output, cols);
3899
+ let buffer = "";
3900
+ if (lastLineCount > 0) {
3901
+ buffer += `\x1B[${lastLineCount}A`;
3902
+ }
3903
+ const lines = output.split("\n");
3904
+ buffer += lines.map((line) => line + "\x1B[K").join("\n");
3905
+ const leftover = lastLineCount - newLineCount;
3906
+ if (leftover > 0) {
3907
+ for (let i = 0; i < leftover; i++) {
3908
+ buffer += "\n\x1B[K";
3909
+ }
3910
+ buffer += `\x1B[${leftover}A`;
3911
+ }
3912
+ process.stdout.write(buffer);
3913
+ lastLineCount = newLineCount;
3491
3914
  }
3492
3915
  function createTui() {
3493
3916
  const state = {
@@ -3517,7 +3940,7 @@ init_providers();
3517
3940
 
3518
3941
  // src/orchestrator/datasource-helpers.ts
3519
3942
  init_logger();
3520
- import { basename as basename2, join as join9 } from "path";
3943
+ import { basename as basename2, join as join10 } from "path";
3521
3944
  import { mkdtemp, writeFile as writeFile7 } from "fs/promises";
3522
3945
  import { tmpdir } from "os";
3523
3946
  import { execFile as execFile8 } from "child_process";
@@ -3545,13 +3968,13 @@ async function fetchItemsById(issueIds, datasource4, fetchOpts) {
3545
3968
  return items;
3546
3969
  }
3547
3970
  async function writeItemsToTempDir(items) {
3548
- const tempDir = await mkdtemp(join9(tmpdir(), "dispatch-"));
3971
+ const tempDir = await mkdtemp(join10(tmpdir(), "dispatch-"));
3549
3972
  const files = [];
3550
3973
  const issueDetailsByFile = /* @__PURE__ */ new Map();
3551
3974
  for (const item of items) {
3552
3975
  const slug = slugify(item.title, MAX_SLUG_LENGTH);
3553
3976
  const filename = `${item.number}-${slug}.md`;
3554
- const filepath = join9(tempDir, filename);
3977
+ const filepath = join10(tempDir, filename);
3555
3978
  await writeFile7(filepath, item.body, "utf-8");
3556
3979
  files.push(filepath);
3557
3980
  issueDetailsByFile.set(filepath, item);
@@ -3564,34 +3987,6 @@ async function writeItemsToTempDir(items) {
3564
3987
  });
3565
3988
  return { files, issueDetailsByFile };
3566
3989
  }
3567
- async function closeCompletedSpecIssues(taskFiles, results, cwd, source, org, project, workItemType) {
3568
- let datasourceName = source;
3569
- if (!datasourceName) {
3570
- datasourceName = await detectDatasource(cwd) ?? void 0;
3571
- }
3572
- if (!datasourceName) return;
3573
- const datasource4 = getDatasource(datasourceName);
3574
- const succeededTasks = new Set(
3575
- results.filter((r) => r.success).map((r) => r.task)
3576
- );
3577
- const fetchOpts = { cwd, org, project, workItemType };
3578
- for (const taskFile of taskFiles) {
3579
- const fileTasks = taskFile.tasks;
3580
- if (fileTasks.length === 0) continue;
3581
- const allSucceeded = fileTasks.every((t) => succeededTasks.has(t));
3582
- if (!allSucceeded) continue;
3583
- const parsed = parseIssueFilename(taskFile.path);
3584
- if (!parsed) continue;
3585
- const { issueId } = parsed;
3586
- const filename = basename2(taskFile.path);
3587
- try {
3588
- await datasource4.close(issueId, fetchOpts);
3589
- log.success(`Closed issue #${issueId} (all tasks in ${filename} completed)`);
3590
- } catch (err) {
3591
- log.warn(`Could not close issue #${issueId}: ${log.formatErrorChain(err)}`);
3592
- }
3593
- }
3594
- }
3595
3990
  async function getCommitSummaries(defaultBranch, cwd) {
3596
3991
  try {
3597
3992
  const { stdout } = await exec8(
@@ -3675,48 +4070,51 @@ async function buildPrTitle(issueTitle, defaultBranch, cwd) {
3675
4070
  }
3676
4071
  return `${commits[commits.length - 1]} (+${commits.length - 1} more)`;
3677
4072
  }
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;
4073
+ function buildFeaturePrTitle(featureBranchName, issues) {
4074
+ if (issues.length === 1) {
4075
+ return issues[0].title;
3688
4076
  }
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(() => {
4077
+ const issueRefs = issues.map((d) => `#${d.number}`).join(", ");
4078
+ return `feat: ${featureBranchName} (${issueRefs})`;
4079
+ }
4080
+ function buildFeaturePrBody(issues, tasks, results, datasourceName) {
4081
+ const sections = [];
4082
+ sections.push("## Issues\n");
4083
+ for (const issue of issues) {
4084
+ sections.push(`- #${issue.number}: ${issue.title}`);
4085
+ }
4086
+ sections.push("");
4087
+ const taskResults = new Map(results.map((r) => [r.task, r]));
4088
+ const completedTasks = tasks.filter((t) => taskResults.get(t)?.success);
4089
+ const failedTasks = tasks.filter((t) => {
4090
+ const r = taskResults.get(t);
4091
+ return r && !r.success;
3714
4092
  });
3715
- return p;
4093
+ if (completedTasks.length > 0 || failedTasks.length > 0) {
4094
+ sections.push("## Tasks\n");
4095
+ for (const task of completedTasks) {
4096
+ sections.push(`- [x] ${task.text}`);
4097
+ }
4098
+ for (const task of failedTasks) {
4099
+ sections.push(`- [ ] ${task.text}`);
4100
+ }
4101
+ sections.push("");
4102
+ }
4103
+ for (const issue of issues) {
4104
+ if (datasourceName === "github") {
4105
+ sections.push(`Closes #${issue.number}`);
4106
+ } else if (datasourceName === "azdevops") {
4107
+ sections.push(`Resolves AB#${issue.number}`);
4108
+ }
4109
+ }
4110
+ return sections.join("\n");
3716
4111
  }
3717
4112
 
3718
4113
  // src/orchestrator/dispatch-pipeline.ts
4114
+ init_timeout();
3719
4115
  import chalk7 from "chalk";
4116
+ init_file_logger();
4117
+ var exec9 = promisify9(execFile9);
3720
4118
  var DEFAULT_PLAN_TIMEOUT_MIN = 10;
3721
4119
  var DEFAULT_PLAN_RETRIES = 1;
3722
4120
  async function runDispatchPipeline(opts, cwd) {
@@ -3728,12 +4126,15 @@ async function runDispatchPipeline(opts, cwd) {
3728
4126
  noPlan,
3729
4127
  noBranch,
3730
4128
  noWorktree,
4129
+ feature,
3731
4130
  provider = "opencode",
3732
4131
  model,
3733
4132
  source,
3734
4133
  org,
3735
4134
  project,
3736
4135
  workItemType,
4136
+ iteration,
4137
+ area,
3737
4138
  planTimeout,
3738
4139
  planRetries,
3739
4140
  retries
@@ -3742,7 +4143,7 @@ async function runDispatchPipeline(opts, cwd) {
3742
4143
  const maxPlanAttempts = (planRetries ?? retries ?? DEFAULT_PLAN_RETRIES) + 1;
3743
4144
  log.debug(`Plan timeout: ${planTimeout ?? DEFAULT_PLAN_TIMEOUT_MIN}m (${planTimeoutMs}ms), max attempts: ${maxPlanAttempts}`);
3744
4145
  if (dryRun) {
3745
- return dryRunMode(issueIds, cwd, source, org, project, workItemType);
4146
+ return dryRunMode(issueIds, cwd, source, org, project, workItemType, iteration, area);
3746
4147
  }
3747
4148
  const verbose = log.verbose;
3748
4149
  let tui;
@@ -3778,7 +4179,7 @@ async function runDispatchPipeline(opts, cwd) {
3778
4179
  return { total: 0, completed: 0, failed: 0, skipped: 0, results: [] };
3779
4180
  }
3780
4181
  const datasource4 = getDatasource(source);
3781
- const fetchOpts = { cwd, org, project, workItemType };
4182
+ const fetchOpts = { cwd, org, project, workItemType, iteration, area };
3782
4183
  const items = issueIds.length > 0 ? await fetchItemsById(issueIds, datasource4, fetchOpts) : await datasource4.list(fetchOpts);
3783
4184
  if (items.length === 0) {
3784
4185
  tui.state.phase = "done";
@@ -3820,7 +4221,7 @@ async function runDispatchPipeline(opts, cwd) {
3820
4221
  list.push(task);
3821
4222
  tasksByFile.set(task.file, list);
3822
4223
  }
3823
- const useWorktrees = !noWorktree && !noBranch && tasksByFile.size > 1;
4224
+ const useWorktrees = !noWorktree && (feature || !noBranch && tasksByFile.size > 1);
3824
4225
  tui.state.phase = "booting";
3825
4226
  if (verbose) log.info(`Booting ${provider} provider...`);
3826
4227
  if (serverUrl) {
@@ -3848,6 +4249,30 @@ async function runDispatchPipeline(opts, cwd) {
3848
4249
  let completed = 0;
3849
4250
  let failed = 0;
3850
4251
  const lifecycleOpts = { cwd };
4252
+ let featureBranchName;
4253
+ let featureDefaultBranch;
4254
+ if (feature) {
4255
+ try {
4256
+ featureDefaultBranch = await datasource4.getDefaultBranch(lifecycleOpts);
4257
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4258
+ featureBranchName = generateFeatureBranchName();
4259
+ await datasource4.createAndSwitchBranch(featureBranchName, lifecycleOpts);
4260
+ log.debug(`Created feature branch ${featureBranchName} from ${featureDefaultBranch}`);
4261
+ registerCleanup(async () => {
4262
+ try {
4263
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4264
+ } catch {
4265
+ }
4266
+ });
4267
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4268
+ log.debug(`Switched back to ${featureDefaultBranch} for worktree creation`);
4269
+ } catch (err) {
4270
+ log.error(`Feature branch creation failed: ${log.extractMessage(err)}`);
4271
+ tui.state.phase = "done";
4272
+ tui.stop();
4273
+ return { total: allTasks.length, completed: 0, failed: allTasks.length, skipped: 0, results: [] };
4274
+ }
4275
+ }
3851
4276
  let username = "";
3852
4277
  try {
3853
4278
  username = await datasource4.getUsername(lifecycleOpts);
@@ -3856,275 +4281,373 @@ async function runDispatchPipeline(opts, cwd) {
3856
4281
  }
3857
4282
  const processIssueFile = async (file, fileTasks) => {
3858
4283
  const details = issueDetailsByFile.get(file);
3859
- let defaultBranch;
3860
- let branchName;
3861
- let worktreePath;
3862
- let issueCwd = cwd;
3863
- if (!noBranch && details) {
3864
- try {
3865
- defaultBranch = await datasource4.getDefaultBranch(lifecycleOpts);
3866
- branchName = datasource4.buildBranchName(details.number, details.title, username);
3867
- if (useWorktrees) {
3868
- worktreePath = await createWorktree(cwd, file, branchName);
3869
- registerCleanup(async () => {
3870
- await removeWorktree(cwd, file);
3871
- });
3872
- issueCwd = worktreePath;
3873
- log.debug(`Created worktree for issue #${details.number} at ${worktreePath}`);
3874
- const wtName = worktreeName(file);
4284
+ const fileLogger = verbose && details ? new FileLogger(details.number, cwd) : null;
4285
+ const body = async () => {
4286
+ let defaultBranch;
4287
+ let branchName;
4288
+ let worktreePath;
4289
+ let issueCwd = cwd;
4290
+ if (!noBranch && details) {
4291
+ fileLogger?.phase("Branch/worktree setup");
4292
+ try {
4293
+ defaultBranch = feature ? featureBranchName : await datasource4.getDefaultBranch(lifecycleOpts);
4294
+ branchName = datasource4.buildBranchName(details.number, details.title, username);
4295
+ if (useWorktrees) {
4296
+ worktreePath = await createWorktree(cwd, file, branchName, ...feature && featureBranchName ? [featureBranchName] : []);
4297
+ registerCleanup(async () => {
4298
+ await removeWorktree(cwd, file);
4299
+ });
4300
+ issueCwd = worktreePath;
4301
+ log.debug(`Created worktree for issue #${details.number} at ${worktreePath}`);
4302
+ fileLogger?.info(`Worktree created at ${worktreePath}`);
4303
+ const wtName = worktreeName(file);
4304
+ for (const task of fileTasks) {
4305
+ const tuiTask = tui.state.tasks.find((t) => t.task === task);
4306
+ if (tuiTask) tuiTask.worktree = wtName;
4307
+ }
4308
+ } else if (datasource4.supportsGit()) {
4309
+ await datasource4.createAndSwitchBranch(branchName, lifecycleOpts);
4310
+ log.debug(`Switched to branch ${branchName}`);
4311
+ fileLogger?.info(`Switched to branch ${branchName}`);
4312
+ }
4313
+ } catch (err) {
4314
+ const errorMsg = `Branch creation failed for issue #${details.number}: ${log.extractMessage(err)}`;
4315
+ fileLogger?.error(`Branch creation failed: ${log.extractMessage(err)}${err instanceof Error && err.stack ? `
4316
+ ${err.stack}` : ""}`);
4317
+ log.error(errorMsg);
3875
4318
  for (const task of fileTasks) {
3876
4319
  const tuiTask = tui.state.tasks.find((t) => t.task === task);
3877
- if (tuiTask) tuiTask.worktree = wtName;
3878
- }
3879
- } else {
3880
- await datasource4.createAndSwitchBranch(branchName, lifecycleOpts);
3881
- log.debug(`Switched to branch ${branchName}`);
3882
- }
3883
- } catch (err) {
3884
- const errorMsg = `Branch creation failed for issue #${details.number}: ${log.extractMessage(err)}`;
3885
- log.error(errorMsg);
3886
- for (const task of fileTasks) {
3887
- const tuiTask = tui.state.tasks.find((t) => t.task === task);
3888
- if (tuiTask) {
3889
- tuiTask.status = "failed";
3890
- tuiTask.error = errorMsg;
4320
+ if (tuiTask) {
4321
+ tuiTask.status = "failed";
4322
+ tuiTask.error = errorMsg;
4323
+ }
4324
+ results.push({ task, success: false, error: errorMsg });
3891
4325
  }
3892
- results.push({ task, success: false, error: errorMsg });
4326
+ failed += fileTasks.length;
4327
+ return;
3893
4328
  }
3894
- failed += fileTasks.length;
3895
- return;
3896
4329
  }
3897
- }
3898
- const issueLifecycleOpts = { cwd: issueCwd };
3899
- let localInstance;
3900
- let localPlanner;
3901
- let localExecutor;
3902
- let localCommitAgent;
3903
- if (useWorktrees) {
3904
- localInstance = await bootProvider(provider, { url: serverUrl, cwd: issueCwd, model });
3905
- registerCleanup(() => localInstance.cleanup());
3906
- if (localInstance.model && !tui.state.model) {
3907
- tui.state.model = localInstance.model;
4330
+ const worktreeRoot = useWorktrees ? worktreePath : void 0;
4331
+ const issueLifecycleOpts = { cwd: issueCwd };
4332
+ fileLogger?.phase("Provider/agent boot");
4333
+ let localInstance;
4334
+ let localPlanner;
4335
+ let localExecutor;
4336
+ let localCommitAgent;
4337
+ if (useWorktrees) {
4338
+ localInstance = await bootProvider(provider, { url: serverUrl, cwd: issueCwd, model });
4339
+ registerCleanup(() => localInstance.cleanup());
4340
+ if (localInstance.model && !tui.state.model) {
4341
+ tui.state.model = localInstance.model;
4342
+ }
4343
+ if (verbose && localInstance.model) log.debug(`Model: ${localInstance.model}`);
4344
+ localPlanner = noPlan ? null : await boot6({ provider: localInstance, cwd: issueCwd });
4345
+ localExecutor = await boot7({ provider: localInstance, cwd: issueCwd });
4346
+ localCommitAgent = await boot8({ provider: localInstance, cwd: issueCwd });
4347
+ fileLogger?.info(`Provider booted: ${localInstance.model ?? provider}`);
4348
+ } else {
4349
+ localInstance = instance;
4350
+ localPlanner = planner;
4351
+ localExecutor = executor;
4352
+ localCommitAgent = commitAgent;
3908
4353
  }
3909
- if (verbose && localInstance.model) log.debug(`Model: ${localInstance.model}`);
3910
- localPlanner = noPlan ? null : await boot6({ provider: localInstance, cwd: issueCwd });
3911
- localExecutor = await boot7({ provider: localInstance, cwd: issueCwd });
3912
- localCommitAgent = await boot8({ provider: localInstance, cwd: issueCwd });
3913
- } else {
3914
- localInstance = instance;
3915
- localPlanner = planner;
3916
- localExecutor = executor;
3917
- localCommitAgent = commitAgent;
3918
- }
3919
- const groups = groupTasksByMode(fileTasks);
3920
- const issueResults = [];
3921
- for (const group of groups) {
3922
- const groupQueue = [...group];
3923
- while (groupQueue.length > 0) {
3924
- const batch = groupQueue.splice(0, concurrency);
3925
- const batchResults = await Promise.all(
3926
- batch.map(async (task) => {
3927
- const tuiTask = tui.state.tasks.find((t) => t.task === task);
3928
- const startTime = Date.now();
3929
- tuiTask.elapsed = startTime;
3930
- let plan;
3931
- if (localPlanner) {
3932
- tuiTask.status = "planning";
3933
- if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: planning \u2014 "${task.text}"`);
3934
- const rawContent = fileContentMap.get(task.file);
3935
- const fileContext = rawContent ? buildTaskContext(rawContent, task) : void 0;
3936
- let planResult;
3937
- for (let attempt = 1; attempt <= maxPlanAttempts; attempt++) {
3938
- try {
3939
- planResult = await withTimeout(
3940
- localPlanner.plan(task, fileContext, issueCwd),
3941
- planTimeoutMs,
3942
- "planner.plan()"
3943
- );
3944
- break;
3945
- } catch (err) {
3946
- if (err instanceof TimeoutError) {
3947
- log.warn(
3948
- `Planning timed out for task "${task.text}" (attempt ${attempt}/${maxPlanAttempts})`
4354
+ const groups = groupTasksByMode(fileTasks);
4355
+ const issueResults = [];
4356
+ for (const group of groups) {
4357
+ const groupQueue = [...group];
4358
+ while (groupQueue.length > 0) {
4359
+ const batch = groupQueue.splice(0, concurrency);
4360
+ const batchResults = await Promise.all(
4361
+ batch.map(async (task) => {
4362
+ const tuiTask = tui.state.tasks.find((t) => t.task === task);
4363
+ const startTime = Date.now();
4364
+ tuiTask.elapsed = startTime;
4365
+ let plan;
4366
+ if (localPlanner) {
4367
+ tuiTask.status = "planning";
4368
+ fileLogger?.phase(`Planning task: ${task.text}`);
4369
+ if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: planning \u2014 "${task.text}"`);
4370
+ const rawContent = fileContentMap.get(task.file);
4371
+ const fileContext = rawContent ? buildTaskContext(rawContent, task) : void 0;
4372
+ let planResult;
4373
+ for (let attempt = 1; attempt <= maxPlanAttempts; attempt++) {
4374
+ try {
4375
+ planResult = await withTimeout(
4376
+ localPlanner.plan(task, fileContext, issueCwd, worktreeRoot),
4377
+ planTimeoutMs,
4378
+ "planner.plan()"
3949
4379
  );
3950
- if (attempt < maxPlanAttempts) {
3951
- log.debug(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
3952
- }
3953
- } else {
3954
- planResult = {
3955
- prompt: "",
3956
- success: false,
3957
- error: log.extractMessage(err)
3958
- };
3959
4380
  break;
4381
+ } catch (err) {
4382
+ if (err instanceof TimeoutError) {
4383
+ log.warn(
4384
+ `Planning timed out for task "${task.text}" (attempt ${attempt}/${maxPlanAttempts})`
4385
+ );
4386
+ fileLogger?.warn(`Planning timeout (attempt ${attempt}/${maxPlanAttempts})`);
4387
+ if (attempt < maxPlanAttempts) {
4388
+ log.debug(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
4389
+ fileLogger?.info(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
4390
+ }
4391
+ } else {
4392
+ planResult = {
4393
+ data: null,
4394
+ success: false,
4395
+ error: log.extractMessage(err),
4396
+ durationMs: 0
4397
+ };
4398
+ break;
4399
+ }
3960
4400
  }
3961
4401
  }
4402
+ if (!planResult) {
4403
+ const timeoutMin = planTimeout ?? 10;
4404
+ planResult = {
4405
+ data: null,
4406
+ success: false,
4407
+ error: `Planning timed out after ${timeoutMin}m (${maxPlanAttempts} attempts)`,
4408
+ durationMs: 0
4409
+ };
4410
+ }
4411
+ if (!planResult.success) {
4412
+ tuiTask.status = "failed";
4413
+ tuiTask.error = `Planning failed: ${planResult.error}`;
4414
+ fileLogger?.error(`Planning failed: ${planResult.error}`);
4415
+ tuiTask.elapsed = Date.now() - startTime;
4416
+ if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 ${tuiTask.error} (${elapsed(tuiTask.elapsed)})`);
4417
+ failed++;
4418
+ return { task, success: false, error: tuiTask.error };
4419
+ }
4420
+ plan = planResult.data.prompt;
4421
+ fileLogger?.info(`Planning completed (${planResult.durationMs ?? 0}ms)`);
3962
4422
  }
3963
- if (!planResult) {
3964
- const timeoutMin = planTimeout ?? 10;
3965
- planResult = {
3966
- prompt: "",
3967
- success: false,
3968
- error: `Planning timed out after ${timeoutMin}m (${maxPlanAttempts} attempts)`
3969
- };
3970
- }
3971
- if (!planResult.success) {
4423
+ tuiTask.status = "running";
4424
+ fileLogger?.phase(`Executing task: ${task.text}`);
4425
+ if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: executing \u2014 "${task.text}"`);
4426
+ const execRetries = 2;
4427
+ const execResult = await withRetry(
4428
+ async () => {
4429
+ const result = await localExecutor.execute({
4430
+ task,
4431
+ cwd: issueCwd,
4432
+ plan: plan ?? null,
4433
+ worktreeRoot
4434
+ });
4435
+ if (!result.success) {
4436
+ throw new Error(result.error ?? "Execution failed");
4437
+ }
4438
+ return result;
4439
+ },
4440
+ execRetries,
4441
+ { label: `executor "${task.text}"` }
4442
+ ).catch((err) => ({
4443
+ data: null,
4444
+ success: false,
4445
+ error: log.extractMessage(err),
4446
+ durationMs: 0
4447
+ }));
4448
+ if (execResult.success) {
4449
+ fileLogger?.info(`Execution completed successfully (${Date.now() - startTime}ms)`);
4450
+ try {
4451
+ const parsed = parseIssueFilename(task.file);
4452
+ if (parsed) {
4453
+ const updatedContent = await readFile7(task.file, "utf-8");
4454
+ const issueDetails = issueDetailsByFile.get(task.file);
4455
+ const title = issueDetails?.title ?? parsed.slug;
4456
+ await datasource4.update(parsed.issueId, title, updatedContent, fetchOpts);
4457
+ log.success(`Synced task completion to issue #${parsed.issueId}`);
4458
+ }
4459
+ } catch (err) {
4460
+ log.warn(`Could not sync task completion to datasource: ${log.formatErrorChain(err)}`);
4461
+ }
4462
+ tuiTask.status = "done";
4463
+ tuiTask.elapsed = Date.now() - startTime;
4464
+ if (verbose) log.success(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: done \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})`);
4465
+ completed++;
4466
+ } else {
4467
+ fileLogger?.error(`Execution failed: ${execResult.error}`);
3972
4468
  tuiTask.status = "failed";
3973
- tuiTask.error = `Planning failed: ${planResult.error}`;
4469
+ tuiTask.error = execResult.error;
3974
4470
  tuiTask.elapsed = Date.now() - startTime;
3975
- if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 ${tuiTask.error} (${elapsed(tuiTask.elapsed)})`);
4471
+ if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})${tuiTask.error ? `: ${tuiTask.error}` : ""}`);
3976
4472
  failed++;
3977
- return { task, success: false, error: tuiTask.error };
3978
4473
  }
3979
- plan = planResult.prompt;
3980
- }
3981
- tuiTask.status = "running";
3982
- if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: executing \u2014 "${task.text}"`);
3983
- const execRetries = 2;
3984
- const execResult = await withRetry(
3985
- async () => {
3986
- const result = await localExecutor.execute({
3987
- task,
3988
- cwd: issueCwd,
3989
- plan: plan ?? null
3990
- });
3991
- if (!result.success) {
3992
- throw new Error(result.error ?? "Execution failed");
3993
- }
3994
- return result;
3995
- },
3996
- execRetries,
3997
- { label: `executor "${task.text}"` }
3998
- ).catch((err) => ({
3999
- dispatchResult: { task, success: false, error: log.extractMessage(err) },
4000
- success: false,
4001
- error: log.extractMessage(err),
4002
- elapsedMs: 0
4003
- }));
4004
- if (execResult.success) {
4474
+ const dispatchResult = execResult.success ? execResult.data.dispatchResult : {
4475
+ task,
4476
+ success: false,
4477
+ error: execResult.error ?? "Executor failed without returning a dispatch result."
4478
+ };
4479
+ return dispatchResult;
4480
+ })
4481
+ );
4482
+ issueResults.push(...batchResults);
4483
+ if (!tui.state.model && localInstance.model) {
4484
+ tui.state.model = localInstance.model;
4485
+ }
4486
+ }
4487
+ }
4488
+ results.push(...issueResults);
4489
+ if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
4490
+ try {
4491
+ await datasource4.commitAllChanges(
4492
+ `chore: stage uncommitted changes for issue #${details.number}`,
4493
+ issueLifecycleOpts
4494
+ );
4495
+ log.debug(`Staged uncommitted changes for issue #${details.number}`);
4496
+ } catch (err) {
4497
+ log.warn(`Could not commit uncommitted changes for issue #${details.number}: ${log.formatErrorChain(err)}`);
4498
+ }
4499
+ }
4500
+ fileLogger?.phase("Commit generation");
4501
+ let commitAgentResult;
4502
+ if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
4503
+ try {
4504
+ const branchDiff = await getBranchDiff(defaultBranch, issueCwd);
4505
+ if (branchDiff) {
4506
+ const result = await localCommitAgent.generate({
4507
+ branchDiff,
4508
+ issue: details,
4509
+ taskResults: issueResults,
4510
+ cwd: issueCwd,
4511
+ worktreeRoot
4512
+ });
4513
+ if (result.success) {
4514
+ commitAgentResult = result;
4515
+ fileLogger?.info(`Commit message generated for issue #${details.number}`);
4005
4516
  try {
4006
- const parsed = parseIssueFilename(task.file);
4007
- if (parsed) {
4008
- const updatedContent = await readFile7(task.file, "utf-8");
4009
- const issueDetails = issueDetailsByFile.get(task.file);
4010
- const title = issueDetails?.title ?? parsed.slug;
4011
- await datasource4.update(parsed.issueId, title, updatedContent, fetchOpts);
4012
- log.success(`Synced task completion to issue #${parsed.issueId}`);
4013
- }
4517
+ await squashBranchCommits(defaultBranch, result.commitMessage, issueCwd);
4518
+ log.debug(`Rewrote commit message for issue #${details.number}`);
4519
+ fileLogger?.info(`Rewrote commit history for issue #${details.number}`);
4014
4520
  } catch (err) {
4015
- log.warn(`Could not sync task completion to datasource: ${log.formatErrorChain(err)}`);
4521
+ log.warn(`Could not rewrite commit message for issue #${details.number}: ${log.formatErrorChain(err)}`);
4016
4522
  }
4017
- tuiTask.status = "done";
4018
- tuiTask.elapsed = Date.now() - startTime;
4019
- if (verbose) log.success(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: done \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})`);
4020
- completed++;
4021
4523
  } else {
4022
- tuiTask.status = "failed";
4023
- tuiTask.error = execResult.error;
4024
- tuiTask.elapsed = Date.now() - startTime;
4025
- if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})${tuiTask.error ? `: ${tuiTask.error}` : ""}`);
4026
- failed++;
4524
+ log.warn(`Commit agent failed for issue #${details.number}: ${result.error}`);
4525
+ fileLogger?.warn(`Commit agent failed: ${result.error}`);
4027
4526
  }
4028
- return execResult.dispatchResult;
4029
- })
4030
- );
4031
- issueResults.push(...batchResults);
4032
- if (!tui.state.model && localInstance.model) {
4033
- tui.state.model = localInstance.model;
4527
+ }
4528
+ } catch (err) {
4529
+ log.warn(`Commit agent error for issue #${details.number}: ${log.formatErrorChain(err)}`);
4034
4530
  }
4035
4531
  }
4036
- }
4037
- results.push(...issueResults);
4038
- if (!noBranch && branchName && defaultBranch && details) {
4039
- try {
4040
- await datasource4.commitAllChanges(
4041
- `chore: stage uncommitted changes for issue #${details.number}`,
4042
- issueLifecycleOpts
4043
- );
4044
- log.debug(`Staged uncommitted changes for issue #${details.number}`);
4045
- } catch (err) {
4046
- log.warn(`Could not commit uncommitted changes for issue #${details.number}: ${log.formatErrorChain(err)}`);
4047
- }
4048
- }
4049
- let commitAgentResult;
4050
- if (!noBranch && branchName && defaultBranch && details) {
4051
- try {
4052
- const branchDiff = await getBranchDiff(defaultBranch, issueCwd);
4053
- if (branchDiff) {
4054
- const result = await localCommitAgent.generate({
4055
- branchDiff,
4056
- issue: details,
4057
- taskResults: issueResults,
4058
- cwd: issueCwd
4059
- });
4060
- if (result.success) {
4061
- commitAgentResult = result;
4532
+ fileLogger?.phase("PR lifecycle");
4533
+ if (!noBranch && branchName && defaultBranch && details) {
4534
+ if (feature && featureBranchName) {
4535
+ if (worktreePath) {
4062
4536
  try {
4063
- await squashBranchCommits(defaultBranch, result.commitMessage, issueCwd);
4064
- log.debug(`Rewrote commit message for issue #${details.number}`);
4537
+ await removeWorktree(cwd, file);
4065
4538
  } catch (err) {
4066
- log.warn(`Could not rewrite commit message for issue #${details.number}: ${log.formatErrorChain(err)}`);
4539
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
4540
+ }
4541
+ }
4542
+ try {
4543
+ await datasource4.switchBranch(featureBranchName, lifecycleOpts);
4544
+ await exec9("git", ["merge", branchName, "--no-ff", "-m", `merge: issue #${details.number}`], { cwd });
4545
+ log.debug(`Merged ${branchName} into ${featureBranchName}`);
4546
+ } catch (err) {
4547
+ const mergeError = `Could not merge ${branchName} into feature branch: ${log.formatErrorChain(err)}`;
4548
+ log.warn(mergeError);
4549
+ try {
4550
+ await exec9("git", ["merge", "--abort"], { cwd });
4551
+ } catch {
4552
+ }
4553
+ for (const task of fileTasks) {
4554
+ const tuiTask = tui.state.tasks.find((t) => t.task === task);
4555
+ if (tuiTask) {
4556
+ tuiTask.status = "failed";
4557
+ tuiTask.error = mergeError;
4558
+ }
4559
+ const existingResult = results.find((r) => r.task === task);
4560
+ if (existingResult) {
4561
+ existingResult.success = false;
4562
+ existingResult.error = mergeError;
4563
+ }
4564
+ }
4565
+ return;
4566
+ }
4567
+ try {
4568
+ await exec9("git", ["branch", "-d", branchName], { cwd });
4569
+ log.debug(`Deleted local branch ${branchName}`);
4570
+ } catch (err) {
4571
+ log.warn(`Could not delete local branch ${branchName}: ${log.formatErrorChain(err)}`);
4572
+ }
4573
+ try {
4574
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4575
+ } catch (err) {
4576
+ log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
4577
+ }
4578
+ } else {
4579
+ if (datasource4.supportsGit()) {
4580
+ try {
4581
+ await datasource4.pushBranch(branchName, issueLifecycleOpts);
4582
+ log.debug(`Pushed branch ${branchName}`);
4583
+ fileLogger?.info(`Pushed branch ${branchName}`);
4584
+ } catch (err) {
4585
+ log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
4586
+ }
4587
+ }
4588
+ if (datasource4.supportsGit()) {
4589
+ try {
4590
+ const prTitle = commitAgentResult?.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
4591
+ const prBody = commitAgentResult?.prDescription || await buildPrBody(
4592
+ details,
4593
+ fileTasks,
4594
+ issueResults,
4595
+ defaultBranch,
4596
+ datasource4.name,
4597
+ issueLifecycleOpts.cwd
4598
+ );
4599
+ const prUrl = await datasource4.createPullRequest(
4600
+ branchName,
4601
+ details.number,
4602
+ prTitle,
4603
+ prBody,
4604
+ issueLifecycleOpts
4605
+ );
4606
+ if (prUrl) {
4607
+ log.success(`Created PR for issue #${details.number}: ${prUrl}`);
4608
+ fileLogger?.info(`Created PR: ${prUrl}`);
4609
+ }
4610
+ } catch (err) {
4611
+ log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
4612
+ fileLogger?.warn(`PR creation failed: ${log.extractMessage(err)}`);
4613
+ }
4614
+ }
4615
+ if (useWorktrees && worktreePath) {
4616
+ try {
4617
+ await removeWorktree(cwd, file);
4618
+ } catch (err) {
4619
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
4620
+ }
4621
+ } else if (!useWorktrees && datasource4.supportsGit()) {
4622
+ try {
4623
+ await datasource4.switchBranch(defaultBranch, lifecycleOpts);
4624
+ log.debug(`Switched back to ${defaultBranch}`);
4625
+ } catch (err) {
4626
+ log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
4067
4627
  }
4068
- } else {
4069
- log.warn(`Commit agent failed for issue #${details.number}: ${result.error}`);
4070
4628
  }
4071
4629
  }
4072
- } catch (err) {
4073
- log.warn(`Commit agent error for issue #${details.number}: ${log.formatErrorChain(err)}`);
4074
- }
4075
- }
4076
- 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
4630
  }
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}`);
4102
- }
4103
- } catch (err) {
4104
- log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
4631
+ fileLogger?.phase("Resource cleanup");
4632
+ if (useWorktrees) {
4633
+ await localExecutor.cleanup();
4634
+ await localPlanner?.cleanup();
4635
+ await localInstance.cleanup();
4105
4636
  }
4106
- if (useWorktrees && worktreePath) {
4107
- try {
4108
- await removeWorktree(cwd, file);
4109
- } catch (err) {
4110
- log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
4111
- }
4112
- } else if (!useWorktrees) {
4637
+ };
4638
+ if (fileLogger) {
4639
+ await fileLoggerStorage.run(fileLogger, async () => {
4113
4640
  try {
4114
- await datasource4.switchBranch(defaultBranch, lifecycleOpts);
4115
- log.debug(`Switched back to ${defaultBranch}`);
4116
- } catch (err) {
4117
- log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
4641
+ await body();
4642
+ } finally {
4643
+ fileLogger.close();
4118
4644
  }
4119
- }
4120
- }
4121
- if (useWorktrees) {
4122
- await localExecutor.cleanup();
4123
- await localPlanner?.cleanup();
4124
- await localInstance.cleanup();
4645
+ });
4646
+ } else {
4647
+ await body();
4125
4648
  }
4126
4649
  };
4127
- if (useWorktrees) {
4650
+ if (useWorktrees && !feature) {
4128
4651
  await Promise.all(
4129
4652
  Array.from(tasksByFile).map(
4130
4653
  ([file, fileTasks]) => processIssueFile(file, fileTasks)
@@ -4135,10 +4658,42 @@ async function runDispatchPipeline(opts, cwd) {
4135
4658
  await processIssueFile(file, fileTasks);
4136
4659
  }
4137
4660
  }
4138
- try {
4139
- await closeCompletedSpecIssues(taskFiles, results, cwd, source, org, project, workItemType);
4140
- } catch (err) {
4141
- log.warn(`Could not close completed spec issues: ${log.formatErrorChain(err)}`);
4661
+ if (feature && featureBranchName && featureDefaultBranch) {
4662
+ try {
4663
+ await datasource4.switchBranch(featureBranchName, lifecycleOpts);
4664
+ log.debug(`Switched to feature branch ${featureBranchName}`);
4665
+ } catch (err) {
4666
+ log.warn(`Could not switch to feature branch: ${log.formatErrorChain(err)}`);
4667
+ }
4668
+ try {
4669
+ await datasource4.pushBranch(featureBranchName, lifecycleOpts);
4670
+ log.debug(`Pushed feature branch ${featureBranchName}`);
4671
+ } catch (err) {
4672
+ log.warn(`Could not push feature branch: ${log.formatErrorChain(err)}`);
4673
+ }
4674
+ try {
4675
+ const allIssueDetails = Array.from(issueDetailsByFile.values());
4676
+ const prTitle = buildFeaturePrTitle(featureBranchName, allIssueDetails);
4677
+ const prBody = buildFeaturePrBody(allIssueDetails, allTasks, results, source);
4678
+ const primaryIssue = allIssueDetails[0]?.number ?? "";
4679
+ const prUrl = await datasource4.createPullRequest(
4680
+ featureBranchName,
4681
+ primaryIssue,
4682
+ prTitle,
4683
+ prBody,
4684
+ lifecycleOpts
4685
+ );
4686
+ if (prUrl) {
4687
+ log.success(`Created feature PR: ${prUrl}`);
4688
+ }
4689
+ } catch (err) {
4690
+ log.warn(`Could not create feature PR: ${log.formatErrorChain(err)}`);
4691
+ }
4692
+ try {
4693
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
4694
+ } catch (err) {
4695
+ log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
4696
+ }
4142
4697
  }
4143
4698
  await commitAgent?.cleanup();
4144
4699
  await executor?.cleanup();
@@ -4153,13 +4708,13 @@ async function runDispatchPipeline(opts, cwd) {
4153
4708
  throw err;
4154
4709
  }
4155
4710
  }
4156
- async function dryRunMode(issueIds, cwd, source, org, project, workItemType) {
4711
+ async function dryRunMode(issueIds, cwd, source, org, project, workItemType, iteration, area) {
4157
4712
  if (!source) {
4158
4713
  log.error("No datasource configured. Use --source or run 'dispatch config' to set up defaults.");
4159
4714
  return { total: 0, completed: 0, failed: 0, skipped: 0, results: [] };
4160
4715
  }
4161
4716
  const datasource4 = getDatasource(source);
4162
- const fetchOpts = { cwd, org, project, workItemType };
4717
+ const fetchOpts = { cwd, org, project, workItemType, iteration, area };
4163
4718
  const lifecycleOpts = { cwd };
4164
4719
  let username = "";
4165
4720
  try {
@@ -4233,12 +4788,17 @@ async function boot9(opts) {
4233
4788
  const modeFlags = [
4234
4789
  m.spec !== void 0 && "--spec",
4235
4790
  m.respec !== void 0 && "--respec",
4236
- m.fixTests && "--fix-tests"
4791
+ m.fixTests && "--fix-tests",
4792
+ m.feature && "--feature"
4237
4793
  ].filter(Boolean);
4238
4794
  if (modeFlags.length > 1) {
4239
4795
  log.error(`${modeFlags.join(" and ")} are mutually exclusive`);
4240
4796
  process.exit(1);
4241
4797
  }
4798
+ if (m.feature && m.noBranch) {
4799
+ log.error("--feature and --no-branch are mutually exclusive");
4800
+ process.exit(1);
4801
+ }
4242
4802
  if (m.fixTests && m.issueIds.length > 0) {
4243
4803
  log.error("--fix-tests cannot be combined with issue IDs");
4244
4804
  process.exit(1);
@@ -4259,6 +4819,8 @@ async function boot9(opts) {
4259
4819
  org: m.org,
4260
4820
  project: m.project,
4261
4821
  workItemType: m.workItemType,
4822
+ iteration: m.iteration,
4823
+ area: m.area,
4262
4824
  concurrency: m.concurrency,
4263
4825
  dryRun: m.dryRun
4264
4826
  });
@@ -4273,7 +4835,7 @@ async function boot9(opts) {
4273
4835
  process.exit(1);
4274
4836
  }
4275
4837
  const datasource4 = getDatasource(source);
4276
- const existing = await datasource4.list({ cwd: m.cwd, org: m.org, project: m.project, workItemType: m.workItemType });
4838
+ const existing = await datasource4.list({ cwd: m.cwd, org: m.org, project: m.project, workItemType: m.workItemType, iteration: m.iteration, area: m.area });
4277
4839
  if (existing.length === 0) {
4278
4840
  log.error("No existing specs found to regenerate");
4279
4841
  process.exit(1);
@@ -4299,6 +4861,8 @@ async function boot9(opts) {
4299
4861
  org: m.org,
4300
4862
  project: m.project,
4301
4863
  workItemType: m.workItemType,
4864
+ iteration: m.iteration,
4865
+ area: m.area,
4302
4866
  concurrency: m.concurrency,
4303
4867
  dryRun: m.dryRun
4304
4868
  });
@@ -4317,10 +4881,13 @@ async function boot9(opts) {
4317
4881
  org: m.org,
4318
4882
  project: m.project,
4319
4883
  workItemType: m.workItemType,
4884
+ iteration: m.iteration,
4885
+ area: m.area,
4320
4886
  planTimeout: m.planTimeout,
4321
4887
  planRetries: m.planRetries,
4322
4888
  retries: m.retries,
4323
- force: m.force
4889
+ force: m.force,
4890
+ feature: m.feature
4324
4891
  });
4325
4892
  }
4326
4893
  };
@@ -4331,7 +4898,7 @@ async function boot9(opts) {
4331
4898
  init_logger();
4332
4899
  init_cleanup();
4333
4900
  init_providers();
4334
- var MAX_CONCURRENCY = 64;
4901
+ var MAX_CONCURRENCY = CONFIG_BOUNDS.concurrency.max;
4335
4902
  var HELP = `
4336
4903
  dispatch \u2014 AI agent orchestration CLI
4337
4904
 
@@ -4350,8 +4917,9 @@ var HELP = `
4350
4917
  --no-plan Skip the planner agent, dispatch directly
4351
4918
  --no-branch Skip branch creation, push, and PR lifecycle
4352
4919
  --no-worktree Skip git worktree isolation for parallel issues
4920
+ --feature Group issues into a single feature branch and PR
4353
4921
  --force Ignore prior run state and re-run all tasks
4354
- --concurrency <n> Max parallel dispatches (default: min(cpus, freeMB/500), max: 64)
4922
+ --concurrency <n> Max parallel dispatches (default: min(cpus, freeMB/500), max: ${MAX_CONCURRENCY})
4355
4923
  --provider <name> Agent backend: ${PROVIDER_NAMES.join(", ")} (default: opencode)
4356
4924
  --server-url <url> URL of a running provider server
4357
4925
  --plan-timeout <min> Planning timeout in minutes (default: 10)
@@ -4398,180 +4966,156 @@ var HELP = `
4398
4966
  dispatch config
4399
4967
  `.trimStart();
4400
4968
  function parseArgs(argv) {
4969
+ const program = new Command();
4970
+ program.exitOverride().configureOutput({
4971
+ writeOut: () => {
4972
+ },
4973
+ writeErr: () => {
4974
+ }
4975
+ }).helpOption(false).argument("[issueIds...]").option("-h, --help", "Show help").option("-v, --version", "Show version").option("--dry-run", "List tasks without dispatching").option("--no-plan", "Skip the planner agent").option("--no-branch", "Skip branch creation").option("--no-worktree", "Skip git worktree isolation").option("--feature", "Group issues into a single feature branch").option("--force", "Ignore prior run state").option("--verbose", "Show detailed debug output").option("--fix-tests", "Run tests and fix failures").option("--spec <values...>", "Spec mode: issue numbers, glob, or text").option("--respec [values...]", "Regenerate specs").addOption(
4976
+ new Option("--provider <name>", "Agent backend").choices(PROVIDER_NAMES)
4977
+ ).addOption(
4978
+ new Option("--source <name>", "Issue source").choices(
4979
+ DATASOURCE_NAMES
4980
+ )
4981
+ ).option(
4982
+ "--concurrency <n>",
4983
+ "Max parallel dispatches",
4984
+ (val) => {
4985
+ const n = parseInt(val, 10);
4986
+ if (isNaN(n) || n < 1) throw new CommanderError(1, "commander.invalidArgument", "--concurrency must be a positive integer");
4987
+ if (n > MAX_CONCURRENCY) throw new CommanderError(1, "commander.invalidArgument", `--concurrency must not exceed ${MAX_CONCURRENCY}`);
4988
+ return n;
4989
+ }
4990
+ ).option(
4991
+ "--plan-timeout <min>",
4992
+ "Planning timeout in minutes",
4993
+ (val) => {
4994
+ const n = parseFloat(val);
4995
+ if (isNaN(n) || n < CONFIG_BOUNDS.planTimeout.min) throw new CommanderError(1, "commander.invalidArgument", "--plan-timeout must be a positive number (minutes)");
4996
+ if (n > CONFIG_BOUNDS.planTimeout.max) throw new CommanderError(1, "commander.invalidArgument", `--plan-timeout must not exceed ${CONFIG_BOUNDS.planTimeout.max}`);
4997
+ return n;
4998
+ }
4999
+ ).option(
5000
+ "--retries <n>",
5001
+ "Retry attempts",
5002
+ (val) => {
5003
+ const n = parseInt(val, 10);
5004
+ if (isNaN(n) || n < 0) throw new CommanderError(1, "commander.invalidArgument", "--retries must be a non-negative integer");
5005
+ return n;
5006
+ }
5007
+ ).option(
5008
+ "--plan-retries <n>",
5009
+ "Planner retry attempts",
5010
+ (val) => {
5011
+ const n = parseInt(val, 10);
5012
+ if (isNaN(n) || n < 0) throw new CommanderError(1, "commander.invalidArgument", "--plan-retries must be a non-negative integer");
5013
+ return n;
5014
+ }
5015
+ ).option(
5016
+ "--test-timeout <min>",
5017
+ "Test timeout in minutes",
5018
+ (val) => {
5019
+ const n = parseFloat(val);
5020
+ if (isNaN(n) || n <= 0) throw new CommanderError(1, "commander.invalidArgument", "--test-timeout must be a positive number (minutes)");
5021
+ return n;
5022
+ }
5023
+ ).option("--cwd <dir>", "Working directory", (val) => resolve3(val)).option("--output-dir <dir>", "Output directory", (val) => resolve3(val)).option("--org <url>", "Azure DevOps organization URL").option("--project <name>", "Azure DevOps project name").option("--server-url <url>", "Provider server URL");
5024
+ try {
5025
+ program.parse(argv, { from: "user" });
5026
+ } catch (err) {
5027
+ if (err instanceof CommanderError) {
5028
+ log.error(err.message);
5029
+ process.exit(1);
5030
+ }
5031
+ throw err;
5032
+ }
5033
+ const opts = program.opts();
4401
5034
  const args = {
4402
- issueIds: [],
4403
- dryRun: false,
4404
- noPlan: false,
4405
- noBranch: false,
4406
- noWorktree: false,
4407
- force: false,
4408
- provider: "opencode",
4409
- cwd: process.cwd(),
4410
- help: false,
4411
- version: false,
4412
- verbose: false
5035
+ issueIds: program.args,
5036
+ dryRun: opts.dryRun ?? false,
5037
+ noPlan: !opts.plan,
5038
+ noBranch: !opts.branch,
5039
+ noWorktree: !opts.worktree,
5040
+ force: opts.force ?? false,
5041
+ provider: opts.provider ?? "opencode",
5042
+ cwd: opts.cwd ?? process.cwd(),
5043
+ help: opts.help ?? false,
5044
+ version: opts.version ?? false,
5045
+ verbose: opts.verbose ?? false
4413
5046
  };
4414
- const explicitFlags = /* @__PURE__ */ new Set();
4415
- let i = 0;
4416
- while (i < argv.length) {
4417
- const arg = argv[i];
4418
- if (arg === "--help" || arg === "-h") {
4419
- args.help = true;
4420
- explicitFlags.add("help");
4421
- } else if (arg === "--version" || arg === "-v") {
4422
- args.version = true;
4423
- explicitFlags.add("version");
4424
- } else if (arg === "--dry-run") {
4425
- args.dryRun = true;
4426
- explicitFlags.add("dryRun");
4427
- } else if (arg === "--no-plan") {
4428
- args.noPlan = true;
4429
- explicitFlags.add("noPlan");
4430
- } else if (arg === "--no-branch") {
4431
- args.noBranch = true;
4432
- explicitFlags.add("noBranch");
4433
- } else if (arg === "--no-worktree") {
4434
- args.noWorktree = true;
4435
- explicitFlags.add("noWorktree");
4436
- } else if (arg === "--force") {
4437
- args.force = true;
4438
- explicitFlags.add("force");
4439
- } else if (arg === "--verbose") {
4440
- args.verbose = true;
4441
- explicitFlags.add("verbose");
4442
- } else if (arg === "--spec") {
4443
- i++;
4444
- const specs = [];
4445
- while (i < argv.length && !argv[i].startsWith("--")) {
4446
- specs.push(argv[i]);
4447
- i++;
4448
- }
4449
- i--;
4450
- args.spec = specs.length === 1 ? specs[0] : specs;
4451
- explicitFlags.add("spec");
4452
- } else if (arg === "--respec") {
4453
- i++;
4454
- const respecs = [];
4455
- while (i < argv.length && !argv[i].startsWith("--")) {
4456
- respecs.push(argv[i]);
4457
- i++;
4458
- }
4459
- i--;
4460
- args.respec = respecs.length === 1 ? respecs[0] : respecs;
4461
- explicitFlags.add("respec");
4462
- } else if (arg === "--fix-tests") {
4463
- args.fixTests = true;
4464
- explicitFlags.add("fixTests");
4465
- } else if (arg === "--source") {
4466
- i++;
4467
- const val = argv[i];
4468
- if (!DATASOURCE_NAMES.includes(val)) {
4469
- log.error(
4470
- `Unknown source "${val}". Available: ${DATASOURCE_NAMES.join(", ")}`
4471
- );
4472
- process.exit(1);
4473
- }
4474
- args.issueSource = val;
4475
- explicitFlags.add("issueSource");
4476
- } else if (arg === "--org") {
4477
- i++;
4478
- args.org = argv[i];
4479
- explicitFlags.add("org");
4480
- } else if (arg === "--project") {
4481
- i++;
4482
- args.project = argv[i];
4483
- explicitFlags.add("project");
4484
- } else if (arg === "--output-dir") {
4485
- i++;
4486
- args.outputDir = resolve(argv[i]);
4487
- explicitFlags.add("outputDir");
4488
- } else if (arg === "--concurrency") {
4489
- i++;
4490
- const val = parseInt(argv[i], 10);
4491
- if (isNaN(val) || val < 1) {
4492
- log.error("--concurrency must be a positive integer");
4493
- process.exit(1);
4494
- }
4495
- if (val > MAX_CONCURRENCY) {
4496
- log.error(`--concurrency must not exceed ${MAX_CONCURRENCY}`);
4497
- process.exit(1);
4498
- }
4499
- args.concurrency = val;
4500
- explicitFlags.add("concurrency");
4501
- } else if (arg === "--provider") {
4502
- i++;
4503
- const val = argv[i];
4504
- if (!PROVIDER_NAMES.includes(val)) {
4505
- log.error(`Unknown provider "${val}". Available: ${PROVIDER_NAMES.join(", ")}`);
4506
- process.exit(1);
4507
- }
4508
- args.provider = val;
4509
- explicitFlags.add("provider");
4510
- } else if (arg === "--server-url") {
4511
- i++;
4512
- args.serverUrl = argv[i];
4513
- explicitFlags.add("serverUrl");
4514
- } else if (arg === "--plan-timeout") {
4515
- i++;
4516
- const val = parseFloat(argv[i]);
4517
- if (isNaN(val) || val <= 0) {
4518
- log.error("--plan-timeout must be a positive number (minutes)");
4519
- process.exit(1);
4520
- }
4521
- args.planTimeout = val;
4522
- explicitFlags.add("planTimeout");
4523
- } else if (arg === "--retries") {
4524
- i++;
4525
- const val = parseInt(argv[i], 10);
4526
- if (isNaN(val) || val < 0) {
4527
- log.error("--retries must be a non-negative integer");
4528
- process.exit(1);
4529
- }
4530
- args.retries = val;
4531
- explicitFlags.add("retries");
4532
- } else if (arg === "--plan-retries") {
4533
- i++;
4534
- const val = parseInt(argv[i], 10);
4535
- if (isNaN(val) || val < 0) {
4536
- log.error("--plan-retries must be a non-negative integer");
4537
- process.exit(1);
4538
- }
4539
- args.planRetries = val;
4540
- explicitFlags.add("planRetries");
4541
- } else if (arg === "--test-timeout") {
4542
- i++;
4543
- const val = parseFloat(argv[i]);
4544
- if (isNaN(val) || val <= 0) {
4545
- log.error("--test-timeout must be a positive number (minutes)");
4546
- process.exit(1);
4547
- }
4548
- args.testTimeout = val;
4549
- explicitFlags.add("testTimeout");
4550
- } else if (arg === "--cwd") {
4551
- i++;
4552
- args.cwd = resolve(argv[i]);
4553
- explicitFlags.add("cwd");
4554
- } else if (!arg.startsWith("-")) {
4555
- args.issueIds.push(arg);
5047
+ if (opts.spec !== void 0) {
5048
+ args.spec = opts.spec.length === 1 ? opts.spec[0] : opts.spec;
5049
+ }
5050
+ if (opts.respec !== void 0) {
5051
+ if (opts.respec === true) {
5052
+ args.respec = [];
4556
5053
  } else {
4557
- log.error(`Unknown option: ${arg}`);
4558
- process.exit(1);
5054
+ args.respec = opts.respec.length === 1 ? opts.respec[0] : opts.respec;
5055
+ }
5056
+ }
5057
+ if (opts.fixTests) args.fixTests = true;
5058
+ if (opts.feature) args.feature = true;
5059
+ if (opts.source !== void 0) args.issueSource = opts.source;
5060
+ if (opts.concurrency !== void 0) args.concurrency = opts.concurrency;
5061
+ if (opts.serverUrl !== void 0) args.serverUrl = opts.serverUrl;
5062
+ if (opts.planTimeout !== void 0) args.planTimeout = opts.planTimeout;
5063
+ if (opts.retries !== void 0) args.retries = opts.retries;
5064
+ if (opts.planRetries !== void 0) args.planRetries = opts.planRetries;
5065
+ if (opts.testTimeout !== void 0) args.testTimeout = opts.testTimeout;
5066
+ if (opts.org !== void 0) args.org = opts.org;
5067
+ if (opts.project !== void 0) args.project = opts.project;
5068
+ if (opts.outputDir !== void 0) args.outputDir = opts.outputDir;
5069
+ const explicitFlags = /* @__PURE__ */ new Set();
5070
+ const SOURCE_MAP = {
5071
+ help: "help",
5072
+ version: "version",
5073
+ dryRun: "dryRun",
5074
+ plan: "noPlan",
5075
+ branch: "noBranch",
5076
+ worktree: "noWorktree",
5077
+ force: "force",
5078
+ verbose: "verbose",
5079
+ spec: "spec",
5080
+ respec: "respec",
5081
+ fixTests: "fixTests",
5082
+ feature: "feature",
5083
+ source: "issueSource",
5084
+ provider: "provider",
5085
+ concurrency: "concurrency",
5086
+ serverUrl: "serverUrl",
5087
+ planTimeout: "planTimeout",
5088
+ retries: "retries",
5089
+ planRetries: "planRetries",
5090
+ testTimeout: "testTimeout",
5091
+ cwd: "cwd",
5092
+ org: "org",
5093
+ project: "project",
5094
+ outputDir: "outputDir"
5095
+ };
5096
+ for (const [attr, flag] of Object.entries(SOURCE_MAP)) {
5097
+ if (program.getOptionValueSource(attr) === "cli") {
5098
+ explicitFlags.add(flag);
4559
5099
  }
4560
- i++;
4561
5100
  }
4562
5101
  return [args, explicitFlags];
4563
5102
  }
4564
5103
  async function main() {
4565
5104
  const rawArgv = process.argv.slice(2);
4566
5105
  if (rawArgv[0] === "config") {
4567
- let cwd = process.cwd();
4568
- for (let i = 1; i < rawArgv.length; i++) {
4569
- if (rawArgv[i] === "--cwd" && i + 1 < rawArgv.length) {
4570
- cwd = resolve(rawArgv[i + 1]);
4571
- break;
5106
+ const configProgram = new Command("dispatch-config").exitOverride().configureOutput({ writeOut: () => {
5107
+ }, writeErr: () => {
5108
+ } }).helpOption(false).allowUnknownOption(true).allowExcessArguments(true).option("--cwd <dir>", "Working directory", (v) => resolve3(v));
5109
+ try {
5110
+ configProgram.parse(rawArgv.slice(1), { from: "user" });
5111
+ } catch (err) {
5112
+ if (err instanceof CommanderError) {
5113
+ log.error(err.message);
5114
+ process.exit(1);
4572
5115
  }
5116
+ throw err;
4573
5117
  }
4574
- const configDir = join11(cwd, ".dispatch");
5118
+ const configDir = join12(configProgram.opts().cwd ?? process.cwd(), ".dispatch");
4575
5119
  await handleConfigCommand(rawArgv.slice(1), configDir);
4576
5120
  process.exit(0);
4577
5121
  }
@@ -4592,7 +5136,7 @@ async function main() {
4592
5136
  process.exit(0);
4593
5137
  }
4594
5138
  if (args.version) {
4595
- console.log(`dispatch v${"0.0.1"}`);
5139
+ console.log(`dispatch v${"1.3.0"}`);
4596
5140
  process.exit(0);
4597
5141
  }
4598
5142
  const orchestrator = await boot9({ cwd: args.cwd });