@fermindi/pwn-cli 0.6.0 → 0.8.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 +73 -0
- package/cli/batch.js +106 -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 +860 -0
- package/src/services/batch-service.js +115 -21
- package/src/ui/backlog-viewer.js +394 -0
- package/templates/workspace/.ai/README.md +20 -0
- package/templates/workspace/.ai/batch/prompt.md +36 -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
|
*/
|
|
@@ -294,9 +392,14 @@ export function convertBacklogToPrd(backlogContent, projectName = 'my-project')
|
|
|
294
392
|
* @returns {object|null} Selected task or null
|
|
295
393
|
*/
|
|
296
394
|
export function selectNextTask(cwd = process.cwd(), options = {}) {
|
|
395
|
+
const filterRe = options.filter ? new RegExp(options.filter, 'i') : null;
|
|
396
|
+
|
|
297
397
|
// First check active tasks for incomplete ones
|
|
298
398
|
const activeTasks = parseActiveTasks(cwd);
|
|
299
|
-
const pendingActive = activeTasks.filter(t =>
|
|
399
|
+
const pendingActive = activeTasks.filter(t =>
|
|
400
|
+
!t.completed && !t.blockedBy &&
|
|
401
|
+
(!filterRe || filterRe.test(t.id) || filterRe.test(t.title))
|
|
402
|
+
);
|
|
300
403
|
|
|
301
404
|
if (pendingActive.length > 0) {
|
|
302
405
|
return pendingActive[0];
|
|
@@ -309,7 +412,8 @@ export function selectNextTask(cwd = process.cwd(), options = {}) {
|
|
|
309
412
|
const eligible = stories.find(s =>
|
|
310
413
|
!s.passes &&
|
|
311
414
|
s.dependencies.every(dep => doneIds.includes(dep)) &&
|
|
312
|
-
(!options.phase || s.phase === options.phase)
|
|
415
|
+
(!options.phase || s.phase === options.phase) &&
|
|
416
|
+
(!filterRe || filterRe.test(s.id) || filterRe.test(s.title))
|
|
313
417
|
);
|
|
314
418
|
|
|
315
419
|
return eligible || null;
|
|
@@ -345,7 +449,10 @@ export function markStoryDone(taskId, cwd = process.cwd()) {
|
|
|
345
449
|
* @returns {Promise<{success: boolean, output?: string, error?: string}>}
|
|
346
450
|
*/
|
|
347
451
|
export async function runQualityGate(gate, cwd = process.cwd()) {
|
|
348
|
-
|
|
452
|
+
// Check for custom gate commands in config
|
|
453
|
+
const config = loadConfig(cwd);
|
|
454
|
+
const customCmd = config.gate_commands?.[gate];
|
|
455
|
+
const commands = customCmd ? [customCmd] : GATE_COMMANDS[gate];
|
|
349
456
|
|
|
350
457
|
if (!commands) {
|
|
351
458
|
return { success: false, error: `Unknown gate: ${gate}` };
|
|
@@ -699,14 +806,6 @@ export async function executeTask(task, options = {}, cwd = process.cwd()) {
|
|
|
699
806
|
|
|
700
807
|
pauseBatch(`Quality gates failed: ${failedGates.join(', ')}`, cwd);
|
|
701
808
|
|
|
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
809
|
return {
|
|
711
810
|
success: false,
|
|
712
811
|
error: 'Quality gates failed',
|
|
@@ -753,11 +852,6 @@ export async function executeTask(task, options = {}, cwd = process.cwd()) {
|
|
|
753
852
|
last_completed_at: new Date().toISOString()
|
|
754
853
|
}, cwd);
|
|
755
854
|
|
|
756
|
-
// Notify completion
|
|
757
|
-
if (config.notify_on_complete) {
|
|
758
|
-
await notifications.notifyTaskComplete(task.id, task.title, { cwd });
|
|
759
|
-
}
|
|
760
|
-
|
|
761
855
|
return { success: true, details };
|
|
762
856
|
}
|
|
763
857
|
|
|
@@ -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
|
+
}
|
|
@@ -111,6 +111,26 @@ Defines:
|
|
|
111
111
|
- Commit patterns
|
|
112
112
|
- Completion signals
|
|
113
113
|
|
|
114
|
+
### Writing stories for `prd.json`
|
|
115
|
+
|
|
116
|
+
Stories run with `--dangerously-skip-permissions` — the agent has full access. Write defensively.
|
|
117
|
+
|
|
118
|
+
**Never put these in batch stories:**
|
|
119
|
+
- Destructive git ops (`git filter-repo`, `BFG`, `push --force`, history rewriting)
|
|
120
|
+
- Destructive file ops (`rm -rf`, wiping directories)
|
|
121
|
+
- Database ops (`DROP TABLE`, prod migrations)
|
|
122
|
+
- Secret rotation (revoking keys, rotating credentials)
|
|
123
|
+
- External side effects (sending emails, creating PRs, publishing packages)
|
|
124
|
+
|
|
125
|
+
**Rule of thumb**: if a mistake needs human intervention to fix, it's not a batch story.
|
|
126
|
+
|
|
127
|
+
**Instead**, ask the agent to **prepare and document** — write the script, the docs, the config — but let a human execute the dangerous part.
|
|
128
|
+
|
|
129
|
+
**Always include in `notes`** what the agent must NOT do:
|
|
130
|
+
```json
|
|
131
|
+
"notes": "Do NOT run git-filter-repo. Do NOT modify prd.json."
|
|
132
|
+
```
|
|
133
|
+
|
|
114
134
|
## 🤖 Agents
|
|
115
135
|
|
|
116
136
|
### agent/claude.md
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
You are working on this project autonomously as part of a batch execution run.
|
|
2
|
+
|
|
3
|
+
## Project Context
|
|
4
|
+
- Read CLAUDE.md (or .ai/agents/claude.md) for full project instructions and conventions
|
|
5
|
+
- Read .ai/memory/decisions.md for architectural decisions
|
|
6
|
+
- Read .ai/memory/patterns.md for established patterns
|
|
7
|
+
- Read .ai/batch/progress.txt for learnings from previous iterations
|
|
8
|
+
|
|
9
|
+
## Current Task
|
|
10
|
+
**{STORY_ID}**: {STORY_TITLE}
|
|
11
|
+
|
|
12
|
+
### Acceptance Criteria
|
|
13
|
+
{ACCEPTANCE_CRITERIA}
|
|
14
|
+
|
|
15
|
+
### Notes
|
|
16
|
+
{NOTES}
|
|
17
|
+
|
|
18
|
+
### Dependencies (already implemented)
|
|
19
|
+
{DEPENDENCIES}
|
|
20
|
+
|
|
21
|
+
## Instructions
|
|
22
|
+
1. Explore the codebase to understand existing patterns for similar features
|
|
23
|
+
2. Implement the feature following existing conventions
|
|
24
|
+
3. Write comprehensive tests (see tests/ for patterns)
|
|
25
|
+
4. Run quality gates and fix any failures before committing
|
|
26
|
+
5. Commit with: feat({STORY_ID}): {short description}
|
|
27
|
+
6. Update .ai/tasks/active.md marking this task as done with today's date
|
|
28
|
+
|
|
29
|
+
## Important
|
|
30
|
+
- Do NOT push to remote
|
|
31
|
+
- Do NOT modify unrelated files
|
|
32
|
+
- Do NOT edit .ai/tasks/prd.json or batch configuration files
|
|
33
|
+
- Do NOT run destructive operations (git filter-repo, rm -rf, DROP TABLE, force push)
|
|
34
|
+
- Do NOT rotate secrets, revoke keys, or modify credentials — those are human tasks
|
|
35
|
+
- Follow existing patterns exactly
|
|
36
|
+
- If you discover useful patterns, note them for progress.txt
|
|
File without changes
|