@fermindi/pwn-cli 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fermindi/pwn-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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": {
@@ -9,10 +9,13 @@
9
9
  * Completed files are cleaned up at the end; failed are kept for review.
10
10
  */
11
11
 
12
- import { spawn } from 'child_process';
12
+ import { spawn, exec } from 'child_process';
13
13
  import { existsSync, readFileSync, writeFileSync, mkdirSync, createWriteStream, appendFileSync, unlinkSync, readdirSync } from 'fs';
14
14
  import { join } from 'path';
15
+ import { promisify } from 'util';
15
16
  import ora from 'ora';
17
+
18
+ const execAsync = promisify(exec);
16
19
  import chalk from 'chalk';
17
20
  import {
18
21
  parsePrdTasks,
@@ -22,7 +25,12 @@ import {
22
25
  loadConfig,
23
26
  commitTask,
24
27
  updateBatchState,
25
- clearBatchState
28
+ clearBatchState,
29
+ getCurrentBranch,
30
+ createTaskBranch,
31
+ checkoutBranch,
32
+ createBatchBranch,
33
+ mergeBranch
26
34
  } from './batch-service.js';
27
35
 
28
36
  // --- Constants ---
@@ -274,10 +282,21 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
274
282
 
275
283
  // NO custom SIGINT handler — Ctrl+C uses default Node.js behavior (kills process group)
276
284
 
285
+ // --- Save original branch for isolation ---
286
+ const originalBranch = await getCurrentBranch(cwd);
287
+
288
+ // --- Create batch branch ---
289
+ const batchBranch = await createBatchBranch(cwd);
290
+ console.log(chalk.blue(` Batch branch: ${chalk.bold(batchBranch)}`));
291
+
277
292
  // --- Main loop ---
278
293
  let iteration = 0;
279
294
  let noProgressCount = 0;
280
295
  let storiesCompleted = 0;
296
+ let mergedCount = 0;
297
+ const branchesCreated = [];
298
+ const mergedBranches = [];
299
+ const unmergedBranches = [];
281
300
  const batchStart = Date.now();
282
301
 
283
302
  updateBatchState({
@@ -372,6 +391,23 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
372
391
  }
373
392
  }
374
393
 
394
+ // --- Branch isolation: create/checkout task branch ---
395
+ let taskBranch = null;
396
+ try {
397
+ taskBranch = await createTaskBranch(task.id, cwd);
398
+ branchesCreated.push(taskBranch);
399
+ console.log(chalk.blue(` Branch: ${chalk.bold(taskBranch)}`));
400
+
401
+ // Track branch in task file
402
+ if (taskFile) {
403
+ taskFile.branch = taskBranch;
404
+ saveTaskFile(taskFile, cwd);
405
+ }
406
+ } catch (err) {
407
+ console.log(chalk.red(` Failed to create branch feat/${task.id}: ${err.message}`));
408
+ console.log(chalk.yellow(' Continuing on current branch...'));
409
+ }
410
+
375
411
  // --- Phase 2: Execution ---
376
412
  const phaseLabel = noPlan ? '' : 'Phase 2';
377
413
  console.log(chalk.blue(` ${noPlan ? 'Executing' : 'Phase 2: Executing'} ${task.id}...`));
@@ -400,8 +436,14 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
400
436
  // Killed by signal (user did kill or Ctrl+C) — don't retry, exit
401
437
  if (result.signal) {
402
438
  console.log(chalk.yellow(` Killed by ${result.signal}`));
439
+ if (taskBranch) {
440
+ try {
441
+ try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
442
+ await checkoutBranch(batchBranch, cwd, { force: true });
443
+ } catch {}
444
+ }
403
445
  clearBatchState(cwd);
404
- printSummary(cwd, iteration, storiesCompleted, batchStart);
446
+ printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
405
447
  return;
406
448
  }
407
449
 
@@ -504,6 +546,27 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
504
546
  current_task: null,
505
547
  last_completed_at: new Date().toISOString()
506
548
  }, cwd);
549
+
550
+ // Merge successful task branch into batch branch
551
+ if (taskBranch) {
552
+ try {
553
+ // Stash uncommitted metadata (task files etc.) before switching
554
+ await execAsync('git stash --include-untracked', { cwd });
555
+ await checkoutBranch(batchBranch, cwd);
556
+ await mergeBranch(taskBranch, cwd);
557
+ mergedCount++;
558
+ mergedBranches.push(taskBranch);
559
+ console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
560
+ // Restore stashed metadata on batch branch
561
+ try { await execAsync('git stash pop', { cwd }); } catch {}
562
+ } catch (err) {
563
+ console.log(chalk.red(` Merge failed: ${err.message}`));
564
+ console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
565
+ unmergedBranches.push(taskBranch);
566
+ // Drop stash if checkout/merge failed (still on feat branch)
567
+ try { await execAsync('git stash drop', { cwd }); } catch {}
568
+ }
569
+ }
507
570
  } else {
508
571
  console.log(chalk.red(` Quality gates FAILED`));
509
572
  errorContext = gatesResult.errorOutput;
@@ -535,6 +598,21 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
535
598
  noProgressCount++;
536
599
  }
537
600
 
601
+ // --- Return to batch branch (stash untracked + force for dirty state) ---
602
+ if (taskBranch) {
603
+ 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 {}
607
+ console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
608
+ } catch (err) {
609
+ console.log(chalk.red(` Warning: failed to return to ${batchBranch}: ${err.message}`));
610
+ }
611
+ if (!storyDone) {
612
+ unmergedBranches.push(taskBranch);
613
+ }
614
+ }
615
+
538
616
  if (noProgressCount >= MAX_NO_PROGRESS) {
539
617
  console.log(chalk.red(`\nCIRCUIT BREAKER: ${MAX_NO_PROGRESS} consecutive failures. Stopping.`));
540
618
  break;
@@ -542,7 +620,7 @@ export async function runBatch(options = {}, cwd = process.cwd()) {
542
620
  }
543
621
 
544
622
  clearBatchState(cwd);
545
- printSummary(cwd, iteration, storiesCompleted, batchStart);
623
+ printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
546
624
  }
547
625
 
548
626
  /**
@@ -791,7 +869,7 @@ function printHeader(maxIter, phase, total, done, noPlan = false, cwd = process.
791
869
  console.log(chalk.dim('─'.repeat(40)));
792
870
  }
793
871
 
794
- function printSummary(cwd, iterations, completed, startTime) {
872
+ function printSummary(cwd, iterations, completed, startTime, branchesCreated = [], batchBranch = null, originalBranch = null, mergedCount = 0, unmergedBranches = []) {
795
873
  const stories = parsePrdTasks(cwd);
796
874
  const total = stories.length;
797
875
  const done = stories.filter(s => s.passes).length;
@@ -828,6 +906,21 @@ function printSummary(cwd, iterations, completed, startTime) {
828
906
  if (cleaned > 0 || failedKept > 0) {
829
907
  console.log(` Cleanup: ${chalk.green(`${cleaned} completed`)} removed, ${failedKept > 0 ? chalk.red(`${failedKept} failed`) : '0 failed'} kept for review`);
830
908
  }
909
+ if (batchBranch) {
910
+ console.log(` Batch branch: ${chalk.cyan(batchBranch)}`);
911
+ console.log(` Merged: ${chalk.green(mergedCount)} tasks merged into batch branch`);
912
+ if (unmergedBranches.length > 0) {
913
+ const unique = [...new Set(unmergedBranches)];
914
+ console.log(` Unmerged: ${chalk.yellow(unique.length)} isolated: ${unique.map(b => chalk.yellow(b)).join(', ')}`);
915
+ }
916
+ }
917
+ if (branchesCreated.length > 0) {
918
+ const unique = [...new Set(branchesCreated)];
919
+ console.log(` Branches: ${unique.map(b => chalk.cyan(b)).join(', ')}`);
920
+ }
921
+ if (originalBranch) {
922
+ console.log(` Original branch: ${chalk.green(originalBranch)} (intact)`);
923
+ }
831
924
  console.log(chalk.dim('─'.repeat(40)));
832
925
  console.log('');
833
926
  }
@@ -13,6 +13,79 @@ 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
+ let exists = false;
38
+ try {
39
+ await execAsync(`git rev-parse --verify ${branch}`, { cwd });
40
+ exists = true;
41
+ } catch {
42
+ // Branch doesn't exist
43
+ }
44
+
45
+ if (exists) {
46
+ // Rerun — checkout and reset to current HEAD
47
+ // so we don't carry stale/dirty state from previous attempt
48
+ const { stdout } = await execAsync('git rev-parse HEAD', { cwd });
49
+ const baseRef = stdout.trim();
50
+ await execAsync(`git checkout ${branch}`, { cwd });
51
+ await execAsync(`git reset --hard ${baseRef}`, { cwd });
52
+ } else {
53
+ await execAsync(`git checkout -b ${branch}`, { cwd });
54
+ }
55
+ return branch;
56
+ }
57
+
58
+ /**
59
+ * Checkout an existing branch
60
+ * @param {string} branch - Branch name
61
+ * @param {string} cwd - Working directory
62
+ */
63
+ export async function checkoutBranch(branch, cwd = process.cwd(), { force = false } = {}) {
64
+ const flag = force ? ' --force' : '';
65
+ await execAsync(`git checkout${flag} ${branch}`, { cwd });
66
+ }
67
+
68
+ /**
69
+ * Create a batch branch from the current HEAD
70
+ * @param {string} cwd - Working directory
71
+ * @returns {Promise<string>} Branch name (batch/{timestamp})
72
+ */
73
+ export async function createBatchBranch(cwd = process.cwd()) {
74
+ const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
75
+ const branch = `batch/${ts}`;
76
+ await execAsync(`git checkout -b ${branch}`, { cwd });
77
+ return branch;
78
+ }
79
+
80
+ /**
81
+ * Merge a source branch into the current branch
82
+ * @param {string} source - Source branch name
83
+ * @param {string} cwd - Working directory
84
+ */
85
+ export async function mergeBranch(source, cwd = process.cwd()) {
86
+ await execAsync(`git merge ${source} --no-edit`, { cwd });
87
+ }
88
+
16
89
  /**
17
90
  * Default batch configuration
18
91
  */