@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.
- package/cli/backlog.js +60 -0
- package/cli/batch.js +101 -14
- package/cli/index.js +7 -29
- package/cli/inject.js +8 -33
- package/cli/update.js +31 -24
- package/package.json +6 -3
- package/src/core/inject.js +18 -39
- package/src/core/state.js +0 -1
- package/src/core/validate.js +1 -3
- package/src/index.js +0 -1
- package/src/services/batch-runner.js +769 -0
- package/src/services/batch-service.js +107 -19
- package/src/ui/backlog-viewer.js +394 -0
- package/templates/workspace/.ai/batch/tasks/.gitkeep +0 -0
- 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/.claude/commands/mode.md +0 -104
- package/templates/workspace/.claude/settings.json +0 -24
|
@@ -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
|
*/
|
|
@@ -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
|
-
|
|
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
|