@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fermindi/pwn-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Professional AI Workspace - Inject structured memory and automation into any project for AI-powered development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@ import {
34
34
  } from './batch-service.js';
35
35
 
36
36
  // --- Constants ---
37
- const RUNNER_VERSION = '2.1';
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
- // Stash uncommitted metadata (task files etc.) before switching
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 (stash untracked + force for dirty state) ---
657
+ // --- Return to batch branch ---
602
658
  if (taskBranch) {
603
659
  try {
604
- try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
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 execAsync(`git checkout ${branch}`, { cwd });
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
- // Restore stashed files on the new branch
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
- await execAsync(`git checkout${flag} ${branch}`, { cwd });
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
  /**