@proletariat/cli 0.3.19 → 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 (81) 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.d.ts +1 -0
  6. package/dist/commands/agent/staff/remove.js +36 -28
  7. package/dist/commands/agent/status.js +2 -2
  8. package/dist/commands/agent/temp/cleanup.js +10 -17
  9. package/dist/commands/agent/themes/add-names.d.ts +1 -0
  10. package/dist/commands/agent/themes/add-names.js +5 -1
  11. package/dist/commands/agent/visit.js +2 -2
  12. package/dist/commands/board/view.d.ts +15 -0
  13. package/dist/commands/board/view.js +136 -0
  14. package/dist/commands/config/index.js +6 -3
  15. package/dist/commands/epic/link/index.js +17 -0
  16. package/dist/commands/execution/config.d.ts +34 -0
  17. package/dist/commands/execution/config.js +433 -0
  18. package/dist/commands/execution/index.js +6 -1
  19. package/dist/commands/execution/kill.d.ts +12 -0
  20. package/dist/commands/execution/kill.js +17 -0
  21. package/dist/commands/execution/list.js +5 -4
  22. package/dist/commands/execution/logs.js +1 -0
  23. package/dist/commands/execution/view.d.ts +17 -0
  24. package/dist/commands/execution/view.js +288 -0
  25. package/dist/commands/phase/move.js +8 -0
  26. package/dist/commands/phase/template/apply.js +2 -2
  27. package/dist/commands/phase/template/create.js +67 -20
  28. package/dist/commands/phase/template/list.js +1 -1
  29. package/dist/commands/pr/index.js +6 -2
  30. package/dist/commands/pr/list.d.ts +17 -0
  31. package/dist/commands/pr/list.js +163 -0
  32. package/dist/commands/project/update.d.ts +19 -0
  33. package/dist/commands/project/update.js +163 -0
  34. package/dist/commands/roadmap/create.js +5 -0
  35. package/dist/commands/spec/delete.d.ts +18 -0
  36. package/dist/commands/spec/delete.js +111 -0
  37. package/dist/commands/spec/edit.d.ts +23 -0
  38. package/dist/commands/spec/edit.js +232 -0
  39. package/dist/commands/spec/index.js +5 -0
  40. package/dist/commands/status/create.js +38 -34
  41. package/dist/commands/status/list.js +5 -3
  42. package/dist/commands/template/phase/create.d.ts +1 -0
  43. package/dist/commands/template/phase/create.js +10 -1
  44. package/dist/commands/template/phase/index.js +4 -4
  45. package/dist/commands/template/ticket/create.d.ts +20 -0
  46. package/dist/commands/template/ticket/create.js +87 -0
  47. package/dist/commands/template/ticket/delete.d.ts +1 -1
  48. package/dist/commands/template/ticket/delete.js +4 -2
  49. package/dist/commands/template/ticket/save.d.ts +2 -0
  50. package/dist/commands/template/ticket/save.js +11 -0
  51. package/dist/commands/ticket/create.js +8 -1
  52. package/dist/commands/ticket/edit.js +1 -1
  53. package/dist/commands/ticket/list.d.ts +2 -0
  54. package/dist/commands/ticket/list.js +39 -2
  55. package/dist/commands/ticket/template/create.d.ts +9 -1
  56. package/dist/commands/ticket/template/create.js +224 -52
  57. package/dist/commands/ticket/template/save.d.ts +2 -1
  58. package/dist/commands/ticket/template/save.js +58 -7
  59. package/dist/commands/ticket/update.js +2 -2
  60. package/dist/commands/work/ready.js +8 -8
  61. package/dist/commands/work/spawn.js +32 -8
  62. package/dist/commands/work/watch.js +2 -0
  63. package/dist/lib/agents/commands.d.ts +7 -0
  64. package/dist/lib/agents/commands.js +11 -0
  65. package/dist/lib/agents/index.js +14 -4
  66. package/dist/lib/branch/index.js +24 -0
  67. package/dist/lib/execution/config.d.ts +2 -0
  68. package/dist/lib/execution/config.js +12 -0
  69. package/dist/lib/execution/runners.js +1 -2
  70. package/dist/lib/pmo/storage/epics.js +20 -10
  71. package/dist/lib/pmo/storage/helpers.d.ts +10 -0
  72. package/dist/lib/pmo/storage/helpers.js +59 -1
  73. package/dist/lib/pmo/storage/projects.js +20 -8
  74. package/dist/lib/pmo/storage/specs.js +23 -13
  75. package/dist/lib/pmo/storage/statuses.js +39 -18
  76. package/dist/lib/pmo/storage/subtasks.js +19 -8
  77. package/dist/lib/pmo/storage/tickets.js +27 -15
  78. package/dist/lib/pmo/utils.d.ts +4 -2
  79. package/dist/lib/pmo/utils.js +4 -2
  80. package/oclif.manifest.json +4037 -3234
  81. package/package.json +2 -4
@@ -129,6 +129,14 @@ export function validateBranchName(name) {
129
129
  if (parts.length === 2) {
130
130
  // {type}/{description}
131
131
  const description = parts[1];
132
+ // Check if description looks like a ticket ID (user put ticket in wrong position)
133
+ if (isTicketId(description)) {
134
+ return {
135
+ valid: false,
136
+ error: `Segment "${description}" looks like a ticket ID, but ticket IDs must be the first segment. ` +
137
+ `Expected format: {ticketId}/{type}/{description}`,
138
+ };
139
+ }
132
140
  if (!isKebabCase(description)) {
133
141
  return {
134
142
  valid: false,
@@ -143,12 +151,28 @@ export function validateBranchName(name) {
143
151
  // {type}/{owner}/{description}
144
152
  const owner = parts[1];
145
153
  const description = parts[2];
154
+ // Check if owner looks like a ticket ID (user put ticket in wrong position)
155
+ if (isTicketId(owner)) {
156
+ return {
157
+ valid: false,
158
+ error: `Segment "${owner}" looks like a ticket ID, but it's in the owner position (segment 2). ` +
159
+ `Ticket IDs must be the first segment. Expected format: {ticketId}/{type}/{owner}/{description}`,
160
+ };
161
+ }
146
162
  if (!isKebabCase(owner)) {
147
163
  return {
148
164
  valid: false,
149
165
  error: `Owner name must be kebab-case: "${owner}"`,
150
166
  };
151
167
  }
168
+ // Check if description looks like a ticket ID (user put ticket in wrong position)
169
+ if (isTicketId(description)) {
170
+ return {
171
+ valid: false,
172
+ error: `Segment "${description}" looks like a ticket ID, but it's in the description position (segment 3). ` +
173
+ `Ticket IDs must be the first segment.`,
174
+ };
175
+ }
152
176
  if (!isKebabCase(description)) {
153
177
  return {
154
178
  valid: false,
@@ -14,6 +14,8 @@ declare const CONFIG_KEYS: {
14
14
  defaultMode: string;
15
15
  defaultExecutor: string;
16
16
  autoExecute: string;
17
+ outputMode: string;
18
+ sandboxed: string;
17
19
  tmuxSession: string;
18
20
  tmuxLayout: string;
19
21
  tmuxControlMode: string;
@@ -18,6 +18,8 @@ const CONFIG_KEYS = {
18
18
  defaultMode: 'execution.default_mode',
19
19
  defaultExecutor: 'execution.default_executor',
20
20
  autoExecute: 'execution.auto_execute',
21
+ outputMode: 'execution.output_mode',
22
+ sandboxed: 'execution.sandboxed',
21
23
  tmuxSession: 'execution.tmux.session',
22
24
  tmuxLayout: 'execution.tmux.layout',
23
25
  tmuxControlMode: 'execution.tmux.control_mode',
@@ -85,6 +87,16 @@ export function loadExecutionConfig(db) {
85
87
  if (autoExecute !== null) {
86
88
  config.autoExecute = autoExecute === 'true';
87
89
  }
90
+ // Load output mode
91
+ const outputMode = getSetting(db, CONFIG_KEYS.outputMode);
92
+ if (outputMode) {
93
+ config.outputMode = outputMode;
94
+ }
95
+ // Load sandboxed preference
96
+ const sandboxed = getSetting(db, CONFIG_KEYS.sandboxed);
97
+ if (sandboxed !== null) {
98
+ config.sandboxed = sandboxed === 'true';
99
+ }
88
100
  // Load tmux settings
89
101
  const tmuxSession = getSetting(db, CONFIG_KEYS.tmuxSession);
90
102
  if (tmuxSession) {
@@ -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);
@@ -75,14 +75,16 @@ export declare function deepClone<T>(obj: T): T;
75
75
  /**
76
76
  * Default column names for work commands (Linear-style workflow)
77
77
  *
78
- * Linear-style: Backlog → Planned → In Progress → Done
78
+ * Linear-style: Backlog → Planned → In Progress → Review → Done
79
79
  * - planned: Move tickets here when scheduled/assigned
80
80
  * - in_progress: Move tickets here when work starts
81
- * - done: Move tickets here when work is complete (includes review/merged)
81
+ * - review: Move tickets here when work is ready for review
82
+ * - done: Move tickets here when work is complete (reviewed/merged)
82
83
  */
83
84
  export declare const DEFAULT_WORK_COLUMNS: {
84
85
  readonly planned: "Planned";
85
86
  readonly in_progress: "In Progress";
87
+ readonly review: "Review";
86
88
  readonly done: "Done";
87
89
  };
88
90
  export type WorkColumnType = keyof typeof DEFAULT_WORK_COLUMNS;
@@ -122,14 +122,16 @@ export function deepClone(obj) {
122
122
  /**
123
123
  * Default column names for work commands (Linear-style workflow)
124
124
  *
125
- * Linear-style: Backlog → Planned → In Progress → Done
125
+ * Linear-style: Backlog → Planned → In Progress → Review → Done
126
126
  * - planned: Move tickets here when scheduled/assigned
127
127
  * - in_progress: Move tickets here when work starts
128
- * - done: Move tickets here when work is complete (includes review/merged)
128
+ * - review: Move tickets here when work is ready for review
129
+ * - done: Move tickets here when work is complete (reviewed/merged)
129
130
  */
130
131
  export const DEFAULT_WORK_COLUMNS = {
131
132
  planned: 'Planned',
132
133
  in_progress: 'In Progress',
134
+ review: 'Review',
133
135
  done: 'Done',
134
136
  };
135
137
  /**