@fermindi/pwn-cli 0.9.2 → 0.9.4
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 +71 -17
- package/src/services/batch-service.js +34 -16
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from './batch-service.js';
|
|
35
35
|
|
|
36
36
|
// --- Constants ---
|
|
37
|
-
const RUNNER_VERSION = '2.
|
|
37
|
+
const RUNNER_VERSION = '2.3';
|
|
38
38
|
const DEFAULT_TIMEOUT_MS = 900_000; // 15 minutes fallback
|
|
39
39
|
const MIN_TIMEOUT_MS = 300_000; // 5 minutes minimum (claude init ~30-40s + real work)
|
|
40
40
|
|
|
@@ -408,13 +408,78 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
408
408
|
console.log(chalk.yellow(' Continuing on current branch...'));
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
// --- Pre-validation: check if existing branch already passes gates ---
|
|
412
|
+
let storyDone = false;
|
|
413
|
+
if (taskBranch) {
|
|
414
|
+
// Check if this branch has commits beyond the batch branch
|
|
415
|
+
try {
|
|
416
|
+
const { stdout: diffStat } = await execAsync('git diff --stat HEAD~1 2>/dev/null || echo ""', { cwd });
|
|
417
|
+
const { stdout: logCount } = await execAsync(`git rev-list --count ${batchBranch}..HEAD`, { cwd });
|
|
418
|
+
const commits = parseInt(logCount.trim(), 10);
|
|
419
|
+
|
|
420
|
+
if (commits > 0) {
|
|
421
|
+
console.log(chalk.blue(` Pre-validation: found ${commits} commit(s) on ${taskBranch}, running quality gates...`));
|
|
422
|
+
const preGates = await runGatesWithStatus(cwd);
|
|
423
|
+
|
|
424
|
+
if (preGates.success) {
|
|
425
|
+
console.log(chalk.green(` Pre-validation PASSED — skipping execution`));
|
|
426
|
+
markStoryDone(task.id, cwd);
|
|
427
|
+
appendProgress(progressPath, task.id, 'Pre-validation: existing code passed quality gates');
|
|
428
|
+
|
|
429
|
+
storyDone = true;
|
|
430
|
+
storiesCompleted++;
|
|
431
|
+
noProgressCount = 0;
|
|
432
|
+
|
|
433
|
+
if (taskFile) {
|
|
434
|
+
taskFile.status = 'completed';
|
|
435
|
+
taskFile.completed_at = new Date().toISOString();
|
|
436
|
+
saveTaskFile(taskFile, cwd);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
updateBatchState({
|
|
440
|
+
completed: [task.id],
|
|
441
|
+
current_task: null,
|
|
442
|
+
last_completed_at: new Date().toISOString()
|
|
443
|
+
}, cwd);
|
|
444
|
+
|
|
445
|
+
// Merge into batch branch
|
|
446
|
+
try {
|
|
447
|
+
await checkoutBranch(batchBranch, cwd, { stash: true });
|
|
448
|
+
await mergeBranch(taskBranch, cwd);
|
|
449
|
+
mergedCount++;
|
|
450
|
+
mergedBranches.push(taskBranch);
|
|
451
|
+
console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
|
|
452
|
+
} catch (err) {
|
|
453
|
+
console.log(chalk.red(` Merge failed: ${err.message}`));
|
|
454
|
+
console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
|
|
455
|
+
unmergedBranches.push(taskBranch);
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
console.log(chalk.yellow(` Pre-validation FAILED — will re-execute`));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch {
|
|
462
|
+
// No commits or comparison failed — proceed normally
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (storyDone) {
|
|
467
|
+
// Return to batch branch
|
|
468
|
+
if (taskBranch) {
|
|
469
|
+
try {
|
|
470
|
+
await checkoutBranch(batchBranch, cwd, { force: true, stash: true });
|
|
471
|
+
console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
|
|
472
|
+
} catch {}
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
411
477
|
// --- Phase 2: Execution ---
|
|
412
478
|
const phaseLabel = noPlan ? '' : 'Phase 2';
|
|
413
479
|
console.log(chalk.blue(` ${noPlan ? 'Executing' : 'Phase 2: Executing'} ${task.id}...`));
|
|
414
480
|
|
|
415
481
|
let retry = 0;
|
|
416
482
|
let rateLimitAttempts = 0;
|
|
417
|
-
let storyDone = false;
|
|
418
483
|
let errorContext = '';
|
|
419
484
|
|
|
420
485
|
while (retry <= MAX_RETRIES && !storyDone) {
|
|
@@ -437,10 +502,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
437
502
|
if (result.signal) {
|
|
438
503
|
console.log(chalk.yellow(` Killed by ${result.signal}`));
|
|
439
504
|
if (taskBranch) {
|
|
440
|
-
try {
|
|
441
|
-
try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
|
|
442
|
-
await checkoutBranch(batchBranch, cwd, { force: true });
|
|
443
|
-
} catch {}
|
|
505
|
+
try { await checkoutBranch(batchBranch, cwd, { force: true, stash: true }); } catch {}
|
|
444
506
|
}
|
|
445
507
|
clearBatchState(cwd);
|
|
446
508
|
printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
|
|
@@ -550,21 +612,15 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
550
612
|
// Merge successful task branch into batch branch
|
|
551
613
|
if (taskBranch) {
|
|
552
614
|
try {
|
|
553
|
-
|
|
554
|
-
await execAsync('git stash --include-untracked', { cwd });
|
|
555
|
-
await checkoutBranch(batchBranch, cwd);
|
|
615
|
+
await checkoutBranch(batchBranch, cwd, { stash: true });
|
|
556
616
|
await mergeBranch(taskBranch, cwd);
|
|
557
617
|
mergedCount++;
|
|
558
618
|
mergedBranches.push(taskBranch);
|
|
559
619
|
console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
|
|
560
|
-
// Restore stashed metadata on batch branch
|
|
561
|
-
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
562
620
|
} catch (err) {
|
|
563
621
|
console.log(chalk.red(` Merge failed: ${err.message}`));
|
|
564
622
|
console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
|
|
565
623
|
unmergedBranches.push(taskBranch);
|
|
566
|
-
// Drop stash if checkout/merge failed (still on feat branch)
|
|
567
|
-
try { await execAsync('git stash drop', { cwd }); } catch {}
|
|
568
624
|
}
|
|
569
625
|
}
|
|
570
626
|
} else {
|
|
@@ -598,12 +654,10 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
|
|
|
598
654
|
noProgressCount++;
|
|
599
655
|
}
|
|
600
656
|
|
|
601
|
-
// --- Return to batch branch
|
|
657
|
+
// --- Return to batch branch ---
|
|
602
658
|
if (taskBranch) {
|
|
603
659
|
try {
|
|
604
|
-
|
|
605
|
-
await checkoutBranch(batchBranch, cwd, { force: true });
|
|
606
|
-
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
660
|
+
await checkoutBranch(batchBranch, cwd, { force: true, stash: true });
|
|
607
661
|
console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
|
|
608
662
|
} catch (err) {
|
|
609
663
|
console.log(chalk.red(` Warning: failed to return to ${batchBranch}: ${err.message}`));
|
|
@@ -42,39 +42,57 @@ export async function createTaskBranch(taskId, cwd = process.cwd()) {
|
|
|
42
42
|
// Branch doesn't exist
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Stash untracked files (task metadata etc.) that block checkout
|
|
46
|
-
let stashed = false;
|
|
47
|
-
try {
|
|
48
|
-
const { stdout: stashOut } = await execAsync('git stash --include-untracked', { cwd });
|
|
49
|
-
stashed = !stashOut.includes('No local changes');
|
|
50
|
-
} catch {}
|
|
51
|
-
|
|
52
45
|
if (exists) {
|
|
53
46
|
// Rerun — checkout and reset to current HEAD
|
|
54
47
|
// so we don't carry stale/dirty state from previous attempt
|
|
55
48
|
const { stdout } = await execAsync('git rev-parse HEAD', { cwd });
|
|
56
49
|
const baseRef = stdout.trim();
|
|
57
|
-
await
|
|
50
|
+
await checkoutBranch(branch, cwd, { stash: true });
|
|
58
51
|
await execAsync(`git reset --hard ${baseRef}`, { cwd });
|
|
59
52
|
} 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 {}
|
|
60
59
|
await execAsync(`git checkout -b ${branch}`, { cwd });
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (stashed) {
|
|
65
|
-
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
60
|
+
if (didStash) {
|
|
61
|
+
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
62
|
+
}
|
|
66
63
|
}
|
|
67
64
|
return branch;
|
|
68
65
|
}
|
|
69
66
|
|
|
70
67
|
/**
|
|
71
|
-
* Checkout an existing branch
|
|
68
|
+
* Checkout an existing branch.
|
|
69
|
+
* When stash=true, stashes untracked/dirty files before checkout and
|
|
70
|
+
* restores them after — prevents .ai/batch/tasks/*.json from blocking.
|
|
72
71
|
* @param {string} branch - Branch name
|
|
73
72
|
* @param {string} cwd - Working directory
|
|
73
|
+
* @param {{ force?: boolean, stash?: boolean }} options
|
|
74
74
|
*/
|
|
75
|
-
export async function checkoutBranch(branch, cwd = process.cwd(), { force = false } = {}) {
|
|
75
|
+
export async function checkoutBranch(branch, cwd = process.cwd(), { force = false, stash = 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
|
+
|
|
76
84
|
const flag = force ? ' --force' : '';
|
|
77
|
-
|
|
85
|
+
try {
|
|
86
|
+
await execAsync(`git checkout${flag} ${branch}`, { cwd });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// If checkout failed, drop stash so it doesn't pile up
|
|
89
|
+
if (didStash) { try { await execAsync('git stash drop', { cwd }); } catch {} }
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (didStash) {
|
|
94
|
+
try { await execAsync('git stash pop', { cwd }); } catch {}
|
|
95
|
+
}
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
/**
|