@proletariat/cli 0.3.36 → 0.3.40

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.
Files changed (64) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/branch/where.js +6 -17
  4. package/dist/commands/epic/ticket.js +7 -24
  5. package/dist/commands/execution/config.js +4 -14
  6. package/dist/commands/execution/logs.js +6 -0
  7. package/dist/commands/execution/view.js +8 -0
  8. package/dist/commands/mcp-server.js +2 -1
  9. package/dist/commands/pmo/init.js +12 -40
  10. package/dist/commands/qa/index.d.ts +54 -0
  11. package/dist/commands/qa/index.js +762 -0
  12. package/dist/commands/repo/view.js +2 -8
  13. package/dist/commands/session/attach.js +4 -4
  14. package/dist/commands/session/health.js +4 -4
  15. package/dist/commands/session/list.js +1 -19
  16. package/dist/commands/session/peek.js +6 -6
  17. package/dist/commands/session/poke.js +2 -2
  18. package/dist/commands/ticket/epic.js +17 -43
  19. package/dist/commands/work/spawn-all.js +1 -1
  20. package/dist/commands/work/spawn.js +15 -4
  21. package/dist/commands/work/start.js +17 -9
  22. package/dist/commands/work/watch.js +1 -1
  23. package/dist/commands/workspace/prune.js +3 -3
  24. package/dist/hooks/init.js +10 -2
  25. package/dist/lib/agents/commands.d.ts +5 -0
  26. package/dist/lib/agents/commands.js +143 -97
  27. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  28. package/dist/lib/database/drizzle-schema.js +53 -0
  29. package/dist/lib/database/index.d.ts +47 -1
  30. package/dist/lib/database/index.js +138 -20
  31. package/dist/lib/execution/runners.d.ts +34 -0
  32. package/dist/lib/execution/runners.js +134 -7
  33. package/dist/lib/execution/session-utils.d.ts +5 -0
  34. package/dist/lib/execution/session-utils.js +45 -3
  35. package/dist/lib/execution/spawner.js +15 -2
  36. package/dist/lib/execution/storage.d.ts +1 -1
  37. package/dist/lib/execution/storage.js +17 -2
  38. package/dist/lib/execution/types.d.ts +1 -0
  39. package/dist/lib/mcp/tools/index.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.js +1 -0
  41. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  42. package/dist/lib/mcp/tools/tmux.js +182 -0
  43. package/dist/lib/mcp/tools/work.js +52 -0
  44. package/dist/lib/pmo/schema.d.ts +1 -1
  45. package/dist/lib/pmo/schema.js +1 -0
  46. package/dist/lib/pmo/storage/base.js +207 -0
  47. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  48. package/dist/lib/pmo/storage/dependencies.js +11 -3
  49. package/dist/lib/pmo/storage/epics.js +1 -1
  50. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  51. package/dist/lib/pmo/storage/helpers.js +36 -26
  52. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  53. package/dist/lib/pmo/storage/projects.js +207 -119
  54. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  55. package/dist/lib/pmo/storage/specs.js +274 -188
  56. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  57. package/dist/lib/pmo/storage/tickets.js +350 -290
  58. package/dist/lib/pmo/storage/views.d.ts +2 -0
  59. package/dist/lib/pmo/storage/views.js +183 -130
  60. package/dist/lib/prompt-json.d.ts +5 -0
  61. package/dist/lib/prompt-json.js +9 -0
  62. package/oclif.manifest.json +3922 -3819
  63. package/package.json +11 -6
  64. package/LICENSE +0 -190
@@ -0,0 +1,762 @@
1
+ import { Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { execSync } from 'node:child_process';
6
+ import { PromptCommand } from '../../lib/prompt-command.js';
7
+ import { machineOutputFlags } from '../../lib/pmo/index.js';
8
+ import Database from 'better-sqlite3';
9
+ import { findHQRoot } from '../../lib/workspace.js';
10
+ import { getWorkspaceInfo, createEphemeralAgent, } from '../../lib/agents/commands.js';
11
+ import { shouldOutputJson, outputErrorAsJson, createMetadata, } from '../../lib/prompt-json.js';
12
+ import { styles } from '../../lib/styles.js';
13
+ import { DEFAULT_EXECUTION_CONFIG, } from '../../lib/execution/types.js';
14
+ import { runExecution, isDockerRunning, isGitHubTokenAvailable, isDevcontainerCliInstalled } from '../../lib/execution/runners.js';
15
+ import { ExecutionStorage } from '../../lib/execution/storage.js';
16
+ import { loadExecutionConfig, getTerminalApp, promptTerminalPreference, getShell, promptShellPreference, hasTerminalPreference, hasShellPreference, } from '../../lib/execution/config.js';
17
+ import { hasDevcontainerConfig } from '../../lib/execution/devcontainer.js';
18
+ // Catch-all devcontainer image for directories without .devcontainer
19
+ const CATCHALL_DEVCONTAINER_IMAGE = 'ghcr.io/chrismcdermut/proletariat-claude:latest';
20
+ export default class QA extends PromptCommand {
21
+ static description = 'Spawn an exploratory QA agent to autonomously test the CLI (no ticket required)';
22
+ static aliases = ['explore'];
23
+ static examples = [
24
+ '<%= config.bin %> <%= command.id %> # Quick launch QA agent',
25
+ '<%= config.bin %> <%= command.id %> --seed # Seed test data first',
26
+ '<%= config.bin %> <%= command.id %> --watch # Stream agent\'s tmux screen',
27
+ '<%= config.bin %> <%= command.id %> --environment host # Run on host instead of container',
28
+ '<%= config.bin %> <%= command.id %> --seed --watch # Seed data and watch live',
29
+ ];
30
+ static flags = {
31
+ ...machineOutputFlags,
32
+ seed: Flags.boolean({
33
+ char: 's',
34
+ description: 'Seed test data before starting QA (runs seed-explore-data.mjs)',
35
+ default: false,
36
+ }),
37
+ watch: Flags.boolean({
38
+ char: 'w',
39
+ description: 'Stream the agent\'s tmux screen to your terminal in real-time',
40
+ default: false,
41
+ }),
42
+ environment: Flags.string({
43
+ char: 'e',
44
+ description: 'Where to run (devcontainer or host)',
45
+ options: ['devcontainer', 'host'],
46
+ }),
47
+ 'display-mode': Flags.string({
48
+ char: 'd',
49
+ description: 'How to display output (foreground=current terminal, terminal=new tab, background=detached)',
50
+ options: ['terminal', 'background', 'foreground'],
51
+ }),
52
+ 'permission-mode': Flags.string({
53
+ char: 'p',
54
+ description: 'Permission mode (danger: skip prompts, safe: require approval)',
55
+ options: ['danger', 'safe'],
56
+ }),
57
+ prompt: Flags.string({
58
+ description: 'Additional instructions to append to the QA prompt',
59
+ }),
60
+ };
61
+ async run() {
62
+ const { flags } = await this.parse(QA);
63
+ const jsonMode = shouldOutputJson(flags);
64
+ const workDir = process.cwd();
65
+ // Check if we're inside an HQ
66
+ const hqPath = findHQRoot(workDir);
67
+ if (hqPath) {
68
+ await this.runTracked(hqPath, workDir, flags, jsonMode);
69
+ }
70
+ else {
71
+ await this.runYolo(workDir, flags, jsonMode);
72
+ }
73
+ }
74
+ /**
75
+ * Load the explore-cli action prompt from PMO storage.
76
+ */
77
+ async getExploreCLIPrompt() {
78
+ try {
79
+ const { getPMOContext } = await import('../../lib/pmo/index.js');
80
+ const pmoContext = await getPMOContext();
81
+ const action = await pmoContext.storage.getAction('explore-cli');
82
+ if (action) {
83
+ return { prompt: action.prompt, endPrompt: action.endPrompt ?? undefined };
84
+ }
85
+ }
86
+ catch {
87
+ // PMO not available - use fallback
88
+ }
89
+ // Fallback: return a minimal explore-cli prompt
90
+ return {
91
+ prompt: `# Action: Explore CLI (Autonomous QA)
92
+
93
+ You are an AI QA tester for the prlt CLI. You have access to a tmux session where the CLI is running.
94
+ Your job is to **systematically explore every menu, try every option, and find bugs**.
95
+
96
+ ## Getting Started
97
+
98
+ 1. Start a tmux session for testing:
99
+ \`\`\`
100
+ tmux_start_session({ session: "qa-test", command: "prlt" })
101
+ \`\`\`
102
+ 2. Wait a moment, then capture the screen to see the main menu:
103
+ \`\`\`
104
+ tmux_capture_pane({ session: "qa-test" })
105
+ \`\`\`
106
+ 3. Begin systematic exploration of all menus and features.
107
+
108
+ ## When You Find a Bug
109
+
110
+ 1. Document exact reproduction steps
111
+ 2. Capture the screen showing the bug
112
+ 3. File a ticket using the ticket_create MCP tool with category "bug"
113
+
114
+ ## Session Management
115
+
116
+ - If the CLI crashes, restart it: kill the session and start a new one
117
+ - If you get stuck, use Ctrl+C to escape, or kill and restart the session`,
118
+ endPrompt: `## Wrap-Up
119
+
120
+ Summarize your findings: list all bugs found with their ticket IDs, areas tested, and overall assessment.
121
+ Clean up your tmux session when done.`,
122
+ };
123
+ }
124
+ /**
125
+ * Run seed-explore-data.mjs to pre-populate test data.
126
+ */
127
+ async runSeedData(hqPath) {
128
+ // Look for seed script in the prlt source repo or fallback paths
129
+ const seedPaths = [
130
+ path.join(hqPath, 'scripts', 'seed-explore-data.mjs'),
131
+ path.join(hqPath, 'repos', 'proletariat', 'scripts', 'seed-explore-data.mjs'),
132
+ ];
133
+ // Also check workspace repos for the script
134
+ try {
135
+ const workspaceInfo = getWorkspaceInfo();
136
+ for (const repo of workspaceInfo.repositories) {
137
+ const repoSeedPath = path.join(workspaceInfo.path, repo.path || repo.name, 'scripts', 'seed-explore-data.mjs');
138
+ if (!seedPaths.includes(repoSeedPath)) {
139
+ seedPaths.push(repoSeedPath);
140
+ }
141
+ }
142
+ }
143
+ catch {
144
+ // Workspace info not available
145
+ }
146
+ // Find the global prlt installation directory for bundled seed script
147
+ try {
148
+ const prltPath = execSync('which prlt', { encoding: 'utf-8' }).trim();
149
+ const prltDir = path.dirname(path.dirname(prltPath)); // Go up from bin/ to package root
150
+ const globalSeedPath = path.join(prltDir, 'lib', 'node_modules', '@proletariat', 'cli', 'scripts', 'seed-explore-data.mjs');
151
+ if (!seedPaths.includes(globalSeedPath)) {
152
+ seedPaths.push(globalSeedPath);
153
+ }
154
+ }
155
+ catch {
156
+ // Can't find prlt path
157
+ }
158
+ for (const seedPath of seedPaths) {
159
+ if (fs.existsSync(seedPath)) {
160
+ this.log(styles.muted(` Running seed script: ${seedPath}`));
161
+ try {
162
+ execSync(`node "${seedPath}"`, {
163
+ cwd: hqPath,
164
+ stdio: 'inherit',
165
+ timeout: 60_000,
166
+ });
167
+ this.log(styles.success(' Test data seeded successfully'));
168
+ return true;
169
+ }
170
+ catch (error) {
171
+ this.warn(`Seed script failed: ${error instanceof Error ? error.message : error}`);
172
+ return false;
173
+ }
174
+ }
175
+ }
176
+ // No seed script found - use prlt commands directly as fallback
177
+ this.log(styles.muted(' Seed script not found, creating minimal test data with prlt commands...'));
178
+ try {
179
+ execSync('prlt project create --name "QA Test Project" --description "Auto-generated project for QA testing" --json 2>/dev/null || true', {
180
+ cwd: hqPath,
181
+ encoding: 'utf-8',
182
+ timeout: 30_000,
183
+ });
184
+ execSync('prlt ticket create --title "Sample ticket for QA" --priority P2 --category feature --project "QA Test Project" --json 2>/dev/null || true', {
185
+ cwd: hqPath,
186
+ encoding: 'utf-8',
187
+ timeout: 30_000,
188
+ });
189
+ this.log(styles.success(' Minimal test data created'));
190
+ return true;
191
+ }
192
+ catch {
193
+ this.warn('Could not seed test data. QA agent will work with existing data.');
194
+ return false;
195
+ }
196
+ }
197
+ /**
198
+ * Run in tracked mode - inside an HQ
199
+ * Creates an ephemeral QA agent with full tracking
200
+ */
201
+ async runTracked(hqPath, workDir, flags, jsonMode) {
202
+ this.log('');
203
+ this.log(styles.header('🔍 Exploratory QA Agent'));
204
+ this.log(styles.muted(` HQ: ${hqPath}`));
205
+ this.log('');
206
+ // Get workspace info
207
+ let workspaceInfo;
208
+ try {
209
+ workspaceInfo = getWorkspaceInfo();
210
+ }
211
+ catch {
212
+ if (jsonMode) {
213
+ outputErrorAsJson('WORKSPACE_ERROR', 'Failed to get workspace info', createMetadata('qa', flags));
214
+ this.exit(1);
215
+ }
216
+ this.error('Failed to get workspace info');
217
+ }
218
+ // Open database
219
+ const dbPath = path.join(hqPath, '.proletariat', 'workspace.db');
220
+ const db = new Database(dbPath);
221
+ const executionStorage = new ExecutionStorage(db);
222
+ try {
223
+ // Seed data if requested
224
+ if (flags.seed) {
225
+ this.log(styles.muted(' Seeding test data...'));
226
+ await this.runSeedData(hqPath);
227
+ this.log('');
228
+ }
229
+ // Get PMO context for ticket creation
230
+ const { getPMOContext } = await import('../../lib/pmo/index.js');
231
+ let pmoPath;
232
+ let storage;
233
+ try {
234
+ const pmoContext = await getPMOContext();
235
+ pmoPath = pmoContext.pmoPath;
236
+ storage = pmoContext.storage;
237
+ }
238
+ catch {
239
+ if (jsonMode) {
240
+ outputErrorAsJson('PMO_NOT_FOUND', 'PMO not found. Run "prlt pmo init" first.', createMetadata('qa', flags));
241
+ this.exit(1);
242
+ }
243
+ this.error('PMO not found. Run "prlt pmo init" first.');
244
+ }
245
+ // Select project for filing bugs
246
+ const jsonModeConfig = jsonMode ? { flags: flags, commandName: 'qa' } : null;
247
+ const projects = await storage.listProjects();
248
+ let projectId;
249
+ if (projects.length === 0) {
250
+ db.close();
251
+ if (jsonMode) {
252
+ outputErrorAsJson('NO_PROJECTS', 'No projects found. Create a project first, or use --seed.', createMetadata('qa', flags));
253
+ this.exit(1);
254
+ }
255
+ this.error('No projects found. Create a project first, or use --seed to create test data.');
256
+ }
257
+ else if (projects.length === 1) {
258
+ projectId = projects[0].id;
259
+ }
260
+ else {
261
+ const { selectedProject } = await this.prompt([
262
+ {
263
+ type: 'list',
264
+ name: 'selectedProject',
265
+ message: 'Select project for QA (bugs will be filed here):',
266
+ choices: projects.map((p) => ({
267
+ name: `${p.name} (${p.id})`,
268
+ value: p.id,
269
+ command: `prlt qa --json`,
270
+ })),
271
+ },
272
+ ], jsonModeConfig);
273
+ if (jsonMode) {
274
+ db.close();
275
+ return;
276
+ }
277
+ projectId = selectedProject;
278
+ }
279
+ // Resolve environment
280
+ const environment = await this.resolveEnvironment(workDir, flags, jsonMode, jsonModeConfig);
281
+ if (jsonMode && !flags.environment) {
282
+ db.close();
283
+ return;
284
+ }
285
+ // Resolve display mode (--watch forces foreground)
286
+ let displayMode;
287
+ if (flags.watch) {
288
+ displayMode = 'foreground';
289
+ }
290
+ else {
291
+ displayMode = await this.resolveDisplayMode(flags, jsonMode, jsonModeConfig, environment);
292
+ if (jsonMode && !flags['display-mode'] && !flags.watch) {
293
+ db.close();
294
+ return;
295
+ }
296
+ }
297
+ // Resolve permission mode (default to danger for QA since it's in a container)
298
+ const sandboxed = await this.resolvePermissionMode(flags, jsonMode, jsonModeConfig, environment, displayMode);
299
+ if (jsonMode && !flags['permission-mode']) {
300
+ db.close();
301
+ return;
302
+ }
303
+ // Create an adhoc QA ticket
304
+ const ticket = await storage.createTicket(projectId, {
305
+ title: `QA: Exploratory testing session`,
306
+ description: 'Automated QA exploration session spawned by `prlt qa`',
307
+ category: 'test',
308
+ priority: 'P2',
309
+ });
310
+ this.log(styles.success(` Created QA ticket: ${ticket.id}`));
311
+ // Create ephemeral agent
312
+ this.log(styles.muted(' Creating ephemeral agent...'));
313
+ let ephemeralResult;
314
+ try {
315
+ ephemeralResult = await createEphemeralAgent(workspaceInfo, {
316
+ skipDevcontainer: environment === 'host',
317
+ log: (msg) => this.log(styles.muted(` ${msg}`)),
318
+ });
319
+ }
320
+ catch (agentError) {
321
+ // Rollback ticket
322
+ this.log(styles.muted(' Rolling back ticket creation...'));
323
+ try {
324
+ await storage.deleteTicket(ticket.id);
325
+ }
326
+ catch { /* ignore */ }
327
+ throw agentError;
328
+ }
329
+ const agentName = ephemeralResult.name;
330
+ const agentDir = ephemeralResult.worktreePath;
331
+ this.log(styles.success(` Agent: ${agentName}`));
332
+ // Load explore-cli action prompt
333
+ const actionData = await this.getExploreCLIPrompt();
334
+ let actionPrompt = actionData.prompt;
335
+ if (flags.prompt) {
336
+ actionPrompt += `\n\n## Additional Instructions\n\n${flags.prompt}`;
337
+ }
338
+ // Build execution context
339
+ const context = {
340
+ ticketId: ticket.id,
341
+ ticketTitle: ticket.title,
342
+ ticketDescription: ticket.description,
343
+ agentName,
344
+ agentDir,
345
+ worktreePath: agentDir,
346
+ branch: 'main',
347
+ hqPath,
348
+ pmoPath,
349
+ actionId: 'explore-cli',
350
+ actionName: 'Explore-CLI',
351
+ actionPrompt,
352
+ actionEndPrompt: actionData.endPrompt,
353
+ modifiesCode: false,
354
+ };
355
+ // Create execution record
356
+ const execution = executionStorage.createExecution({
357
+ ticketId: ticket.id,
358
+ agentName,
359
+ executor: 'claude-code',
360
+ environment,
361
+ displayMode,
362
+ sandboxed,
363
+ branch: 'main',
364
+ });
365
+ // Update ticket assignee
366
+ await storage.updateTicket(ticket.id, { assignee: agentName });
367
+ // Load execution config
368
+ const executionConfig = loadExecutionConfig(db);
369
+ executionConfig.sandboxed = sandboxed;
370
+ executionConfig.outputMode = 'interactive';
371
+ // For terminal mode, ensure terminal preference is set
372
+ if (displayMode === 'terminal' && !jsonMode) {
373
+ if (!hasTerminalPreference(db)) {
374
+ executionConfig.terminal.app = await promptTerminalPreference(db);
375
+ }
376
+ else {
377
+ executionConfig.terminal.app = await getTerminalApp(db);
378
+ }
379
+ if (!hasShellPreference(db)) {
380
+ executionConfig.shell = await promptShellPreference(db);
381
+ }
382
+ else {
383
+ executionConfig.shell = await getShell(db);
384
+ }
385
+ }
386
+ // Show summary
387
+ this.log('');
388
+ this.log(styles.muted(` Ticket: ${ticket.id}`));
389
+ this.log(styles.muted(` Agent: ${agentName}`));
390
+ this.log(styles.muted(` Work ID: ${execution.id}`));
391
+ this.log(styles.muted(` Environment: ${environment === 'devcontainer' ? '🐳' : '💻'} ${environment}`));
392
+ this.log(styles.muted(` Display: ${displayMode}${flags.watch ? ' (watch mode)' : ''}`));
393
+ this.log(styles.muted(` Permissions: ${sandboxed ? '🔒 safe' : '⚠️ danger'}`));
394
+ this.log('');
395
+ // Run execution
396
+ this.log(styles.muted('Starting QA agent...'));
397
+ const result = await runExecution(environment, context, 'claude-code', executionConfig, {
398
+ displayMode,
399
+ sessionManager: environment === 'devcontainer' ? 'tmux' : undefined,
400
+ });
401
+ if (result.success) {
402
+ executionStorage.updateStatus(execution.id, 'running');
403
+ executionStorage.updateProcessInfo(execution.id, {
404
+ pid: result.pid,
405
+ containerId: result.containerId,
406
+ sessionId: result.sessionId,
407
+ logPath: result.logPath,
408
+ });
409
+ this.log('');
410
+ this.log(styles.success(`✓ QA session started (${execution.id})`));
411
+ this.log('');
412
+ this.log(styles.muted('Commands:'));
413
+ this.log(styles.muted(` prlt work status View work status`));
414
+ this.log(styles.muted(` prlt session attach Attach to session`));
415
+ this.log(styles.muted(` prlt work stop ${execution.id} Stop QA`));
416
+ if (!flags.watch && result.sessionId) {
417
+ this.log(styles.muted(` tmux attach -t "${result.sessionId}" Watch live`));
418
+ }
419
+ }
420
+ else {
421
+ executionStorage.updateStatus(execution.id, 'failed');
422
+ this.error(`Failed to start QA session: ${result.error}`);
423
+ }
424
+ db.close();
425
+ }
426
+ catch (error) {
427
+ db.close();
428
+ throw error;
429
+ }
430
+ }
431
+ /**
432
+ * Run in yolo mode - outside any HQ
433
+ */
434
+ async runYolo(workDir, flags, jsonMode) {
435
+ this.log('');
436
+ this.log(styles.header('🔍 Exploratory QA Agent (Yolo Mode)'));
437
+ this.log(styles.muted(' No HQ detected - running without tracking'));
438
+ this.log('');
439
+ if (flags.seed) {
440
+ this.log(styles.warning(' --seed requires an HQ. Skipping seed step.'));
441
+ this.log(styles.muted(' Run "prlt init" first to set up an HQ, then use --seed.'));
442
+ this.log('');
443
+ }
444
+ const jsonModeConfig = jsonMode ? { flags: flags, commandName: 'qa' } : null;
445
+ // Resolve environment
446
+ const environment = await this.resolveEnvironment(workDir, flags, jsonMode, jsonModeConfig);
447
+ if (jsonMode && !flags.environment)
448
+ return;
449
+ // Resolve display mode (--watch forces foreground)
450
+ let displayMode;
451
+ if (flags.watch) {
452
+ displayMode = 'foreground';
453
+ }
454
+ else {
455
+ displayMode = await this.resolveDisplayMode(flags, jsonMode, jsonModeConfig, environment);
456
+ if (jsonMode && !flags['display-mode'] && !flags.watch)
457
+ return;
458
+ }
459
+ // Resolve permission mode
460
+ const sandboxed = await this.resolvePermissionMode(flags, jsonMode, jsonModeConfig, environment, displayMode);
461
+ if (jsonMode && !flags['permission-mode'])
462
+ return;
463
+ const sessionName = `qa-explore-${Date.now().toString(36)}`;
464
+ // Load explore-cli prompt
465
+ const actionData = await this.getExploreCLIPrompt();
466
+ let actionPrompt = actionData.prompt;
467
+ if (flags.prompt) {
468
+ actionPrompt += `\n\n## Additional Instructions\n\n${flags.prompt}`;
469
+ }
470
+ // Prepare devcontainer if needed
471
+ const hasProjectDevcontainer = hasDevcontainerConfig(workDir);
472
+ let devcontainerConfigDir = workDir;
473
+ let cleanupDevcontainer;
474
+ if (environment === 'devcontainer' && !hasProjectDevcontainer) {
475
+ const imageCheck = await this.checkCatchallImage();
476
+ if (!imageCheck.available) {
477
+ if (jsonMode) {
478
+ outputErrorAsJson('CONTAINER_IMAGE_UNAVAILABLE', imageCheck.error, createMetadata('qa', flags));
479
+ this.exit(1);
480
+ }
481
+ this.error(imageCheck.error);
482
+ }
483
+ this.log(styles.muted(` Using catch-all devcontainer: ${CATCHALL_DEVCONTAINER_IMAGE}`));
484
+ const devcontainerSetup = await this.setupCatchallDevcontainer(workDir, 'qa-explore');
485
+ devcontainerConfigDir = devcontainerSetup.configDir;
486
+ cleanupDevcontainer = devcontainerSetup.cleanup;
487
+ }
488
+ // Build execution context
489
+ const context = {
490
+ ticketId: 'QA',
491
+ ticketTitle: 'Exploratory QA Session',
492
+ agentName: 'qa-agent',
493
+ agentDir: workDir,
494
+ worktreePath: devcontainerConfigDir,
495
+ branch: 'main',
496
+ actionId: 'explore-cli',
497
+ actionName: 'Explore-CLI',
498
+ actionPrompt,
499
+ actionEndPrompt: actionData.endPrompt,
500
+ modifiesCode: false,
501
+ };
502
+ // Load execution config
503
+ const executionConfig = { ...DEFAULT_EXECUTION_CONFIG };
504
+ executionConfig.sandboxed = sandboxed;
505
+ executionConfig.outputMode = 'interactive';
506
+ // For terminal mode, prompt for terminal preference
507
+ if (displayMode === 'terminal' && !jsonMode) {
508
+ const homePrltDir = path.join(process.env.HOME || '', '.proletariat');
509
+ fs.mkdirSync(homePrltDir, { recursive: true });
510
+ const tempDbPath = path.join(homePrltDir, 'adhoc.db');
511
+ const tempDb = new Database(tempDbPath);
512
+ tempDb.exec(`
513
+ CREATE TABLE IF NOT EXISTS settings (
514
+ key TEXT PRIMARY KEY,
515
+ value TEXT NOT NULL
516
+ )
517
+ `);
518
+ if (!hasTerminalPreference(tempDb)) {
519
+ executionConfig.terminal.app = await promptTerminalPreference(tempDb);
520
+ }
521
+ else {
522
+ executionConfig.terminal.app = await getTerminalApp(tempDb);
523
+ }
524
+ if (!hasShellPreference(tempDb)) {
525
+ executionConfig.shell = await promptShellPreference(tempDb);
526
+ }
527
+ else {
528
+ executionConfig.shell = await getShell(tempDb);
529
+ }
530
+ tempDb.close();
531
+ }
532
+ // Show summary
533
+ this.log('');
534
+ this.log(styles.muted(` Session: ${sessionName}`));
535
+ this.log(styles.muted(` Directory: ${workDir}`));
536
+ this.log(styles.muted(` Environment: ${environment === 'devcontainer' ? '🐳' : '💻'} ${environment}`));
537
+ this.log(styles.muted(` Display: ${displayMode}${flags.watch ? ' (watch mode)' : ''}`));
538
+ this.log(styles.muted(` Permissions: ${sandboxed ? '🔒 safe' : '⚠️ danger'}`));
539
+ this.log('');
540
+ // Run execution
541
+ this.log(styles.muted('Starting QA agent...'));
542
+ try {
543
+ const result = await runExecution(environment, context, 'claude-code', executionConfig, {
544
+ displayMode,
545
+ sessionManager: environment === 'devcontainer' ? 'tmux' : undefined,
546
+ });
547
+ if (result.success) {
548
+ this.log('');
549
+ this.log(styles.success(`✓ QA session started: ${sessionName}`));
550
+ if (displayMode === 'background' && result.sessionId) {
551
+ this.log(styles.muted(` Watch live: tmux attach -t "${result.sessionId}"`));
552
+ }
553
+ if (cleanupDevcontainer)
554
+ cleanupDevcontainer();
555
+ }
556
+ else {
557
+ if (cleanupDevcontainer)
558
+ cleanupDevcontainer();
559
+ this.error(`Failed to start QA session: ${result.error}`);
560
+ }
561
+ }
562
+ catch (error) {
563
+ if (cleanupDevcontainer)
564
+ cleanupDevcontainer();
565
+ throw error;
566
+ }
567
+ }
568
+ // ─── Shared helpers ─────────────────────────────────────────────────
569
+ /**
570
+ * Resolve execution environment (devcontainer vs host).
571
+ */
572
+ async resolveEnvironment(workDir, flags, jsonMode, jsonModeConfig) {
573
+ if (flags.environment) {
574
+ return flags.environment;
575
+ }
576
+ const hasProjectDevcontainer = hasDevcontainerConfig(workDir);
577
+ const devcontainerLabel = hasProjectDevcontainer
578
+ ? '🐳 devcontainer (uses project config, sandboxed)'
579
+ : '🐳 devcontainer (uses catch-all container, sandboxed)';
580
+ if (jsonMode) {
581
+ await this.prompt([
582
+ {
583
+ type: 'list',
584
+ name: 'selectedEnv',
585
+ message: 'Where should the QA agent run?',
586
+ choices: [
587
+ { name: devcontainerLabel, value: 'devcontainer', command: `prlt qa --environment devcontainer --json` },
588
+ { name: '💻 host (runs directly on your machine)', value: 'host', command: `prlt qa --environment host --json` },
589
+ ],
590
+ default: 'devcontainer',
591
+ },
592
+ ], jsonModeConfig);
593
+ return 'host'; // unreachable after JSON output
594
+ }
595
+ // Interactive: loop to handle Docker not running
596
+ while (true) {
597
+ // eslint-disable-next-line no-await-in-loop -- Interactive user prompt in loop
598
+ const { selectedEnv } = await this.prompt([
599
+ {
600
+ type: 'list',
601
+ name: 'selectedEnv',
602
+ message: 'Where should the QA agent run?',
603
+ choices: [
604
+ { name: devcontainerLabel, value: 'devcontainer' },
605
+ { name: '💻 host (runs directly on your machine)', value: 'host' },
606
+ ],
607
+ default: 'devcontainer',
608
+ },
609
+ ], null);
610
+ if (selectedEnv === 'devcontainer') {
611
+ if (!isDockerRunning()) {
612
+ this.log('');
613
+ this.warn('Docker is not running. Please start Docker and try again.');
614
+ this.log('');
615
+ continue;
616
+ }
617
+ if (!isDevcontainerCliInstalled()) {
618
+ this.log('');
619
+ this.warn('devcontainer CLI is not installed.\n' +
620
+ 'Install with: npm install -g @devcontainers/cli\n' +
621
+ 'Or select "host" to run directly on your machine.');
622
+ this.log('');
623
+ continue;
624
+ }
625
+ if (!isGitHubTokenAvailable()) {
626
+ this.log('');
627
+ this.warn('GitHub token not found. Git push may fail inside the container.');
628
+ this.log('');
629
+ // eslint-disable-next-line no-await-in-loop -- Interactive user prompt in loop
630
+ const { tokenAction } = await this.prompt([
631
+ {
632
+ type: 'list',
633
+ name: 'tokenAction',
634
+ message: 'Continue without GitHub token?',
635
+ choices: [
636
+ { name: 'Yes, continue anyway', value: 'continue' },
637
+ { name: 'No, let me run gh auth login first', value: 'cancel' },
638
+ { name: 'Switch to host mode instead', value: 'host' },
639
+ ],
640
+ default: 'continue',
641
+ },
642
+ ], null);
643
+ if (tokenAction === 'cancel') {
644
+ this.log(styles.muted('Run `gh auth login` and try again.'));
645
+ this.exit(0);
646
+ }
647
+ if (tokenAction === 'host')
648
+ return 'host';
649
+ }
650
+ }
651
+ return selectedEnv;
652
+ }
653
+ }
654
+ /**
655
+ * Resolve display mode.
656
+ */
657
+ async resolveDisplayMode(flags, jsonMode, jsonModeConfig, environment) {
658
+ if (flags['display-mode']) {
659
+ return flags['display-mode'];
660
+ }
661
+ const { selectedDisplay } = await this.prompt([
662
+ {
663
+ type: 'list',
664
+ name: 'selectedDisplay',
665
+ message: 'How should output be displayed?',
666
+ choices: [
667
+ { name: '▶️ Foreground - Run in current terminal, watch live (recommended for QA)', value: 'foreground', command: `prlt qa --environment ${environment} --display-mode foreground --json` },
668
+ { name: '🖥️ New tab - Opens in new terminal tab', value: 'terminal', command: `prlt qa --environment ${environment} --display-mode terminal --json` },
669
+ { name: '📦 Background - Runs detached, reattach later', value: 'background', command: `prlt qa --environment ${environment} --display-mode background --json` },
670
+ ],
671
+ default: 'foreground',
672
+ },
673
+ ], jsonModeConfig);
674
+ if (jsonMode)
675
+ return 'foreground'; // unreachable
676
+ return selectedDisplay;
677
+ }
678
+ /**
679
+ * Resolve permission mode. Defaults to danger for QA (container provides isolation).
680
+ */
681
+ async resolvePermissionMode(flags, jsonMode, jsonModeConfig, environment, displayMode) {
682
+ if (flags['permission-mode']) {
683
+ return flags['permission-mode'] === 'safe';
684
+ }
685
+ const containerNote = environment === 'devcontainer' ? ' (container provides isolation)' : '';
686
+ const { permissionMode } = await this.prompt([
687
+ {
688
+ type: 'list',
689
+ name: 'permissionMode',
690
+ message: `Permission mode${containerNote}:`,
691
+ choices: [
692
+ { name: '⚠️ danger - Skip permission checks (faster, recommended for QA)', value: 'danger', command: `prlt qa --environment ${environment} --display-mode ${displayMode} --permission-mode danger --json` },
693
+ { name: '🔒 safe - Requires approval for dangerous operations', value: 'safe', command: `prlt qa --environment ${environment} --display-mode ${displayMode} --permission-mode safe --json` },
694
+ ],
695
+ default: 'danger',
696
+ },
697
+ ], jsonModeConfig);
698
+ if (jsonMode)
699
+ return true; // unreachable
700
+ return permissionMode === 'safe';
701
+ }
702
+ /**
703
+ * Set up catch-all devcontainer for directories without one.
704
+ */
705
+ async setupCatchallDevcontainer(workDir, slug) {
706
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prlt-qa-'));
707
+ const devcontainerDir = path.join(tempDir, '.devcontainer');
708
+ fs.mkdirSync(devcontainerDir, { recursive: true });
709
+ const devcontainerJson = {
710
+ name: `qa-${slug}`,
711
+ image: CATCHALL_DEVCONTAINER_IMAGE,
712
+ customizations: {
713
+ vscode: {
714
+ extensions: ['anthropic.claude-code'],
715
+ },
716
+ },
717
+ remoteUser: 'node',
718
+ workspaceFolder: '/workspace',
719
+ mounts: [
720
+ `source=${workDir},target=/workspace,type=bind`,
721
+ ],
722
+ containerEnv: {
723
+ ANTHROPIC_API_KEY: '${localEnv:ANTHROPIC_API_KEY}',
724
+ },
725
+ };
726
+ fs.writeFileSync(path.join(devcontainerDir, 'devcontainer.json'), JSON.stringify(devcontainerJson, null, 2));
727
+ this.log(styles.muted(' Created temporary .devcontainer config'));
728
+ const cleanup = () => {
729
+ try {
730
+ if (fs.existsSync(tempDir)) {
731
+ fs.rmSync(tempDir, { recursive: true, force: true });
732
+ }
733
+ }
734
+ catch {
735
+ // Ignore cleanup errors
736
+ }
737
+ };
738
+ return { configDir: tempDir, workDir, cleanup };
739
+ }
740
+ /**
741
+ * Check if catch-all container image is available.
742
+ */
743
+ async checkCatchallImage() {
744
+ try {
745
+ execSync(`docker image inspect ${CATCHALL_DEVCONTAINER_IMAGE}`, { stdio: 'pipe' });
746
+ return { available: true };
747
+ }
748
+ catch {
749
+ this.log(styles.muted(` Pulling container image: ${CATCHALL_DEVCONTAINER_IMAGE}`));
750
+ try {
751
+ execSync(`docker pull ${CATCHALL_DEVCONTAINER_IMAGE}`, { stdio: 'pipe', timeout: 120000 });
752
+ return { available: true };
753
+ }
754
+ catch {
755
+ return {
756
+ available: false,
757
+ error: `Failed to pull catch-all container image: ${CATCHALL_DEVCONTAINER_IMAGE}. Try running on host instead.`,
758
+ };
759
+ }
760
+ }
761
+ }
762
+ }