@proletariat/cli 0.3.36 → 0.3.41

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 (65) 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/init.js +4 -8
  9. package/dist/commands/mcp-server.js +2 -1
  10. package/dist/commands/pmo/init.js +12 -40
  11. package/dist/commands/qa/index.d.ts +54 -0
  12. package/dist/commands/qa/index.js +762 -0
  13. package/dist/commands/repo/view.js +2 -8
  14. package/dist/commands/session/attach.js +4 -4
  15. package/dist/commands/session/health.js +4 -4
  16. package/dist/commands/session/list.js +1 -19
  17. package/dist/commands/session/peek.js +6 -6
  18. package/dist/commands/session/poke.js +2 -2
  19. package/dist/commands/ticket/epic.js +17 -43
  20. package/dist/commands/work/spawn-all.js +1 -1
  21. package/dist/commands/work/spawn.js +15 -4
  22. package/dist/commands/work/start.js +17 -9
  23. package/dist/commands/work/watch.js +1 -1
  24. package/dist/commands/workspace/prune.js +3 -3
  25. package/dist/hooks/init.js +21 -10
  26. package/dist/lib/agents/commands.d.ts +5 -0
  27. package/dist/lib/agents/commands.js +143 -97
  28. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  29. package/dist/lib/database/drizzle-schema.js +53 -0
  30. package/dist/lib/database/index.d.ts +47 -1
  31. package/dist/lib/database/index.js +138 -20
  32. package/dist/lib/execution/runners.d.ts +34 -0
  33. package/dist/lib/execution/runners.js +134 -7
  34. package/dist/lib/execution/session-utils.d.ts +5 -0
  35. package/dist/lib/execution/session-utils.js +45 -3
  36. package/dist/lib/execution/spawner.js +15 -2
  37. package/dist/lib/execution/storage.d.ts +1 -1
  38. package/dist/lib/execution/storage.js +17 -2
  39. package/dist/lib/execution/types.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.d.ts +1 -0
  41. package/dist/lib/mcp/tools/index.js +1 -0
  42. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  43. package/dist/lib/mcp/tools/tmux.js +182 -0
  44. package/dist/lib/mcp/tools/work.js +52 -0
  45. package/dist/lib/pmo/schema.d.ts +1 -1
  46. package/dist/lib/pmo/schema.js +1 -0
  47. package/dist/lib/pmo/storage/base.js +207 -0
  48. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  49. package/dist/lib/pmo/storage/dependencies.js +11 -3
  50. package/dist/lib/pmo/storage/epics.js +1 -1
  51. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  52. package/dist/lib/pmo/storage/helpers.js +36 -26
  53. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  54. package/dist/lib/pmo/storage/projects.js +207 -119
  55. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  56. package/dist/lib/pmo/storage/specs.js +274 -188
  57. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  58. package/dist/lib/pmo/storage/tickets.js +350 -290
  59. package/dist/lib/pmo/storage/views.d.ts +2 -0
  60. package/dist/lib/pmo/storage/views.js +183 -130
  61. package/dist/lib/prompt-json.d.ts +5 -0
  62. package/dist/lib/prompt-json.js +9 -0
  63. package/oclif.manifest.json +3293 -3190
  64. package/package.json +11 -6
  65. package/LICENSE +0 -190
@@ -0,0 +1,182 @@
1
+ /**
2
+ * MCP Tmux Tools
3
+ *
4
+ * Provides tmux interaction tools for AI agents to drive interactive CLI sessions.
5
+ * Used by the explore-cli action to perform exploratory QA testing.
6
+ *
7
+ * Tools:
8
+ * - tmux_send_keys: Send keystrokes to a tmux session
9
+ * - tmux_capture_pane: Capture current terminal screen from a tmux session
10
+ * - tmux_start_session: Start a new tmux session with an optional command
11
+ * - tmux_list_sessions: List active tmux sessions
12
+ * - tmux_kill_session: Kill a tmux session
13
+ */
14
+ import { z } from 'zod';
15
+ import { execFileSync } from 'node:child_process';
16
+ import { errorResponse, strictTool, successResponse } from '../helpers.js';
17
+ const TMUX_ENV = { ...process.env, TERM: process.env.TERM || 'xterm-256color' };
18
+ /**
19
+ * Execute a tmux command with args passed as an array (no shell interpretation).
20
+ */
21
+ function runTmux(args, timeoutMs = 10_000) {
22
+ return execFileSync('tmux', args, {
23
+ encoding: 'utf-8',
24
+ timeout: timeoutMs,
25
+ env: TMUX_ENV,
26
+ }).trim();
27
+ }
28
+ /** Validate session name — alphanumeric, hyphens, underscores, dots only. */
29
+ function validateSessionName(name) {
30
+ if (!/^[a-zA-Z0-9_.-]+$/.test(name)) {
31
+ throw new Error(`Invalid session name: "${name}". Only alphanumeric, hyphens, underscores, and dots allowed.`);
32
+ }
33
+ }
34
+ /**
35
+ * Check if a tmux session exists.
36
+ */
37
+ function sessionExists(sessionName) {
38
+ try {
39
+ runTmux(['has-session', '-t', sessionName]);
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export function registerTmuxTools(server, _ctx) {
47
+ // ── tmux_send_keys ───────────────────────────────────────────────────
48
+ strictTool(server, 'tmux_send_keys', 'Send keystrokes to a tmux session. Supports text input and special keys like Enter, Escape, Up, Down, Left, Right, C-c (Ctrl+C), Tab, BSpace, etc. Use this to interact with interactive CLI menus and prompts.', {
49
+ session: z.string().describe('Tmux session name to send keys to'),
50
+ keys: z.string().describe('Keys to send. Text is sent literally; special keys: Enter, Escape, Up, Down, Left, Right, C-c, C-d, Tab, BSpace, Space, Home, End, PPage, NPage'),
51
+ literal: z.boolean().optional().describe('If true, send keys as literal text (disables special key lookup). Default false.'),
52
+ delay_ms: z.number().optional().describe('Milliseconds to wait after sending keys before returning (default 100). Useful to let the UI render.'),
53
+ }, async (params) => {
54
+ try {
55
+ validateSessionName(params.session);
56
+ if (!sessionExists(params.session)) {
57
+ return errorResponse(new Error(`Tmux session not found: ${params.session}`));
58
+ }
59
+ const args = ['send-keys', '-t', params.session];
60
+ if (params.literal)
61
+ args.push('-l');
62
+ args.push(params.keys);
63
+ runTmux(args);
64
+ // Wait for UI to render
65
+ const delay = Math.min(params.delay_ms ?? 100, 30_000);
66
+ if (delay > 0) {
67
+ await new Promise((resolve) => setTimeout(resolve, delay));
68
+ }
69
+ return successResponse({ sent: params.keys, session: params.session });
70
+ }
71
+ catch (error) {
72
+ return errorResponse(error);
73
+ }
74
+ });
75
+ // ── tmux_capture_pane ────────────────────────────────────────────────
76
+ strictTool(server, 'tmux_capture_pane', 'Capture the current visible content of a tmux pane. Returns the terminal screen text, useful for reading CLI output, menu states, error messages, and interactive prompt displays.', {
77
+ session: z.string().describe('Tmux session name to capture from'),
78
+ start_line: z.number().optional().describe('Start line for capture (negative = scrollback). Default: beginning of visible pane.'),
79
+ end_line: z.number().optional().describe('End line for capture. Default: end of visible pane.'),
80
+ escape_sequences: z.boolean().optional().describe('If true, include ANSI escape sequences in output. Default false (plain text).'),
81
+ }, async (params) => {
82
+ try {
83
+ validateSessionName(params.session);
84
+ if (!sessionExists(params.session)) {
85
+ return errorResponse(new Error(`Tmux session not found: ${params.session}`));
86
+ }
87
+ const args = ['capture-pane', '-t', params.session, '-p'];
88
+ if (params.escape_sequences)
89
+ args.push('-e');
90
+ if (params.start_line !== undefined)
91
+ args.push('-S', String(params.start_line));
92
+ if (params.end_line !== undefined)
93
+ args.push('-E', String(params.end_line));
94
+ const content = runTmux(args);
95
+ return successResponse({
96
+ session: params.session,
97
+ content,
98
+ lines: content.split('\n').length,
99
+ });
100
+ }
101
+ catch (error) {
102
+ return errorResponse(error);
103
+ }
104
+ });
105
+ // ── tmux_start_session ───────────────────────────────────────────────
106
+ strictTool(server, 'tmux_start_session', 'Start a new tmux session, optionally running a command inside it. Use this to launch a CLI session for exploratory testing.', {
107
+ session: z.string().describe('Name for the new tmux session'),
108
+ command: z.string().optional().describe('Command to run in the session (e.g., "prlt" to launch the CLI)'),
109
+ width: z.number().optional().describe('Terminal width in columns (default 120)'),
110
+ height: z.number().optional().describe('Terminal height in rows (default 40)'),
111
+ }, async (params) => {
112
+ try {
113
+ validateSessionName(params.session);
114
+ if (sessionExists(params.session)) {
115
+ return errorResponse(new Error(`Tmux session already exists: ${params.session}`));
116
+ }
117
+ const width = params.width ?? 120;
118
+ const height = params.height ?? 40;
119
+ const args = ['new-session', '-d', '-s', params.session, '-x', String(width), '-y', String(height)];
120
+ if (params.command)
121
+ args.push(params.command);
122
+ runTmux(args);
123
+ // Give the session a moment to initialize
124
+ await new Promise((resolve) => setTimeout(resolve, 500));
125
+ return successResponse({
126
+ session: params.session,
127
+ width,
128
+ height,
129
+ command: params.command || '(default shell)',
130
+ });
131
+ }
132
+ catch (error) {
133
+ return errorResponse(error);
134
+ }
135
+ });
136
+ // ── tmux_list_sessions ───────────────────────────────────────────────
137
+ strictTool(server, 'tmux_list_sessions', 'List all active tmux sessions with their status and dimensions.', {}, async () => {
138
+ try {
139
+ let output;
140
+ try {
141
+ output = runTmux(['list-sessions', '-F', '#{session_name}|#{session_width}|#{session_height}|#{session_windows}|#{session_created}']);
142
+ }
143
+ catch {
144
+ // No sessions running
145
+ return successResponse({ sessions: [] });
146
+ }
147
+ const sessions = output.split('\n').filter(Boolean).map((line) => {
148
+ const [name, width, height, windows, created] = line.split('|');
149
+ return {
150
+ name,
151
+ width: parseInt(width, 10),
152
+ height: parseInt(height, 10),
153
+ windows: parseInt(windows, 10),
154
+ created: new Date(parseInt(created, 10) * 1000).toISOString(),
155
+ };
156
+ });
157
+ return successResponse({ sessions });
158
+ }
159
+ catch (error) {
160
+ return errorResponse(error);
161
+ }
162
+ });
163
+ // ── tmux_kill_session ────────────────────────────────────────────────
164
+ strictTool(server, 'tmux_kill_session', 'Kill (terminate) a tmux session and all processes running in it.', {
165
+ session: z.string().describe('Name of the tmux session to kill'),
166
+ }, async (params) => {
167
+ try {
168
+ validateSessionName(params.session);
169
+ if (!sessionExists(params.session)) {
170
+ return errorResponse(new Error(`Tmux session not found: ${params.session}`));
171
+ }
172
+ runTmux(['kill-session', '-t', params.session]);
173
+ return successResponse({
174
+ killed: params.session,
175
+ message: `Session "${params.session}" terminated`,
176
+ });
177
+ }
178
+ catch (error) {
179
+ return errorResponse(error);
180
+ }
181
+ });
182
+ }
@@ -219,4 +219,56 @@ export function registerWorkTools(server, ctx) {
219
219
  return errorResponse(error);
220
220
  }
221
221
  });
222
+ strictTool(server, 'work_spawn', 'Spawn work on a ticket using the full CLI pipeline (agent selection, Docker build, container creation, branch setup, tmux session). Shells out to "prlt work spawn" — works whenever prlt is installed, no workspace context needed in-process.', {
223
+ ticket_id: z.string().describe('Ticket ID to spawn work for'),
224
+ action: z.string().optional().describe('Action to perform (e.g., implement, groom, review, custom). Defaults to implement.'),
225
+ display_mode: z.enum(['terminal', 'background']).optional().describe('Display mode (default: background)'),
226
+ skip_permissions: z.boolean().optional().describe('Skip permission prompts — danger mode (default: false)'),
227
+ create_pr: z.boolean().optional().describe('Create PR when work is ready (default: false)'),
228
+ agent: z.string().optional().describe('Agent name to use (default: ephemeral agent created on-demand)'),
229
+ environment: z.enum(['devcontainer', 'host']).optional().describe('Execution environment (default: devcontainer if available)'),
230
+ }, async (params) => {
231
+ try {
232
+ // Build the prlt work spawn command
233
+ const args = [params.ticket_id];
234
+ // Add flags
235
+ if (params.action) {
236
+ args.push('--action', params.action);
237
+ }
238
+ if (params.display_mode) {
239
+ args.push('--display', params.display_mode);
240
+ }
241
+ else {
242
+ args.push('--display', 'background');
243
+ }
244
+ if (params.skip_permissions) {
245
+ args.push('--skip-permissions');
246
+ }
247
+ if (params.create_pr) {
248
+ args.push('--create-pr');
249
+ }
250
+ if (params.environment === 'host') {
251
+ args.push('--run-on-host');
252
+ }
253
+ // Always skip confirmation in MCP context
254
+ args.push('--yes');
255
+ const cmd = `prlt work spawn ${args.join(' ')}`;
256
+ const output = ctx.runCommand(cmd);
257
+ return {
258
+ content: [{
259
+ type: 'text',
260
+ text: JSON.stringify({
261
+ success: true,
262
+ ticketId: params.ticket_id,
263
+ command: cmd,
264
+ output,
265
+ message: `Spawned work for ${params.ticket_id} via CLI pipeline`,
266
+ }, null, 2),
267
+ }],
268
+ };
269
+ }
270
+ catch (error) {
271
+ return errorResponse(error);
272
+ }
273
+ });
222
274
  }
@@ -66,7 +66,7 @@ export declare const PMO_TABLE_SCHEMAS: {
66
66
  readonly project_specs: "\n CREATE TABLE IF NOT EXISTS pmo_project_specs (\n project_id TEXT NOT NULL REFERENCES pmo_projects(id) ON DELETE CASCADE,\n spec_id TEXT NOT NULL REFERENCES pmo_specs(id) ON DELETE CASCADE,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (project_id, spec_id)\n )";
67
67
  readonly cache_metadata: "\n CREATE TABLE IF NOT EXISTS pmo_cache_metadata (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n )";
68
68
  readonly settings: "\n CREATE TABLE IF NOT EXISTS pmo_settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n )";
69
- readonly agent_work: "\n CREATE TABLE IF NOT EXISTS agent_work (\n id TEXT PRIMARY KEY,\n ticket_id TEXT NOT NULL,\n agent_name TEXT NOT NULL,\n executor TEXT NOT NULL,\n environment TEXT NOT NULL DEFAULT 'host',\n display_mode TEXT NOT NULL DEFAULT 'terminal',\n sandboxed INTEGER NOT NULL DEFAULT 0,\n status TEXT NOT NULL DEFAULT 'starting',\n branch TEXT,\n pid TEXT,\n container_id TEXT,\n session_id TEXT,\n host TEXT,\n log_path TEXT,\n started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP,\n exit_code INTEGER,\n FOREIGN KEY (ticket_id) REFERENCES pmo_tickets(id) ON DELETE CASCADE\n )";
69
+ readonly agent_work: "\n CREATE TABLE IF NOT EXISTS agent_work (\n id TEXT PRIMARY KEY,\n ticket_id TEXT NOT NULL,\n agent_name TEXT NOT NULL,\n executor TEXT NOT NULL,\n environment TEXT NOT NULL DEFAULT 'host',\n display_mode TEXT NOT NULL DEFAULT 'terminal',\n sandboxed INTEGER NOT NULL DEFAULT 0,\n status TEXT NOT NULL DEFAULT 'starting',\n branch TEXT,\n pid TEXT,\n container_id TEXT,\n session_id TEXT,\n host TEXT,\n log_path TEXT,\n started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP,\n exit_code INTEGER,\n error_message TEXT,\n FOREIGN KEY (ticket_id) REFERENCES pmo_tickets(id) ON DELETE CASCADE\n )";
70
70
  readonly containers: "\n CREATE TABLE IF NOT EXISTS containers (\n id TEXT PRIMARY KEY,\n agent_name TEXT NOT NULL,\n docker_id TEXT NOT NULL,\n docker_name TEXT,\n image TEXT,\n status TEXT NOT NULL DEFAULT 'unknown',\n current_execution_id TEXT,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (agent_name) REFERENCES agents(name) ON DELETE CASCADE,\n FOREIGN KEY (current_execution_id) REFERENCES agent_work(id) ON DELETE SET NULL\n )";
71
71
  readonly id_sequences: "\n CREATE TABLE IF NOT EXISTS id_sequences (\n table_name TEXT PRIMARY KEY,\n next_id INTEGER NOT NULL DEFAULT 1\n )";
72
72
  readonly statuses: "\n CREATE TABLE IF NOT EXISTS pmo_statuses (\n id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n name TEXT NOT NULL,\n category TEXT NOT NULL,\n position INTEGER NOT NULL DEFAULT 0,\n color TEXT,\n description TEXT,\n is_default INTEGER NOT NULL DEFAULT 0,\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (project_id) REFERENCES pmo_projects(id) ON DELETE CASCADE,\n UNIQUE(project_id, name)\n )";
@@ -337,6 +337,7 @@ export const PMO_TABLE_SCHEMAS = {
337
337
  started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
338
338
  completed_at TIMESTAMP,
339
339
  exit_code INTEGER,
340
+ error_message TEXT,
340
341
  FOREIGN KEY (ticket_id) REFERENCES ${PMO_TABLES.tickets}(id) ON DELETE CASCADE
341
342
  )`,
342
343
  // Docker containers (per-agent, reused across executions)
@@ -178,6 +178,39 @@ export function runMigrations(db) {
178
178
  }
179
179
  }
180
180
  }
181
+ // Migration: Migrate old ticket_dependencies schema (blocked_by_ticket_id) to new format
182
+ if (tableExists(T.ticket_dependencies)) {
183
+ const depsColumns = db.pragma(`table_info(${T.ticket_dependencies})`);
184
+ const depsColumnNames = new Set(depsColumns.map(c => c.name));
185
+ if (depsColumnNames.has('blocked_by_ticket_id') && !depsColumnNames.has('depends_on_ticket_id')) {
186
+ // Old schema detected - migrate to new format
187
+ try {
188
+ // Create new table with correct schema
189
+ db.exec(`
190
+ CREATE TABLE pmo_ticket_dependencies_new (
191
+ ticket_id TEXT NOT NULL REFERENCES ${T.tickets}(id) ON DELETE RESTRICT,
192
+ depends_on_ticket_id TEXT NOT NULL REFERENCES ${T.tickets}(id) ON DELETE RESTRICT,
193
+ dependency_type TEXT NOT NULL DEFAULT 'blocks' CHECK (dependency_type IN ('blocks', 'relates_to', 'duplicates')),
194
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
195
+ PRIMARY KEY (ticket_id, depends_on_ticket_id, dependency_type),
196
+ CHECK (ticket_id != depends_on_ticket_id)
197
+ )
198
+ `);
199
+ // Copy existing blocking relationships (old schema: ticket_id is blocked by blocked_by_ticket_id)
200
+ db.exec(`
201
+ INSERT OR IGNORE INTO pmo_ticket_dependencies_new (ticket_id, depends_on_ticket_id, dependency_type, created_at)
202
+ SELECT ticket_id, blocked_by_ticket_id, 'blocks', created_at
203
+ FROM ${T.ticket_dependencies}
204
+ `);
205
+ // Replace old table with new one
206
+ db.exec(`DROP TABLE ${T.ticket_dependencies}`);
207
+ db.exec(`ALTER TABLE pmo_ticket_dependencies_new RENAME TO ${T.ticket_dependencies}`);
208
+ }
209
+ catch {
210
+ // Migration may have already been applied partially
211
+ }
212
+ }
213
+ }
181
214
  // Migration: Convert legacy priority values (URGENT/HIGH/MEDIUM/LOW) to P0-P3
182
215
  if (tableExists(T.tickets)) {
183
216
  try {
@@ -252,6 +285,19 @@ export function runMigrations(db) {
252
285
  // Column may already exist
253
286
  }
254
287
  }
288
+ // Migration: Add error_message column to agent_work table (TKT-1082)
289
+ if (tableExists(T.agent_work)) {
290
+ const agentWorkColumns = db.pragma(`table_info(${T.agent_work})`);
291
+ const agentWorkColumnNames = new Set(agentWorkColumns.map(c => c.name));
292
+ if (!agentWorkColumnNames.has('error_message')) {
293
+ try {
294
+ db.exec(`ALTER TABLE ${T.agent_work} ADD COLUMN error_message TEXT`);
295
+ }
296
+ catch {
297
+ // Column may already exist
298
+ }
299
+ }
300
+ }
255
301
  // Migration: Reassign orphaned tickets (TKT-940)
256
302
  // Tickets with project_id that doesn't match any existing project are "orphaned".
257
303
  // This can happen when a 'default' project never existed or was deleted.
@@ -887,6 +933,167 @@ The PR will be updated automatically with your pushed changes.`,
887
933
  modifiesCode: true,
888
934
  position: 6,
889
935
  },
936
+ {
937
+ id: 'explore-cli',
938
+ name: 'Explore CLI',
939
+ description: 'AI QA agent that autonomously explores the interactive CLI to discover bugs',
940
+ prompt: `${PRLT_USAGE_RULE}
941
+
942
+ ---
943
+
944
+ # Action: Explore CLI (Autonomous QA)
945
+
946
+ You are an AI QA tester for the prlt CLI. You have access to a tmux session where the CLI is running.
947
+ Your job is to **systematically explore every menu, try every option, and find bugs**.
948
+
949
+ ## Your Tools
950
+
951
+ - **tmux_send_keys** — send keystrokes to the tmux session (typing, arrows, Enter, Escape, Ctrl+C, etc.)
952
+ - **tmux_capture_pane** — read the current terminal screen to see what's displayed
953
+ - **tmux_start_session** — start a new tmux session to run the CLI in
954
+ - **tmux_list_sessions** — list active tmux sessions
955
+ - **ticket_create** — file a bug ticket when you find something broken
956
+
957
+ ## Getting Started
958
+
959
+ 1. Start a tmux session for testing:
960
+ \`\`\`
961
+ tmux_start_session({ session: "qa-test", command: "prlt" })
962
+ \`\`\`
963
+ 2. Wait a moment, then capture the screen to see the main menu:
964
+ \`\`\`
965
+ tmux_capture_pane({ session: "qa-test" })
966
+ \`\`\`
967
+ 3. Begin systematic exploration.
968
+
969
+ ## Exploration Strategies
970
+
971
+ Work through these systematically. After each action, always capture the screen to see the result.
972
+
973
+ ### 1. Navigate Every Top-Level Menu Item
974
+ - Use arrow keys (Up/Down) to highlight each option
975
+ - Press Enter to select each one
976
+ - Capture the screen to see what happens
977
+ - Press Escape or Ctrl+C to go back
978
+
979
+ ### 2. Test Every Submenu Option
980
+ - For each menu item, explore all sub-options
981
+ - Try selecting each choice in every list/menu
982
+
983
+ ### 3. Test Input Handling
984
+ - **Valid inputs**: Normal expected values
985
+ - **Empty inputs**: Just press Enter without typing anything
986
+ - **Invalid inputs**: Random strings, numbers where text is expected, etc.
987
+ - **Long strings**: Very long ticket titles, descriptions (100+ characters)
988
+ - **Special characters**: Quotes, backslashes, Unicode (emojis, CJK characters)
989
+ - **SQL injection-like**: \`'; DROP TABLE --\` style strings
990
+ - **Boundary values**: 0, -1, 999999, etc. for numeric inputs
991
+
992
+ ### 4. Test Cancellation Flows
993
+ - Press Ctrl+C mid-flow (should exit gracefully, no crash)
994
+ - Press Escape in menus (should go back)
995
+ - Press q or Q in menus (common quit shortcut)
996
+ - Rapidly press Escape multiple times
997
+
998
+ ### 5. Test Navigation Edge Cases
999
+ - Rapid arrow key presses (Up Up Up Down Down Down)
1000
+ - Arrow keys at the top/bottom of lists (should not crash)
1001
+ - Tab key in various contexts
1002
+ - Home/End keys
1003
+
1004
+ ### 6. Test Error Recovery
1005
+ - Navigate to ticket operations without any tickets
1006
+ - Try to create items with duplicate names
1007
+ - Try operations on non-existent IDs
1008
+
1009
+ ## What to Look For
1010
+
1011
+ - **Crashes**: Unhandled exceptions, stack traces visible on screen
1012
+ - **Rendering glitches**: Items cut off, wrong alignment, overlapping text, garbled output
1013
+ - **Wrong data**: Incorrect values displayed, stale data, missing information
1014
+ - **Missing options**: Menu items that should exist but don't
1015
+ - **Broken navigation**: Arrow keys don't work, can't go back, infinite loops
1016
+ - **Unhelpful errors**: Generic "Error" with no details, or raw error objects
1017
+ - **Hangs**: Screen freezes, no response to input (wait 10 seconds before declaring a hang)
1018
+ - **Screen overflow**: Content pushed off screen, no scrolling available
1019
+ - **Partial renders**: Screen shows half-drawn UI elements
1020
+ - **State corruption**: Operations succeed but data is wrong afterward
1021
+
1022
+ ## Cross-Validation
1023
+
1024
+ Compare what you see on screen with what the MCP tools return:
1025
+ - After creating a ticket via the interactive menu, use \`ticket_list\` to verify it was created correctly
1026
+ - After moving a ticket, verify the status changed via \`ticket_show\`
1027
+ - Check that counts displayed in menus match actual data
1028
+
1029
+ ## When You Find a Bug
1030
+
1031
+ 1. **Document exact reproduction steps** — which keys you pressed, in what order
1032
+ 2. **Capture the screen** showing the bug
1033
+ 3. **File a ticket** using the ticket_create MCP tool:
1034
+ - Title: Clear description of the bug
1035
+ - Category: "bug"
1036
+ - Priority: P1 for crashes/data loss, P2 for rendering/UX issues, P3 for minor issues
1037
+ - Description: Include exact reproduction steps, screen capture, and expected vs actual behavior
1038
+
1039
+ ## Session Management
1040
+
1041
+ - If the CLI crashes, restart it: kill the session and start a new one
1042
+ - If you get stuck, use Ctrl+C to escape, or kill and restart the session
1043
+ - Periodically capture the screen even when not expecting changes (catch intermittent issues)
1044
+
1045
+ ## Test Prioritization
1046
+
1047
+ Start with the most commonly used flows, then move to edge cases:
1048
+ 1. Main menu navigation
1049
+ 2. Ticket operations (create, list, view, edit, move)
1050
+ 3. Project operations
1051
+ 4. Board view
1052
+ 5. Work/session operations
1053
+ 6. Epic and spec operations
1054
+ 7. Settings and configuration
1055
+ 8. Edge cases and stress testing`,
1056
+ endPrompt: `## Wrap-Up
1057
+
1058
+ When you've completed your exploration:
1059
+
1060
+ 1. **Summarize your findings**: List all bugs found with their ticket IDs
1061
+ 2. **Note areas not tested**: If you couldn't reach certain features, list them
1062
+ 3. **Rate overall CLI quality**: Brief assessment of stability, UX, and completeness
1063
+
1064
+ Output a structured summary:
1065
+ \`\`\`
1066
+ ## Exploratory QA Summary
1067
+
1068
+ ### Bugs Filed
1069
+ - TKT-XXX: [brief description]
1070
+ - TKT-YYY: [brief description]
1071
+
1072
+ ### Areas Tested
1073
+ - [ ] Main menu navigation
1074
+ - [ ] Ticket CRUD
1075
+ - [ ] Project operations
1076
+ - [ ] Board view
1077
+ - [ ] Work sessions
1078
+ - [ ] Epics & specs
1079
+ - [ ] Error handling
1080
+ - [ ] Input validation
1081
+
1082
+ ### Areas Not Tested
1083
+ - [list any areas you couldn't reach]
1084
+
1085
+ ### Overall Assessment
1086
+ [Brief quality assessment]
1087
+ \`\`\`
1088
+
1089
+ Clean up your tmux session:
1090
+ \`\`\`
1091
+ tmux_kill_session({ session: "qa-test" })
1092
+ \`\`\``,
1093
+ suggestedForCategories: [],
1094
+ modifiesCode: false,
1095
+ position: 8,
1096
+ },
890
1097
  {
891
1098
  id: 'test',
892
1099
  name: 'Write Tests',
@@ -16,6 +16,7 @@ export declare class DependencyStorage {
16
16
  deleteTicketDependency(ticketId: string, dependsOnTicketId: string, dependencyType?: TicketDependencyType): Promise<void>;
17
17
  /**
18
18
  * List dependencies for a ticket.
19
+ * For relates_to dependencies, also returns reverse relationships (symmetric).
19
20
  */
20
21
  listTicketDependencies(ticketId: string): Promise<TicketDependency[]>;
21
22
  /**
@@ -63,14 +63,22 @@ export class DependencyStorage {
63
63
  }
64
64
  /**
65
65
  * List dependencies for a ticket.
66
+ * For relates_to dependencies, also returns reverse relationships (symmetric).
66
67
  */
67
68
  async listTicketDependencies(ticketId) {
68
69
  const rows = this.ctx.db.prepare(`
69
70
  SELECT ticket_id, depends_on_ticket_id, dependency_type, created_at
70
71
  FROM ${T.ticket_dependencies}
71
72
  WHERE ticket_id = ?
73
+
74
+ UNION
75
+
76
+ SELECT depends_on_ticket_id AS ticket_id, ticket_id AS depends_on_ticket_id, dependency_type, created_at
77
+ FROM ${T.ticket_dependencies}
78
+ WHERE depends_on_ticket_id = ? AND dependency_type = 'relates_to'
79
+
72
80
  ORDER BY created_at DESC
73
- `).all(ticketId);
81
+ `).all(ticketId, ticketId);
74
82
  return rows.map((row) => ({
75
83
  ticketId: row.ticket_id,
76
84
  dependsOnTicketId: row.depends_on_ticket_id,
@@ -92,7 +100,7 @@ export class DependencyStorage {
92
100
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
93
101
  WHERE d.ticket_id = ? AND d.dependency_type = 'blocks'
94
102
  `).all(ticketId);
95
- return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
103
+ return Promise.all(rows.map((row) => rowToTicket(this.ctx.drizzle, row)));
96
104
  }
97
105
  /**
98
106
  * Get tickets that depend on this ticket (blocking).
@@ -108,7 +116,7 @@ export class DependencyStorage {
108
116
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
109
117
  WHERE d.depends_on_ticket_id = ? AND d.dependency_type = 'blocks'
110
118
  `).all(ticketId);
111
- return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
119
+ return Promise.all(rows.map((row) => rowToTicket(this.ctx.drizzle, row)));
112
120
  }
113
121
  /**
114
122
  * Check if a ticket is blocked by incomplete dependencies.
@@ -186,7 +186,7 @@ export class EpicStorage {
186
186
  WHERE t.project_id = ? AND t.epic_id = ?
187
187
  ORDER BY ws.position, t.position ASC, t.created_at ASC
188
188
  `).all(projectId, epicId);
189
- return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
189
+ return Promise.all(rows.map((row) => rowToTicket(this.ctx.drizzle, row)));
190
190
  }
191
191
  /**
192
192
  * Link a ticket to an epic.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Helper functions for converting database rows to domain types.
3
3
  */
4
- import Database from 'better-sqlite3';
4
+ import type { DrizzleDB } from '../../database/drizzle.js';
5
5
  import { AcceptanceCriterion, Spec, StateCategory, Ticket } from '../types.js';
6
6
  import { SpecRow, TicketRow, WorkflowStatusRow } from './types.js';
7
7
  /**
@@ -16,13 +16,13 @@ import { SpecRow, TicketRow, WorkflowStatusRow } from './types.js';
16
16
  export declare function wrapSqliteError(entityType: string, operation: 'create' | 'update' | 'delete', err: unknown): never;
17
17
  /**
18
18
  * Convert a database row to a Ticket object.
19
- * Fetches related data (subtasks, metadata, status info).
19
+ * Fetches related data (subtasks, metadata, status info) using Drizzle ORM.
20
20
  */
21
- export declare function rowToTicket(db: Database.Database, row: TicketRow): Promise<Ticket>;
21
+ export declare function rowToTicket(drizzle: DrizzleDB, row: TicketRow): Promise<Ticket>;
22
22
  /**
23
23
  * Get acceptance criteria for a ticket (sync version).
24
24
  */
25
- export declare function getAcceptanceCriteriaSync(db: Database.Database, ticketId: string): AcceptanceCriterion[];
25
+ export declare function getAcceptanceCriteriaSync(drizzle: DrizzleDB, ticketId: string): AcceptanceCriterion[];
26
26
  /**
27
27
  * Convert a database row to a Spec object.
28
28
  */