@fermindi/pwn-cli 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/cli/batch.js +333 -0
  4. package/cli/codespaces.js +303 -0
  5. package/cli/index.js +91 -0
  6. package/cli/inject.js +53 -0
  7. package/cli/knowledge.js +531 -0
  8. package/cli/notify.js +135 -0
  9. package/cli/patterns.js +665 -0
  10. package/cli/status.js +91 -0
  11. package/cli/validate.js +61 -0
  12. package/package.json +70 -0
  13. package/src/core/inject.js +128 -0
  14. package/src/core/state.js +91 -0
  15. package/src/core/validate.js +202 -0
  16. package/src/core/workspace.js +176 -0
  17. package/src/index.js +20 -0
  18. package/src/knowledge/gc.js +308 -0
  19. package/src/knowledge/lifecycle.js +401 -0
  20. package/src/knowledge/promote.js +364 -0
  21. package/src/knowledge/references.js +342 -0
  22. package/src/patterns/matcher.js +218 -0
  23. package/src/patterns/registry.js +375 -0
  24. package/src/patterns/triggers.js +423 -0
  25. package/src/services/batch-service.js +849 -0
  26. package/src/services/notification-service.js +342 -0
  27. package/templates/codespaces/devcontainer.json +52 -0
  28. package/templates/codespaces/setup.sh +70 -0
  29. package/templates/workspace/.ai/README.md +164 -0
  30. package/templates/workspace/.ai/agents/README.md +204 -0
  31. package/templates/workspace/.ai/agents/claude.md +625 -0
  32. package/templates/workspace/.ai/config/.gitkeep +0 -0
  33. package/templates/workspace/.ai/config/README.md +79 -0
  34. package/templates/workspace/.ai/config/notifications.template.json +20 -0
  35. package/templates/workspace/.ai/memory/deadends.md +79 -0
  36. package/templates/workspace/.ai/memory/decisions.md +58 -0
  37. package/templates/workspace/.ai/memory/patterns.md +65 -0
  38. package/templates/workspace/.ai/patterns/backend/README.md +126 -0
  39. package/templates/workspace/.ai/patterns/frontend/README.md +103 -0
  40. package/templates/workspace/.ai/patterns/index.md +256 -0
  41. package/templates/workspace/.ai/patterns/triggers.json +1087 -0
  42. package/templates/workspace/.ai/patterns/universal/README.md +141 -0
  43. package/templates/workspace/.ai/state.template.json +8 -0
  44. package/templates/workspace/.ai/tasks/active.md +77 -0
  45. package/templates/workspace/.ai/tasks/backlog.md +95 -0
  46. package/templates/workspace/.ai/workflows/batch-task.md +356 -0
@@ -0,0 +1,849 @@
1
+ /**
2
+ * PWN Batch Service
3
+ *
4
+ * Autonomous batch task execution with quality gates,
5
+ * checkpointing, and signal handling.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { exec } from 'child_process';
11
+ import { promisify } from 'util';
12
+ import { getState, updateState, hasWorkspace } from '../core/state.js';
13
+ import * as notifications from './notification-service.js';
14
+
15
+ const execAsync = promisify(exec);
16
+
17
+ /**
18
+ * Default batch configuration
19
+ */
20
+ const DEFAULT_CONFIG = {
21
+ max_tasks: 5,
22
+ max_duration_hours: 4,
23
+ quality_gates: ['typecheck', 'lint', 'test'],
24
+ skip_gates: [],
25
+ auto_commit: true,
26
+ auto_push: false,
27
+ create_pr: false,
28
+ branch_format: 'feature/{id}-{slug}',
29
+ commit_format: 'conventional',
30
+ selection_strategy: 'priority', // priority, effort, due_date
31
+ notify_on_complete: true,
32
+ notify_on_error: true
33
+ };
34
+
35
+ /**
36
+ * Quality gate commands mapping
37
+ */
38
+ const GATE_COMMANDS = {
39
+ typecheck: ['npm run typecheck', 'npx tsc --noEmit'],
40
+ lint: ['npm run lint', 'npx eslint .'],
41
+ test: ['npm run test', 'npm test', 'npx vitest run', 'npx jest'],
42
+ build: ['npm run build'],
43
+ security: ['npm run security', 'npm audit']
44
+ };
45
+
46
+ /**
47
+ * Load batch configuration from state.json
48
+ * @param {string} cwd - Working directory
49
+ * @returns {object} Batch configuration
50
+ */
51
+ export function loadConfig(cwd = process.cwd()) {
52
+ const state = getState(cwd);
53
+
54
+ if (state?.batch_config) {
55
+ return { ...DEFAULT_CONFIG, ...state.batch_config };
56
+ }
57
+
58
+ return DEFAULT_CONFIG;
59
+ }
60
+
61
+ /**
62
+ * Save batch configuration to state.json
63
+ * @param {object} config - Configuration to save
64
+ * @param {string} cwd - Working directory
65
+ */
66
+ export function saveConfig(config, cwd = process.cwd()) {
67
+ updateState({ batch_config: config }, cwd);
68
+ }
69
+
70
+ /**
71
+ * Get current batch state
72
+ * @param {string} cwd - Working directory
73
+ * @returns {object|null} Batch state or null if no batch running
74
+ */
75
+ export function getBatchState(cwd = process.cwd()) {
76
+ const state = getState(cwd);
77
+ return state?.batch_state || null;
78
+ }
79
+
80
+ /**
81
+ * Update batch state
82
+ * @param {object} updates - State updates
83
+ * @param {string} cwd - Working directory
84
+ */
85
+ export function updateBatchState(updates, cwd = process.cwd()) {
86
+ const currentBatchState = getBatchState(cwd) || {};
87
+ updateState({
88
+ batch_state: {
89
+ ...currentBatchState,
90
+ ...updates,
91
+ last_updated: new Date().toISOString()
92
+ }
93
+ }, cwd);
94
+ }
95
+
96
+ /**
97
+ * Clear batch state (after completion)
98
+ * @param {string} cwd - Working directory
99
+ */
100
+ export function clearBatchState(cwd = process.cwd()) {
101
+ const state = getState(cwd);
102
+ if (state) {
103
+ const { batch_state, ...rest } = state;
104
+ writeFileSync(
105
+ join(cwd, '.ai', 'state.json'),
106
+ JSON.stringify({ ...rest, last_updated: new Date().toISOString() }, null, 2)
107
+ );
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Parse tasks from active.md
113
+ * @param {string} cwd - Working directory
114
+ * @returns {Array<object>} Array of tasks
115
+ */
116
+ export function parseActiveTasks(cwd = process.cwd()) {
117
+ const activePath = join(cwd, '.ai', 'tasks', 'active.md');
118
+
119
+ if (!existsSync(activePath)) {
120
+ return [];
121
+ }
122
+
123
+ const content = readFileSync(activePath, 'utf8');
124
+ return parseTasksFromMarkdown(content);
125
+ }
126
+
127
+ /**
128
+ * Parse tasks from backlog.md
129
+ * @param {string} cwd - Working directory
130
+ * @returns {Array<object>} Array of tasks
131
+ */
132
+ export function parseBacklogTasks(cwd = process.cwd()) {
133
+ const backlogPath = join(cwd, '.ai', 'tasks', 'backlog.md');
134
+
135
+ if (!existsSync(backlogPath)) {
136
+ return [];
137
+ }
138
+
139
+ const content = readFileSync(backlogPath, 'utf8');
140
+ return parseBacklogFromMarkdown(content);
141
+ }
142
+
143
+ /**
144
+ * Parse task items from markdown checkbox format
145
+ * @param {string} content - Markdown content
146
+ * @returns {Array<object>} Parsed tasks
147
+ */
148
+ function parseTasksFromMarkdown(content) {
149
+ const tasks = [];
150
+ const lines = content.split('\n');
151
+
152
+ for (let i = 0; i < lines.length; i++) {
153
+ const line = lines[i];
154
+ // Match: - [ ] US-001: Task title or - [x] US-001: Task title
155
+ const match = line.match(/^- \[([ x])\]\s*([A-Z]+-\d+):\s*(.+)$/i);
156
+
157
+ if (match) {
158
+ const task = {
159
+ id: match[2],
160
+ title: match[3].trim(),
161
+ completed: match[1].toLowerCase() === 'x',
162
+ line: i + 1,
163
+ raw: line
164
+ };
165
+
166
+ // Look for metadata in following lines (indented)
167
+ let j = i + 1;
168
+ while (j < lines.length && lines[j].match(/^\s{2,}-?\s*/)) {
169
+ const metaLine = lines[j].trim();
170
+
171
+ if (metaLine.startsWith('- Assignee:') || metaLine.startsWith('Assignee:')) {
172
+ task.assignee = metaLine.split(':')[1]?.trim();
173
+ } else if (metaLine.startsWith('- Priority:') || metaLine.startsWith('Priority:')) {
174
+ task.priority = metaLine.split(':')[1]?.trim().toLowerCase();
175
+ } else if (metaLine.startsWith('- Blocked by:') || metaLine.startsWith('Blocked by:')) {
176
+ task.blockedBy = metaLine.split(':')[1]?.trim();
177
+ } else if (metaLine.startsWith('- Notes:') || metaLine.startsWith('Notes:')) {
178
+ task.notes = metaLine.split(':').slice(1).join(':').trim();
179
+ }
180
+ j++;
181
+ }
182
+
183
+ tasks.push(task);
184
+ }
185
+ }
186
+
187
+ return tasks;
188
+ }
189
+
190
+ /**
191
+ * Parse backlog items from markdown header format
192
+ * @param {string} content - Markdown content
193
+ * @returns {Array<object>} Parsed tasks
194
+ */
195
+ function parseBacklogFromMarkdown(content) {
196
+ const tasks = [];
197
+ const lines = content.split('\n');
198
+
199
+ let currentSection = '';
200
+
201
+ for (let i = 0; i < lines.length; i++) {
202
+ const line = lines[i];
203
+
204
+ // Track section headers (## High Priority, ## Medium Priority, etc.)
205
+ const sectionMatch = line.match(/^##\s+(.+)/);
206
+ if (sectionMatch) {
207
+ const section = sectionMatch[1].toLowerCase();
208
+ if (section.includes('high')) currentSection = 'high';
209
+ else if (section.includes('medium')) currentSection = 'medium';
210
+ else if (section.includes('low')) currentSection = 'low';
211
+ else if (section.includes('roadmap')) currentSection = 'roadmap';
212
+ continue;
213
+ }
214
+
215
+ // Match: ### US-001: Task title
216
+ const taskMatch = line.match(/^###\s*([A-Z]+-\d+):\s*(.+)$/i);
217
+
218
+ if (taskMatch) {
219
+ const task = {
220
+ id: taskMatch[1],
221
+ title: taskMatch[2].trim(),
222
+ section: currentSection,
223
+ priority: currentSection === 'high' ? 'high' :
224
+ currentSection === 'medium' ? 'medium' : 'low',
225
+ line: i + 1
226
+ };
227
+
228
+ // Look ahead for metadata lines
229
+ let j = i + 1;
230
+ while (j < lines.length) {
231
+ const metaLine = lines[j];
232
+
233
+ // Stop at next task or section
234
+ if (metaLine.match(/^###?\s/)) break;
235
+
236
+ // Parse metadata (may have leading whitespace)
237
+ // Format: **Key:** Value (note: colon inside the bold)
238
+ const metaMatch = metaLine.match(/^\s*\*\*([^:*]+):\*\*\s*(.+)/);
239
+ if (metaMatch) {
240
+ const key = metaMatch[1].toLowerCase().trim();
241
+ const value = metaMatch[2].trim();
242
+
243
+ if (key === 'type') task.type = value.toLowerCase();
244
+ else if (key === 'priority') task.priority = value.toLowerCase();
245
+ else if (key === 'effort') task.effort = value.toUpperCase();
246
+ else if (key === 'description') task.description = value;
247
+ else if (key === 'dependencies') task.dependencies = value;
248
+ }
249
+ j++;
250
+ }
251
+
252
+ tasks.push(task);
253
+ }
254
+ }
255
+
256
+ return tasks;
257
+ }
258
+
259
+ /**
260
+ * Select next task to execute based on strategy
261
+ * @param {string} cwd - Working directory
262
+ * @param {object} options - Selection options
263
+ * @returns {object|null} Selected task or null
264
+ */
265
+ export function selectNextTask(cwd = process.cwd(), options = {}) {
266
+ const config = loadConfig(cwd);
267
+ const strategy = options.strategy || config.selection_strategy;
268
+ const priorityFilter = options.priority;
269
+
270
+ // First check active tasks for incomplete ones
271
+ const activeTasks = parseActiveTasks(cwd);
272
+ const pendingActive = activeTasks.filter(t => !t.completed && !t.blockedBy);
273
+
274
+ if (pendingActive.length > 0) {
275
+ return pendingActive[0];
276
+ }
277
+
278
+ // Then check backlog
279
+ let backlogTasks = parseBacklogTasks(cwd);
280
+
281
+ // Filter by priority if specified
282
+ if (priorityFilter) {
283
+ backlogTasks = backlogTasks.filter(t => t.priority === priorityFilter);
284
+ }
285
+
286
+ // Filter out blocked tasks
287
+ backlogTasks = backlogTasks.filter(t => !t.dependencies || t.dependencies === 'None');
288
+
289
+ if (backlogTasks.length === 0) {
290
+ return null;
291
+ }
292
+
293
+ // Sort based on strategy
294
+ switch (strategy) {
295
+ case 'effort':
296
+ // Sort by effort (smallest first)
297
+ const effortOrder = { 'XS': 1, 'S': 2, 'M': 3, 'L': 4, 'XL': 5 };
298
+ backlogTasks.sort((a, b) => {
299
+ const aEffort = effortOrder[a.effort] || 3;
300
+ const bEffort = effortOrder[b.effort] || 3;
301
+ return aEffort - bEffort;
302
+ });
303
+ break;
304
+
305
+ case 'priority':
306
+ default:
307
+ // Sort by priority (highest first), then by position
308
+ const priorityOrder = { 'high': 1, 'medium': 2, 'low': 3 };
309
+ backlogTasks.sort((a, b) => {
310
+ const aPriority = priorityOrder[a.priority] || 2;
311
+ const bPriority = priorityOrder[b.priority] || 2;
312
+ if (aPriority !== bPriority) return aPriority - bPriority;
313
+ return a.line - b.line;
314
+ });
315
+ break;
316
+ }
317
+
318
+ return backlogTasks[0];
319
+ }
320
+
321
+ /**
322
+ * Run a single quality gate
323
+ * @param {string} gate - Gate name (typecheck, lint, test, build, security)
324
+ * @param {string} cwd - Working directory
325
+ * @returns {Promise<{success: boolean, output?: string, error?: string}>}
326
+ */
327
+ export async function runQualityGate(gate, cwd = process.cwd()) {
328
+ const commands = GATE_COMMANDS[gate];
329
+
330
+ if (!commands) {
331
+ return { success: false, error: `Unknown gate: ${gate}` };
332
+ }
333
+
334
+ // Try each command variant
335
+ for (const command of commands) {
336
+ try {
337
+ const { stdout, stderr } = await execAsync(command, {
338
+ cwd,
339
+ timeout: 300000, // 5 minutes
340
+ maxBuffer: 10 * 1024 * 1024 // 10MB
341
+ });
342
+
343
+ return {
344
+ success: true,
345
+ output: stdout + (stderr ? '\n' + stderr : ''),
346
+ command
347
+ };
348
+ } catch (error) {
349
+ // If command not found, try next variant
350
+ if (error.message?.includes('not found') ||
351
+ error.message?.includes('ENOENT') ||
352
+ error.message?.includes('missing script')) {
353
+ continue;
354
+ }
355
+
356
+ // Command found but failed
357
+ return {
358
+ success: false,
359
+ error: error.message,
360
+ output: error.stdout,
361
+ stderr: error.stderr,
362
+ command
363
+ };
364
+ }
365
+ }
366
+
367
+ // No command variant worked
368
+ return {
369
+ success: true, // Skip if no command available
370
+ skipped: true,
371
+ output: `No ${gate} command available, skipping`
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Run all configured quality gates
377
+ * @param {string} cwd - Working directory
378
+ * @param {object} options - Gate options
379
+ * @returns {Promise<{success: boolean, results: object}>}
380
+ */
381
+ export async function runQualityGates(cwd = process.cwd(), options = {}) {
382
+ const config = loadConfig(cwd);
383
+ const gates = options.gates || config.quality_gates;
384
+ const skipGates = options.skip || config.skip_gates;
385
+
386
+ const results = {};
387
+ let allPassed = true;
388
+
389
+ for (const gate of gates) {
390
+ if (skipGates.includes(gate)) {
391
+ results[gate] = { success: true, skipped: true, reason: 'Configured to skip' };
392
+ continue;
393
+ }
394
+
395
+ const result = await runQualityGate(gate, cwd);
396
+ results[gate] = result;
397
+
398
+ if (!result.success && !result.skipped) {
399
+ allPassed = false;
400
+ if (options.failFast !== false) {
401
+ break; // Stop on first failure
402
+ }
403
+ }
404
+ }
405
+
406
+ return { success: allPassed, results };
407
+ }
408
+
409
+ /**
410
+ * Create a slug from task title
411
+ * @param {string} title - Task title
412
+ * @returns {string} URL-safe slug
413
+ */
414
+ function createSlug(title) {
415
+ return title
416
+ .toLowerCase()
417
+ .replace(/[^a-z0-9]+/g, '-')
418
+ .replace(/^-|-$/g, '')
419
+ .substring(0, 50);
420
+ }
421
+
422
+ /**
423
+ * Create feature branch for task
424
+ * @param {object} task - Task object
425
+ * @param {string} cwd - Working directory
426
+ * @returns {Promise<{success: boolean, branch?: string, error?: string}>}
427
+ */
428
+ export async function createFeatureBranch(task, cwd = process.cwd()) {
429
+ const config = loadConfig(cwd);
430
+ const slug = createSlug(task.title);
431
+ const branch = config.branch_format
432
+ .replace('{id}', task.id.toLowerCase())
433
+ .replace('{slug}', slug);
434
+
435
+ try {
436
+ // Check if branch exists
437
+ try {
438
+ await execAsync(`git rev-parse --verify ${branch}`, { cwd });
439
+ // Branch exists, switch to it
440
+ await execAsync(`git checkout ${branch}`, { cwd });
441
+ } catch {
442
+ // Branch doesn't exist, create it
443
+ await execAsync(`git checkout -b ${branch}`, { cwd });
444
+ }
445
+
446
+ return { success: true, branch };
447
+ } catch (error) {
448
+ return { success: false, error: error.message };
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Commit task changes
454
+ * @param {object} task - Task object
455
+ * @param {object} options - Commit options
456
+ * @param {string} cwd - Working directory
457
+ * @returns {Promise<{success: boolean, error?: string}>}
458
+ */
459
+ export async function commitTask(task, options = {}, cwd = process.cwd()) {
460
+ const config = loadConfig(cwd);
461
+
462
+ try {
463
+ // Stage all changes
464
+ await execAsync('git add .', { cwd });
465
+
466
+ // Check if there are changes to commit
467
+ try {
468
+ await execAsync('git diff --cached --quiet', { cwd });
469
+ // No changes
470
+ return { success: true, noChanges: true };
471
+ } catch {
472
+ // There are changes, continue with commit
473
+ }
474
+
475
+ // Build commit message
476
+ const type = task.id.startsWith('BUG') ? 'fix' :
477
+ task.id.startsWith('DEV') ? 'refactor' :
478
+ task.id.startsWith('DOCS') ? 'docs' :
479
+ task.id.startsWith('SPIKE') ? 'chore' : 'feat';
480
+
481
+ const scope = options.scope || '';
482
+ const scopePart = scope ? `(${scope})` : '';
483
+ const message = options.message || task.title;
484
+
485
+ const commitMessage = config.commit_format === 'conventional'
486
+ ? `${type}${scopePart}: ${task.id} - ${message}\n\nFixes: ${task.id}\n\nCo-Authored-By: Claude <noreply@anthropic.com>`
487
+ : `${task.id}: ${message}`;
488
+
489
+ // Commit using heredoc for proper formatting
490
+ await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd });
491
+
492
+ return { success: true };
493
+ } catch (error) {
494
+ return { success: false, error: error.message };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Push changes to remote
500
+ * @param {string} branch - Branch name
501
+ * @param {string} cwd - Working directory
502
+ * @returns {Promise<{success: boolean, error?: string}>}
503
+ */
504
+ export async function pushToRemote(branch, cwd = process.cwd()) {
505
+ try {
506
+ await execAsync(`git push -u origin ${branch}`, { cwd });
507
+ return { success: true };
508
+ } catch (error) {
509
+ return { success: false, error: error.message };
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Mark task as complete in active.md
515
+ * @param {string} taskId - Task ID
516
+ * @param {string} cwd - Working directory
517
+ * @returns {boolean} Success
518
+ */
519
+ export function markTaskComplete(taskId, cwd = process.cwd()) {
520
+ const activePath = join(cwd, '.ai', 'tasks', 'active.md');
521
+
522
+ if (!existsSync(activePath)) {
523
+ return false;
524
+ }
525
+
526
+ const content = readFileSync(activePath, 'utf8');
527
+ const date = new Date().toISOString().split('T')[0];
528
+
529
+ // Replace [ ] with [x] for the task
530
+ const updated = content.replace(
531
+ new RegExp(`^(- \\[) \\](\\s*${taskId}:.*)$`, 'mi'),
532
+ `$1x]$2 (${date})`
533
+ );
534
+
535
+ if (updated !== content) {
536
+ writeFileSync(activePath, updated);
537
+ return true;
538
+ }
539
+
540
+ return false;
541
+ }
542
+
543
+ /**
544
+ * Add task to active.md from backlog
545
+ * @param {object} task - Task object
546
+ * @param {string} cwd - Working directory
547
+ * @returns {boolean} Success
548
+ */
549
+ export function addToActive(task, cwd = process.cwd()) {
550
+ const activePath = join(cwd, '.ai', 'tasks', 'active.md');
551
+
552
+ if (!existsSync(activePath)) {
553
+ return false;
554
+ }
555
+
556
+ const content = readFileSync(activePath, 'utf8');
557
+
558
+ // Find the "Today's Focus" or "Current Sprint" section and add after
559
+ const newTask = `- [ ] ${task.id}: ${task.title}\n - Priority: ${task.priority || 'medium'}\n`;
560
+
561
+ // Add before "## Notes" or at end
562
+ const notesIndex = content.indexOf('## Notes');
563
+ let updated;
564
+
565
+ if (notesIndex !== -1) {
566
+ updated = content.slice(0, notesIndex) + newTask + '\n' + content.slice(notesIndex);
567
+ } else {
568
+ updated = content + '\n' + newTask;
569
+ }
570
+
571
+ writeFileSync(activePath, updated);
572
+ return true;
573
+ }
574
+
575
+ /**
576
+ * Pause batch execution
577
+ * @param {string} reason - Pause reason
578
+ * @param {string} cwd - Working directory
579
+ */
580
+ export function pauseBatch(reason, cwd = process.cwd()) {
581
+ updateBatchState({
582
+ paused_at: new Date().toISOString(),
583
+ pause_reason: reason,
584
+ status: 'paused'
585
+ }, cwd);
586
+ }
587
+
588
+ /**
589
+ * Get batch status summary
590
+ * @param {string} cwd - Working directory
591
+ * @returns {object} Status summary
592
+ */
593
+ export function getStatus(cwd = process.cwd()) {
594
+ if (!hasWorkspace(cwd)) {
595
+ return { hasWorkspace: false };
596
+ }
597
+
598
+ const config = loadConfig(cwd);
599
+ const batchState = getBatchState(cwd);
600
+ const activeTasks = parseActiveTasks(cwd);
601
+ const backlogTasks = parseBacklogTasks(cwd);
602
+
603
+ const pendingActive = activeTasks.filter(t => !t.completed);
604
+ const completedActive = activeTasks.filter(t => t.completed);
605
+
606
+ return {
607
+ hasWorkspace: true,
608
+ config,
609
+ batchState,
610
+ tasks: {
611
+ activeTotal: activeTasks.length,
612
+ activePending: pendingActive.length,
613
+ activeCompleted: completedActive.length,
614
+ backlogTotal: backlogTasks.length,
615
+ backlogHigh: backlogTasks.filter(t => t.priority === 'high').length,
616
+ backlogMedium: backlogTasks.filter(t => t.priority === 'medium').length,
617
+ backlogLow: backlogTasks.filter(t => t.priority === 'low').length
618
+ },
619
+ isRunning: batchState?.status === 'running',
620
+ isPaused: batchState?.status === 'paused',
621
+ currentTask: batchState?.current_task,
622
+ pauseReason: batchState?.pause_reason
623
+ };
624
+ }
625
+
626
+ /**
627
+ * Execute a single task with full workflow
628
+ * @param {object} task - Task to execute
629
+ * @param {object} options - Execution options
630
+ * @param {string} cwd - Working directory
631
+ * @returns {Promise<{success: boolean, error?: string, details?: object}>}
632
+ */
633
+ export async function executeTask(task, options = {}, cwd = process.cwd()) {
634
+ const config = loadConfig(cwd);
635
+ const details = { task: task.id, steps: [] };
636
+
637
+ // Update batch state
638
+ updateBatchState({
639
+ current_task: task.id,
640
+ status: 'running',
641
+ task_started_at: new Date().toISOString()
642
+ }, cwd);
643
+
644
+ // Step 1: Create feature branch (if auto_commit enabled)
645
+ if (config.auto_commit && !options.skipBranch) {
646
+ const branchResult = await createFeatureBranch(task, cwd);
647
+ details.steps.push({ step: 'create_branch', ...branchResult });
648
+
649
+ if (!branchResult.success) {
650
+ return { success: false, error: `Failed to create branch: ${branchResult.error}`, details };
651
+ }
652
+ details.branch = branchResult.branch;
653
+ }
654
+
655
+ // Step 2: This is where actual task execution would happen
656
+ // In batch mode, this is typically done by an AI agent
657
+ // The batch service just provides the infrastructure
658
+ details.steps.push({
659
+ step: 'execute',
660
+ success: true,
661
+ note: 'Task execution handled by AI agent'
662
+ });
663
+
664
+ // Step 3: Run quality gates
665
+ if (!options.skipGates) {
666
+ const gatesResult = await runQualityGates(cwd, {
667
+ gates: config.quality_gates,
668
+ skip: config.skip_gates
669
+ });
670
+ details.steps.push({ step: 'quality_gates', ...gatesResult });
671
+
672
+ if (!gatesResult.success) {
673
+ const failedGates = Object.entries(gatesResult.results)
674
+ .filter(([_, r]) => !r.success && !r.skipped)
675
+ .map(([name, r]) => `${name}: ${r.error}`);
676
+
677
+ pauseBatch(`Quality gates failed: ${failedGates.join(', ')}`, cwd);
678
+
679
+ if (config.notify_on_error) {
680
+ await notifications.notifyError(
681
+ 'Batch Paused',
682
+ `Quality gates failed for ${task.id}: ${failedGates.join(', ')}`,
683
+ { cwd }
684
+ );
685
+ }
686
+
687
+ return {
688
+ success: false,
689
+ error: 'Quality gates failed',
690
+ details,
691
+ gateFailures: failedGates
692
+ };
693
+ }
694
+ }
695
+
696
+ // Step 4: Commit changes
697
+ if (config.auto_commit && !options.skipCommit) {
698
+ const commitResult = await commitTask(task, {}, cwd);
699
+ details.steps.push({ step: 'commit', ...commitResult });
700
+
701
+ if (!commitResult.success) {
702
+ return { success: false, error: `Failed to commit: ${commitResult.error}`, details };
703
+ }
704
+
705
+ // Step 5: Push to remote
706
+ if (config.auto_push && details.branch) {
707
+ const pushResult = await pushToRemote(details.branch, cwd);
708
+ details.steps.push({ step: 'push', ...pushResult });
709
+
710
+ if (!pushResult.success) {
711
+ // Don't fail the task, just log warning
712
+ details.pushWarning = pushResult.error;
713
+ }
714
+ }
715
+ }
716
+
717
+ // Step 6: Mark task complete
718
+ markTaskComplete(task.id, cwd);
719
+ details.steps.push({ step: 'mark_complete', success: true });
720
+
721
+ // Update batch state
722
+ const batchState = getBatchState(cwd);
723
+ const completed = [...(batchState?.completed || []), task.id];
724
+ const pending = (batchState?.pending || []).filter(id => id !== task.id);
725
+
726
+ updateBatchState({
727
+ completed,
728
+ pending,
729
+ current_task: null,
730
+ last_completed_at: new Date().toISOString()
731
+ }, cwd);
732
+
733
+ // Notify completion
734
+ if (config.notify_on_complete) {
735
+ await notifications.notifyTaskComplete(task.id, task.title, { cwd });
736
+ }
737
+
738
+ return { success: true, details };
739
+ }
740
+
741
+ /**
742
+ * Start batch execution
743
+ * @param {object} options - Batch options
744
+ * @param {string} cwd - Working directory
745
+ * @returns {Promise<{success: boolean, completed: string[], errors: object[]}>}
746
+ */
747
+ export async function startBatch(options = {}, cwd = process.cwd()) {
748
+ const config = loadConfig(cwd);
749
+ const count = options.count || config.max_tasks;
750
+ const dryRun = options.dryRun || false;
751
+ const priorityFilter = options.priority;
752
+
753
+ const completed = [];
754
+ const errors = [];
755
+ const startTime = Date.now();
756
+ const maxDuration = config.max_duration_hours * 60 * 60 * 1000;
757
+
758
+ // Initialize batch state
759
+ updateBatchState({
760
+ started_at: new Date().toISOString(),
761
+ status: 'running',
762
+ completed: [],
763
+ pending: [],
764
+ max_tasks: count
765
+ }, cwd);
766
+
767
+ for (let i = 0; i < count; i++) {
768
+ // Check time limit
769
+ if (Date.now() - startTime > maxDuration) {
770
+ pauseBatch('Time limit reached', cwd);
771
+ break;
772
+ }
773
+
774
+ // Select next task
775
+ const task = selectNextTask(cwd, { priority: priorityFilter });
776
+
777
+ if (!task) {
778
+ // No more tasks
779
+ break;
780
+ }
781
+
782
+ if (dryRun) {
783
+ completed.push({ id: task.id, title: task.title, dryRun: true });
784
+ continue;
785
+ }
786
+
787
+ // Execute task
788
+ const result = await executeTask(task, options, cwd);
789
+
790
+ if (result.success) {
791
+ completed.push({ id: task.id, title: task.title });
792
+ } else {
793
+ errors.push({ id: task.id, error: result.error, details: result.details });
794
+
795
+ // Stop on error unless configured otherwise
796
+ if (!options.continueOnError) {
797
+ break;
798
+ }
799
+ }
800
+ }
801
+
802
+ // Finalize batch if all tasks completed successfully
803
+ if (errors.length === 0) {
804
+ clearBatchState(cwd);
805
+ }
806
+
807
+ return { success: errors.length === 0, completed, errors };
808
+ }
809
+
810
+ /**
811
+ * Resume a paused batch
812
+ * @param {object} options - Resume options
813
+ * @param {string} cwd - Working directory
814
+ * @returns {Promise<{success: boolean, message: string}>}
815
+ */
816
+ export async function resumeBatch(options = {}, cwd = process.cwd()) {
817
+ const batchState = getBatchState(cwd);
818
+
819
+ if (!batchState) {
820
+ return { success: false, message: 'No batch state found' };
821
+ }
822
+
823
+ if (batchState.status !== 'paused') {
824
+ return { success: false, message: `Batch is not paused (status: ${batchState.status})` };
825
+ }
826
+
827
+ // Clear pause state
828
+ updateBatchState({
829
+ paused_at: null,
830
+ pause_reason: null,
831
+ status: 'running'
832
+ }, cwd);
833
+
834
+ // Skip current task if requested
835
+ if (options.skip && batchState.current_task) {
836
+ const pending = (batchState.pending || []).filter(id => id !== batchState.current_task);
837
+ updateBatchState({
838
+ current_task: null,
839
+ pending,
840
+ skipped: [...(batchState.skipped || []), batchState.current_task]
841
+ }, cwd);
842
+ }
843
+
844
+ // Continue batch
845
+ return startBatch({
846
+ ...options,
847
+ count: batchState.pending?.length || 1
848
+ }, cwd);
849
+ }