@fermindi/pwn-cli 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +265 -251
- package/cli/batch.js +333 -333
- package/cli/codespaces.js +303 -303
- package/cli/index.js +112 -91
- package/cli/inject.js +90 -67
- package/cli/knowledge.js +531 -531
- package/cli/migrate.js +466 -0
- package/cli/notify.js +135 -135
- package/cli/patterns.js +665 -665
- package/cli/save.js +206 -0
- package/cli/status.js +91 -91
- package/cli/update.js +189 -0
- package/cli/validate.js +61 -61
- package/package.json +70 -70
- package/src/core/inject.js +300 -204
- package/src/core/state.js +91 -91
- package/src/core/validate.js +202 -202
- package/src/core/workspace.js +176 -176
- package/src/index.js +20 -20
- package/src/knowledge/gc.js +308 -308
- package/src/knowledge/lifecycle.js +401 -401
- package/src/knowledge/promote.js +364 -364
- package/src/knowledge/references.js +342 -342
- package/src/patterns/matcher.js +218 -218
- package/src/patterns/registry.js +375 -375
- package/src/patterns/triggers.js +423 -423
- package/src/services/batch-service.js +849 -849
- package/src/services/notification-service.js +342 -342
- package/templates/codespaces/devcontainer.json +52 -52
- package/templates/codespaces/setup.sh +70 -70
- package/templates/workspace/.ai/README.md +164 -164
- package/templates/workspace/.ai/agents/README.md +204 -204
- package/templates/workspace/.ai/agents/claude.md +625 -625
- package/templates/workspace/.ai/config/README.md +79 -79
- package/templates/workspace/.ai/config/notifications.template.json +20 -20
- package/templates/workspace/.ai/memory/deadends.md +79 -79
- package/templates/workspace/.ai/memory/decisions.md +58 -58
- package/templates/workspace/.ai/memory/patterns.md +65 -65
- package/templates/workspace/.ai/patterns/backend/README.md +126 -126
- package/templates/workspace/.ai/patterns/frontend/README.md +103 -103
- package/templates/workspace/.ai/patterns/index.md +256 -256
- package/templates/workspace/.ai/patterns/triggers.json +1087 -1087
- package/templates/workspace/.ai/patterns/universal/README.md +141 -141
- package/templates/workspace/.ai/state.template.json +8 -8
- package/templates/workspace/.ai/tasks/active.md +77 -77
- package/templates/workspace/.ai/tasks/backlog.md +95 -95
- package/templates/workspace/.ai/workflows/batch-task.md +356 -356
|
@@ -1,849 +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
|
-
}
|
|
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
|
+
}
|