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