@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.
- package/README.md +37 -2
- package/bin/dev.js +0 -0
- package/dist/commands/branch/where.js +6 -17
- package/dist/commands/epic/ticket.js +7 -24
- package/dist/commands/execution/config.js +4 -14
- package/dist/commands/execution/logs.js +6 -0
- package/dist/commands/execution/view.js +8 -0
- package/dist/commands/mcp-server.js +2 -1
- package/dist/commands/pmo/init.js +12 -40
- package/dist/commands/qa/index.d.ts +54 -0
- package/dist/commands/qa/index.js +762 -0
- package/dist/commands/repo/view.js +2 -8
- package/dist/commands/session/attach.js +4 -4
- package/dist/commands/session/health.js +4 -4
- package/dist/commands/session/list.js +1 -19
- package/dist/commands/session/peek.js +6 -6
- package/dist/commands/session/poke.js +2 -2
- package/dist/commands/ticket/epic.js +17 -43
- package/dist/commands/work/spawn-all.js +1 -1
- package/dist/commands/work/spawn.js +15 -4
- package/dist/commands/work/start.js +17 -9
- package/dist/commands/work/watch.js +1 -1
- package/dist/commands/workspace/prune.js +3 -3
- package/dist/hooks/init.js +10 -2
- package/dist/lib/agents/commands.d.ts +5 -0
- package/dist/lib/agents/commands.js +143 -97
- package/dist/lib/database/drizzle-schema.d.ts +465 -0
- package/dist/lib/database/drizzle-schema.js +53 -0
- package/dist/lib/database/index.d.ts +47 -1
- package/dist/lib/database/index.js +138 -20
- package/dist/lib/execution/runners.d.ts +34 -0
- package/dist/lib/execution/runners.js +134 -7
- package/dist/lib/execution/session-utils.d.ts +5 -0
- package/dist/lib/execution/session-utils.js +45 -3
- package/dist/lib/execution/spawner.js +15 -2
- package/dist/lib/execution/storage.d.ts +1 -1
- package/dist/lib/execution/storage.js +17 -2
- package/dist/lib/execution/types.d.ts +1 -0
- package/dist/lib/mcp/tools/index.d.ts +1 -0
- package/dist/lib/mcp/tools/index.js +1 -0
- package/dist/lib/mcp/tools/tmux.d.ts +16 -0
- package/dist/lib/mcp/tools/tmux.js +182 -0
- package/dist/lib/mcp/tools/work.js +52 -0
- package/dist/lib/pmo/schema.d.ts +1 -1
- package/dist/lib/pmo/schema.js +1 -0
- package/dist/lib/pmo/storage/base.js +207 -0
- package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
- package/dist/lib/pmo/storage/dependencies.js +11 -3
- package/dist/lib/pmo/storage/epics.js +1 -1
- package/dist/lib/pmo/storage/helpers.d.ts +4 -4
- package/dist/lib/pmo/storage/helpers.js +36 -26
- package/dist/lib/pmo/storage/projects.d.ts +2 -0
- package/dist/lib/pmo/storage/projects.js +207 -119
- package/dist/lib/pmo/storage/specs.d.ts +2 -0
- package/dist/lib/pmo/storage/specs.js +274 -188
- package/dist/lib/pmo/storage/tickets.d.ts +2 -0
- package/dist/lib/pmo/storage/tickets.js +350 -290
- package/dist/lib/pmo/storage/views.d.ts +2 -0
- package/dist/lib/pmo/storage/views.js +183 -130
- package/dist/lib/prompt-json.d.ts +5 -0
- package/dist/lib/prompt-json.js +9 -0
- package/oclif.manifest.json +3922 -3819
- package/package.json +11 -6
- 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
|
}
|
package/dist/lib/pmo/schema.d.ts
CHANGED
|
@@ -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 )";
|
package/dist/lib/pmo/schema.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
25
|
+
export declare function getAcceptanceCriteriaSync(drizzle: DrizzleDB, ticketId: string): AcceptanceCriterion[];
|
|
26
26
|
/**
|
|
27
27
|
* Convert a database row to a Spec object.
|
|
28
28
|
*/
|