@fermindi/pwn-cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/cli/backlog.js +60 -0
- package/cli/batch.js +112 -12
- package/cli/index.js +9 -31
- package/cli/inject.js +8 -32
- package/cli/status.js +1 -1
- package/cli/update.js +78 -27
- package/package.json +6 -3
- package/src/core/inject.js +41 -45
- package/src/core/state.js +0 -1
- package/src/core/validate.js +14 -1
- package/src/core/workspace.js +13 -11
- package/src/index.js +0 -1
- package/src/services/batch-runner.js +769 -0
- package/src/services/batch-service.js +185 -74
- package/src/ui/backlog-viewer.js +394 -0
- package/templates/workspace/.ai/agents/claude.md +47 -146
- package/templates/workspace/.ai/batch/tasks/.gitkeep +0 -0
- package/templates/workspace/.ai/memory/patterns.md +57 -11
- package/templates/workspace/.ai/tasks/active.md +1 -1
- package/templates/workspace/.ai/workflows/batch-task.md +43 -67
- package/templates/workspace/.claude/commands/save.md +0 -42
- package/cli/codespaces.js +0 -303
- package/cli/migrate.js +0 -466
- package/cli/mode.js +0 -206
- package/cli/notify.js +0 -135
- package/src/services/notification-service.js +0 -342
- package/templates/codespaces/devcontainer.json +0 -52
- package/templates/codespaces/setup.sh +0 -70
- package/templates/workspace/.ai/config/notifications.template.json +0 -20
- package/templates/workspace/.ai/tasks/backlog.md +0 -95
- package/templates/workspace/.claude/commands/mode.md +0 -103
- package/templates/workspace/.claude/settings.json +0 -15
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
* checkpointing, and signal handling.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { exec } from 'child_process';
|
|
11
11
|
import { promisify } from 'util';
|
|
12
12
|
import { getState, updateState, hasWorkspace } from '../core/state.js';
|
|
13
|
-
import * as notifications from './notification-service.js';
|
|
14
13
|
|
|
15
14
|
const execAsync = promisify(exec);
|
|
16
15
|
|
|
@@ -27,11 +26,110 @@ const DEFAULT_CONFIG = {
|
|
|
27
26
|
create_pr: false,
|
|
28
27
|
branch_format: 'feature/{id}-{slug}',
|
|
29
28
|
commit_format: 'conventional',
|
|
30
|
-
selection_strategy: 'priority'
|
|
31
|
-
notify_on_complete: true,
|
|
32
|
-
notify_on_error: true
|
|
29
|
+
selection_strategy: 'priority' // priority, effort, due_date
|
|
33
30
|
};
|
|
34
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Detect which quality gates a project can actually run.
|
|
34
|
+
* Checks for tooling config files and package.json scripts.
|
|
35
|
+
* @param {string} cwd - Working directory
|
|
36
|
+
* @returns {{ available: string[], missing: string[], details: Record<string, string> }}
|
|
37
|
+
*/
|
|
38
|
+
export function detectAvailableGates(cwd = process.cwd()) {
|
|
39
|
+
const available = [];
|
|
40
|
+
const missing = [];
|
|
41
|
+
const details = {};
|
|
42
|
+
|
|
43
|
+
let pkg = {};
|
|
44
|
+
const pkgPath = join(cwd, 'package.json');
|
|
45
|
+
if (existsSync(pkgPath)) {
|
|
46
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch {}
|
|
47
|
+
}
|
|
48
|
+
const scripts = pkg.scripts || {};
|
|
49
|
+
|
|
50
|
+
// --- typecheck ---
|
|
51
|
+
if (scripts.typecheck) {
|
|
52
|
+
available.push('typecheck');
|
|
53
|
+
details.typecheck = 'npm run typecheck';
|
|
54
|
+
} else if (existsSync(join(cwd, 'tsconfig.json'))) {
|
|
55
|
+
available.push('typecheck');
|
|
56
|
+
details.typecheck = 'tsconfig.json (tsc --noEmit)';
|
|
57
|
+
} else if (existsSync(join(cwd, 'jsconfig.json'))) {
|
|
58
|
+
available.push('typecheck');
|
|
59
|
+
details.typecheck = 'jsconfig.json';
|
|
60
|
+
} else {
|
|
61
|
+
missing.push('typecheck');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- lint ---
|
|
65
|
+
if (scripts.lint) {
|
|
66
|
+
available.push('lint');
|
|
67
|
+
details.lint = 'npm run lint';
|
|
68
|
+
} else if (
|
|
69
|
+
globExists(cwd, '.eslintrc') ||
|
|
70
|
+
globExists(cwd, 'eslint.config') ||
|
|
71
|
+
existsSync(join(cwd, 'biome.json')) ||
|
|
72
|
+
existsSync(join(cwd, 'biome.jsonc'))
|
|
73
|
+
) {
|
|
74
|
+
available.push('lint');
|
|
75
|
+
const tool = existsSync(join(cwd, 'biome.json')) || existsSync(join(cwd, 'biome.jsonc'))
|
|
76
|
+
? 'biome' : 'eslint';
|
|
77
|
+
details.lint = tool;
|
|
78
|
+
} else {
|
|
79
|
+
missing.push('lint');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- test ---
|
|
83
|
+
const defaultTestScript = 'echo "Error: no test specified" && exit 1';
|
|
84
|
+
if (scripts.test && !scripts.test.includes(defaultTestScript)) {
|
|
85
|
+
available.push('test');
|
|
86
|
+
details.test = 'npm test';
|
|
87
|
+
} else if (
|
|
88
|
+
globExists(cwd, 'vitest.config') ||
|
|
89
|
+
globExists(cwd, 'jest.config') ||
|
|
90
|
+
existsSync(join(cwd, 'pytest.ini')) ||
|
|
91
|
+
existsSync(join(cwd, 'pyproject.toml')) ||
|
|
92
|
+
existsSync(join(cwd, 'Cargo.toml')) ||
|
|
93
|
+
existsSync(join(cwd, 'go.mod'))
|
|
94
|
+
) {
|
|
95
|
+
available.push('test');
|
|
96
|
+
let tool = 'detected';
|
|
97
|
+
if (globExists(cwd, 'vitest.config')) tool = 'vitest';
|
|
98
|
+
else if (globExists(cwd, 'jest.config')) tool = 'jest';
|
|
99
|
+
else if (existsSync(join(cwd, 'pytest.ini')) || existsSync(join(cwd, 'pyproject.toml'))) tool = 'pytest';
|
|
100
|
+
else if (existsSync(join(cwd, 'Cargo.toml'))) tool = 'cargo test';
|
|
101
|
+
else if (existsSync(join(cwd, 'go.mod'))) tool = 'go test';
|
|
102
|
+
details.test = tool;
|
|
103
|
+
} else {
|
|
104
|
+
missing.push('test');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- build ---
|
|
108
|
+
if (scripts.build) {
|
|
109
|
+
available.push('build');
|
|
110
|
+
details.build = 'npm run build';
|
|
111
|
+
} else {
|
|
112
|
+
missing.push('build');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { available, missing, details };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if any file matching a prefix exists (e.g. "eslint.config" matches eslint.config.js, .mjs, etc.)
|
|
120
|
+
* @param {string} cwd - Directory to check
|
|
121
|
+
* @param {string} prefix - File prefix to match
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function globExists(cwd, prefix) {
|
|
125
|
+
try {
|
|
126
|
+
const files = readdirSync(cwd);
|
|
127
|
+
return files.some(f => f.startsWith(prefix));
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
35
133
|
/**
|
|
36
134
|
* Quality gate commands mapping
|
|
37
135
|
*/
|
|
@@ -125,19 +223,26 @@ export function parseActiveTasks(cwd = process.cwd()) {
|
|
|
125
223
|
}
|
|
126
224
|
|
|
127
225
|
/**
|
|
128
|
-
* Parse
|
|
226
|
+
* Parse stories from prd.json
|
|
129
227
|
* @param {string} cwd - Working directory
|
|
130
|
-
* @returns {Array<object>} Array of
|
|
228
|
+
* @returns {Array<object>} Array of stories
|
|
131
229
|
*/
|
|
132
|
-
export function
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
230
|
+
export function parsePrdTasks(cwd = process.cwd()) {
|
|
231
|
+
const prdPath = join(cwd, '.ai', 'tasks', 'prd.json');
|
|
232
|
+
if (!existsSync(prdPath)) return [];
|
|
233
|
+
try {
|
|
234
|
+
const prd = JSON.parse(readFileSync(prdPath, 'utf8'));
|
|
235
|
+
return prd.stories || [];
|
|
236
|
+
} catch {
|
|
136
237
|
return [];
|
|
137
238
|
}
|
|
239
|
+
}
|
|
138
240
|
|
|
139
|
-
|
|
140
|
-
|
|
241
|
+
/**
|
|
242
|
+
* @deprecated Use parsePrdTasks instead
|
|
243
|
+
*/
|
|
244
|
+
export function parseBacklogTasks(cwd = process.cwd()) {
|
|
245
|
+
return parsePrdTasks(cwd);
|
|
141
246
|
}
|
|
142
247
|
|
|
143
248
|
/**
|
|
@@ -257,16 +362,36 @@ function parseBacklogFromMarkdown(content) {
|
|
|
257
362
|
}
|
|
258
363
|
|
|
259
364
|
/**
|
|
260
|
-
*
|
|
365
|
+
* Convert backlog.md content to prd.json format
|
|
366
|
+
* @param {string} backlogContent - Raw markdown content from backlog.md
|
|
367
|
+
* @param {string} projectName - Project name for prd.json
|
|
368
|
+
* @returns {object} PRD JSON structure
|
|
369
|
+
*/
|
|
370
|
+
export function convertBacklogToPrd(backlogContent, projectName = 'my-project') {
|
|
371
|
+
const tasks = parseBacklogFromMarkdown(backlogContent);
|
|
372
|
+
return {
|
|
373
|
+
project: projectName,
|
|
374
|
+
branch: "main",
|
|
375
|
+
stories: tasks.map(t => ({
|
|
376
|
+
id: t.id,
|
|
377
|
+
title: t.title,
|
|
378
|
+
phase: t.section ? `Phase ${t.section}` : "Phase 1",
|
|
379
|
+
effort: t.effort || "M",
|
|
380
|
+
dependencies: t.dependencies ? t.dependencies.split(',').map(d => d.trim()) : [],
|
|
381
|
+
passes: false,
|
|
382
|
+
acceptance_criteria: [],
|
|
383
|
+
notes: t.description || ""
|
|
384
|
+
}))
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Select next task to execute based on prd.json dependencies
|
|
261
390
|
* @param {string} cwd - Working directory
|
|
262
391
|
* @param {object} options - Selection options
|
|
263
392
|
* @returns {object|null} Selected task or null
|
|
264
393
|
*/
|
|
265
394
|
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
395
|
// First check active tasks for incomplete ones
|
|
271
396
|
const activeTasks = parseActiveTasks(cwd);
|
|
272
397
|
const pendingActive = activeTasks.filter(t => !t.completed && !t.blockedBy);
|
|
@@ -275,47 +400,40 @@ export function selectNextTask(cwd = process.cwd(), options = {}) {
|
|
|
275
400
|
return pendingActive[0];
|
|
276
401
|
}
|
|
277
402
|
|
|
278
|
-
// Then check
|
|
279
|
-
|
|
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');
|
|
403
|
+
// Then check prd.json stories
|
|
404
|
+
const stories = parsePrdTasks(cwd);
|
|
405
|
+
const doneIds = stories.filter(s => s.passes).map(s => s.id);
|
|
288
406
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
407
|
+
const eligible = stories.find(s =>
|
|
408
|
+
!s.passes &&
|
|
409
|
+
s.dependencies.every(dep => doneIds.includes(dep)) &&
|
|
410
|
+
(!options.phase || s.phase === options.phase)
|
|
411
|
+
);
|
|
292
412
|
|
|
293
|
-
|
|
294
|
-
|
|
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;
|
|
413
|
+
return eligible || null;
|
|
414
|
+
}
|
|
304
415
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Mark a story as done in prd.json
|
|
418
|
+
* @param {string} taskId - Story ID
|
|
419
|
+
* @param {string} cwd - Working directory
|
|
420
|
+
* @returns {boolean} Success
|
|
421
|
+
*/
|
|
422
|
+
export function markStoryDone(taskId, cwd = process.cwd()) {
|
|
423
|
+
const prdPath = join(cwd, '.ai', 'tasks', 'prd.json');
|
|
424
|
+
if (!existsSync(prdPath)) return false;
|
|
425
|
+
try {
|
|
426
|
+
const prd = JSON.parse(readFileSync(prdPath, 'utf8'));
|
|
427
|
+
const story = prd.stories.find(s => s.id === taskId);
|
|
428
|
+
if (story) {
|
|
429
|
+
story.passes = true;
|
|
430
|
+
writeFileSync(prdPath, JSON.stringify(prd, null, 2));
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
// Invalid JSON
|
|
316
435
|
}
|
|
317
|
-
|
|
318
|
-
return backlogTasks[0];
|
|
436
|
+
return false;
|
|
319
437
|
}
|
|
320
438
|
|
|
321
439
|
/**
|
|
@@ -325,7 +443,10 @@ export function selectNextTask(cwd = process.cwd(), options = {}) {
|
|
|
325
443
|
* @returns {Promise<{success: boolean, output?: string, error?: string}>}
|
|
326
444
|
*/
|
|
327
445
|
export async function runQualityGate(gate, cwd = process.cwd()) {
|
|
328
|
-
|
|
446
|
+
// Check for custom gate commands in config
|
|
447
|
+
const config = loadConfig(cwd);
|
|
448
|
+
const customCmd = config.gate_commands?.[gate];
|
|
449
|
+
const commands = customCmd ? [customCmd] : GATE_COMMANDS[gate];
|
|
329
450
|
|
|
330
451
|
if (!commands) {
|
|
331
452
|
return { success: false, error: `Unknown gate: ${gate}` };
|
|
@@ -598,10 +719,12 @@ export function getStatus(cwd = process.cwd()) {
|
|
|
598
719
|
const config = loadConfig(cwd);
|
|
599
720
|
const batchState = getBatchState(cwd);
|
|
600
721
|
const activeTasks = parseActiveTasks(cwd);
|
|
601
|
-
const
|
|
722
|
+
const stories = parsePrdTasks(cwd);
|
|
602
723
|
|
|
603
724
|
const pendingActive = activeTasks.filter(t => !t.completed);
|
|
604
725
|
const completedActive = activeTasks.filter(t => t.completed);
|
|
726
|
+
const doneStories = stories.filter(s => s.passes);
|
|
727
|
+
const pendingStories = stories.filter(s => !s.passes);
|
|
605
728
|
|
|
606
729
|
return {
|
|
607
730
|
hasWorkspace: true,
|
|
@@ -611,10 +734,11 @@ export function getStatus(cwd = process.cwd()) {
|
|
|
611
734
|
activeTotal: activeTasks.length,
|
|
612
735
|
activePending: pendingActive.length,
|
|
613
736
|
activeCompleted: completedActive.length,
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
737
|
+
storiesTotal: stories.length,
|
|
738
|
+
storiesDone: doneStories.length,
|
|
739
|
+
storiesPending: pendingStories.length,
|
|
740
|
+
// Legacy compat
|
|
741
|
+
backlogTotal: stories.length
|
|
618
742
|
},
|
|
619
743
|
isRunning: batchState?.status === 'running',
|
|
620
744
|
isPaused: batchState?.status === 'paused',
|
|
@@ -676,14 +800,6 @@ export async function executeTask(task, options = {}, cwd = process.cwd()) {
|
|
|
676
800
|
|
|
677
801
|
pauseBatch(`Quality gates failed: ${failedGates.join(', ')}`, cwd);
|
|
678
802
|
|
|
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
803
|
return {
|
|
688
804
|
success: false,
|
|
689
805
|
error: 'Quality gates failed',
|
|
@@ -730,11 +846,6 @@ export async function executeTask(task, options = {}, cwd = process.cwd()) {
|
|
|
730
846
|
last_completed_at: new Date().toISOString()
|
|
731
847
|
}, cwd);
|
|
732
848
|
|
|
733
|
-
// Notify completion
|
|
734
|
-
if (config.notify_on_complete) {
|
|
735
|
-
await notifications.notifyTaskComplete(task.id, task.title, { cwd });
|
|
736
|
-
}
|
|
737
|
-
|
|
738
849
|
return { success: true, details };
|
|
739
850
|
}
|
|
740
851
|
|