@fermindi/pwn-cli 0.9.5 → 0.9.7
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/package.json +1 -1
- package/src/services/batch-runner.js +255 -194
- package/src/services/batch-service.js +260 -35
package/package.json
CHANGED
|
@@ -24,17 +24,23 @@ import {
|
|
|
24
24
|
runQualityGate,
|
|
25
25
|
loadConfig,
|
|
26
26
|
commitTask,
|
|
27
|
+
commitPrdUpdate,
|
|
27
28
|
updateBatchState,
|
|
28
29
|
clearBatchState,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
mergeBranch,
|
|
31
|
+
ensureMetadataIgnored,
|
|
32
|
+
createBatchRef,
|
|
33
|
+
createBatchWorktree,
|
|
34
|
+
createTaskWorktree,
|
|
35
|
+
addExistingBranchWorktree,
|
|
36
|
+
removeWorktree,
|
|
37
|
+
cleanupAllWorktrees,
|
|
38
|
+
prepareWorktree,
|
|
39
|
+
unprepareWorktree
|
|
34
40
|
} from './batch-service.js';
|
|
35
41
|
|
|
36
42
|
// --- Constants ---
|
|
37
|
-
const RUNNER_VERSION = '
|
|
43
|
+
const RUNNER_VERSION = '3.0';
|
|
38
44
|
const DEFAULT_TIMEOUT_MS = 900_000; // 15 minutes fallback
|
|
39
45
|
const MIN_TIMEOUT_MS = 300_000; // 5 minutes minimum (claude init ~30-40s + real work)
|
|
40
46
|
|
|
@@ -114,8 +120,8 @@ async function waitForRateLimit(waitSeconds, attempt) {
|
|
|
114
120
|
|
|
115
121
|
// --- Planning Phase ---
|
|
116
122
|
|
|
117
|
-
function buildPlanPrompt(task,
|
|
118
|
-
const prdPath = join(
|
|
123
|
+
function buildPlanPrompt(task, batchWorktreePath, replanContext = null) {
|
|
124
|
+
const prdPath = join(batchWorktreePath, '.ai', 'tasks', 'prd.json');
|
|
119
125
|
const prd = JSON.parse(readFileSync(prdPath, 'utf8'));
|
|
120
126
|
const story = prd.stories.find(s => s.id === task.id);
|
|
121
127
|
if (!story) return '';
|
|
@@ -158,8 +164,8 @@ Recommend a model for execution:
|
|
|
158
164
|
- "opus": high complexity (new module, multi-file refactor, architecture)`;
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
async function planTask(task,
|
|
162
|
-
const prompt = buildPlanPrompt(task,
|
|
167
|
+
async function planTask(task, batchWorktreePath, replanContext = null) {
|
|
168
|
+
const prompt = buildPlanPrompt(task, batchWorktreePath, replanContext);
|
|
163
169
|
if (!prompt) return null;
|
|
164
170
|
|
|
165
171
|
const env = { ...process.env };
|
|
@@ -174,7 +180,7 @@ async function planTask(task, cwd, replanContext = null) {
|
|
|
174
180
|
'-c',
|
|
175
181
|
`claude --model opus --print -p "$(cat)"`,
|
|
176
182
|
], {
|
|
177
|
-
cwd,
|
|
183
|
+
cwd: batchWorktreePath,
|
|
178
184
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
179
185
|
env,
|
|
180
186
|
});
|
|
@@ -235,10 +241,11 @@ function computeTimeout(estimatedSeconds) {
|
|
|
235
241
|
* Main entry point for the TUI batch runner.
|
|
236
242
|
*/
|
|
237
243
|
export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
244
|
+
const mainCwd = cwd;
|
|
245
|
+
const prdPath = join(mainCwd, '.ai', 'tasks', 'prd.json');
|
|
246
|
+
const promptPath = join(mainCwd, '.ai', 'batch', 'prompt.md');
|
|
247
|
+
const logDir = join(mainCwd, 'logs');
|
|
248
|
+
const progressPath = join(mainCwd, '.ai', 'batch', 'progress.txt');
|
|
242
249
|
const noPlan = options.noPlan || false;
|
|
243
250
|
const rateLimitWait = options.rateLimitWait || DEFAULT_RATE_LIMIT_WAIT;
|
|
244
251
|
|
|
@@ -253,41 +260,68 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
253
260
|
}
|
|
254
261
|
|
|
255
262
|
mkdirSync(logDir, { recursive: true });
|
|
256
|
-
mkdirSync(getTasksDir(
|
|
263
|
+
mkdirSync(getTasksDir(mainCwd), { recursive: true });
|
|
257
264
|
|
|
258
|
-
const stories = parsePrdTasks(
|
|
265
|
+
const stories = parsePrdTasks(mainCwd);
|
|
259
266
|
const totalStories = stories.length;
|
|
260
267
|
const doneAtStart = stories.filter(s => s.passes).length;
|
|
261
268
|
const phaseFilter = options.phase ? `Phase ${options.phase}` : undefined;
|
|
262
269
|
const taskFilter = options.filter || null;
|
|
263
270
|
|
|
264
|
-
// Count
|
|
265
|
-
|
|
271
|
+
// Count ALL stories matching the filter (not just currently eligible).
|
|
272
|
+
// Dependencies resolve dynamically as tasks complete within the batch,
|
|
273
|
+
// so we can't limit iterations by initial eligibility.
|
|
266
274
|
const filterRe = taskFilter ? new RegExp(taskFilter, 'i') : null;
|
|
267
|
-
const
|
|
275
|
+
const matchingCount = stories.filter(s =>
|
|
268
276
|
!s.passes &&
|
|
269
|
-
s.dependencies.every(dep => doneIds.includes(dep)) &&
|
|
270
277
|
(!phaseFilter || s.phase === phaseFilter) &&
|
|
271
278
|
(!filterRe || filterRe.test(s.id) || filterRe.test(s.title))
|
|
272
279
|
).length;
|
|
273
|
-
const maxIterations = options.maxIterations ||
|
|
280
|
+
const maxIterations = options.maxIterations || matchingCount;
|
|
274
281
|
|
|
275
282
|
// --- Dry run ---
|
|
276
283
|
if (options.dryRun) {
|
|
277
|
-
return dryRunPreview(
|
|
284
|
+
return dryRunPreview(mainCwd, phaseFilter, maxIterations, taskFilter);
|
|
278
285
|
}
|
|
279
286
|
|
|
280
287
|
// --- Print header ---
|
|
281
|
-
printHeader(maxIterations, phaseFilter, totalStories, doneAtStart, noPlan,
|
|
288
|
+
printHeader(maxIterations, phaseFilter, totalStories, doneAtStart, noPlan, mainCwd, taskFilter);
|
|
289
|
+
|
|
290
|
+
// --- Ensure metadata is gitignored ---
|
|
291
|
+
ensureMetadataIgnored(mainCwd);
|
|
292
|
+
// Auto-commit .gitignore if ensureMetadataIgnored modified it
|
|
293
|
+
try {
|
|
294
|
+
const { stdout: gitignoreDiff } = await execAsync('git diff --name-only -- .gitignore', { cwd: mainCwd });
|
|
295
|
+
if (gitignoreDiff.trim()) {
|
|
296
|
+
await execAsync('git add .gitignore && git commit -m "chore: gitignore batch runner metadata"', { cwd: mainCwd });
|
|
297
|
+
}
|
|
298
|
+
} catch {}
|
|
282
299
|
|
|
283
|
-
//
|
|
300
|
+
// --- Crash recovery: clean up any leftover worktrees from previous runs ---
|
|
301
|
+
await cleanupAllWorktrees(mainCwd);
|
|
284
302
|
|
|
285
|
-
// ---
|
|
286
|
-
const
|
|
303
|
+
// --- Create batch ref + worktree ---
|
|
304
|
+
const batchBranch = await createBatchRef(mainCwd);
|
|
305
|
+
const batchWorktreePath = await createBatchWorktree(batchBranch, mainCwd);
|
|
306
|
+
prepareWorktree(batchWorktreePath, mainCwd);
|
|
307
|
+
const activeWorktrees = new Set([batchWorktreePath]);
|
|
287
308
|
|
|
288
|
-
// --- Create batch branch ---
|
|
289
|
-
const batchBranch = await createBatchBranch(cwd);
|
|
290
309
|
console.log(chalk.blue(` Batch branch: ${chalk.bold(batchBranch)}`));
|
|
310
|
+
console.log(chalk.dim(` Batch worktree: ${batchWorktreePath}`));
|
|
311
|
+
|
|
312
|
+
// --- Signal handler: set flag + schedule cleanup, let main loop bail ---
|
|
313
|
+
let sigintReceived = false;
|
|
314
|
+
const signalHandler = (signal) => {
|
|
315
|
+
if (sigintReceived) {
|
|
316
|
+
// Second Ctrl+C — force exit immediately
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
sigintReceived = true;
|
|
320
|
+
console.log(chalk.yellow(`\n Received ${signal}. Stopping after current step...`));
|
|
321
|
+
// Deferred cleanup — main loop will bail, then cleanup runs in finally block
|
|
322
|
+
};
|
|
323
|
+
process.on('SIGINT', signalHandler);
|
|
324
|
+
process.on('SIGTERM', signalHandler);
|
|
291
325
|
|
|
292
326
|
// --- Main loop ---
|
|
293
327
|
let iteration = 0;
|
|
@@ -304,32 +338,105 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
304
338
|
status: 'running',
|
|
305
339
|
completed: [],
|
|
306
340
|
max_tasks: maxIterations
|
|
307
|
-
},
|
|
341
|
+
}, mainCwd);
|
|
308
342
|
|
|
309
|
-
while (iteration < maxIterations) {
|
|
343
|
+
while (iteration < maxIterations && !sigintReceived) {
|
|
310
344
|
iteration++;
|
|
311
345
|
|
|
312
|
-
const task = selectNextTask(
|
|
346
|
+
const task = selectNextTask(batchWorktreePath, { phase: phaseFilter, filter: taskFilter });
|
|
313
347
|
if (!task) {
|
|
314
348
|
console.log(chalk.green('\nAll eligible stories completed!'));
|
|
315
349
|
break;
|
|
316
350
|
}
|
|
317
351
|
|
|
318
|
-
const currentDone = parsePrdTasks(
|
|
352
|
+
const currentDone = parsePrdTasks(batchWorktreePath).filter(s => s.passes).length;
|
|
319
353
|
|
|
320
354
|
console.log(chalk.dim(`\n--- Iteration ${iteration}/${maxIterations} ---`));
|
|
321
355
|
console.log(`${chalk.cyan(`[${currentDone + 1}/${totalStories}]`)} ${chalk.bold(task.id)}: ${task.title}`);
|
|
322
356
|
|
|
357
|
+
// --- Pre-validation: check existing feat branch BEFORE planning ---
|
|
358
|
+
let storyDone = false;
|
|
359
|
+
const taskBranchName = `feat/${task.id}`;
|
|
360
|
+
let branchExists = false;
|
|
361
|
+
try {
|
|
362
|
+
await execAsync(`git rev-parse --verify ${taskBranchName}`, { cwd: mainCwd });
|
|
363
|
+
branchExists = true;
|
|
364
|
+
} catch {}
|
|
365
|
+
|
|
366
|
+
if (branchExists) {
|
|
367
|
+
let prevalWorktreePath = null;
|
|
368
|
+
try {
|
|
369
|
+
const prevalResult = await addExistingBranchWorktree(task.id, mainCwd);
|
|
370
|
+
prevalWorktreePath = prevalResult.worktreePath;
|
|
371
|
+
prepareWorktree(prevalWorktreePath, mainCwd);
|
|
372
|
+
|
|
373
|
+
// Count commits on feat branch that aren't on the current HEAD
|
|
374
|
+
// (using HEAD, not batchBranch, because batchBranch is recreated each run)
|
|
375
|
+
const { stdout: logCount } = await execAsync(`git rev-list --count HEAD..${taskBranchName}`, { cwd: mainCwd });
|
|
376
|
+
const commits = parseInt(logCount.trim(), 10);
|
|
377
|
+
|
|
378
|
+
if (commits > 0) {
|
|
379
|
+
console.log(chalk.blue(` Pre-validation: found ${commits} commit(s) on ${taskBranchName}, running quality gates...`));
|
|
380
|
+
const preGates = await runGatesWithStatus(prevalWorktreePath, mainCwd);
|
|
381
|
+
|
|
382
|
+
if (preGates.success) {
|
|
383
|
+
console.log(chalk.green(` Pre-validation PASSED — skipping execution`));
|
|
384
|
+
markStoryDone(task.id, prevalWorktreePath);
|
|
385
|
+
await commitPrdUpdate(task.id, prevalWorktreePath);
|
|
386
|
+
appendProgress(progressPath, task.id, 'Pre-validation: existing code passed quality gates');
|
|
387
|
+
|
|
388
|
+
storyDone = true;
|
|
389
|
+
storiesCompleted++;
|
|
390
|
+
noProgressCount = 0;
|
|
391
|
+
|
|
392
|
+
updateBatchState({
|
|
393
|
+
completed: [task.id],
|
|
394
|
+
current_task: null,
|
|
395
|
+
last_completed_at: new Date().toISOString()
|
|
396
|
+
}, mainCwd);
|
|
397
|
+
|
|
398
|
+
// Merge into batch worktree
|
|
399
|
+
try {
|
|
400
|
+
unprepareWorktree(batchWorktreePath);
|
|
401
|
+
await mergeBranch(taskBranchName, batchWorktreePath);
|
|
402
|
+
prepareWorktree(batchWorktreePath, mainCwd);
|
|
403
|
+
mergedCount++;
|
|
404
|
+
mergedBranches.push(taskBranchName);
|
|
405
|
+
console.log(chalk.green(` Merged ${taskBranchName} → ${batchBranch}`));
|
|
406
|
+
} catch (err) {
|
|
407
|
+
prepareWorktree(batchWorktreePath, mainCwd);
|
|
408
|
+
console.log(chalk.red(` Merge failed: ${err.message}`));
|
|
409
|
+
console.log(chalk.yellow(` Branch ${taskBranchName} available for manual merge`));
|
|
410
|
+
unmergedBranches.push(taskBranchName);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
console.log(chalk.yellow(` Pre-validation FAILED — will re-execute`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.log(chalk.dim(` Pre-validation worktree failed: ${err.message}`));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Always clean up pre-validation worktree
|
|
421
|
+
if (prevalWorktreePath) {
|
|
422
|
+
try { await removeWorktree(prevalWorktreePath, mainCwd, { force: true }); } catch {}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (storyDone) {
|
|
427
|
+
branchesCreated.push(taskBranchName);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
323
431
|
// --- Phase 1: Planning (skip if already planned) ---
|
|
324
432
|
let taskTimeoutMs = DEFAULT_TIMEOUT_MS;
|
|
325
433
|
let taskFile = null;
|
|
326
434
|
|
|
327
435
|
if (!noPlan) {
|
|
328
|
-
const existing = loadTaskFile(task.id,
|
|
436
|
+
const existing = loadTaskFile(task.id, mainCwd);
|
|
329
437
|
|
|
330
438
|
const needsReplan = existing && existing.last_failure_type && existing.status !== 'completed';
|
|
331
439
|
if (existing && existing.status === 'planned' && existing.complexity !== 'unknown' && !needsReplan) {
|
|
332
|
-
// Reuse previous plan (only if it hasn't failed before)
|
|
333
440
|
taskFile = existing;
|
|
334
441
|
taskTimeoutMs = computeTimeout(existing.estimated_time_seconds);
|
|
335
442
|
console.log(chalk.dim(` Phase 1: Reusing plan for ${task.id} (${existing.complexity}, ~${formatDuration(existing.estimated_time_seconds)}, model: ${existing.recommended_model || 'sonnet'})`));
|
|
@@ -337,7 +444,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
337
444
|
console.log(chalk.blue(` Phase 1: Planning ${task.id}...`));
|
|
338
445
|
const planSpinner = ora({ text: `Planning ${task.id}...`, indent: 2 }).start();
|
|
339
446
|
|
|
340
|
-
const planResult = await planTask(task,
|
|
447
|
+
const planResult = await planTask(task, batchWorktreePath);
|
|
341
448
|
|
|
342
449
|
if (planResult) {
|
|
343
450
|
const complexity = planResult.complexity || 'medium';
|
|
@@ -361,13 +468,12 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
361
468
|
completed_at: null,
|
|
362
469
|
failure_reason: null
|
|
363
470
|
};
|
|
364
|
-
saveTaskFile(taskFile,
|
|
471
|
+
saveTaskFile(taskFile, mainCwd);
|
|
365
472
|
|
|
366
473
|
planSpinner.succeed(chalk.green(
|
|
367
474
|
`Planned: ${complexity} complexity, timeout ${tier.label}, model: ${recommendedModel}`
|
|
368
475
|
));
|
|
369
476
|
} else {
|
|
370
|
-
// Fallback when planning fails
|
|
371
477
|
const fallbackSeconds = DEFAULT_TIMEOUT_MS / 1000;
|
|
372
478
|
taskFile = {
|
|
373
479
|
id: task.id,
|
|
@@ -383,7 +489,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
383
489
|
completed_at: null,
|
|
384
490
|
failure_reason: null
|
|
385
491
|
};
|
|
386
|
-
saveTaskFile(taskFile,
|
|
492
|
+
saveTaskFile(taskFile, mainCwd);
|
|
387
493
|
taskTimeoutMs = DEFAULT_TIMEOUT_MS;
|
|
388
494
|
|
|
389
495
|
planSpinner.warn(chalk.yellow(`Planning failed, using default timeout (${formatDuration(fallbackSeconds)})`));
|
|
@@ -391,98 +497,28 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
391
497
|
}
|
|
392
498
|
}
|
|
393
499
|
|
|
394
|
-
// ---
|
|
395
|
-
let storyDone = false;
|
|
396
|
-
const taskBranchName = `feat/${task.id}`;
|
|
397
|
-
let branchExists = false;
|
|
398
|
-
try {
|
|
399
|
-
await execAsync(`git rev-parse --verify ${taskBranchName}`, { cwd });
|
|
400
|
-
branchExists = true;
|
|
401
|
-
} catch {}
|
|
402
|
-
|
|
403
|
-
if (branchExists) {
|
|
404
|
-
try {
|
|
405
|
-
// Checkout WITHOUT reset to inspect existing work
|
|
406
|
-
await checkoutBranch(taskBranchName, cwd, { stash: true });
|
|
407
|
-
const { stdout: logCount } = await execAsync(`git rev-list --count ${batchBranch}..HEAD`, { cwd });
|
|
408
|
-
const commits = parseInt(logCount.trim(), 10);
|
|
409
|
-
|
|
410
|
-
if (commits > 0) {
|
|
411
|
-
console.log(chalk.blue(` Pre-validation: found ${commits} commit(s) on ${taskBranchName}, running quality gates...`));
|
|
412
|
-
const preGates = await runGatesWithStatus(cwd);
|
|
413
|
-
|
|
414
|
-
if (preGates.success) {
|
|
415
|
-
console.log(chalk.green(` Pre-validation PASSED — skipping execution`));
|
|
416
|
-
markStoryDone(task.id, cwd);
|
|
417
|
-
appendProgress(progressPath, task.id, 'Pre-validation: existing code passed quality gates');
|
|
418
|
-
|
|
419
|
-
storyDone = true;
|
|
420
|
-
storiesCompleted++;
|
|
421
|
-
noProgressCount = 0;
|
|
422
|
-
|
|
423
|
-
if (taskFile) {
|
|
424
|
-
taskFile.status = 'completed';
|
|
425
|
-
taskFile.completed_at = new Date().toISOString();
|
|
426
|
-
saveTaskFile(taskFile, cwd);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
updateBatchState({
|
|
430
|
-
completed: [task.id],
|
|
431
|
-
current_task: null,
|
|
432
|
-
last_completed_at: new Date().toISOString()
|
|
433
|
-
}, cwd);
|
|
434
|
-
|
|
435
|
-
// Merge into batch branch
|
|
436
|
-
try {
|
|
437
|
-
await checkoutBranch(batchBranch, cwd, { stash: true });
|
|
438
|
-
await mergeBranch(taskBranchName, cwd);
|
|
439
|
-
mergedCount++;
|
|
440
|
-
mergedBranches.push(taskBranchName);
|
|
441
|
-
console.log(chalk.green(` Merged ${taskBranchName} → ${batchBranch}`));
|
|
442
|
-
} catch (err) {
|
|
443
|
-
console.log(chalk.red(` Merge failed: ${err.message}`));
|
|
444
|
-
console.log(chalk.yellow(` Branch ${taskBranchName} available for manual merge`));
|
|
445
|
-
unmergedBranches.push(taskBranchName);
|
|
446
|
-
}
|
|
447
|
-
} else {
|
|
448
|
-
console.log(chalk.yellow(` Pre-validation FAILED — will re-execute`));
|
|
449
|
-
// Return to batch branch so createTaskBranch resets properly
|
|
450
|
-
await checkoutBranch(batchBranch, cwd, { force: true, stash: true });
|
|
451
|
-
}
|
|
452
|
-
} else {
|
|
453
|
-
// No commits — return to batch branch for normal flow
|
|
454
|
-
await checkoutBranch(batchBranch, cwd, { force: true, stash: true });
|
|
455
|
-
}
|
|
456
|
-
} catch {
|
|
457
|
-
// Checkout failed — proceed normally
|
|
458
|
-
try { await checkoutBranch(batchBranch, cwd, { force: true, stash: true }); } catch {}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (storyDone) {
|
|
463
|
-
branchesCreated.push(taskBranchName);
|
|
464
|
-
try {
|
|
465
|
-
await checkoutBranch(batchBranch, cwd, { force: true, stash: true });
|
|
466
|
-
console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
|
|
467
|
-
} catch {}
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// --- Branch isolation: create/checkout task branch (resets existing) ---
|
|
500
|
+
// --- Create task worktree ---
|
|
472
501
|
let taskBranch = null;
|
|
502
|
+
let taskWorktreePath = null;
|
|
473
503
|
try {
|
|
474
|
-
|
|
504
|
+
const result = await createTaskWorktree(task.id, batchBranch, mainCwd);
|
|
505
|
+
taskBranch = result.branch;
|
|
506
|
+
taskWorktreePath = result.worktreePath;
|
|
507
|
+
prepareWorktree(taskWorktreePath, mainCwd);
|
|
508
|
+
activeWorktrees.add(taskWorktreePath);
|
|
475
509
|
branchesCreated.push(taskBranch);
|
|
476
510
|
console.log(chalk.blue(` Branch: ${chalk.bold(taskBranch)}`));
|
|
511
|
+
console.log(chalk.dim(` Worktree: ${taskWorktreePath}`));
|
|
477
512
|
|
|
478
|
-
// Track branch in task file
|
|
479
513
|
if (taskFile) {
|
|
480
514
|
taskFile.branch = taskBranch;
|
|
481
|
-
saveTaskFile(taskFile,
|
|
515
|
+
saveTaskFile(taskFile, mainCwd);
|
|
482
516
|
}
|
|
483
517
|
} catch (err) {
|
|
484
|
-
console.log(chalk.red(` Failed to create
|
|
485
|
-
console.log(chalk.yellow('
|
|
518
|
+
console.log(chalk.red(` Failed to create worktree for feat/${task.id}: ${err.message}`));
|
|
519
|
+
console.log(chalk.yellow(' Skipping task...'));
|
|
520
|
+
noProgressCount++;
|
|
521
|
+
continue;
|
|
486
522
|
}
|
|
487
523
|
|
|
488
524
|
// --- Phase 2: Execution ---
|
|
@@ -498,7 +534,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
498
534
|
console.log(chalk.yellow(` Retry ${retry}/${MAX_RETRIES}`));
|
|
499
535
|
}
|
|
500
536
|
|
|
501
|
-
const prompt = buildPrompt(task.id,
|
|
537
|
+
const prompt = buildPrompt(task.id, batchWorktreePath, mainCwd, errorContext);
|
|
502
538
|
if (!prompt) {
|
|
503
539
|
console.log(chalk.red(` Cannot build prompt for ${task.id} — skipping`));
|
|
504
540
|
break;
|
|
@@ -507,22 +543,17 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
507
543
|
|
|
508
544
|
const estimatedSeconds = taskFile?.estimated_time_seconds || null;
|
|
509
545
|
const executionModel = taskFile?.recommended_model || null;
|
|
510
|
-
const result = await spawnClaude(prompt, task, iteration, maxIterations, currentDone, totalStories, phaseFilter, logFile,
|
|
546
|
+
const result = await spawnClaude(prompt, task, iteration, maxIterations, currentDone, totalStories, phaseFilter, logFile, taskWorktreePath, taskTimeoutMs, estimatedSeconds, phaseLabel, executionModel);
|
|
511
547
|
|
|
512
|
-
// Killed by signal
|
|
513
|
-
if (result.signal) {
|
|
514
|
-
console.log(chalk.yellow(` Killed by ${result.signal}`));
|
|
515
|
-
|
|
516
|
-
try { await checkoutBranch(batchBranch, cwd, { force: true, stash: true }); } catch {}
|
|
517
|
-
}
|
|
518
|
-
clearBatchState(cwd);
|
|
519
|
-
printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
|
|
548
|
+
// Killed by signal or Ctrl+C — bail immediately
|
|
549
|
+
if (result.signal || sigintReceived) {
|
|
550
|
+
if (result.signal) console.log(chalk.yellow(` Killed by ${result.signal}`));
|
|
551
|
+
clearBatchState(mainCwd);
|
|
520
552
|
return;
|
|
521
553
|
}
|
|
522
554
|
|
|
523
555
|
// Timeout
|
|
524
556
|
if (result.timedOut) {
|
|
525
|
-
// Timeout can also be caused by rate limit — check output
|
|
526
557
|
if (isRateLimitError(result.output)) {
|
|
527
558
|
rateLimitAttempts++;
|
|
528
559
|
await waitForRateLimit(rateLimitWait, rateLimitAttempts);
|
|
@@ -532,17 +563,14 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
532
563
|
const prevTimeout = Math.round(taskTimeoutMs / 1000);
|
|
533
564
|
console.log(chalk.yellow(` Timed out after ${prevTimeout}s`));
|
|
534
565
|
|
|
535
|
-
// --- Replan on timeout: bump complexity tier ---
|
|
536
566
|
if (taskFile) {
|
|
537
567
|
const prevComplexity = taskFile.complexity;
|
|
538
568
|
const prevEstimate = taskFile.estimated_time_seconds;
|
|
539
569
|
|
|
540
|
-
// Escalate complexity: low → medium → high, high stays high but doubles
|
|
541
570
|
const escalation = { low: 'medium', medium: 'high' };
|
|
542
571
|
const newComplexity = escalation[prevComplexity] || 'high';
|
|
543
572
|
const tier = COMPLEXITY_TIMEOUT[newComplexity] || COMPLEXITY_TIMEOUT.high;
|
|
544
573
|
|
|
545
|
-
// If already high, double the previous timeout
|
|
546
574
|
const newEstimate = prevComplexity === 'high'
|
|
547
575
|
? prevEstimate * 2
|
|
548
576
|
: tier.seconds;
|
|
@@ -552,10 +580,10 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
552
580
|
taskFile.estimated_time_seconds = newEstimate;
|
|
553
581
|
taskFile.timeout_seconds = newTimeout;
|
|
554
582
|
taskFile.complexity = newComplexity;
|
|
555
|
-
taskFile.recommended_model = 'opus';
|
|
583
|
+
taskFile.recommended_model = 'opus';
|
|
556
584
|
taskFile.replanned_at = new Date().toISOString();
|
|
557
585
|
taskFile.replan_reason = `timeout after ${prevTimeout}s (${prevComplexity} → ${newComplexity})`;
|
|
558
|
-
saveTaskFile(taskFile,
|
|
586
|
+
saveTaskFile(taskFile, mainCwd);
|
|
559
587
|
|
|
560
588
|
console.log(chalk.blue(
|
|
561
589
|
` Escalated: ${prevComplexity} → ${newComplexity}, timeout ${formatDuration(newTimeout)}, model: opus`
|
|
@@ -567,82 +595,82 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
567
595
|
continue;
|
|
568
596
|
}
|
|
569
597
|
|
|
570
|
-
// Non-zero exit
|
|
571
|
-
// Rate limit detection only on FAILURES. Successful output (exit 0) may
|
|
572
|
-
// mention "rate limit" as a feature description, not an actual API error.
|
|
598
|
+
// Non-zero exit
|
|
573
599
|
if (result.exitCode !== 0) {
|
|
574
600
|
if (isRateLimitError(result.output)) {
|
|
575
601
|
rateLimitAttempts++;
|
|
576
602
|
await waitForRateLimit(rateLimitWait, rateLimitAttempts);
|
|
577
|
-
continue;
|
|
603
|
+
continue;
|
|
578
604
|
}
|
|
579
605
|
console.log(chalk.yellow(` Claude exited with code ${result.exitCode}`));
|
|
580
606
|
errorContext = `Claude session failed with exit code ${result.exitCode}.`;
|
|
581
607
|
|
|
582
|
-
// Save error output snippet to task file for debugging
|
|
583
608
|
if (taskFile) {
|
|
584
609
|
taskFile.last_error_output = (result.output || '').slice(-2000);
|
|
585
610
|
taskFile.last_failure_type = 'crash';
|
|
586
|
-
saveTaskFile(taskFile,
|
|
611
|
+
saveTaskFile(taskFile, mainCwd);
|
|
587
612
|
}
|
|
588
613
|
|
|
589
614
|
retry++;
|
|
590
615
|
continue;
|
|
591
616
|
}
|
|
592
617
|
|
|
593
|
-
//
|
|
594
|
-
|
|
618
|
+
// Bail if Ctrl+C received during execution
|
|
619
|
+
if (sigintReceived) break;
|
|
620
|
+
|
|
621
|
+
// Quality gates — run in task worktree, config from mainCwd
|
|
622
|
+
const gatesResult = await runGatesWithStatus(taskWorktreePath, mainCwd);
|
|
595
623
|
|
|
596
624
|
if (gatesResult.success) {
|
|
597
625
|
console.log(chalk.green(` Quality gates PASSED`));
|
|
598
|
-
markStoryDone(task.id,
|
|
626
|
+
markStoryDone(task.id, taskWorktreePath);
|
|
599
627
|
appendProgress(progressPath, task.id, 'All quality gates passed');
|
|
600
628
|
|
|
601
|
-
const config = loadConfig(
|
|
629
|
+
const config = loadConfig(mainCwd);
|
|
602
630
|
if (config.auto_commit) {
|
|
603
|
-
await commitTask(task, {},
|
|
631
|
+
await commitTask(task, {}, taskWorktreePath);
|
|
632
|
+
} else {
|
|
633
|
+
await commitPrdUpdate(task.id, taskWorktreePath);
|
|
604
634
|
}
|
|
605
635
|
|
|
606
636
|
storyDone = true;
|
|
607
637
|
storiesCompleted++;
|
|
608
638
|
noProgressCount = 0;
|
|
609
639
|
|
|
610
|
-
// Update task file status
|
|
611
640
|
if (taskFile) {
|
|
612
641
|
taskFile.status = 'completed';
|
|
613
642
|
taskFile.completed_at = new Date().toISOString();
|
|
614
|
-
saveTaskFile(taskFile,
|
|
643
|
+
saveTaskFile(taskFile, mainCwd);
|
|
615
644
|
}
|
|
616
645
|
|
|
617
646
|
updateBatchState({
|
|
618
647
|
completed: [task.id],
|
|
619
648
|
current_task: null,
|
|
620
649
|
last_completed_at: new Date().toISOString()
|
|
621
|
-
},
|
|
622
|
-
|
|
623
|
-
// Merge
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
650
|
+
}, mainCwd);
|
|
651
|
+
|
|
652
|
+
// Merge into batch worktree
|
|
653
|
+
try {
|
|
654
|
+
unprepareWorktree(batchWorktreePath);
|
|
655
|
+
await mergeBranch(taskBranch, batchWorktreePath);
|
|
656
|
+
prepareWorktree(batchWorktreePath, mainCwd);
|
|
657
|
+
mergedCount++;
|
|
658
|
+
mergedBranches.push(taskBranch);
|
|
659
|
+
console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
|
|
660
|
+
} catch (err) {
|
|
661
|
+
prepareWorktree(batchWorktreePath, mainCwd);
|
|
662
|
+
console.log(chalk.red(` Merge failed: ${err.message}`));
|
|
663
|
+
console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
|
|
664
|
+
unmergedBranches.push(taskBranch);
|
|
636
665
|
}
|
|
637
666
|
} else {
|
|
638
667
|
console.log(chalk.red(` Quality gates FAILED`));
|
|
639
668
|
errorContext = gatesResult.errorOutput;
|
|
640
669
|
|
|
641
|
-
// Save gate failure details to task file
|
|
642
670
|
if (taskFile) {
|
|
643
671
|
taskFile.last_error_output = (gatesResult.errorOutput || '').slice(-2000);
|
|
644
672
|
taskFile.last_failure_type = 'quality_gate';
|
|
645
|
-
saveTaskFile(taskFile,
|
|
673
|
+
saveTaskFile(taskFile, mainCwd);
|
|
646
674
|
}
|
|
647
675
|
|
|
648
676
|
retry++;
|
|
@@ -654,28 +682,23 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
654
682
|
console.log(chalk.red(` FAILED: ${task.id} after ${MAX_RETRIES} retries (${failureType})`));
|
|
655
683
|
appendProgress(progressPath, task.id, `FAILED after ${MAX_RETRIES} retries (${failureType}). Skipping.`);
|
|
656
684
|
|
|
657
|
-
// Update task file with failure
|
|
658
685
|
if (taskFile) {
|
|
659
686
|
taskFile.status = 'failed';
|
|
660
687
|
taskFile.failure_reason = errorContext || `Failed after ${MAX_RETRIES} retries`;
|
|
661
688
|
taskFile.completed_at = new Date().toISOString();
|
|
662
|
-
saveTaskFile(taskFile,
|
|
689
|
+
saveTaskFile(taskFile, mainCwd);
|
|
663
690
|
}
|
|
664
691
|
|
|
665
692
|
noProgressCount++;
|
|
693
|
+
unmergedBranches.push(taskBranch);
|
|
666
694
|
}
|
|
667
695
|
|
|
668
|
-
// ---
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
console.log(chalk.red(` Warning: failed to return to ${batchBranch}: ${err.message}`));
|
|
675
|
-
}
|
|
676
|
-
if (!storyDone) {
|
|
677
|
-
unmergedBranches.push(taskBranch);
|
|
678
|
-
}
|
|
696
|
+
// --- Remove task worktree ---
|
|
697
|
+
try {
|
|
698
|
+
await removeWorktree(taskWorktreePath, mainCwd, { force: !storyDone });
|
|
699
|
+
activeWorktrees.delete(taskWorktreePath);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.log(chalk.dim(` Warning: failed to remove worktree: ${err.message}`));
|
|
679
702
|
}
|
|
680
703
|
|
|
681
704
|
if (noProgressCount >= MAX_NO_PROGRESS) {
|
|
@@ -684,8 +707,36 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
684
707
|
}
|
|
685
708
|
}
|
|
686
709
|
|
|
687
|
-
|
|
688
|
-
|
|
710
|
+
if (sigintReceived) {
|
|
711
|
+
console.log(chalk.yellow('\n Interrupted. Cleaning up...'));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// --- Summary (before removing batch worktree, so prd.json is readable) ---
|
|
715
|
+
printSummary(mainCwd, batchWorktreePath, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, mergedCount, unmergedBranches);
|
|
716
|
+
|
|
717
|
+
// --- Merge batch branch into main repo ---
|
|
718
|
+
if (mergedCount > 0 && !sigintReceived) {
|
|
719
|
+
try {
|
|
720
|
+
await mergeBranch(batchBranch, mainCwd);
|
|
721
|
+
console.log(chalk.green(` Merged ${batchBranch} into main repo`));
|
|
722
|
+
} catch (err) {
|
|
723
|
+
console.log(chalk.red(` Failed to merge ${batchBranch} into main repo: ${err.message}`));
|
|
724
|
+
console.log(chalk.yellow(` Run manually: git merge ${batchBranch}`));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// --- Cleanup all worktrees ---
|
|
729
|
+
for (const wt of activeWorktrees) {
|
|
730
|
+
try { await removeWorktree(wt, mainCwd, { force: true }); } catch {}
|
|
731
|
+
}
|
|
732
|
+
await cleanupAllWorktrees(mainCwd);
|
|
733
|
+
clearBatchState(mainCwd);
|
|
734
|
+
|
|
735
|
+
// Cleanup signal handlers
|
|
736
|
+
process.removeListener('SIGINT', signalHandler);
|
|
737
|
+
process.removeListener('SIGTERM', signalHandler);
|
|
738
|
+
|
|
739
|
+
// Main repo never left its original branch — nothing to restore
|
|
689
740
|
}
|
|
690
741
|
|
|
691
742
|
/**
|
|
@@ -791,9 +842,11 @@ function spawnClaude(prompt, task, iteration, maxIter, done, total, phase, logFi
|
|
|
791
842
|
|
|
792
843
|
/**
|
|
793
844
|
* Run quality gates with real-time PASS/FAIL display.
|
|
845
|
+
* @param {string} taskCwd - Directory where gates run (task worktree)
|
|
846
|
+
* @param {string} mainCwd - Main repo directory for config (defaults to taskCwd for backwards compat)
|
|
794
847
|
*/
|
|
795
|
-
async function runGatesWithStatus(
|
|
796
|
-
const config = loadConfig(
|
|
848
|
+
async function runGatesWithStatus(taskCwd, mainCwd = taskCwd) {
|
|
849
|
+
const config = loadConfig(mainCwd);
|
|
797
850
|
const gates = config.quality_gates || [];
|
|
798
851
|
const skipGates = config.skip_gates || [];
|
|
799
852
|
let allPassed = true;
|
|
@@ -806,7 +859,7 @@ async function runGatesWithStatus(cwd) {
|
|
|
806
859
|
}
|
|
807
860
|
|
|
808
861
|
const spinner = ora({ text: `Running ${gate}...`, indent: 2 }).start();
|
|
809
|
-
const result = await runQualityGate(gate,
|
|
862
|
+
const result = await runQualityGate(gate, taskCwd, { configCwd: mainCwd });
|
|
810
863
|
|
|
811
864
|
if (result.success || result.skipped) {
|
|
812
865
|
spinner.succeed(chalk.green(`${gate}: PASS`));
|
|
@@ -822,8 +875,15 @@ async function runGatesWithStatus(cwd) {
|
|
|
822
875
|
|
|
823
876
|
/**
|
|
824
877
|
* Build the prompt from template, substituting placeholders.
|
|
878
|
+
* @param {string} storyId - Story ID
|
|
879
|
+
* @param {string} batchWorktreePath - Batch worktree (for prd.json, tracked file)
|
|
880
|
+
* @param {string} mainCwd - Main repo (for prompt.md, gitignored file)
|
|
881
|
+
* @param {string} extraContext - Error context from previous attempt
|
|
825
882
|
*/
|
|
826
|
-
function buildPrompt(storyId,
|
|
883
|
+
function buildPrompt(storyId, batchWorktreePath, mainCwd, extraContext) {
|
|
884
|
+
const prdPath = join(batchWorktreePath, '.ai', 'tasks', 'prd.json');
|
|
885
|
+
const promptPath = join(mainCwd, '.ai', 'batch', 'prompt.md');
|
|
886
|
+
|
|
827
887
|
const prd = JSON.parse(readFileSync(prdPath, 'utf8'));
|
|
828
888
|
const story = prd.stories.find(s => s.id === storyId);
|
|
829
889
|
if (!story) {
|
|
@@ -934,17 +994,20 @@ function printHeader(maxIter, phase, total, done, noPlan = false, cwd = process.
|
|
|
934
994
|
console.log(chalk.dim('─'.repeat(40)));
|
|
935
995
|
}
|
|
936
996
|
|
|
937
|
-
function printSummary(
|
|
938
|
-
|
|
997
|
+
function printSummary(mainCwd, batchWorktreePath, iterations, completed, startTime, branchesCreated = [], batchBranch = null, mergedCount = 0, unmergedBranches = []) {
|
|
998
|
+
// Read prd.json from batch worktree (still alive at this point)
|
|
999
|
+
const stories = existsSync(join(batchWorktreePath, '.ai', 'tasks', 'prd.json'))
|
|
1000
|
+
? parsePrdTasks(batchWorktreePath)
|
|
1001
|
+
: parsePrdTasks(mainCwd);
|
|
939
1002
|
const total = stories.length;
|
|
940
1003
|
const done = stories.filter(s => s.passes).length;
|
|
941
1004
|
const remaining = total - done;
|
|
942
1005
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
943
1006
|
|
|
944
|
-
// Cleanup: delete completed task files, keep failed
|
|
1007
|
+
// Cleanup: delete completed task files, keep failed (from mainCwd)
|
|
945
1008
|
let cleaned = 0;
|
|
946
1009
|
let failedKept = 0;
|
|
947
|
-
const tasksDir = getTasksDir(
|
|
1010
|
+
const tasksDir = getTasksDir(mainCwd);
|
|
948
1011
|
if (existsSync(tasksDir)) {
|
|
949
1012
|
const files = readdirSync(tasksDir).filter(f => f.endsWith('.json'));
|
|
950
1013
|
for (const file of files) {
|
|
@@ -983,9 +1046,7 @@ function printSummary(cwd, iterations, completed, startTime, branchesCreated = [
|
|
|
983
1046
|
const unique = [...new Set(branchesCreated)];
|
|
984
1047
|
console.log(` Branches: ${unique.map(b => chalk.cyan(b)).join(', ')}`);
|
|
985
1048
|
}
|
|
986
|
-
|
|
987
|
-
console.log(` Original branch: ${chalk.green(originalBranch)} (intact)`);
|
|
988
|
-
}
|
|
1049
|
+
console.log(` Main repo: ${chalk.green('intact (never changed branch)')}`);
|
|
989
1050
|
console.log(chalk.dim('─'.repeat(40)));
|
|
990
1051
|
console.log('');
|
|
991
1052
|
}
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
* checkpointing, and signal handling.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, appendFileSync, symlinkSync, unlinkSync, lstatSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
|
+
import os from 'os';
|
|
10
11
|
import { exec } from 'child_process';
|
|
11
12
|
import { promisify } from 'util';
|
|
12
13
|
import { getState, updateState, hasWorkspace } from '../core/state.js';
|
|
13
14
|
|
|
14
15
|
const execAsync = promisify(exec);
|
|
15
16
|
|
|
17
|
+
// --- Metadata patterns that should never be tracked by git ---
|
|
18
|
+
const METADATA_IGNORE_ENTRIES = ['.ai/batch/', '.ai/state.json', 'logs/'];
|
|
19
|
+
|
|
16
20
|
// --- Git Helpers ---
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -25,6 +29,31 @@ export async function getCurrentBranch(cwd = process.cwd()) {
|
|
|
25
29
|
return stdout.trim();
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Ensure metadata paths are in .gitignore so they never block checkout or get committed.
|
|
34
|
+
* Idempotent — safe to call multiple times.
|
|
35
|
+
* @param {string} cwd - Working directory
|
|
36
|
+
*/
|
|
37
|
+
export function ensureMetadataIgnored(cwd = process.cwd()) {
|
|
38
|
+
const gitignorePath = join(cwd, '.gitignore');
|
|
39
|
+
let content = '';
|
|
40
|
+
if (existsSync(gitignorePath)) {
|
|
41
|
+
content = readFileSync(gitignorePath, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const missing = METADATA_IGNORE_ENTRIES.filter(entry => {
|
|
45
|
+
// Check if entry already exists as a line (ignoring trailing whitespace)
|
|
46
|
+
return !content.split('\n').some(line => line.trim() === entry);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (missing.length > 0) {
|
|
50
|
+
const suffix = (content && !content.endsWith('\n') ? '\n' : '')
|
|
51
|
+
+ '\n# PWN batch runner metadata (auto-added)\n'
|
|
52
|
+
+ missing.join('\n') + '\n';
|
|
53
|
+
appendFileSync(gitignorePath, suffix);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/**
|
|
29
58
|
* Create and checkout a task branch from the current HEAD
|
|
30
59
|
* If branch already exists, just checkout.
|
|
@@ -47,60 +76,218 @@ export async function createTaskBranch(taskId, cwd = process.cwd()) {
|
|
|
47
76
|
// so we don't carry stale/dirty state from previous attempt
|
|
48
77
|
const { stdout } = await execAsync('git rev-parse HEAD', { cwd });
|
|
49
78
|
const baseRef = stdout.trim();
|
|
50
|
-
await checkoutBranch(branch, cwd
|
|
79
|
+
await checkoutBranch(branch, cwd);
|
|
51
80
|
await execAsync(`git reset --hard ${baseRef}`, { cwd });
|
|
52
81
|
} else {
|
|
53
|
-
// Stash before -b too (untracked files block checkout -b as well)
|
|
54
|
-
let didStash = false;
|
|
55
|
-
try {
|
|
56
|
-
const { stdout } = await execAsync('git stash --include-untracked', { cwd });
|
|
57
|
-
didStash = !stdout.includes('No local changes');
|
|
58
|
-
} catch {}
|
|
59
82
|
await execAsync(`git checkout -b ${branch}`, { cwd });
|
|
60
|
-
if (didStash) {
|
|
61
|
-
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
62
|
-
}
|
|
63
83
|
}
|
|
64
84
|
return branch;
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
/**
|
|
68
88
|
* Checkout an existing branch.
|
|
69
|
-
*
|
|
70
|
-
* restores them after — prevents .ai/batch/tasks/*.json from blocking.
|
|
89
|
+
* With metadata gitignored, no stash is needed — checkouts are clean.
|
|
71
90
|
* @param {string} branch - Branch name
|
|
72
91
|
* @param {string} cwd - Working directory
|
|
73
|
-
* @param {{ force?: boolean
|
|
92
|
+
* @param {{ force?: boolean }} options
|
|
74
93
|
*/
|
|
75
|
-
export async function checkoutBranch(branch, cwd = process.cwd(), { force = false
|
|
76
|
-
let didStash = false;
|
|
77
|
-
if (stash) {
|
|
78
|
-
try {
|
|
79
|
-
const { stdout } = await execAsync('git stash --include-untracked', { cwd });
|
|
80
|
-
didStash = !stdout.includes('No local changes');
|
|
81
|
-
} catch {}
|
|
82
|
-
}
|
|
83
|
-
|
|
94
|
+
export async function checkoutBranch(branch, cwd = process.cwd(), { force = false } = {}) {
|
|
84
95
|
const flag = force ? ' --force' : '';
|
|
96
|
+
await execAsync(`git checkout${flag} ${branch}`, { cwd });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Worktree Helpers ---
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a batch ref (branch) without checkout.
|
|
103
|
+
* Fails fast if the working tree has dirty tracked files.
|
|
104
|
+
* @param {string} cwd - Working directory
|
|
105
|
+
* @returns {Promise<string>} Branch name (batch/{timestamp})
|
|
106
|
+
*/
|
|
107
|
+
export async function createBatchRef(cwd = process.cwd()) {
|
|
108
|
+
// Check for dirty tracked files (excluding batch runner metadata)
|
|
85
109
|
try {
|
|
86
|
-
await execAsync(
|
|
110
|
+
const { stdout: dirty } = await execAsync('git diff --name-only HEAD', { cwd });
|
|
111
|
+
const { stdout: staged } = await execAsync('git diff --cached --name-only', { cwd });
|
|
112
|
+
const allDirty = (dirty + staged).trim()
|
|
113
|
+
.split('\n')
|
|
114
|
+
.filter(f => f && !METADATA_IGNORE_ENTRIES.some(m => f.startsWith(m)))
|
|
115
|
+
.join('\n');
|
|
116
|
+
if (allDirty) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Working tree has uncommitted changes:\n${allDirty}\n\n` +
|
|
119
|
+
'Commit or stash your changes before starting a batch run.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
87
122
|
} catch (err) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
123
|
+
if (err.message.includes('uncommitted changes')) throw err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
127
|
+
const branch = `batch/${ts}`;
|
|
128
|
+
await execAsync(`git branch ${branch}`, { cwd });
|
|
129
|
+
return branch;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Add a git worktree.
|
|
134
|
+
* @param {string} worktreePath - Path for the new worktree
|
|
135
|
+
* @param {string} branch - Branch to checkout in the worktree
|
|
136
|
+
* @param {string} cwd - Main repo directory
|
|
137
|
+
* @param {{ createBranch?: boolean, baseBranch?: string }} opts
|
|
138
|
+
*/
|
|
139
|
+
export async function addWorktree(worktreePath, branch, cwd, { createBranch = false, baseBranch = null } = {}) {
|
|
140
|
+
if (createBranch) {
|
|
141
|
+
await execAsync(`git worktree add "${worktreePath}" -b ${branch} ${baseBranch || 'HEAD'}`, { cwd });
|
|
142
|
+
} else {
|
|
143
|
+
await execAsync(`git worktree add "${worktreePath}" ${branch}`, { cwd });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove a git worktree.
|
|
149
|
+
* @param {string} worktreePath - Path of the worktree to remove
|
|
150
|
+
* @param {string} cwd - Main repo directory
|
|
151
|
+
* @param {{ force?: boolean }} opts
|
|
152
|
+
*/
|
|
153
|
+
export async function removeWorktree(worktreePath, cwd, { force = false } = {}) {
|
|
154
|
+
try {
|
|
155
|
+
await execAsync(`git worktree remove "${worktreePath}"${force ? ' --force' : ''}`, { cwd });
|
|
156
|
+
} catch {
|
|
157
|
+
if (!force) await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create the batch worktree from an existing batch branch ref.
|
|
163
|
+
* @param {string} batchBranch - Branch name (e.g. batch/20260228T...)
|
|
164
|
+
* @param {string} cwd - Main repo directory
|
|
165
|
+
* @returns {Promise<string>} Worktree path
|
|
166
|
+
*/
|
|
167
|
+
export async function createBatchWorktree(batchBranch, cwd) {
|
|
168
|
+
const basePath = join(os.tmpdir(), `pwn-batch-${process.pid}`);
|
|
169
|
+
const worktreePath = join(basePath, 'batch');
|
|
170
|
+
await addWorktree(worktreePath, batchBranch, cwd);
|
|
171
|
+
return worktreePath;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create a task worktree for feat/{taskId}.
|
|
176
|
+
* If the feat branch already exists, resets it to the batch tip.
|
|
177
|
+
* @param {string} taskId - Task ID
|
|
178
|
+
* @param {string} batchBranch - Batch branch name
|
|
179
|
+
* @param {string} cwd - Main repo directory
|
|
180
|
+
* @returns {Promise<{branch: string, worktreePath: string}>}
|
|
181
|
+
*/
|
|
182
|
+
export async function createTaskWorktree(taskId, batchBranch, cwd) {
|
|
183
|
+
const branch = `feat/${taskId}`;
|
|
184
|
+
const basePath = join(os.tmpdir(), `pwn-batch-${process.pid}`);
|
|
185
|
+
const worktreePath = join(basePath, `task-${taskId}`);
|
|
186
|
+
|
|
187
|
+
let branchExists = false;
|
|
188
|
+
try { await execAsync(`git rev-parse --verify ${branch}`, { cwd }); branchExists = true; } catch {}
|
|
189
|
+
|
|
190
|
+
if (branchExists) {
|
|
191
|
+
const { stdout } = await execAsync(`git rev-parse ${batchBranch}`, { cwd });
|
|
192
|
+
await execAsync(`git branch -f ${branch} ${stdout.trim()}`, { cwd });
|
|
193
|
+
await addWorktree(worktreePath, branch, cwd);
|
|
194
|
+
} else {
|
|
195
|
+
await addWorktree(worktreePath, branch, cwd, { createBranch: true, baseBranch: batchBranch });
|
|
91
196
|
}
|
|
197
|
+
return { branch, worktreePath };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create a worktree for an existing feat branch (for pre-validation).
|
|
202
|
+
* Does NOT reset the branch — inspects it as-is.
|
|
203
|
+
* @param {string} taskId - Task ID
|
|
204
|
+
* @param {string} cwd - Main repo directory
|
|
205
|
+
* @returns {Promise<{branch: string, worktreePath: string}>}
|
|
206
|
+
*/
|
|
207
|
+
export async function addExistingBranchWorktree(taskId, cwd) {
|
|
208
|
+
const branch = `feat/${taskId}`;
|
|
209
|
+
const basePath = join(os.tmpdir(), `pwn-batch-${process.pid}`);
|
|
210
|
+
const worktreePath = join(basePath, `preval-${taskId}`);
|
|
211
|
+
await addWorktree(worktreePath, branch, cwd);
|
|
212
|
+
return { branch, worktreePath };
|
|
213
|
+
}
|
|
92
214
|
|
|
93
|
-
|
|
94
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Remove all pwn-batch worktrees (from any PID) and prune.
|
|
217
|
+
* Called at startup for crash recovery and at end for cleanup.
|
|
218
|
+
* @param {string} cwd - Main repo directory
|
|
219
|
+
*/
|
|
220
|
+
export async function cleanupAllWorktrees(cwd) {
|
|
221
|
+
const prefix = join(os.tmpdir(), 'pwn-batch-');
|
|
222
|
+
try {
|
|
223
|
+
const { stdout } = await execAsync('git worktree list --porcelain', { cwd });
|
|
224
|
+
const paths = stdout.split('\n')
|
|
225
|
+
.filter(line => line.startsWith('worktree '))
|
|
226
|
+
.map(line => line.slice('worktree '.length))
|
|
227
|
+
.filter(p => p.startsWith(prefix));
|
|
228
|
+
for (const p of paths) {
|
|
229
|
+
try { await execAsync(`git worktree remove "${p}" --force`, { cwd }); } catch {}
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
try { await execAsync('git worktree prune', { cwd }); } catch {}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Symlink node_modules from mainCwd into worktree so quality gates work.
|
|
237
|
+
* @param {string} worktreePath - Worktree directory
|
|
238
|
+
* @param {string} mainCwd - Main repo directory
|
|
239
|
+
*/
|
|
240
|
+
const DEP_DIRS = ['node_modules', '.venv', 'venv', 'vendor'];
|
|
241
|
+
|
|
242
|
+
export function prepareWorktree(worktreePath, mainCwd) {
|
|
243
|
+
// Symlink dependency directories so quality gates work in worktrees
|
|
244
|
+
for (const dir of DEP_DIRS) {
|
|
245
|
+
const source = join(mainCwd, dir);
|
|
246
|
+
const target = join(worktreePath, dir);
|
|
247
|
+
if (existsSync(source) && !existsSync(target)) {
|
|
248
|
+
symlinkSync(source, target);
|
|
249
|
+
}
|
|
95
250
|
}
|
|
96
251
|
}
|
|
97
252
|
|
|
98
253
|
/**
|
|
99
|
-
*
|
|
254
|
+
* Remove dep symlinks from worktree so git merge doesn't choke on untracked files.
|
|
255
|
+
* @param {string} worktreePath - Worktree directory
|
|
256
|
+
*/
|
|
257
|
+
export function unprepareWorktree(worktreePath) {
|
|
258
|
+
for (const dir of DEP_DIRS) {
|
|
259
|
+
const target = join(worktreePath, dir);
|
|
260
|
+
try {
|
|
261
|
+
if (lstatSync(target).isSymbolicLink()) unlinkSync(target);
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create a batch branch from the current HEAD.
|
|
268
|
+
* Fails fast if the working tree has dirty tracked files (metadata is excluded).
|
|
269
|
+
* @deprecated Use createBatchRef + createBatchWorktree instead
|
|
100
270
|
* @param {string} cwd - Working directory
|
|
101
271
|
* @returns {Promise<string>} Branch name (batch/{timestamp})
|
|
102
272
|
*/
|
|
103
273
|
export async function createBatchBranch(cwd = process.cwd()) {
|
|
274
|
+
// Check for dirty tracked files (excluding gitignored metadata)
|
|
275
|
+
try {
|
|
276
|
+
const { stdout: dirty } = await execAsync('git diff --name-only HEAD', { cwd });
|
|
277
|
+
const { stdout: staged } = await execAsync('git diff --cached --name-only', { cwd });
|
|
278
|
+
const allDirty = (dirty + staged).trim();
|
|
279
|
+
if (allDirty) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`Working tree has uncommitted changes:\n${allDirty}\n\n` +
|
|
282
|
+
'Commit or stash your changes before starting a batch run.'
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// If the error is our own, rethrow
|
|
287
|
+
if (err.message.includes('uncommitted changes')) throw err;
|
|
288
|
+
// Otherwise (e.g. no HEAD yet), proceed
|
|
289
|
+
}
|
|
290
|
+
|
|
104
291
|
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
105
292
|
const branch = `batch/${ts}`;
|
|
106
293
|
await execAsync(`git checkout -b ${branch}`, { cwd });
|
|
@@ -545,15 +732,46 @@ export function markStoryDone(taskId, cwd = process.cwd()) {
|
|
|
545
732
|
return false;
|
|
546
733
|
}
|
|
547
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Commit ONLY prd.json on the current branch.
|
|
737
|
+
* Used after markStoryDone to persist the "passes: true" update
|
|
738
|
+
* before switching branches. No-op if prd.json has no changes.
|
|
739
|
+
* @param {string} taskId - Task ID for commit message
|
|
740
|
+
* @param {string} cwd - Working directory
|
|
741
|
+
* @returns {Promise<{success: boolean, noChanges?: boolean, error?: string}>}
|
|
742
|
+
*/
|
|
743
|
+
export async function commitPrdUpdate(taskId, cwd = process.cwd()) {
|
|
744
|
+
try {
|
|
745
|
+
await execAsync('git add .ai/tasks/prd.json', { cwd });
|
|
746
|
+
|
|
747
|
+
// Check if there's actually a change staged
|
|
748
|
+
try {
|
|
749
|
+
await execAsync('git diff --cached --quiet -- .ai/tasks/prd.json', { cwd });
|
|
750
|
+
return { success: true, noChanges: true };
|
|
751
|
+
} catch {
|
|
752
|
+
// There are changes, commit them
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
await execAsync(
|
|
756
|
+
`git commit -m "chore: mark ${taskId} done in prd.json"`,
|
|
757
|
+
{ cwd }
|
|
758
|
+
);
|
|
759
|
+
return { success: true };
|
|
760
|
+
} catch (error) {
|
|
761
|
+
return { success: false, error: error.message };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
548
765
|
/**
|
|
549
766
|
* Run a single quality gate
|
|
550
767
|
* @param {string} gate - Gate name (typecheck, lint, test, build, security)
|
|
551
|
-
* @param {string} cwd - Working directory
|
|
768
|
+
* @param {string} cwd - Working directory where the gate command runs
|
|
769
|
+
* @param {{ configCwd?: string }} opts - configCwd: directory to load config from (defaults to cwd)
|
|
552
770
|
* @returns {Promise<{success: boolean, output?: string, error?: string}>}
|
|
553
771
|
*/
|
|
554
|
-
export async function runQualityGate(gate, cwd = process.cwd()) {
|
|
555
|
-
// Check for custom gate commands in config
|
|
556
|
-
const config = loadConfig(cwd);
|
|
772
|
+
export async function runQualityGate(gate, cwd = process.cwd(), { configCwd } = {}) {
|
|
773
|
+
// Check for custom gate commands in config (from configCwd, not execution cwd)
|
|
774
|
+
const config = loadConfig(configCwd || cwd);
|
|
557
775
|
const customCmd = config.gate_commands?.[gate];
|
|
558
776
|
const commands = customCmd ? [customCmd] : GATE_COMMANDS[gate];
|
|
559
777
|
|
|
@@ -690,9 +908,16 @@ export async function commitTask(task, options = {}, cwd = process.cwd()) {
|
|
|
690
908
|
const config = loadConfig(cwd);
|
|
691
909
|
|
|
692
910
|
try {
|
|
693
|
-
// Stage all changes
|
|
911
|
+
// Stage all changes (respects .gitignore)
|
|
694
912
|
await execAsync('git add .', { cwd });
|
|
695
913
|
|
|
914
|
+
// Defense-in-depth: unstage metadata and worktree symlinks
|
|
915
|
+
try {
|
|
916
|
+
await execAsync('git reset HEAD -- ".ai/batch/" ".ai/state.json" "logs/" "node_modules" ".venv" "venv" "vendor"', { cwd });
|
|
917
|
+
} catch {
|
|
918
|
+
// Files may not exist in index — that's fine
|
|
919
|
+
}
|
|
920
|
+
|
|
696
921
|
// Check if there are changes to commit
|
|
697
922
|
try {
|
|
698
923
|
await execAsync('git diff --cached --quiet', { cwd });
|