@fermindi/pwn-cli 0.6.0 → 0.7.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.
@@ -0,0 +1,769 @@
1
+ /**
2
+ * PWN Batch Runner — Node.js TUI with chalk
3
+ *
4
+ * Two-phase execution model:
5
+ * Phase 1: Planning — Claude estimates time and creates action plan
6
+ * Phase 2: Execution — Dynamic timeout based on estimate + 5%
7
+ *
8
+ * Task files (.ai/batch/tasks/{US-ID}.json) track status per story.
9
+ * Completed files are cleaned up at the end; failed are kept for review.
10
+ */
11
+
12
+ import { spawn } from 'child_process';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, createWriteStream, appendFileSync, unlinkSync, readdirSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { tmpdir } from 'os';
16
+ import ora from 'ora';
17
+ import chalk from 'chalk';
18
+ import {
19
+ parsePrdTasks,
20
+ selectNextTask,
21
+ markStoryDone,
22
+ runQualityGate,
23
+ loadConfig,
24
+ commitTask,
25
+ updateBatchState,
26
+ clearBatchState
27
+ } from './batch-service.js';
28
+
29
+ // --- Constants ---
30
+ const RUNNER_VERSION = '2.0';
31
+ const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes fallback
32
+ const MIN_TIMEOUT_MS = 120_000; // 2 minutes minimum (claude init takes ~30s)
33
+ const PLAN_TIMEOUT_MS = 120_000; // 2 minutes for planning phase (claude init ~30s)
34
+ const DEFAULT_RATE_LIMIT_WAIT = 1800; // 30 minutes (seconds)
35
+ const MAX_RETRIES = 2;
36
+ const MAX_NO_PROGRESS = 3; // circuit breaker
37
+ const RATE_LIMIT_RE = /rate.?limit|429|overloaded|hit your limit|quota exceeded|resets|too many requests/i;
38
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
39
+
40
+ // --- Task File CRUD ---
41
+
42
+ function getTasksDir(cwd) {
43
+ return join(cwd, '.ai', 'batch', 'tasks');
44
+ }
45
+
46
+ export function saveTaskFile(taskData, cwd) {
47
+ const tasksDir = getTasksDir(cwd);
48
+ mkdirSync(tasksDir, { recursive: true });
49
+ const filePath = join(tasksDir, `${taskData.id}.json`);
50
+ writeFileSync(filePath, JSON.stringify(taskData, null, 2));
51
+ }
52
+
53
+ export function loadTaskFile(taskId, cwd) {
54
+ const filePath = join(getTasksDir(cwd), `${taskId}.json`);
55
+ if (!existsSync(filePath)) return null;
56
+ return JSON.parse(readFileSync(filePath, 'utf8'));
57
+ }
58
+
59
+ export function deleteTaskFile(taskId, cwd) {
60
+ const filePath = join(getTasksDir(cwd), `${taskId}.json`);
61
+ if (existsSync(filePath)) {
62
+ unlinkSync(filePath);
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ export function listTaskFiles(cwd, { statusFilter } = {}) {
69
+ const tasksDir = getTasksDir(cwd);
70
+ if (!existsSync(tasksDir)) return [];
71
+ const files = readdirSync(tasksDir).filter(f => f.endsWith('.json'));
72
+ const tasks = files.map(f => JSON.parse(readFileSync(join(tasksDir, f), 'utf8')));
73
+ if (statusFilter) return tasks.filter(t => t.status === statusFilter);
74
+ return tasks;
75
+ }
76
+
77
+ // --- Rate Limit Handling ---
78
+
79
+ function isRateLimitError(output) {
80
+ return output && RATE_LIMIT_RE.test(output);
81
+ }
82
+
83
+ async function waitForRateLimit(waitSeconds, attempt) {
84
+ const resumeAt = new Date(Date.now() + waitSeconds * 1000);
85
+ console.log(chalk.yellow(` Rate limited (wait #${attempt}). Cooling down ${formatDuration(waitSeconds)}...`));
86
+ console.log(chalk.dim(` Resumes at: ${resumeAt.toLocaleTimeString()}`));
87
+
88
+ let remaining = waitSeconds;
89
+ while (remaining > 0) {
90
+ const spinner = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
91
+ process.stdout.write(`\r\x1b[K ${chalk.yellow(spinner)} Rate limit cooldown: ${chalk.bold(formatDuration(remaining))} remaining`);
92
+ const tick = Math.min(remaining, 1);
93
+ await sleep(tick * 1000);
94
+ remaining -= tick;
95
+ }
96
+ process.stdout.write('\r\x1b[K');
97
+ console.log(chalk.green(' Cooldown complete. Retrying...'));
98
+ }
99
+
100
+ // --- Planning Phase ---
101
+
102
+ function buildPlanPrompt(task, cwd) {
103
+ const prdPath = join(cwd, '.ai', 'tasks', 'prd.json');
104
+ const prd = JSON.parse(readFileSync(prdPath, 'utf8'));
105
+ const story = prd.stories.find(s => s.id === task.id);
106
+ if (!story) return '';
107
+
108
+ const acList = (story.acceptance_criteria || []).map(ac => `- ${ac}`).join('\n') || 'None';
109
+
110
+ return `You are analyzing task ${task.id}: ${task.title}
111
+
112
+ Acceptance criteria:
113
+ ${acList}
114
+
115
+ Notes: ${story.notes || 'None'}
116
+
117
+ Analyze this task and respond with ONLY a JSON object (no markdown, no code fences):
118
+ {
119
+ "estimated_time_seconds": <number>,
120
+ "plan": ["step 1", "step 2", ...],
121
+ "complexity": "low|medium|high",
122
+ "recommended_model": "opus|sonnet|haiku",
123
+ "files_likely_affected": ["path1", "path2"]
124
+ }
125
+
126
+ Base your estimate on:
127
+ - low complexity (config change, small fix): 30-120s
128
+ - medium complexity (new function, simple feature): 120-300s
129
+ - high complexity (new module, multi-file feature): 300-600s
130
+
131
+ Recommend a model for execution:
132
+ - "haiku": trivial (config change, typo, single-line fix) — ~30s
133
+ - "sonnet": low-medium complexity (new function, simple feature, 1-3 files) — 30-300s
134
+ - "opus": high complexity (new module, multi-file refactor, architecture) — 300-600s`;
135
+ }
136
+
137
+ async function planTask(task, cwd) {
138
+ const prompt = buildPlanPrompt(task, cwd);
139
+ if (!prompt) return null;
140
+
141
+ const promptFile = join(tmpdir(), `pwn-plan-${Date.now()}.md`);
142
+ writeFileSync(promptFile, prompt);
143
+
144
+ const env = { ...process.env };
145
+ delete env.CLAUDECODE;
146
+
147
+ return new Promise((resolve) => {
148
+ let output = '';
149
+
150
+ // Planning uses --print WITHOUT --dangerously-skip-permissions (read-only)
151
+ const child = spawn('bash', [
152
+ '-c',
153
+ `claude --model opus --print -p "$(cat '${promptFile}')"`,
154
+ ], {
155
+ cwd,
156
+ stdio: ['ignore', 'pipe', 'pipe'],
157
+ env,
158
+ });
159
+
160
+ const timeoutId = setTimeout(() => {
161
+ child.kill('SIGTERM');
162
+ }, PLAN_TIMEOUT_MS);
163
+
164
+ child.stdout.on('data', (data) => { output += data.toString(); });
165
+ child.stderr.on('data', (data) => { output += data.toString(); });
166
+
167
+ child.on('close', (code, signal) => {
168
+ clearTimeout(timeoutId);
169
+ try { unlinkSync(promptFile); } catch {}
170
+
171
+ if (signal) {
172
+ console.log(chalk.dim(` Planning killed by ${signal} (timeout=${signal === 'SIGTERM' ? 'likely' : 'no'})`));
173
+ resolve(null);
174
+ return;
175
+ }
176
+ if (code !== 0) {
177
+ console.log(chalk.dim(` Planning exited with code ${code}`));
178
+ resolve(null);
179
+ return;
180
+ }
181
+
182
+ // Try to parse JSON from output
183
+ try {
184
+ let jsonStr = output.trim();
185
+ const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
186
+ if (jsonMatch) jsonStr = jsonMatch[0];
187
+ const parsed = JSON.parse(jsonStr);
188
+ resolve(parsed);
189
+ } catch (e) {
190
+ console.log(chalk.dim(` Planning JSON parse failed: ${e.message}`));
191
+ console.log(chalk.dim(` Raw output (first 200 chars): ${output.slice(0, 200)}`));
192
+ resolve(null);
193
+ }
194
+ });
195
+
196
+ child.on('error', (err) => {
197
+ clearTimeout(timeoutId);
198
+ try { unlinkSync(promptFile); } catch {}
199
+ console.log(chalk.dim(` Planning spawn error: ${err.message}`));
200
+ resolve(null);
201
+ });
202
+ });
203
+ }
204
+
205
+ function computeTimeout(estimatedSeconds) {
206
+ if (!estimatedSeconds || estimatedSeconds <= 0) return DEFAULT_TIMEOUT_MS;
207
+ const withMargin = Math.ceil(estimatedSeconds * 1.05) * 1000;
208
+ return Math.max(withMargin, MIN_TIMEOUT_MS);
209
+ }
210
+
211
+ /**
212
+ * Main entry point for the TUI batch runner.
213
+ */
214
+ export async function runBatch(options = {}, cwd = process.cwd()) {
215
+ const prdPath = join(cwd, '.ai', 'tasks', 'prd.json');
216
+ const promptPath = join(cwd, '.ai', 'batch', 'prompt.md');
217
+ const logDir = join(cwd, 'logs');
218
+ const progressPath = join(cwd, '.ai', 'batch', 'progress.txt');
219
+ const noPlan = options.noPlan || false;
220
+ const rateLimitWait = options.rateLimitWait || DEFAULT_RATE_LIMIT_WAIT;
221
+
222
+ // --- Pre-flight checks ---
223
+ if (!existsSync(prdPath)) {
224
+ console.log(chalk.red('Error:') + ` ${prdPath} not found`);
225
+ process.exit(1);
226
+ }
227
+ if (!existsSync(promptPath)) {
228
+ console.log(chalk.red('Error:') + ` ${promptPath} not found`);
229
+ process.exit(1);
230
+ }
231
+
232
+ mkdirSync(logDir, { recursive: true });
233
+ mkdirSync(getTasksDir(cwd), { recursive: true });
234
+
235
+ const stories = parsePrdTasks(cwd);
236
+ const totalStories = stories.length;
237
+ const doneAtStart = stories.filter(s => s.passes).length;
238
+ const maxIterations = options.maxIterations || 20;
239
+ const phaseFilter = options.phase ? `Phase ${options.phase}` : undefined;
240
+
241
+ // --- Dry run ---
242
+ if (options.dryRun) {
243
+ return dryRunPreview(cwd, phaseFilter, maxIterations);
244
+ }
245
+
246
+ // --- Print header ---
247
+ printHeader(maxIterations, phaseFilter, totalStories, doneAtStart, noPlan, cwd);
248
+
249
+ // NO custom SIGINT handler — Ctrl+C uses default Node.js behavior (kills process group)
250
+
251
+ // --- Main loop ---
252
+ let iteration = 0;
253
+ let noProgressCount = 0;
254
+ let storiesCompleted = 0;
255
+ const batchStart = Date.now();
256
+
257
+ updateBatchState({
258
+ started_at: new Date().toISOString(),
259
+ status: 'running',
260
+ completed: [],
261
+ max_tasks: maxIterations
262
+ }, cwd);
263
+
264
+ while (iteration < maxIterations) {
265
+ iteration++;
266
+
267
+ const task = selectNextTask(cwd, { phase: phaseFilter });
268
+ if (!task) {
269
+ console.log(chalk.green('\nAll eligible stories completed!'));
270
+ break;
271
+ }
272
+
273
+ const currentDone = parsePrdTasks(cwd).filter(s => s.passes).length;
274
+
275
+ console.log(chalk.dim(`\n--- Iteration ${iteration}/${maxIterations} ---`));
276
+ console.log(`${chalk.cyan(`[${currentDone + 1}/${totalStories}]`)} ${chalk.bold(task.id)}: ${task.title}`);
277
+
278
+ // --- Phase 1: Planning (skip if already planned) ---
279
+ let taskTimeoutMs = DEFAULT_TIMEOUT_MS;
280
+ let taskFile = null;
281
+
282
+ if (!noPlan) {
283
+ const existing = loadTaskFile(task.id, cwd);
284
+
285
+ if (existing && existing.status === 'planned' && existing.complexity !== 'unknown') {
286
+ // Reuse previous plan
287
+ taskFile = existing;
288
+ taskTimeoutMs = computeTimeout(existing.estimated_time_seconds);
289
+ console.log(chalk.dim(` Phase 1: Reusing plan for ${task.id} (${existing.complexity}, ~${formatDuration(existing.estimated_time_seconds)}, model: ${existing.recommended_model || 'sonnet'})`));
290
+ } else {
291
+ console.log(chalk.blue(` Phase 1: Planning ${task.id}...`));
292
+ const planSpinner = ora({ text: `Planning ${task.id}...`, indent: 2 }).start();
293
+
294
+ const planResult = await planTask(task, cwd);
295
+
296
+ if (planResult) {
297
+ const estimatedSeconds = planResult.estimated_time_seconds || 600;
298
+ const timeoutSeconds = Math.ceil(Math.max(estimatedSeconds * 1.05, MIN_TIMEOUT_MS / 1000));
299
+ taskTimeoutMs = computeTimeout(estimatedSeconds);
300
+
301
+ const recommendedModel = planResult.recommended_model || 'sonnet';
302
+ taskFile = {
303
+ id: task.id,
304
+ title: task.title,
305
+ status: 'planned',
306
+ estimated_time_seconds: estimatedSeconds,
307
+ timeout_seconds: timeoutSeconds,
308
+ plan: planResult.plan || [],
309
+ complexity: planResult.complexity || 'medium',
310
+ recommended_model: recommendedModel,
311
+ files_likely_affected: planResult.files_likely_affected || [],
312
+ created_at: new Date().toISOString(),
313
+ completed_at: null,
314
+ failure_reason: null
315
+ };
316
+ saveTaskFile(taskFile, cwd);
317
+
318
+ planSpinner.succeed(chalk.green(
319
+ `Planned: ${planResult.complexity} complexity, ~${formatDuration(estimatedSeconds)} estimated, timeout ${formatDuration(timeoutSeconds)}, model: ${recommendedModel}`
320
+ ));
321
+ } else {
322
+ // Fallback when planning fails
323
+ const fallbackSeconds = DEFAULT_TIMEOUT_MS / 1000;
324
+ taskFile = {
325
+ id: task.id,
326
+ title: task.title,
327
+ status: 'planned',
328
+ estimated_time_seconds: fallbackSeconds,
329
+ timeout_seconds: fallbackSeconds,
330
+ plan: ['fallback - no plan available'],
331
+ complexity: 'unknown',
332
+ recommended_model: 'sonnet',
333
+ files_likely_affected: [],
334
+ created_at: new Date().toISOString(),
335
+ completed_at: null,
336
+ failure_reason: null
337
+ };
338
+ saveTaskFile(taskFile, cwd);
339
+ taskTimeoutMs = DEFAULT_TIMEOUT_MS;
340
+
341
+ planSpinner.warn(chalk.yellow(`Planning failed, using default timeout (${formatDuration(fallbackSeconds)})`));
342
+ }
343
+ }
344
+ }
345
+
346
+ // --- Phase 2: Execution ---
347
+ const phaseLabel = noPlan ? '' : 'Phase 2';
348
+ console.log(chalk.blue(` ${noPlan ? 'Executing' : 'Phase 2: Executing'} ${task.id}...`));
349
+
350
+ let retry = 0;
351
+ let rateLimitAttempts = 0;
352
+ let storyDone = false;
353
+ let errorContext = '';
354
+
355
+ while (retry <= MAX_RETRIES && !storyDone) {
356
+ if (retry > 0) {
357
+ console.log(chalk.yellow(` Retry ${retry}/${MAX_RETRIES}`));
358
+ }
359
+
360
+ const prompt = buildPrompt(task.id, cwd, prdPath, promptPath, errorContext);
361
+ const logFile = join(logDir, `${task.id}_${timestamp()}.log`);
362
+
363
+ const estimatedSeconds = taskFile?.estimated_time_seconds || null;
364
+ const executionModel = taskFile?.recommended_model || null;
365
+ const result = await spawnClaude(prompt, task, iteration, maxIterations, currentDone, totalStories, phaseFilter, logFile, cwd, taskTimeoutMs, estimatedSeconds, phaseLabel, executionModel);
366
+
367
+ // Killed by signal (user did kill or Ctrl+C) — don't retry, exit
368
+ if (result.signal) {
369
+ console.log(chalk.yellow(` Killed by ${result.signal}`));
370
+ clearBatchState(cwd);
371
+ printSummary(cwd, iteration, storiesCompleted, batchStart);
372
+ return;
373
+ }
374
+
375
+ // Timeout
376
+ if (result.timedOut) {
377
+ // Timeout can also be caused by rate limit — check output
378
+ if (isRateLimitError(result.output)) {
379
+ rateLimitAttempts++;
380
+ await waitForRateLimit(rateLimitWait, rateLimitAttempts);
381
+ continue;
382
+ }
383
+ console.log(chalk.yellow(` Timed out after ${Math.round(taskTimeoutMs / 1000)}s`));
384
+ errorContext = 'Session timed out. Simplify the implementation or focus on core acceptance criteria.';
385
+ retry++;
386
+ continue;
387
+ }
388
+
389
+ // Non-zero exit (not from signal) — check if it's a rate limit
390
+ // Rate limit detection only on FAILURES. Successful output (exit 0) may
391
+ // mention "rate limit" as a feature description, not an actual API error.
392
+ if (result.exitCode !== 0) {
393
+ if (isRateLimitError(result.output)) {
394
+ rateLimitAttempts++;
395
+ await waitForRateLimit(rateLimitWait, rateLimitAttempts);
396
+ continue; // no retry increment — wait and try again
397
+ }
398
+ console.log(chalk.yellow(` Claude exited with code ${result.exitCode}`));
399
+ errorContext = `Claude session failed with exit code ${result.exitCode}.`;
400
+ retry++;
401
+ continue;
402
+ }
403
+
404
+ // Quality gates
405
+ const gatesResult = await runGatesWithStatus(cwd);
406
+
407
+ if (gatesResult.success) {
408
+ console.log(chalk.green(` Quality gates PASSED`));
409
+ markStoryDone(task.id, cwd);
410
+ appendProgress(progressPath, task.id, 'All quality gates passed');
411
+
412
+ const config = loadConfig(cwd);
413
+ if (config.auto_commit) {
414
+ await commitTask(task, {}, cwd);
415
+ }
416
+
417
+ storyDone = true;
418
+ storiesCompleted++;
419
+ noProgressCount = 0;
420
+
421
+ // Update task file status
422
+ if (taskFile) {
423
+ taskFile.status = 'completed';
424
+ taskFile.completed_at = new Date().toISOString();
425
+ saveTaskFile(taskFile, cwd);
426
+ }
427
+
428
+ updateBatchState({
429
+ completed: [task.id],
430
+ current_task: null,
431
+ last_completed_at: new Date().toISOString()
432
+ }, cwd);
433
+ } else {
434
+ console.log(chalk.red(` Quality gates FAILED`));
435
+ errorContext = gatesResult.errorOutput;
436
+ retry++;
437
+ }
438
+ }
439
+
440
+ if (!storyDone) {
441
+ console.log(chalk.red(` FAILED: ${task.id} after ${MAX_RETRIES} retries`));
442
+ appendProgress(progressPath, task.id, `FAILED after ${MAX_RETRIES} retries. Skipping.`);
443
+
444
+ // Update task file with failure
445
+ if (taskFile) {
446
+ taskFile.status = 'failed';
447
+ taskFile.failure_reason = errorContext || `Failed after ${MAX_RETRIES} retries`;
448
+ taskFile.completed_at = new Date().toISOString();
449
+ saveTaskFile(taskFile, cwd);
450
+ }
451
+
452
+ noProgressCount++;
453
+ }
454
+
455
+ if (noProgressCount >= MAX_NO_PROGRESS) {
456
+ console.log(chalk.red(`\nCIRCUIT BREAKER: ${MAX_NO_PROGRESS} consecutive failures. Stopping.`));
457
+ break;
458
+ }
459
+ }
460
+
461
+ clearBatchState(cwd);
462
+ printSummary(cwd, iteration, storiesCompleted, batchStart);
463
+ }
464
+
465
+ /**
466
+ * Spawn claude with piped stdio + spinner status line.
467
+ * Uses temp file for prompt to avoid shell escaping issues.
468
+ * Ctrl+C works because: no custom SIGINT handler + pipe mode (child not in foreground).
469
+ */
470
+ function spawnClaude(prompt, task, iteration, maxIter, done, total, phase, logFile, cwd, timeoutMs = DEFAULT_TIMEOUT_MS, estimatedSeconds = null, phaseTag = '', model = null) {
471
+ return new Promise((resolve) => {
472
+ let bytesReceived = 0;
473
+ let output = '';
474
+ const logStream = createWriteStream(logFile);
475
+ const startTime = Date.now();
476
+
477
+ // Write prompt to temp file
478
+ const promptFile = join(tmpdir(), `pwn-prompt-${Date.now()}.md`);
479
+ writeFileSync(promptFile, prompt);
480
+
481
+ const env = { ...process.env };
482
+ delete env.CLAUDECODE;
483
+
484
+ const modelFlag = model ? `--model ${model} ` : '';
485
+ const child = spawn('bash', [
486
+ '-c',
487
+ `claude ${modelFlag}--print --dangerously-skip-permissions -p "$(cat '${promptFile}')"`,
488
+ ], {
489
+ cwd,
490
+ stdio: ['ignore', 'pipe', 'pipe'],
491
+ env,
492
+ });
493
+
494
+ const modelLabel = model ? chalk.magenta(model) : chalk.dim('default');
495
+ console.log(chalk.dim(` Log: tail -f ${logFile}`));
496
+ console.log(chalk.dim(` PID: ${child.pid} | Model: `) + modelLabel + chalk.dim(` | Prompt: ${prompt.length} chars | Timeout: ${formatDuration(Math.round(timeoutMs / 1000))}`));
497
+
498
+ // Spinner via \r — no ora, no SIGINT hijack, no hidden cursor
499
+ let frame = 0;
500
+ const timer = setInterval(() => {
501
+ const spinner = chalk.cyan(SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length]);
502
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
503
+ const bytesLabel = bytesReceived === 0
504
+ ? chalk.dim('waiting (~2min)...')
505
+ : chalk.green(formatBytes(bytesReceived));
506
+ const phaseLabel = phase ? chalk.dim(` | ${phase}`) : '';
507
+ const estLabel = estimatedSeconds
508
+ ? ` / ${chalk.dim('~' + formatDuration(estimatedSeconds))}`
509
+ : '';
510
+ const phaseTagLabel = phaseTag ? chalk.dim(` | ${phaseTag}`) : '';
511
+ const modelIndicator = model ? chalk.dim(` | ${model}`) : '';
512
+ const line = ` ${spinner} ${chalk.cyan(`[${done + 1}/${total}]`)} ${task.id}: Executing | Iter ${iteration}/${maxIter} | ${chalk.yellow(formatDuration(elapsed))}${estLabel} | ${bytesLabel}${phaseLabel}${phaseTagLabel}${modelIndicator}`;
513
+ process.stdout.write(`\r\x1b[K${line}`);
514
+ }, 80);
515
+
516
+ const timeoutId = setTimeout(() => {
517
+ child.kill('SIGTERM');
518
+ }, timeoutMs);
519
+
520
+ child.stdout.on('data', (data) => {
521
+ bytesReceived += data.length;
522
+ output += data.toString();
523
+ logStream.write(data);
524
+ });
525
+ child.stderr.on('data', (data) => {
526
+ bytesReceived += data.length;
527
+ output += data.toString();
528
+ logStream.write(data);
529
+ });
530
+
531
+ child.on('close', (code, signal) => {
532
+ clearTimeout(timeoutId);
533
+ clearInterval(timer);
534
+ logStream.end();
535
+ try { unlinkSync(promptFile); } catch {}
536
+
537
+ // Clear spinner line
538
+ process.stdout.write('\r\x1b[K');
539
+
540
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
541
+ const timedOut = signal === 'SIGTERM' && elapsed >= Math.floor(timeoutMs / 1000) - 1;
542
+
543
+ if (signal && !timedOut) {
544
+ console.log(chalk.dim(` Claude killed (${signal}) after ${formatDuration(elapsed)}`));
545
+ resolve({ exitCode: code, output, timedOut: false, signal });
546
+ return;
547
+ }
548
+
549
+ console.log(chalk.dim(` Claude finished in ${formatDuration(elapsed)} | ${formatBytes(bytesReceived)}`));
550
+ resolve({ exitCode: code ?? 1, output, timedOut, signal: null });
551
+ });
552
+
553
+ child.on('error', (err) => {
554
+ clearTimeout(timeoutId);
555
+ clearInterval(timer);
556
+ logStream.end();
557
+ try { unlinkSync(promptFile); } catch {}
558
+ process.stdout.write('\r\x1b[K');
559
+ console.log(chalk.red(` Spawn error: ${err.message}`));
560
+ resolve({ exitCode: 1, output: '', timedOut: false, signal: null });
561
+ });
562
+ });
563
+ }
564
+
565
+ /**
566
+ * Run quality gates with real-time PASS/FAIL display.
567
+ */
568
+ async function runGatesWithStatus(cwd) {
569
+ const config = loadConfig(cwd);
570
+ const gates = config.quality_gates || [];
571
+ const skipGates = config.skip_gates || [];
572
+ let allPassed = true;
573
+ let errorOutput = '';
574
+
575
+ for (const gate of gates) {
576
+ if (skipGates.includes(gate)) {
577
+ console.log(chalk.dim(` ${gate}: SKIP`));
578
+ continue;
579
+ }
580
+
581
+ const spinner = ora({ text: `Running ${gate}...`, indent: 2 }).start();
582
+ const result = await runQualityGate(gate, cwd);
583
+
584
+ if (result.success || result.skipped) {
585
+ spinner.succeed(chalk.green(`${gate}: PASS`));
586
+ } else {
587
+ spinner.fail(chalk.red(`${gate}: FAIL`));
588
+ allPassed = false;
589
+ errorOutput += `=== ${gate} ===\n${result.error || result.stderr || ''}\n\n`;
590
+ }
591
+ }
592
+
593
+ return { success: allPassed, errorOutput };
594
+ }
595
+
596
+ /**
597
+ * Build the prompt from template, substituting placeholders.
598
+ */
599
+ function buildPrompt(storyId, cwd, prdPath, promptPath, extraContext) {
600
+ const prd = JSON.parse(readFileSync(prdPath, 'utf8'));
601
+ const story = prd.stories.find(s => s.id === storyId);
602
+ if (!story) return '';
603
+
604
+ const doneIds = prd.stories.filter(s => s.passes).map(s => s.id);
605
+ const acList = (story.acceptance_criteria || []).map(ac => `- ${ac}`).join('\n') || 'None';
606
+
607
+ let depsList = 'None';
608
+ if (story.dependencies?.length > 0) {
609
+ depsList = story.dependencies
610
+ .filter(dep => doneIds.includes(dep))
611
+ .map(dep => {
612
+ const depStory = prd.stories.find(s => s.id === dep);
613
+ return `- ${dep}: ${depStory?.title || 'unknown'} (done)`;
614
+ })
615
+ .join('\n') || 'None';
616
+ }
617
+
618
+ let prompt = readFileSync(promptPath, 'utf8');
619
+ prompt = prompt.replaceAll('{STORY_ID}', storyId);
620
+ prompt = prompt.replaceAll('{STORY_TITLE}', story.title || '');
621
+ prompt = prompt.replaceAll('{ACCEPTANCE_CRITERIA}', acList);
622
+ prompt = prompt.replaceAll('{NOTES}', story.notes || '');
623
+ prompt = prompt.replaceAll('{DEPENDENCIES}', depsList);
624
+
625
+ if (extraContext) {
626
+ prompt += `\n\n## Previous Attempt Failed\nThe previous attempt failed quality gates. Here is the error output:\n\n\`\`\`\n${extraContext}\n\`\`\`\n\nFix these issues before committing.`;
627
+ }
628
+
629
+ return prompt;
630
+ }
631
+
632
+ /**
633
+ * Dry run: show formatted preview of eligible tasks.
634
+ */
635
+ function dryRunPreview(cwd, phaseFilter, maxIterations) {
636
+ const stories = parsePrdTasks(cwd);
637
+ const total = stories.length;
638
+ const done = stories.filter(s => s.passes).length;
639
+ const remaining = total - done;
640
+
641
+ console.log(chalk.bold('\nDry Run Preview\n'));
642
+ console.log(` Progress: ${chalk.green(done)}/${total} done, ${chalk.yellow(remaining)} remaining`);
643
+ console.log(` Max iterations: ${maxIterations}`);
644
+ if (phaseFilter) console.log(` Phase filter: ${chalk.blue(phaseFilter)}`);
645
+
646
+ const doneIds = stories.filter(s => s.passes).map(s => s.id);
647
+ const eligible = stories.filter(s =>
648
+ !s.passes &&
649
+ s.dependencies.every(dep => doneIds.includes(dep)) &&
650
+ (!phaseFilter || s.phase === phaseFilter)
651
+ );
652
+
653
+ if (eligible.length === 0) {
654
+ console.log(chalk.yellow('\n No eligible stories found.'));
655
+ return;
656
+ }
657
+
658
+ console.log(chalk.bold('\n Eligible stories:\n'));
659
+ for (let i = 0; i < Math.min(eligible.length, maxIterations); i++) {
660
+ const s = eligible[i];
661
+ const phaseLabel = s.phase ? chalk.dim(` (${s.phase})`) : '';
662
+ const effort = s.effort ? chalk.dim(` [${s.effort}]`) : '';
663
+
664
+ // Show planning info if task file exists
665
+ const existing = loadTaskFile(s.id, cwd);
666
+ const planInfo = existing
667
+ ? chalk.dim(` — ${existing.complexity}, ~${formatDuration(existing.estimated_time_seconds)}, model: ${existing.recommended_model || 'sonnet'}`)
668
+ : '';
669
+
670
+ console.log(` ${chalk.cyan(`${i + 1}.`)} ${chalk.bold(s.id)}: ${s.title}${phaseLabel}${effort}${planInfo}`);
671
+ }
672
+
673
+ const config = loadConfig(cwd);
674
+ console.log(chalk.bold('\n Quality gates:'));
675
+ for (const gate of config.quality_gates) {
676
+ if (config.skip_gates.includes(gate)) {
677
+ console.log(chalk.dim(` ${gate} (skip)`));
678
+ } else {
679
+ console.log(` ${chalk.green('>')} ${gate}`);
680
+ }
681
+ }
682
+
683
+ console.log(chalk.dim('\n Run without --dry-run to execute.\n'));
684
+ }
685
+
686
+ function printHeader(maxIter, phase, total, done, noPlan = false, cwd = process.cwd()) {
687
+ const config = loadConfig(cwd);
688
+ const skipGates = config.skip_gates || [];
689
+
690
+ console.log('');
691
+ console.log(chalk.bold('PWN Batch Runner') + chalk.dim(` (v${RUNNER_VERSION})`));
692
+ console.log(chalk.dim('─'.repeat(40)));
693
+ console.log(` Max iterations: ${chalk.cyan(maxIter)}`);
694
+ console.log(` Progress: ${chalk.green(done)}/${total} done`);
695
+ console.log(` Planning: ${noPlan ? chalk.yellow('disabled') : chalk.green('enabled')}`);
696
+ if (phase) console.log(` Phase filter: ${chalk.blue(phase)}`);
697
+ if (skipGates.length > 0) {
698
+ console.log(` ${chalk.yellow('⚠️ Skipping gates (no tooling):')} ${skipGates.join(', ')}`);
699
+ }
700
+ console.log(chalk.dim('─'.repeat(40)));
701
+ }
702
+
703
+ function printSummary(cwd, iterations, completed, startTime) {
704
+ const stories = parsePrdTasks(cwd);
705
+ const total = stories.length;
706
+ const done = stories.filter(s => s.passes).length;
707
+ const remaining = total - done;
708
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
709
+
710
+ // Cleanup: delete completed task files, keep failed
711
+ let cleaned = 0;
712
+ let failedKept = 0;
713
+ const tasksDir = getTasksDir(cwd);
714
+ if (existsSync(tasksDir)) {
715
+ const files = readdirSync(tasksDir).filter(f => f.endsWith('.json'));
716
+ for (const file of files) {
717
+ try {
718
+ const data = JSON.parse(readFileSync(join(tasksDir, file), 'utf8'));
719
+ if (data.status === 'completed') {
720
+ unlinkSync(join(tasksDir, file));
721
+ cleaned++;
722
+ } else if (data.status === 'failed') {
723
+ failedKept++;
724
+ }
725
+ } catch {}
726
+ }
727
+ }
728
+
729
+ console.log('');
730
+ console.log(chalk.bold('Batch Runner Complete'));
731
+ console.log(chalk.dim('─'.repeat(40)));
732
+ console.log(` Iterations: ${iterations}`);
733
+ console.log(` Completed: ${chalk.green(completed)}`);
734
+ console.log(` Total progress: ${chalk.green(done)}/${total} done, ${chalk.yellow(remaining)} remaining`);
735
+ console.log(` Duration: ${formatDuration(elapsed)}`);
736
+ console.log(` Logs: logs/`);
737
+ if (cleaned > 0 || failedKept > 0) {
738
+ console.log(` Cleanup: ${chalk.green(`${cleaned} completed`)} removed, ${failedKept > 0 ? chalk.red(`${failedKept} failed`) : '0 failed'} kept for review`);
739
+ }
740
+ console.log(chalk.dim('─'.repeat(40)));
741
+ console.log('');
742
+ }
743
+
744
+ // --- Utilities ---
745
+
746
+ function formatBytes(bytes) {
747
+ if (bytes < 1024) return `${bytes} B`;
748
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
749
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
750
+ }
751
+
752
+ function formatDuration(seconds) {
753
+ const m = Math.floor(seconds / 60);
754
+ const s = seconds % 60;
755
+ return m > 0 ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`;
756
+ }
757
+
758
+ function timestamp() {
759
+ return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
760
+ }
761
+
762
+ function sleep(ms) {
763
+ return new Promise(resolve => setTimeout(resolve, ms));
764
+ }
765
+
766
+ function appendProgress(progressPath, storyId, notes) {
767
+ const line = `\n=== ${storyId} completed at ${new Date().toISOString()} ===\n- ${notes}\n`;
768
+ appendFileSync(progressPath, line);
769
+ }