@fermindi/pwn-cli 0.9.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fermindi/pwn-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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": {
@@ -24,17 +24,23 @@ import {
24
24
  runQualityGate,
25
25
  loadConfig,
26
26
  commitTask,
27
+ commitPrdUpdate,
27
28
  updateBatchState,
28
29
  clearBatchState,
29
- getCurrentBranch,
30
- createTaskBranch,
31
- checkoutBranch,
32
- createBatchBranch,
33
- mergeBranch
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 = '2.3';
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, cwd, replanContext = null) {
118
- const prdPath = join(cwd, '.ai', 'tasks', 'prd.json');
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, cwd, replanContext = null) {
162
- const prompt = buildPlanPrompt(task, cwd, replanContext);
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 prdPath = join(cwd, '.ai', 'tasks', 'prd.json');
239
- const promptPath = join(cwd, '.ai', 'batch', 'prompt.md');
240
- const logDir = join(cwd, 'logs');
241
- const progressPath = join(cwd, '.ai', 'batch', 'progress.txt');
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(cwd), { recursive: true });
263
+ mkdirSync(getTasksDir(mainCwd), { recursive: true });
257
264
 
258
- const stories = parsePrdTasks(cwd);
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 remaining eligible stories (respecting filters)
265
- const doneIds = stories.filter(s => s.passes).map(s => s.id);
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 eligibleCount = stories.filter(s =>
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 || eligibleCount;
280
+ const maxIterations = options.maxIterations || matchingCount;
274
281
 
275
282
  // --- Dry run ---
276
283
  if (options.dryRun) {
277
- return dryRunPreview(cwd, phaseFilter, maxIterations, taskFilter);
284
+ return dryRunPreview(mainCwd, phaseFilter, maxIterations, taskFilter);
278
285
  }
279
286
 
280
287
  // --- Print header ---
281
- printHeader(maxIterations, phaseFilter, totalStories, doneAtStart, noPlan, cwd, taskFilter);
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
- // NO custom SIGINT handler Ctrl+C uses default Node.js behavior (kills process group)
300
+ // --- Crash recovery: clean up any leftover worktrees from previous runs ---
301
+ await cleanupAllWorktrees(mainCwd);
284
302
 
285
- // --- Save original branch for isolation ---
286
- const originalBranch = await getCurrentBranch(cwd);
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
- }, cwd);
341
+ }, mainCwd);
308
342
 
309
- while (iteration < maxIterations) {
343
+ while (iteration < maxIterations && !sigintReceived) {
310
344
  iteration++;
311
345
 
312
- const task = selectNextTask(cwd, { phase: phaseFilter, filter: taskFilter });
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(cwd).filter(s => s.passes).length;
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, cwd);
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, cwd);
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, cwd);
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, cwd);
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
- // --- Pre-validation: check existing feat branch before reset ---
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
- taskBranch = await createTaskBranch(task.id, cwd);
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, cwd);
515
+ saveTaskFile(taskFile, mainCwd);
482
516
  }
483
517
  } catch (err) {
484
- console.log(chalk.red(` Failed to create branch feat/${task.id}: ${err.message}`));
485
- console.log(chalk.yellow(' Continuing on current branch...'));
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, cwd, prdPath, promptPath, errorContext);
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, cwd, taskTimeoutMs, estimatedSeconds, phaseLabel, executionModel);
546
+ const result = await spawnClaude(prompt, task, iteration, maxIterations, currentDone, totalStories, phaseFilter, logFile, taskWorktreePath, taskTimeoutMs, estimatedSeconds, phaseLabel, executionModel);
511
547
 
512
- // Killed by signal (user did kill or Ctrl+C)don't retry, exit
513
- if (result.signal) {
514
- console.log(chalk.yellow(` Killed by ${result.signal}`));
515
- if (taskBranch) {
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'; // upgrade model on timeout
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, cwd);
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 (not from signal) — check if it's a rate limit
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; // no retry increment — wait and try again
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, cwd);
611
+ saveTaskFile(taskFile, mainCwd);
587
612
  }
588
613
 
589
614
  retry++;
590
615
  continue;
591
616
  }
592
617
 
593
- // Quality gates
594
- const gatesResult = await runGatesWithStatus(cwd);
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, cwd);
626
+ markStoryDone(task.id, taskWorktreePath);
599
627
  appendProgress(progressPath, task.id, 'All quality gates passed');
600
628
 
601
- const config = loadConfig(cwd);
629
+ const config = loadConfig(mainCwd);
602
630
  if (config.auto_commit) {
603
- await commitTask(task, {}, cwd);
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, cwd);
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
- }, cwd);
622
-
623
- // Merge successful task branch into batch branch
624
- if (taskBranch) {
625
- try {
626
- await checkoutBranch(batchBranch, cwd, { stash: true });
627
- await mergeBranch(taskBranch, cwd);
628
- mergedCount++;
629
- mergedBranches.push(taskBranch);
630
- console.log(chalk.green(` Merged ${taskBranch} → ${batchBranch}`));
631
- } catch (err) {
632
- console.log(chalk.red(` Merge failed: ${err.message}`));
633
- console.log(chalk.yellow(` Branch ${taskBranch} available for manual merge`));
634
- unmergedBranches.push(taskBranch);
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, cwd);
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, cwd);
689
+ saveTaskFile(taskFile, mainCwd);
663
690
  }
664
691
 
665
692
  noProgressCount++;
693
+ unmergedBranches.push(taskBranch);
666
694
  }
667
695
 
668
- // --- Return to batch branch ---
669
- if (taskBranch) {
670
- try {
671
- await checkoutBranch(batchBranch, cwd, { force: true, stash: true });
672
- console.log(chalk.dim(` Returned to branch: ${batchBranch}`));
673
- } catch (err) {
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
- clearBatchState(cwd);
688
- printSummary(cwd, iteration, storiesCompleted, batchStart, branchesCreated, batchBranch, originalBranch, mergedCount, unmergedBranches);
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(cwd) {
796
- const config = loadConfig(cwd);
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, cwd);
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, cwd, prdPath, promptPath, extraContext) {
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(cwd, iterations, completed, startTime, branchesCreated = [], batchBranch = null, originalBranch = null, mergedCount = 0, unmergedBranches = []) {
938
- const stories = parsePrdTasks(cwd);
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(cwd);
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
- if (originalBranch) {
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,47 +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, { stash: true });
79
+ await checkoutBranch(branch, cwd);
51
80
  await execAsync(`git reset --hard ${baseRef}`, { cwd });
52
81
  } else {
53
- // Stash + drop to unblock checkout -b (metadata files re-created by runner)
54
- try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
55
82
  await execAsync(`git checkout -b ${branch}`, { cwd });
56
- try { await execAsync('git stash drop', { cwd }); } catch {}
57
83
  }
58
84
  return branch;
59
85
  }
60
86
 
61
87
  /**
62
88
  * Checkout an existing branch.
63
- * When stash=true, stashes untracked/dirty files before checkout and
64
- * restores them after — prevents .ai/batch/tasks/*.json from blocking.
89
+ * With metadata gitignored, no stash is needed checkouts are clean.
65
90
  * @param {string} branch - Branch name
66
91
  * @param {string} cwd - Working directory
67
- * @param {{ force?: boolean, stash?: boolean }} options
92
+ * @param {{ force?: boolean }} options
68
93
  */
69
- export async function checkoutBranch(branch, cwd = process.cwd(), { force = false, stash = false } = {}) {
70
- if (stash) {
71
- // Stash only to unblock checkout, then drop — these are runner
72
- // metadata files (.ai/batch/tasks/*.json) that get re-created by
73
- // saveTaskFile, so restoring them causes merge conflicts for no gain.
74
- try { await execAsync('git stash --include-untracked', { cwd }); } catch {}
75
- }
76
-
94
+ export async function checkoutBranch(branch, cwd = process.cwd(), { force = false } = {}) {
77
95
  const flag = force ? ' --force' : '';
78
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)
109
+ try {
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
+ }
122
+ } catch (err) {
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
+ }
79
160
 
80
- if (stash) {
81
- try { await execAsync('git stash drop', { cwd }); } catch {}
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 });
82
196
  }
197
+ return { branch, worktreePath };
83
198
  }
84
199
 
85
200
  /**
86
- * Create a batch branch from the current HEAD
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
+ }
214
+
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
+ }
250
+ }
251
+ }
252
+
253
+ /**
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
87
270
  * @param {string} cwd - Working directory
88
271
  * @returns {Promise<string>} Branch name (batch/{timestamp})
89
272
  */
90
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
+
91
291
  const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
92
292
  const branch = `batch/${ts}`;
93
293
  await execAsync(`git checkout -b ${branch}`, { cwd });
@@ -532,15 +732,46 @@ export function markStoryDone(taskId, cwd = process.cwd()) {
532
732
  return false;
533
733
  }
534
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
+
535
765
  /**
536
766
  * Run a single quality gate
537
767
  * @param {string} gate - Gate name (typecheck, lint, test, build, security)
538
- * @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)
539
770
  * @returns {Promise<{success: boolean, output?: string, error?: string}>}
540
771
  */
541
- export async function runQualityGate(gate, cwd = process.cwd()) {
542
- // Check for custom gate commands in config
543
- 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);
544
775
  const customCmd = config.gate_commands?.[gate];
545
776
  const commands = customCmd ? [customCmd] : GATE_COMMANDS[gate];
546
777
 
@@ -677,9 +908,16 @@ export async function commitTask(task, options = {}, cwd = process.cwd()) {
677
908
  const config = loadConfig(cwd);
678
909
 
679
910
  try {
680
- // Stage all changes
911
+ // Stage all changes (respects .gitignore)
681
912
  await execAsync('git add .', { cwd });
682
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
+
683
921
  // Check if there are changes to commit
684
922
  try {
685
923
  await execAsync('git diff --cached --quiet', { cwd });