@fermindi/pwn-cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fermindi/pwn-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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": {
@@ -22,7 +22,12 @@ import {
22
22
  loadConfig,
23
23
  commitTask,
24
24
  updateBatchState,
25
- clearBatchState
25
+ clearBatchState,
26
+ getCurrentBranch,
27
+ createTaskBranch,
28
+ checkoutBranch,
29
+ createBatchBranch,
30
+ mergeBranch
26
31
  } from './batch-service.js';
27
32
 
28
33
  // --- Constants ---
@@ -274,10 +279,21 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
274
279
 
275
280
  // NO custom SIGINT handler — Ctrl+C uses default Node.js behavior (kills process group)
276
281
 
282
+ // --- Save original branch for isolation ---
283
+ const originalBranch = await getCurrentBranch(cwd);
284
+
285
+ // --- Create batch branch ---
286
+ const batchBranch = await createBatchBranch(cwd);
287
+ console.log(chalk.blue(` Batch branch: ${chalk.bold(batchBranch)}`));
288
+
277
289
  // --- Main loop ---
278
290
  let iteration = 0;
279
291
  let noProgressCount = 0;
280
292
  let storiesCompleted = 0;
293
+ let mergedCount = 0;
294
+ const branchesCreated = [];
295
+ const mergedBranches = [];
296
+ const unmergedBranches = [];
281
297
  const batchStart = Date.now();
282
298
 
283
299
  updateBatchState({
@@ -372,6 +388,23 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
372
388
  }
373
389
  }
374
390
 
391
+ // --- Branch isolation: create/checkout task branch ---
392
+ let taskBranch = null;
393
+ try {
394
+ taskBranch = await createTaskBranch(task.id, cwd);
395
+ branchesCreated.push(taskBranch);
396
+ console.log(chalk.blue(` Branch: ${chalk.bold(taskBranch)}`));
397
+
398
+ // Track branch in task file
399
+ if (taskFile) {
400
+ taskFile.branch = taskBranch;
401
+ saveTaskFile(taskFile, cwd);
402
+ }
403
+ } catch (err) {
404
+ console.log(chalk.red(` Failed to create branch feat/${task.id}: ${err.message}`));
405
+ console.log(chalk.yellow(' Continuing on current branch...'));
406
+ }
407
+
375
408
  // --- Phase 2: Execution ---
376
409
  const phaseLabel = noPlan ? '' : 'Phase 2';
377
410
  console.log(chalk.blue(` ${noPlan ? 'Executing' : 'Phase 2: Executing'} ${task.id}...`));
@@ -400,8 +433,11 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
400
433
  // Killed by signal (user did kill or Ctrl+C) — don't retry, exit
401
434
  if (result.signal) {
402
435
  console.log(chalk.yellow(` Killed by ${result.signal}`));
436
+ if (taskBranch) {
437
+ try { await checkoutBranch(batchBranch, cwd, { force: true }); } catch {}
438
+ }
403
439
  clearBatchState(cwd);
404
- printSummary(cwd, iteration, storiesCompleted, batchStart);
440
+ printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
405
441
  return;
406
442
  }
407
443
 
@@ -504,6 +540,21 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
504
540
  current_task: null,
505
541
  last_completed_at: new Date().toISOString()
506
542
  }, cwd);
543
+
544
+ // Merge successful task branch into batch branch
545
+ if (taskBranch) {
546
+ try {
547
+ await checkoutBranch(batchBranch, cwd);
548
+ await mergeBranch(taskBranch, cwd);
549
+ mergedCount++;
550
+ mergedBranches.push(taskBranch);
551
+ console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
552
+ } catch (err) {
553
+ console.log(chalk.red(` Merge failed: ${err.message}`));
554
+ console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
555
+ unmergedBranches.push(taskBranch);
556
+ }
557
+ }
507
558
  } else {
508
559
  console.log(chalk.red(` Quality gates FAILED`));
509
560
  errorContext = gatesResult.errorOutput;
@@ -535,6 +586,19 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
535
586
  noProgressCount++;
536
587
  }
537
588
 
589
+ // --- Return to batch branch (force: discard dirty state from failed tasks) ---
590
+ if (taskBranch) {
591
+ try {
592
+ await checkoutBranch(batchBranch, cwd, { force: true });
593
+ console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
594
+ } catch (err) {
595
+ console.log(chalk.red(` Warning: failed to return to ${batchBranch}: ${err.message}`));
596
+ }
597
+ if (!storyDone) {
598
+ unmergedBranches.push(taskBranch);
599
+ }
600
+ }
601
+
538
602
  if (noProgressCount >= MAX_NO_PROGRESS) {
539
603
  console.log(chalk.red(`\nCIRCUIT BREAKER: ${MAX_NO_PROGRESS} consecutive failures. Stopping.`));
540
604
  break;
@@ -542,7 +606,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
542
606
  }
543
607
 
544
608
  clearBatchState(cwd);
545
- printSummary(cwd, iteration, storiesCompleted, batchStart);
609
+ printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
546
610
  }
547
611
 
548
612
  /**
@@ -791,7 +855,7 @@ function printHeader(maxIter, phase, total, done, noPlan = false, cwd = process.
791
855
  console.log(chalk.dim('─'.repeat(40)));
792
856
  }
793
857
 
794
- function printSummary(cwd, iterations, completed, startTime) {
858
+ function printSummary(cwd, iterations, completed, startTime, branchesCreated = [], batchBranch = null, originalBranch = null, mergedCount = 0, unmergedBranches = []) {
795
859
  const stories = parsePrdTasks(cwd);
796
860
  const total = stories.length;
797
861
  const done = stories.filter(s => s.passes).length;
@@ -828,6 +892,21 @@ function printSummary(cwd, iterations, completed, startTime) {
828
892
  if (cleaned > 0 || failedKept > 0) {
829
893
  console.log(` Cleanup: ${chalk.green(`${cleaned} completed`)} removed, ${failedKept > 0 ? chalk.red(`${failedKept} failed`) : '0 failed'} kept for review`);
830
894
  }
895
+ if (batchBranch) {
896
+ console.log(` Batch branch: ${chalk.cyan(batchBranch)}`);
897
+ console.log(` Merged: ${chalk.green(mergedCount)} tasks merged into batch branch`);
898
+ if (unmergedBranches.length > 0) {
899
+ const unique = [...new Set(unmergedBranches)];
900
+ console.log(` Unmerged: ${chalk.yellow(unique.length)} isolated: ${unique.map(b => chalk.yellow(b)).join(', ')}`);
901
+ }
902
+ }
903
+ if (branchesCreated.length > 0) {
904
+ const unique = [...new Set(branchesCreated)];
905
+ console.log(` Branches: ${unique.map(b => chalk.cyan(b)).join(', ')}`);
906
+ }
907
+ if (originalBranch) {
908
+ console.log(` Original branch: ${chalk.green(originalBranch)} (intact)`);
909
+ }
831
910
  console.log(chalk.dim('─'.repeat(40)));
832
911
  console.log('');
833
912
  }
@@ -13,6 +13,73 @@ import { getState, updateState, hasWorkspace } from '../core/state.js';
13
13
 
14
14
  const execAsync = promisify(exec);
15
15
 
16
+ // --- Git Helpers ---
17
+
18
+ /**
19
+ * Get the current git branch name
20
+ * @param {string} cwd - Working directory
21
+ * @returns {Promise<string>} Current branch name
22
+ */
23
+ export async function getCurrentBranch(cwd = process.cwd()) {
24
+ const { stdout } = await execAsync('git branch --show-current', { cwd });
25
+ return stdout.trim();
26
+ }
27
+
28
+ /**
29
+ * Create and checkout a task branch from the current HEAD
30
+ * If branch already exists, just checkout.
31
+ * @param {string} taskId - Task ID (e.g. "SEC-007")
32
+ * @param {string} cwd - Working directory
33
+ * @returns {Promise<string>} Branch name
34
+ */
35
+ export async function createTaskBranch(taskId, cwd = process.cwd()) {
36
+ const branch = `feat/${taskId}`;
37
+ try {
38
+ await execAsync(`git rev-parse --verify ${branch}`, { cwd });
39
+ // Branch exists (rerun) — checkout and reset to current HEAD
40
+ // so we don't carry stale/dirty state from previous attempt
41
+ const { stdout } = await execAsync('git rev-parse HEAD', { cwd });
42
+ const baseRef = stdout.trim();
43
+ await execAsync(`git checkout ${branch}`, { cwd });
44
+ await execAsync(`git reset --hard ${baseRef}`, { cwd });
45
+ } catch {
46
+ // Branch doesn't exist — create
47
+ await execAsync(`git checkout -b ${branch}`, { cwd });
48
+ }
49
+ return branch;
50
+ }
51
+
52
+ /**
53
+ * Checkout an existing branch
54
+ * @param {string} branch - Branch name
55
+ * @param {string} cwd - Working directory
56
+ */
57
+ export async function checkoutBranch(branch, cwd = process.cwd(), { force = false } = {}) {
58
+ const flag = force ? ' --force' : '';
59
+ await execAsync(`git checkout${flag} ${branch}`, { cwd });
60
+ }
61
+
62
+ /**
63
+ * Create a batch branch from the current HEAD
64
+ * @param {string} cwd - Working directory
65
+ * @returns {Promise<string>} Branch name (batch/{timestamp})
66
+ */
67
+ export async function createBatchBranch(cwd = process.cwd()) {
68
+ const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
69
+ const branch = `batch/${ts}`;
70
+ await execAsync(`git checkout -b ${branch}`, { cwd });
71
+ return branch;
72
+ }
73
+
74
+ /**
75
+ * Merge a source branch into the current branch
76
+ * @param {string} source - Source branch name
77
+ * @param {string} cwd - Working directory
78
+ */
79
+ export async function mergeBranch(source, cwd = process.cwd()) {
80
+ await execAsync(`git merge ${source} --no-edit`, { cwd });
81
+ }
82
+
16
83
  /**
17
84
  * Default batch configuration
18
85
  */