@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.
@@ -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', // priority, effort, due_date
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 tasks from backlog.md
226
+ * Parse stories from prd.json
129
227
  * @param {string} cwd - Working directory
130
- * @returns {Array<object>} Array of tasks
228
+ * @returns {Array<object>} Array of stories
131
229
  */
132
- export function parseBacklogTasks(cwd = process.cwd()) {
133
- const backlogPath = join(cwd, '.ai', 'tasks', 'backlog.md');
134
-
135
- if (!existsSync(backlogPath)) {
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
- const content = readFileSync(backlogPath, 'utf8');
140
- return parseBacklogFromMarkdown(content);
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
- * Select next task to execute based on strategy
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 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');
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
- if (backlogTasks.length === 0) {
290
- return null;
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
- // 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;
413
+ return eligible || null;
414
+ }
304
415
 
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;
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
- const commands = GATE_COMMANDS[gate];
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 backlogTasks = parseBacklogTasks(cwd);
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
- 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
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