@proletariat/cli 0.3.20 → 0.3.21

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 (43) hide show
  1. package/dist/commands/agent/login.js +2 -2
  2. package/dist/commands/agent/remove.d.ts +1 -0
  3. package/dist/commands/agent/remove.js +36 -28
  4. package/dist/commands/agent/shell.js +2 -2
  5. package/dist/commands/agent/staff/remove.js +2 -2
  6. package/dist/commands/agent/status.js +2 -2
  7. package/dist/commands/agent/themes/add-names.d.ts +1 -0
  8. package/dist/commands/agent/themes/add-names.js +5 -1
  9. package/dist/commands/agent/visit.js +2 -2
  10. package/dist/commands/epic/link/index.js +17 -0
  11. package/dist/commands/execution/config.js +22 -0
  12. package/dist/commands/execution/kill.d.ts +3 -0
  13. package/dist/commands/execution/kill.js +1 -0
  14. package/dist/commands/execution/list.js +5 -4
  15. package/dist/commands/execution/logs.js +1 -0
  16. package/dist/commands/phase/move.js +8 -0
  17. package/dist/commands/phase/template/apply.js +2 -2
  18. package/dist/commands/phase/template/create.js +6 -6
  19. package/dist/commands/phase/template/list.js +1 -1
  20. package/dist/commands/status/list.js +5 -3
  21. package/dist/commands/template/phase/index.js +4 -4
  22. package/dist/commands/template/ticket/delete.d.ts +1 -1
  23. package/dist/commands/template/ticket/delete.js +4 -2
  24. package/dist/commands/ticket/create.js +1 -1
  25. package/dist/commands/ticket/edit.js +1 -1
  26. package/dist/commands/ticket/list.d.ts +2 -0
  27. package/dist/commands/ticket/list.js +39 -2
  28. package/dist/commands/ticket/update.js +2 -2
  29. package/dist/commands/work/spawn.js +32 -8
  30. package/dist/commands/work/watch.js +2 -0
  31. package/dist/lib/agents/commands.d.ts +7 -0
  32. package/dist/lib/agents/commands.js +11 -0
  33. package/dist/lib/execution/runners.js +1 -2
  34. package/dist/lib/pmo/storage/epics.js +20 -10
  35. package/dist/lib/pmo/storage/helpers.d.ts +10 -0
  36. package/dist/lib/pmo/storage/helpers.js +59 -1
  37. package/dist/lib/pmo/storage/projects.js +20 -8
  38. package/dist/lib/pmo/storage/specs.js +23 -13
  39. package/dist/lib/pmo/storage/statuses.js +39 -18
  40. package/dist/lib/pmo/storage/subtasks.js +19 -8
  41. package/dist/lib/pmo/storage/tickets.js +27 -15
  42. package/oclif.manifest.json +2742 -2713
  43. package/package.json +1 -1
@@ -119,14 +119,6 @@ export default class WorkSpawn extends PMOCommand {
119
119
  const { flags, argv } = await this.parse(WorkSpawn);
120
120
  // Check if JSON output mode is active
121
121
  const jsonMode = shouldOutputJson(flags);
122
- // This command requires project context (pass JSON mode config for AI agents)
123
- const projectId = await this.requireProject({
124
- jsonMode: {
125
- flags,
126
- commandName: 'work spawn',
127
- baseCommand: 'prlt work spawn',
128
- },
129
- });
130
122
  // Helper to handle errors in JSON mode
131
123
  const handleError = (code, message) => {
132
124
  if (jsonMode) {
@@ -137,6 +129,38 @@ export default class WorkSpawn extends PMOCommand {
137
129
  };
138
130
  // Parse ticket IDs from args (everything after flags)
139
131
  const ticketIdArgs = argv;
132
+ // Try to infer project from ticket IDs if provided
133
+ let projectId;
134
+ if (ticketIdArgs.length > 0) {
135
+ // Look up tickets to get their project IDs
136
+ const projectIds = new Set();
137
+ for (const ticketId of ticketIdArgs) {
138
+ // eslint-disable-next-line no-await-in-loop
139
+ const ticket = await this.storage.getTicket(ticketId);
140
+ if (ticket?.projectId) {
141
+ projectIds.add(ticket.projectId);
142
+ }
143
+ }
144
+ if (projectIds.size === 1) {
145
+ // All tickets from same project - use that project
146
+ projectId = [...projectIds][0];
147
+ }
148
+ else if (projectIds.size > 1) {
149
+ // Tickets from multiple projects - warn and require prompt
150
+ this.warn('Tickets are from multiple projects. Please specify --project.');
151
+ }
152
+ // If size === 0, tickets not found - will be handled later in validation
153
+ }
154
+ // Only call requireProject() if we couldn't infer from tickets
155
+ if (!projectId) {
156
+ projectId = await this.requireProject({
157
+ jsonMode: {
158
+ flags,
159
+ commandName: 'work spawn',
160
+ baseCommand: 'prlt work spawn',
161
+ },
162
+ });
163
+ }
140
164
  // Note: Docker check is handled by work:start command when spawning each ticket
141
165
  // This allows for the interactive devcontainer/host selection with retry loop
142
166
  // Get workspace info (for agent worktree paths)
@@ -42,11 +42,13 @@ export default class WorkWatch extends PMOCommand {
42
42
  limit: Flags.integer({
43
43
  char: 'l',
44
44
  description: 'Maximum concurrent executions',
45
+ min: 1,
45
46
  }),
46
47
  interval: Flags.integer({
47
48
  char: 'i',
48
49
  description: 'Polling interval in seconds',
49
50
  default: 5,
51
+ min: 1,
50
52
  }),
51
53
  once: Flags.boolean({
52
54
  description: 'Check once and exit (no continuous watching)',
@@ -1,4 +1,11 @@
1
1
  import { Agent, Repository, MountMode as DBMountMode } from '../database/index.js';
2
+ /**
3
+ * Format a list of agents for display in error messages.
4
+ * Truncates long lists to avoid overwhelming output.
5
+ */
6
+ export declare function formatAgentList(agents: {
7
+ name: string;
8
+ }[], maxShow?: number): string;
2
9
  export interface AgentStatus {
3
10
  name: string;
4
11
  exists: boolean;
@@ -9,6 +9,17 @@ import { getWorkspaceConfig, getWorkspaceAgents, getWorkspaceRepositories, getAg
9
9
  import { isValidAgentName, getSuggestedAgentNames, generateEphemeralAgentName, getThemePersistentDir, getThemeEphemeralDir, extractBaseName, getAgentBaseName, } from '../themes.js';
10
10
  import { createDevcontainerConfig } from '../execution/devcontainer.js';
11
11
  import { getPMOContext } from '../pmo/index.js';
12
+ /**
13
+ * Format a list of agents for display in error messages.
14
+ * Truncates long lists to avoid overwhelming output.
15
+ */
16
+ export function formatAgentList(agents, maxShow = 10) {
17
+ const names = agents.map(a => a.name);
18
+ if (names.length <= maxShow) {
19
+ return names.join(', ');
20
+ }
21
+ return `${names.slice(0, maxShow).join(', ')} ...and ${names.length - maxShow} more. Run 'prlt agent list' to see all.`;
22
+ }
12
23
  /**
13
24
  * Find workspace root and return workspace information.
14
25
  *
@@ -612,8 +612,7 @@ export function isDockerRunning() {
612
612
  execSync('docker info', { stdio: 'pipe', timeout });
613
613
  return true;
614
614
  }
615
- catch (err) {
616
- console.debug(`[runners:docker] Docker check attempt ${attempt}/${maxRetries} failed:`, err);
615
+ catch {
617
616
  if (attempt === maxRetries) {
618
617
  return false;
619
618
  }
@@ -4,7 +4,7 @@
4
4
  import { PMO_TABLES } from '../schema.js';
5
5
  import { PMOError } from '../types.js';
6
6
  import { generateEntityId } from '../utils.js';
7
- import { rowToTicket } from './helpers.js';
7
+ import { rowToTicket, wrapSqliteError } from './helpers.js';
8
8
  const T = PMO_TABLES;
9
9
  export class EpicStorage {
10
10
  ctx;
@@ -27,10 +27,15 @@ export class EpicStorage {
27
27
  `).get(projectId);
28
28
  position = maxPos.max_pos + 1;
29
29
  }
30
- this.ctx.db.prepare(`
31
- INSERT INTO ${T.epics} (id, project_id, title, description, status, position, file_path, spec_id, created_at, updated_at)
32
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33
- `).run(id, projectId, title, epic.description || null, status, position, epic.filePath || null, epic.specId || null, now, now);
30
+ try {
31
+ this.ctx.db.prepare(`
32
+ INSERT INTO ${T.epics} (id, project_id, title, description, status, position, file_path, spec_id, created_at, updated_at)
33
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
34
+ `).run(id, projectId, title, epic.description || null, status, position, epic.filePath || null, epic.specId || null, now, now);
35
+ }
36
+ catch (err) {
37
+ wrapSqliteError('Epic', 'create', err);
38
+ }
34
39
  this.ctx.updateBoardTimestamp(projectId);
35
40
  return {
36
41
  id,
@@ -155,11 +160,16 @@ export class EpicStorage {
155
160
  if (!epic) {
156
161
  throw new PMOError('NOT_FOUND', `Epic not found: ${id}`);
157
162
  }
158
- // Unlink tickets from this epic
159
- this.ctx.db.prepare(`
160
- UPDATE ${T.tickets} SET epic_id = NULL WHERE epic_id = ?
161
- `).run(id);
162
- this.ctx.db.prepare(`DELETE FROM ${T.epics} WHERE id = ?`).run(id);
163
+ try {
164
+ // Unlink tickets from this epic
165
+ this.ctx.db.prepare(`
166
+ UPDATE ${T.tickets} SET epic_id = NULL WHERE epic_id = ?
167
+ `).run(id);
168
+ this.ctx.db.prepare(`DELETE FROM ${T.epics} WHERE id = ?`).run(id);
169
+ }
170
+ catch (err) {
171
+ wrapSqliteError('Epic', 'delete', err);
172
+ }
163
173
  this.ctx.updateBoardTimestamp(epic.projectId);
164
174
  }
165
175
  /**
@@ -4,6 +4,16 @@
4
4
  import Database from 'better-sqlite3';
5
5
  import { AcceptanceCriterion, Spec, StateCategory, Ticket } from '../types.js';
6
6
  import { SpecRow, TicketRow, WorkflowStatusRow } from './types.js';
7
+ /**
8
+ * Wrap SQLite constraint errors with user-friendly messages.
9
+ * This function always throws - it never returns.
10
+ *
11
+ * @param entityType - The type of entity being operated on (e.g., 'Ticket', 'Spec', 'Project')
12
+ * @param operation - The operation being performed ('create', 'update', 'delete')
13
+ * @param err - The error thrown by SQLite
14
+ * @throws {PMOError} Always throws a user-friendly PMOError
15
+ */
16
+ export declare function wrapSqliteError(entityType: string, operation: 'create' | 'update' | 'delete', err: unknown): never;
7
17
  /**
8
18
  * Convert a database row to a Ticket object.
9
19
  * Fetches related data (subtasks, metadata, status info).
@@ -2,7 +2,65 @@
2
2
  * Helper functions for converting database rows to domain types.
3
3
  */
4
4
  import { PMO_TABLES } from '../schema.js';
5
- import { normalizePriority, } from '../types.js';
5
+ import { PMOError, normalizePriority, } from '../types.js';
6
+ /**
7
+ * Check if an error is a SQLite UNIQUE constraint violation.
8
+ */
9
+ function isUniqueConstraintError(err) {
10
+ if (!(err instanceof Error))
11
+ return false;
12
+ const sqliteErr = err;
13
+ return (sqliteErr.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
14
+ sqliteErr.message.includes('UNIQUE constraint failed'));
15
+ }
16
+ /**
17
+ * Check if an error is a SQLite FOREIGN KEY constraint violation.
18
+ */
19
+ function isForeignKeyConstraintError(err) {
20
+ if (!(err instanceof Error))
21
+ return false;
22
+ const sqliteErr = err;
23
+ return (sqliteErr.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' ||
24
+ sqliteErr.message.includes('FOREIGN KEY constraint failed'));
25
+ }
26
+ /**
27
+ * Check if an error is a SQLite CHECK constraint violation.
28
+ */
29
+ function isCheckConstraintError(err) {
30
+ if (!(err instanceof Error))
31
+ return false;
32
+ const sqliteErr = err;
33
+ return (sqliteErr.code === 'SQLITE_CONSTRAINT_CHECK' ||
34
+ sqliteErr.message.includes('CHECK constraint failed'));
35
+ }
36
+ /**
37
+ * Wrap SQLite constraint errors with user-friendly messages.
38
+ * This function always throws - it never returns.
39
+ *
40
+ * @param entityType - The type of entity being operated on (e.g., 'Ticket', 'Spec', 'Project')
41
+ * @param operation - The operation being performed ('create', 'update', 'delete')
42
+ * @param err - The error thrown by SQLite
43
+ * @throws {PMOError} Always throws a user-friendly PMOError
44
+ */
45
+ export function wrapSqliteError(entityType, operation, err) {
46
+ if (isUniqueConstraintError(err)) {
47
+ if (operation === 'create') {
48
+ throw new PMOError('CONFLICT', `${entityType} with this ID already exists`);
49
+ }
50
+ throw new PMOError('CONFLICT', `${entityType} already exists with that value`);
51
+ }
52
+ if (isForeignKeyConstraintError(err)) {
53
+ if (operation === 'delete') {
54
+ throw new PMOError('CONFLICT', `Cannot delete ${entityType.toLowerCase()}: it has dependencies. Remove them first.`);
55
+ }
56
+ throw new PMOError('INVALID', `Cannot ${operation} ${entityType.toLowerCase()}: referenced entity does not exist`);
57
+ }
58
+ if (isCheckConstraintError(err)) {
59
+ throw new PMOError('INVALID', `Invalid ${entityType.toLowerCase()} data: constraint check failed`);
60
+ }
61
+ // Re-throw unknown errors
62
+ throw err;
63
+ }
6
64
  const T = PMO_TABLES;
7
65
  /**
8
66
  * Convert a database row to a Ticket object.
@@ -6,7 +6,7 @@ import { PMO_TABLES } from '../schema.js';
6
6
  import { PMOError, } from '../types.js';
7
7
  import { generateEntityId, slugify } from '../utils.js';
8
8
  import { generateBoardMarkdown } from '../markdown.js';
9
- import { rowToTicket } from './helpers.js';
9
+ import { rowToTicket, wrapSqliteError } from './helpers.js';
10
10
  const T = PMO_TABLES;
11
11
  export class ProjectStorage {
12
12
  ctx;
@@ -144,10 +144,15 @@ export class ProjectStorage {
144
144
  // Use the requested workflow if it exists, otherwise fall back to default
145
145
  const finalWorkflowId = workflow ? workflowId : 'default';
146
146
  // Insert project with workflow
147
- this.ctx.db.prepare(`
148
- INSERT OR REPLACE INTO ${T.projects} (id, name, template, description, workflow_id, created_at, updated_at)
149
- VALUES (?, ?, ?, ?, ?, ?, ?)
150
- `).run(id, project.name, workflowId, project.description || null, finalWorkflowId, now, now);
147
+ try {
148
+ this.ctx.db.prepare(`
149
+ INSERT OR REPLACE INTO ${T.projects} (id, name, template, description, workflow_id, created_at, updated_at)
150
+ VALUES (?, ?, ?, ?, ?, ?, ?)
151
+ `).run(id, project.name, workflowId, project.description || null, finalWorkflowId, now, now);
152
+ }
153
+ catch (err) {
154
+ wrapSqliteError('Project', 'create', err);
155
+ }
151
156
  return this.getBoard(id);
152
157
  }
153
158
  /**
@@ -241,9 +246,16 @@ export class ProjectStorage {
241
246
  if (resolvedId === 'default') {
242
247
  throw new PMOError('INVALID', 'Cannot delete the default project');
243
248
  }
244
- const result = this.ctx.db.prepare(`DELETE FROM ${T.projects} WHERE id = ?`).run(resolvedId);
245
- if (result.changes === 0) {
246
- throw new PMOError('NOT_FOUND', `Project not found: ${projectIdOrName}`);
249
+ try {
250
+ const result = this.ctx.db.prepare(`DELETE FROM ${T.projects} WHERE id = ?`).run(resolvedId);
251
+ if (result.changes === 0) {
252
+ throw new PMOError('NOT_FOUND', `Project not found: ${projectIdOrName}`);
253
+ }
254
+ }
255
+ catch (err) {
256
+ if (err instanceof PMOError)
257
+ throw err;
258
+ wrapSqliteError('Project', 'delete', err);
247
259
  }
248
260
  // Tickets are deleted via CASCADE
249
261
  }
@@ -4,7 +4,7 @@
4
4
  import { PMO_TABLES } from '../schema.js';
5
5
  import { PMOError } from '../types.js';
6
6
  import { generateEntityId } from '../utils.js';
7
- import { rowToSpec, rowToTicket } from './helpers.js';
7
+ import { rowToSpec, rowToTicket, wrapSqliteError } from './helpers.js';
8
8
  const T = PMO_TABLES;
9
9
  export class SpecStorage {
10
10
  ctx;
@@ -17,16 +17,21 @@ export class SpecStorage {
17
17
  async createSpec(spec) {
18
18
  const id = spec.id || generateEntityId(this.ctx.db, 'spec');
19
19
  const now = Date.now();
20
- this.ctx.db.prepare(`
21
- INSERT INTO ${T.specs} (
22
- id, title, status, type, tags,
23
- problem, solution, decisions, not_now, ui_ux,
24
- acceptance_criteria, open_questions,
25
- requirements_functional, requirements_technical,
26
- context, created_at, updated_at
27
- )
28
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
29
- `).run(id, spec.title || 'Untitled Spec', spec.status || 'draft', spec.type || null, spec.tags ? JSON.stringify(spec.tags) : null, spec.problem || null, spec.solution || null, spec.decisions || null, spec.notNow || null, spec.uiUx || null, spec.acceptanceCriteria || null, spec.openQuestions || null, spec.requirementsFunctional || null, spec.requirementsTechnical || null, spec.context || null, now, now);
20
+ try {
21
+ this.ctx.db.prepare(`
22
+ INSERT INTO ${T.specs} (
23
+ id, title, status, type, tags,
24
+ problem, solution, decisions, not_now, ui_ux,
25
+ acceptance_criteria, open_questions,
26
+ requirements_functional, requirements_technical,
27
+ context, created_at, updated_at
28
+ )
29
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
30
+ `).run(id, spec.title || 'Untitled Spec', spec.status || 'draft', spec.type || null, spec.tags ? JSON.stringify(spec.tags) : null, spec.problem || null, spec.solution || null, spec.decisions || null, spec.notNow || null, spec.uiUx || null, spec.acceptanceCriteria || null, spec.openQuestions || null, spec.requirementsFunctional || null, spec.requirementsTechnical || null, spec.context || null, now, now);
31
+ }
32
+ catch (err) {
33
+ wrapSqliteError('Spec', 'create', err);
34
+ }
30
35
  return {
31
36
  id,
32
37
  title: spec.title || 'Untitled Spec',
@@ -176,8 +181,13 @@ export class SpecStorage {
176
181
  if (!existing) {
177
182
  throw new PMOError('NOT_FOUND', `Spec not found: ${id}`);
178
183
  }
179
- this.ctx.db.prepare(`DELETE FROM ${T.specs} WHERE id = ?`).run(id);
180
- this.ctx.db.prepare(`UPDATE ${T.tickets} SET spec_id = NULL WHERE spec_id = ?`).run(id);
184
+ try {
185
+ this.ctx.db.prepare(`DELETE FROM ${T.specs} WHERE id = ?`).run(id);
186
+ this.ctx.db.prepare(`UPDATE ${T.tickets} SET spec_id = NULL WHERE spec_id = ?`).run(id);
187
+ }
188
+ catch (err) {
189
+ wrapSqliteError('Spec', 'delete', err);
190
+ }
181
191
  }
182
192
  /**
183
193
  * Link a ticket to a spec.
@@ -6,6 +6,7 @@
6
6
  import { PMO_TABLES } from '../schema.js';
7
7
  import { PMOError, STATE_CATEGORY_ORDER } from '../types.js';
8
8
  import { slugify } from '../utils.js';
9
+ import { wrapSqliteError } from './helpers.js';
9
10
  const T = PMO_TABLES;
10
11
  /**
11
12
  * Convert database row to Workflow object.
@@ -87,10 +88,15 @@ export class StatusStorage {
87
88
  if (existing) {
88
89
  throw new PMOError('CONFLICT', `Workflow with name "${workflow.name}" already exists`);
89
90
  }
90
- this.ctx.db.prepare(`
91
- INSERT INTO ${T.workflows} (id, name, description, is_builtin, created_at, updated_at)
92
- VALUES (?, ?, ?, ?, ?, ?)
93
- `).run(id, workflow.name || 'New Workflow', workflow.description || null, workflow.isBuiltin ? 1 : 0, now, now);
91
+ try {
92
+ this.ctx.db.prepare(`
93
+ INSERT INTO ${T.workflows} (id, name, description, is_builtin, created_at, updated_at)
94
+ VALUES (?, ?, ?, ?, ?, ?)
95
+ `).run(id, workflow.name || 'New Workflow', workflow.description || null, workflow.isBuiltin ? 1 : 0, now, now);
96
+ }
97
+ catch (err) {
98
+ wrapSqliteError('Workflow', 'create', err);
99
+ }
94
100
  return {
95
101
  id,
96
102
  name: workflow.name || 'New Workflow',
@@ -153,9 +159,14 @@ export class StatusStorage {
153
159
  if (projectCount.count > 0) {
154
160
  throw new PMOError('CONFLICT', `Cannot delete workflow: ${projectCount.count} project(s) are using it`);
155
161
  }
156
- // Delete associated statuses first (cascaded by FK, but explicit for safety)
157
- this.ctx.db.prepare(`DELETE FROM ${T.workflow_statuses} WHERE workflow_id = ?`).run(id);
158
- this.ctx.db.prepare(`DELETE FROM ${T.workflows} WHERE id = ?`).run(id);
162
+ try {
163
+ // Delete associated statuses first (cascaded by FK, but explicit for safety)
164
+ this.ctx.db.prepare(`DELETE FROM ${T.workflow_statuses} WHERE workflow_id = ?`).run(id);
165
+ this.ctx.db.prepare(`DELETE FROM ${T.workflows} WHERE id = ?`).run(id);
166
+ }
167
+ catch (err) {
168
+ wrapSqliteError('Workflow', 'delete', err);
169
+ }
159
170
  }
160
171
  /**
161
172
  * Get the workflow for a project.
@@ -237,10 +248,15 @@ export class StatusStorage {
237
248
  WHERE workflow_id = ?
238
249
  `).run(workflowId);
239
250
  }
240
- this.ctx.db.prepare(`
241
- INSERT INTO ${T.workflow_statuses} (id, workflow_id, name, category, position, color, description, is_default, created_at)
242
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
243
- `).run(id, workflowId, status.name || 'New Status', category, position, status.color || null, status.description || null, status.isDefault ? 1 : 0, now);
251
+ try {
252
+ this.ctx.db.prepare(`
253
+ INSERT INTO ${T.workflow_statuses} (id, workflow_id, name, category, position, color, description, is_default, created_at)
254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
255
+ `).run(id, workflowId, status.name || 'New Status', category, position, status.color || null, status.description || null, status.isDefault ? 1 : 0, now);
256
+ }
257
+ catch (err) {
258
+ wrapSqliteError('Status', 'create', err);
259
+ }
244
260
  // Update workflow's updated_at timestamp
245
261
  this.ctx.db.prepare(`UPDATE ${T.workflows} SET updated_at = ? WHERE id = ?`).run(now, workflowId);
246
262
  return {
@@ -332,13 +348,18 @@ export class StatusStorage {
332
348
  if (ticketCount.count > 0) {
333
349
  throw new PMOError('CONFLICT', `Cannot delete status: ${ticketCount.count} ticket(s) are using it`);
334
350
  }
335
- this.ctx.db.prepare(`DELETE FROM ${T.workflow_statuses} WHERE id = ?`).run(id);
336
- // Reorder remaining statuses
337
- this.ctx.db.prepare(`
338
- UPDATE ${T.workflow_statuses}
339
- SET position = position - 1
340
- WHERE workflow_id = ? AND position > ?
341
- `).run(existing.workflowId, existing.position);
351
+ try {
352
+ this.ctx.db.prepare(`DELETE FROM ${T.workflow_statuses} WHERE id = ?`).run(id);
353
+ // Reorder remaining statuses
354
+ this.ctx.db.prepare(`
355
+ UPDATE ${T.workflow_statuses}
356
+ SET position = position - 1
357
+ WHERE workflow_id = ? AND position > ?
358
+ `).run(existing.workflowId, existing.position);
359
+ }
360
+ catch (err) {
361
+ wrapSqliteError('Status', 'delete', err);
362
+ }
342
363
  // Update workflow's updated_at timestamp
343
364
  this.ctx.db.prepare(`UPDATE ${T.workflows} SET updated_at = ? WHERE id = ?`).run(new Date().toISOString(), existing.workflowId);
344
365
  }
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import { PMO_TABLES } from '../schema.js';
6
6
  import { PMOError } from '../types.js';
7
7
  import { slugify } from '../utils.js';
8
+ import { wrapSqliteError } from './helpers.js';
8
9
  const T = PMO_TABLES;
9
10
  export class SubtaskStorage {
10
11
  ctx;
@@ -42,10 +43,15 @@ export class SubtaskStorage {
42
43
  id = `${baseId}-${counter}`;
43
44
  }
44
45
  const position = maxPos.max_pos + 1;
45
- this.ctx.db.prepare(`
46
- INSERT INTO ${T.subtasks} (id, ticket_id, title, done, position)
47
- VALUES (?, ?, ?, 0, ?)
48
- `).run(id, ticketId, title, position);
46
+ try {
47
+ this.ctx.db.prepare(`
48
+ INSERT INTO ${T.subtasks} (id, ticket_id, title, done, position)
49
+ VALUES (?, ?, ?, 0, ?)
50
+ `).run(id, ticketId, title, position);
51
+ }
52
+ catch (err) {
53
+ wrapSqliteError('Subtask', 'create', err);
54
+ }
49
55
  // Update ticket timestamp
50
56
  this.ctx.db.prepare(`
51
57
  UPDATE ${T.tickets} SET updated_at = ? WHERE id = ?
@@ -139,10 +145,15 @@ export class AcceptanceCriteriaStorage {
139
145
  // Use UUID to guarantee uniqueness even when multiple ACs are added in the same millisecond
140
146
  const id = `ac-${randomUUID()}`;
141
147
  const position = maxPos.max_pos + 1;
142
- this.ctx.db.prepare(`
143
- INSERT INTO ${T.ticket_acceptance_criteria} (id, ticket_id, criterion, verifiable, verified, position)
144
- VALUES (?, ?, ?, 1, 0, ?)
145
- `).run(id, ticketId, criterion, position);
148
+ try {
149
+ this.ctx.db.prepare(`
150
+ INSERT INTO ${T.ticket_acceptance_criteria} (id, ticket_id, criterion, verifiable, verified, position)
151
+ VALUES (?, ?, ?, 1, 0, ?)
152
+ `).run(id, ticketId, criterion, position);
153
+ }
154
+ catch (err) {
155
+ wrapSqliteError('Acceptance criterion', 'create', err);
156
+ }
146
157
  this.ctx.db.prepare(`
147
158
  UPDATE ${T.tickets} SET updated_at = ? WHERE id = ?
148
159
  `).run(Date.now(), ticketId);
@@ -6,7 +6,7 @@
6
6
  import { PMO_TABLES } from '../schema.js';
7
7
  import { PMOError } from '../types.js';
8
8
  import { slugify, generateEntityId } from '../utils.js';
9
- import { rowToTicket } from './helpers.js';
9
+ import { rowToTicket, wrapSqliteError } from './helpers.js';
10
10
  const T = PMO_TABLES;
11
11
  export class TicketStorage {
12
12
  ctx;
@@ -126,14 +126,19 @@ export class TicketStorage {
126
126
  }
127
127
  // Insert ticket
128
128
  const labels = ticket.labels || [];
129
- this.ctx.db.prepare(`
130
- INSERT INTO ${T.tickets} (
131
- id, project_id, title, description, priority, category,
132
- status_id, owner, assignee, spec_id, epic_id, labels,
133
- created_at, updated_at, last_synced_from_spec, last_synced_from_board
134
- )
135
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
136
- `).run(id, projectId, title, ticket.description || null, ticket.priority || null, ticket.category || null, statusId, ticket.owner || null, ticket.assignee || null, specId, ticket.epicId || null, JSON.stringify(labels), now, now, ticket.lastSyncedFromSpec || null, ticket.lastSyncedFromBoard || null);
129
+ try {
130
+ this.ctx.db.prepare(`
131
+ INSERT INTO ${T.tickets} (
132
+ id, project_id, title, description, priority, category,
133
+ status_id, owner, assignee, spec_id, epic_id, labels,
134
+ created_at, updated_at, last_synced_from_spec, last_synced_from_board
135
+ )
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
137
+ `).run(id, projectId, title, ticket.description || null, ticket.priority || null, ticket.category || null, statusId, ticket.owner || null, ticket.assignee || null, specId, ticket.epicId || null, JSON.stringify(labels), now, now, ticket.lastSyncedFromSpec || null, ticket.lastSyncedFromBoard || null);
138
+ }
139
+ catch (err) {
140
+ wrapSqliteError('Ticket', 'create', err);
141
+ }
137
142
  // Insert subtasks
138
143
  if (ticket.subtasks && ticket.subtasks.length > 0) {
139
144
  const insertSubtask = this.ctx.db.prepare(`
@@ -335,12 +340,19 @@ export class TicketStorage {
335
340
  }
336
341
  // Delete ticket (by ID only, since IDs are globally unique)
337
342
  // Related data (subtasks, metadata) are deleted via CASCADE
338
- const result = this.ctx.db.prepare(`
339
- DELETE FROM ${T.tickets}
340
- WHERE id = ?
341
- `).run(id);
342
- if (result.changes === 0) {
343
- throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
343
+ try {
344
+ const result = this.ctx.db.prepare(`
345
+ DELETE FROM ${T.tickets}
346
+ WHERE id = ?
347
+ `).run(id);
348
+ if (result.changes === 0) {
349
+ throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
350
+ }
351
+ }
352
+ catch (err) {
353
+ if (err instanceof PMOError)
354
+ throw err;
355
+ wrapSqliteError('Ticket', 'delete', err);
344
356
  }
345
357
  // Update board timestamp for the ticket's project
346
358
  this.updateProjectTimestamp(ticketProjectId);