@fermindi/pwn-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  */
@@ -345,7 +443,10 @@ export function markStoryDone(taskId, cwd = process.cwd()) {
345
443
  * @returns {Promise<{success: boolean, output?: string, error?: string}>}
346
444
  */
347
445
  export async function runQualityGate(gate, cwd = process.cwd()) {
348
- 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];
349
450
 
350
451
  if (!commands) {
351
452
  return { success: false, error: `Unknown gate: ${gate}` };
@@ -699,14 +800,6 @@ export async function executeTask(task, options = {}, cwd = process.cwd()) {
699
800
 
700
801
  pauseBatch(`Quality gates failed: ${failedGates.join(', ')}`, cwd);
701
802
 
702
- if (config.notify_on_error) {
703
- await notifications.notifyError(
704
- 'Batch Paused',
705
- `Quality gates failed for ${task.id}: ${failedGates.join(', ')}`,
706
- { cwd }
707
- );
708
- }
709
-
710
803
  return {
711
804
  success: false,
712
805
  error: 'Quality gates failed',
@@ -753,11 +846,6 @@ export async function executeTask(task, options = {}, cwd = process.cwd()) {
753
846
  last_completed_at: new Date().toISOString()
754
847
  }, cwd);
755
848
 
756
- // Notify completion
757
- if (config.notify_on_complete) {
758
- await notifications.notifyTaskComplete(task.id, task.title, { cwd });
759
- }
760
-
761
849
  return { success: true, details };
762
850
  }
763
851
 
@@ -0,0 +1,394 @@
1
+ /**
2
+ * PWN Backlog Viewer — Interactive TUI
3
+ *
4
+ * Navigable terminal viewer for prd.json stories.
5
+ * Uses raw readline + ANSI escape codes (no extra deps).
6
+ */
7
+
8
+ import readline from 'readline';
9
+ import chalk from 'chalk';
10
+
11
+ /**
12
+ * Launch the interactive backlog viewer.
13
+ * @param {object} options
14
+ * @param {string} options.project - Project name
15
+ * @param {Array<object>} options.stories - Stories from prd.json
16
+ * @param {Array<object>} options.taskFiles - Task files from .ai/batch/tasks/
17
+ * @returns {Promise<void>} Resolves when user quits
18
+ */
19
+ export function startViewer({ project, stories, taskFiles }) {
20
+ return new Promise((resolve) => {
21
+ if (stories.length === 0) {
22
+ console.log(chalk.yellow('No stories found in prd.json'));
23
+ resolve();
24
+ return;
25
+ }
26
+
27
+ let selectedIndex = 0;
28
+ let detailMode = false; // true = full-screen detail view
29
+ let detailScroll = 0; // scroll offset inside detail view
30
+ const taskMap = new Map(taskFiles.map(t => [t.id, t]));
31
+
32
+ function getStatus(story) {
33
+ if (story.passes) return 'done';
34
+ const tf = taskMap.get(story.id);
35
+ if (tf) {
36
+ if (tf.status === 'completed') return 'done';
37
+ if (tf.status === 'planned' || tf.status === 'running') return 'active';
38
+ }
39
+ return 'pending';
40
+ }
41
+
42
+ function statusIcon(status) {
43
+ if (status === 'done') return chalk.green('✅');
44
+ if (status === 'active') return chalk.yellow('🔄');
45
+ return chalk.dim('⏳');
46
+ }
47
+
48
+ function statusLabel(status) {
49
+ if (status === 'done') return chalk.green('done');
50
+ if (status === 'active') return chalk.yellow('active');
51
+ return chalk.dim('pending');
52
+ }
53
+
54
+ function truncate(str, maxLen) {
55
+ if (!str) return '';
56
+ if (str.length <= maxLen) return str;
57
+ return str.slice(0, maxLen - 1) + '…';
58
+ }
59
+
60
+ function buildDetailLines(story, cols) {
61
+ const status = getStatus(story);
62
+ const contentLines = [];
63
+
64
+ contentLines.push(chalk.bold(` ${story.id} · ${story.title}`));
65
+ contentLines.push('');
66
+
67
+ const phase = story.phase || '—';
68
+ const effort = story.effort || '—';
69
+ const deps = story.dependencies?.length > 0 ? story.dependencies.join(', ') : 'none';
70
+ contentLines.push(` Phase: ${phase} | Effort: ${effort} | Status: ${statusLabel(status)} | Deps: ${deps}`);
71
+ contentLines.push('');
72
+
73
+ if (story.acceptance_criteria?.length > 0) {
74
+ contentLines.push(chalk.bold(' Acceptance Criteria:'));
75
+ for (const ac of story.acceptance_criteria) {
76
+ const check = status === 'done' ? chalk.green('✓') : chalk.dim('○');
77
+ contentLines.push(` ${check} ${truncate(ac, cols - 6)}`);
78
+ }
79
+ contentLines.push('');
80
+ }
81
+
82
+ if (story.notes) {
83
+ contentLines.push(chalk.bold(' Notes:'));
84
+ // Word-wrap notes to terminal width
85
+ const words = story.notes.split(' ');
86
+ let line = ' ';
87
+ for (const word of words) {
88
+ if (line.length + word.length + 1 > cols - 2) {
89
+ contentLines.push(chalk.dim(line));
90
+ line = ' ' + word;
91
+ } else {
92
+ line += (line.length > 1 ? ' ' : '') + word;
93
+ }
94
+ }
95
+ if (line.length > 1) contentLines.push(chalk.dim(line));
96
+ contentLines.push('');
97
+ }
98
+
99
+ // Show task file info if available
100
+ const tf = taskMap.get(story.id);
101
+ if (tf) {
102
+ contentLines.push(chalk.bold(' Batch Task Info:'));
103
+ if (tf.complexity) contentLines.push(` Complexity: ${tf.complexity}`);
104
+ if (tf.estimated_time_seconds) contentLines.push(` Estimated: ~${tf.estimated_time_seconds}s`);
105
+ if (tf.plan?.length > 0 && tf.plan[0] !== 'fallback - no plan available') {
106
+ contentLines.push(chalk.bold(' Plan:'));
107
+ for (const step of tf.plan) {
108
+ contentLines.push(` · ${truncate(step, cols - 8)}`);
109
+ }
110
+ }
111
+ if (tf.failure_reason) {
112
+ contentLines.push(chalk.red(` Failed: ${tf.failure_reason}`));
113
+ }
114
+ contentLines.push('');
115
+ }
116
+
117
+ return contentLines;
118
+ }
119
+
120
+ function renderDetailView() {
121
+ const rows = process.stdout.rows || 24;
122
+ const cols = process.stdout.columns || 80;
123
+ const story = stories[selectedIndex];
124
+
125
+ process.stdout.write('\x1b[H\x1b[2J');
126
+
127
+ const lines = [];
128
+
129
+ // Header
130
+ const header = `📋 ${story.id} — Detail View`;
131
+ const pos = `${selectedIndex + 1}/${stories.length}`;
132
+ const headerLine = `${header}${' '.repeat(Math.max(1, cols - header.length - pos.length - 2))}${pos}`;
133
+ lines.push(chalk.bold(headerLine));
134
+ lines.push(chalk.dim('─'.repeat(cols)));
135
+
136
+ // Build full content and apply scroll
137
+ const contentLines = buildDetailLines(story, cols);
138
+ const maxContent = rows - 4; // header(2) + footer(2)
139
+ const maxScroll = Math.max(0, contentLines.length - maxContent);
140
+ if (detailScroll > maxScroll) detailScroll = maxScroll;
141
+
142
+ const visible = contentLines.slice(detailScroll, detailScroll + maxContent);
143
+ for (const line of visible) {
144
+ lines.push(line);
145
+ }
146
+
147
+ // Scroll indicator
148
+ const scrollInfo = contentLines.length > maxContent
149
+ ? ` (${detailScroll + 1}-${Math.min(detailScroll + maxContent, contentLines.length)}/${contentLines.length})`
150
+ : '';
151
+
152
+ // Pad
153
+ while (lines.length < rows - 2) {
154
+ lines.push('');
155
+ }
156
+
157
+ lines.push(chalk.dim('─'.repeat(cols)));
158
+ lines.push(chalk.dim(` ↑/k ↓/j scroll ←/→ prev/next story Esc/Backspace back to list q quit${scrollInfo}`));
159
+
160
+ process.stdout.write(lines.join('\n'));
161
+ }
162
+
163
+ function render() {
164
+ if (detailMode) {
165
+ renderDetailView();
166
+ return;
167
+ }
168
+
169
+ const rows = process.stdout.rows || 24;
170
+ const cols = process.stdout.columns || 80;
171
+ const doneCount = stories.filter(s => getStatus(s) === 'done').length;
172
+
173
+ // Move cursor to top-left and clear screen
174
+ process.stdout.write('\x1b[H\x1b[2J');
175
+
176
+ const lines = [];
177
+
178
+ // Header
179
+ const header = `📋 Product Backlog — ${project}`;
180
+ const progress = `${doneCount}/${stories.length} done`;
181
+ const headerLine = `${header}${' '.repeat(Math.max(1, cols - header.length - progress.length - 2))}${progress}`;
182
+ lines.push(chalk.bold(headerLine));
183
+ lines.push(chalk.dim('─'.repeat(cols)));
184
+
185
+ // Story list — compute how many rows we can show
186
+ const detailReserved = 12; // lines reserved for detail panel + footer
187
+ const maxListRows = Math.max(3, rows - lines.length - detailReserved);
188
+
189
+ // Scrolling window
190
+ let scrollOffset = 0;
191
+ if (selectedIndex >= scrollOffset + maxListRows) {
192
+ scrollOffset = selectedIndex - maxListRows + 1;
193
+ }
194
+ if (selectedIndex < scrollOffset) {
195
+ scrollOffset = selectedIndex;
196
+ }
197
+
198
+ const visibleStories = stories.slice(scrollOffset, scrollOffset + maxListRows);
199
+
200
+ for (let i = 0; i < visibleStories.length; i++) {
201
+ const story = visibleStories[i];
202
+ const idx = scrollOffset + i;
203
+ const status = getStatus(story);
204
+ const icon = statusIcon(status);
205
+ const pointer = idx === selectedIndex ? chalk.cyan('▸') : ' ';
206
+ const id = chalk.bold(story.id);
207
+ const title = truncate(story.title, cols - story.id.length - 12);
208
+ const line = ` ${pointer} ${id} ${title} ${icon}`;
209
+ lines.push(idx === selectedIndex ? chalk.cyan(line) : line);
210
+ }
211
+
212
+ // Separator
213
+ lines.push(chalk.dim('─'.repeat(cols)));
214
+
215
+ // Detail panel for selected story
216
+ const story = stories[selectedIndex];
217
+ const status = getStatus(story);
218
+
219
+ lines.push(chalk.bold(` ${story.id} · ${story.title}`));
220
+
221
+ const phase = story.phase || '—';
222
+ const effort = story.effort || '—';
223
+ const deps = story.dependencies?.length > 0 ? story.dependencies.join(', ') : 'none';
224
+ lines.push(` Phase: ${phase} | Effort: ${effort} | Status: ${statusLabel(status)} | Deps: ${deps}`);
225
+ lines.push('');
226
+
227
+ // Acceptance criteria
228
+ if (story.acceptance_criteria?.length > 0) {
229
+ lines.push(chalk.bold(' Acceptance Criteria:'));
230
+ const remainingRows = rows - lines.length - 3; // leave room for notes + footer
231
+ const maxAC = Math.max(1, remainingRows - 2);
232
+ const acs = story.acceptance_criteria.slice(0, maxAC);
233
+ for (const ac of acs) {
234
+ const check = status === 'done' ? chalk.green('✓') : chalk.dim('○');
235
+ lines.push(` ${check} ${truncate(ac, cols - 6)}`);
236
+ }
237
+ if (story.acceptance_criteria.length > maxAC) {
238
+ lines.push(chalk.dim(` ... +${story.acceptance_criteria.length - maxAC} more — press Enter for full view`));
239
+ }
240
+ }
241
+
242
+ // Notes
243
+ if (story.notes) {
244
+ lines.push('');
245
+ lines.push(chalk.dim(` Notes: ${truncate(story.notes, cols - 10)}`));
246
+ }
247
+
248
+ // Pad to fill screen
249
+ while (lines.length < rows - 2) {
250
+ lines.push('');
251
+ }
252
+
253
+ // Footer separator + keybindings
254
+ lines.push(chalk.dim('─'.repeat(cols)));
255
+ lines.push(chalk.dim(' ↑/k up ↓/j down Enter detail Home/End first/last q quit'));
256
+
257
+ process.stdout.write(lines.join('\n'));
258
+ }
259
+
260
+ // Enter alternate screen buffer and hide cursor
261
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
262
+
263
+ // Enable raw mode for keypress detection
264
+ readline.emitKeypressEvents(process.stdin);
265
+ if (process.stdin.isTTY) {
266
+ process.stdin.setRawMode(true);
267
+ }
268
+ process.stdin.resume();
269
+
270
+ render();
271
+
272
+ function cleanup() {
273
+ // Restore screen buffer and show cursor
274
+ process.stdout.write('\x1b[?25h\x1b[?1049l');
275
+ if (process.stdin.isTTY) {
276
+ process.stdin.setRawMode(false);
277
+ }
278
+ process.stdin.pause();
279
+ process.stdin.removeListener('keypress', onKeypress);
280
+ resolve();
281
+ }
282
+
283
+ function onKeypress(_str, key) {
284
+ if (!key) return;
285
+
286
+ if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
287
+ cleanup();
288
+ return;
289
+ }
290
+
291
+ if (detailMode) {
292
+ // Detail view keybindings
293
+ if (key.name === 'escape' || key.name === 'backspace') {
294
+ detailMode = false;
295
+ detailScroll = 0;
296
+ render();
297
+ } else if (key.name === 'up' || key.name === 'k') {
298
+ if (detailScroll > 0) {
299
+ detailScroll--;
300
+ render();
301
+ }
302
+ } else if (key.name === 'down' || key.name === 'j') {
303
+ detailScroll++;
304
+ render();
305
+ } else if (key.name === 'left') {
306
+ if (selectedIndex > 0) {
307
+ selectedIndex--;
308
+ detailScroll = 0;
309
+ render();
310
+ }
311
+ } else if (key.name === 'right') {
312
+ if (selectedIndex < stories.length - 1) {
313
+ selectedIndex++;
314
+ detailScroll = 0;
315
+ render();
316
+ }
317
+ } else if (key.name === 'home') {
318
+ detailScroll = 0;
319
+ render();
320
+ } else if (key.name === 'end') {
321
+ detailScroll = 9999; // clamped in renderDetailView
322
+ render();
323
+ }
324
+ return;
325
+ }
326
+
327
+ // List view keybindings
328
+ if (key.name === 'return') {
329
+ detailMode = true;
330
+ detailScroll = 0;
331
+ render();
332
+ } else if (key.name === 'up' || key.name === 'k') {
333
+ if (selectedIndex > 0) {
334
+ selectedIndex--;
335
+ render();
336
+ }
337
+ } else if (key.name === 'down' || key.name === 'j') {
338
+ if (selectedIndex < stories.length - 1) {
339
+ selectedIndex++;
340
+ render();
341
+ }
342
+ } else if (key.name === 'home') {
343
+ selectedIndex = 0;
344
+ render();
345
+ } else if (key.name === 'end') {
346
+ selectedIndex = stories.length - 1;
347
+ render();
348
+ }
349
+ }
350
+
351
+ process.stdin.on('keypress', onKeypress);
352
+
353
+ // Handle terminal resize
354
+ process.stdout.on('resize', render);
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Print backlog as plain text (non-interactive mode).
360
+ * @param {object} options
361
+ * @param {string} options.project - Project name
362
+ * @param {Array<object>} options.stories - Stories from prd.json
363
+ * @param {Array<object>} options.taskFiles - Task files from .ai/batch/tasks/
364
+ */
365
+ export function printPlain({ project, stories, taskFiles }) {
366
+ if (stories.length === 0) {
367
+ console.log('No stories found in prd.json');
368
+ return;
369
+ }
370
+
371
+ const taskMap = new Map(taskFiles.map(t => [t.id, t]));
372
+
373
+ function getStatus(story) {
374
+ if (story.passes) return 'done';
375
+ const tf = taskMap.get(story.id);
376
+ if (tf) {
377
+ if (tf.status === 'completed') return 'done';
378
+ if (tf.status === 'planned' || tf.status === 'running') return 'active';
379
+ }
380
+ return 'pending';
381
+ }
382
+
383
+ const doneCount = stories.filter(s => getStatus(s) === 'done').length;
384
+
385
+ console.log(`\nProduct Backlog — ${project} (${doneCount}/${stories.length} done)\n`);
386
+
387
+ for (const story of stories) {
388
+ const status = getStatus(story);
389
+ const icon = status === 'done' ? '[x]' : status === 'active' ? '[~]' : '[ ]';
390
+ console.log(` ${icon} ${story.id} ${story.title}`);
391
+ }
392
+
393
+ console.log('');
394
+ }
File without changes