@proletariat/cli 0.3.36 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/branch/where.js +6 -17
  4. package/dist/commands/epic/ticket.js +7 -24
  5. package/dist/commands/execution/config.js +4 -14
  6. package/dist/commands/execution/logs.js +6 -0
  7. package/dist/commands/execution/view.js +8 -0
  8. package/dist/commands/mcp-server.js +2 -1
  9. package/dist/commands/pmo/init.js +12 -40
  10. package/dist/commands/qa/index.d.ts +54 -0
  11. package/dist/commands/qa/index.js +762 -0
  12. package/dist/commands/repo/view.js +2 -8
  13. package/dist/commands/session/attach.js +4 -4
  14. package/dist/commands/session/health.js +4 -4
  15. package/dist/commands/session/list.js +1 -19
  16. package/dist/commands/session/peek.js +6 -6
  17. package/dist/commands/session/poke.js +2 -2
  18. package/dist/commands/ticket/epic.js +17 -43
  19. package/dist/commands/work/spawn-all.js +1 -1
  20. package/dist/commands/work/spawn.js +15 -4
  21. package/dist/commands/work/start.js +17 -9
  22. package/dist/commands/work/watch.js +1 -1
  23. package/dist/commands/workspace/prune.js +3 -3
  24. package/dist/hooks/init.js +10 -2
  25. package/dist/lib/agents/commands.d.ts +5 -0
  26. package/dist/lib/agents/commands.js +143 -97
  27. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  28. package/dist/lib/database/drizzle-schema.js +53 -0
  29. package/dist/lib/database/index.d.ts +47 -1
  30. package/dist/lib/database/index.js +138 -20
  31. package/dist/lib/execution/runners.d.ts +34 -0
  32. package/dist/lib/execution/runners.js +134 -7
  33. package/dist/lib/execution/session-utils.d.ts +5 -0
  34. package/dist/lib/execution/session-utils.js +45 -3
  35. package/dist/lib/execution/spawner.js +15 -2
  36. package/dist/lib/execution/storage.d.ts +1 -1
  37. package/dist/lib/execution/storage.js +17 -2
  38. package/dist/lib/execution/types.d.ts +1 -0
  39. package/dist/lib/mcp/tools/index.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.js +1 -0
  41. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  42. package/dist/lib/mcp/tools/tmux.js +182 -0
  43. package/dist/lib/mcp/tools/work.js +52 -0
  44. package/dist/lib/pmo/schema.d.ts +1 -1
  45. package/dist/lib/pmo/schema.js +1 -0
  46. package/dist/lib/pmo/storage/base.js +207 -0
  47. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  48. package/dist/lib/pmo/storage/dependencies.js +11 -3
  49. package/dist/lib/pmo/storage/epics.js +1 -1
  50. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  51. package/dist/lib/pmo/storage/helpers.js +36 -26
  52. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  53. package/dist/lib/pmo/storage/projects.js +207 -119
  54. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  55. package/dist/lib/pmo/storage/specs.js +274 -188
  56. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  57. package/dist/lib/pmo/storage/tickets.js +350 -290
  58. package/dist/lib/pmo/storage/views.d.ts +2 -0
  59. package/dist/lib/pmo/storage/views.js +183 -130
  60. package/dist/lib/prompt-json.d.ts +5 -0
  61. package/dist/lib/prompt-json.js +9 -0
  62. package/oclif.manifest.json +3922 -3819
  63. package/package.json +11 -6
  64. package/LICENSE +0 -190
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Helper functions for converting database rows to domain types.
3
3
  */
4
- import { PMO_TABLES } from '../schema.js';
4
+ import { eq, asc } from 'drizzle-orm';
5
+ import { pmoSubtasks, pmoTicketMetadata, pmoWorkflowStatuses, pmoTicketAcceptanceCriteria, } from '../../database/drizzle-schema.js';
5
6
  import { PMOError, normalizePriority, } from '../types.js';
6
7
  /**
7
8
  * Check if an error is a SQLite UNIQUE constraint violation.
@@ -61,31 +62,37 @@ export function wrapSqliteError(entityType, operation, err) {
61
62
  // Re-throw unknown errors
62
63
  throw err;
63
64
  }
64
- const T = PMO_TABLES;
65
65
  /**
66
66
  * Convert a database row to a Ticket object.
67
- * Fetches related data (subtasks, metadata, status info).
67
+ * Fetches related data (subtasks, metadata, status info) using Drizzle ORM.
68
68
  */
69
- export async function rowToTicket(db, row) {
69
+ export async function rowToTicket(drizzle, row) {
70
70
  // Get subtasks
71
- const subtasks = db
72
- .prepare(`SELECT * FROM ${T.subtasks} WHERE ticket_id = ? ORDER BY position`)
73
- .all(row.id);
71
+ const subtaskRows = drizzle
72
+ .select()
73
+ .from(pmoSubtasks)
74
+ .where(eq(pmoSubtasks.ticketId, row.id))
75
+ .orderBy(asc(pmoSubtasks.position))
76
+ .all();
74
77
  // Get metadata
75
- const metaRows = db
76
- .prepare(`SELECT key, value FROM ${T.ticket_metadata} WHERE ticket_id = ?`)
77
- .all(row.id);
78
+ const metaRows = drizzle
79
+ .select({ key: pmoTicketMetadata.key, value: pmoTicketMetadata.value })
80
+ .from(pmoTicketMetadata)
81
+ .where(eq(pmoTicketMetadata.ticketId, row.id))
82
+ .all();
78
83
  const metadata = {};
79
84
  for (const m of metaRows) {
80
- metadata[m.key] = m.value;
85
+ metadata[m.key] = m.value || '';
81
86
  }
82
87
  // Get status info from workflow_statuses
83
88
  let statusName;
84
89
  let statusCategory;
85
90
  if (row.status_id) {
86
- const statusRow = db
87
- .prepare(`SELECT name, category FROM ${T.workflow_statuses} WHERE id = ?`)
88
- .get(row.status_id);
91
+ const statusRow = drizzle
92
+ .select({ name: pmoWorkflowStatuses.name, category: pmoWorkflowStatuses.category })
93
+ .from(pmoWorkflowStatuses)
94
+ .where(eq(pmoWorkflowStatuses.id, row.status_id))
95
+ .get();
89
96
  if (statusRow) {
90
97
  statusName = statusRow.name;
91
98
  statusCategory = statusRow.category;
@@ -126,14 +133,14 @@ export async function rowToTicket(db, row) {
126
133
  branch: row.branch || undefined,
127
134
  specId: row.spec_id || undefined,
128
135
  epicId: row.epic_id || undefined,
129
- subtasks: subtasks.map((st) => ({
136
+ subtasks: subtaskRows.map((st) => ({
130
137
  id: st.id,
131
138
  title: st.title,
132
- done: st.done === 1,
139
+ done: st.done ?? false,
133
140
  })),
134
141
  labels,
135
142
  metadata,
136
- acceptanceCriteria: getAcceptanceCriteriaSync(db, row.id),
143
+ acceptanceCriteria: getAcceptanceCriteriaSync(drizzle, row.id),
137
144
  createdAt: new Date(row.created_at),
138
145
  updatedAt: new Date(row.updated_at),
139
146
  lastSyncedFromSpec: row.last_synced_from_spec
@@ -148,18 +155,21 @@ export async function rowToTicket(db, row) {
148
155
  /**
149
156
  * Get acceptance criteria for a ticket (sync version).
150
157
  */
151
- export function getAcceptanceCriteriaSync(db, ticketId) {
152
- const rows = db
153
- .prepare(`SELECT * FROM ${T.ticket_acceptance_criteria} WHERE ticket_id = ? ORDER BY position`)
154
- .all(ticketId);
158
+ export function getAcceptanceCriteriaSync(drizzle, ticketId) {
159
+ const rows = drizzle
160
+ .select()
161
+ .from(pmoTicketAcceptanceCriteria)
162
+ .where(eq(pmoTicketAcceptanceCriteria.ticketId, ticketId))
163
+ .orderBy(asc(pmoTicketAcceptanceCriteria.position))
164
+ .all();
155
165
  return rows.map((row) => ({
156
166
  id: row.id,
157
- ticketId: row.ticket_id,
167
+ ticketId: row.ticketId,
158
168
  criterion: row.criterion,
159
- verifiable: row.verifiable === 1,
160
- verified: row.verified === 1,
161
- verifiedAt: row.verified_at ? new Date(row.verified_at) : undefined,
162
- verifiedBy: row.verified_by || undefined,
169
+ verifiable: row.verifiable ?? true,
170
+ verified: row.verified ?? false,
171
+ verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : undefined,
172
+ verifiedBy: row.verifiedBy || undefined,
163
173
  position: row.position,
164
174
  }));
165
175
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Project operations.
3
3
  * Board columns are now derived from workflow statuses (single source of truth).
4
+ *
5
+ * This module uses Drizzle ORM for type-safe database queries.
4
6
  */
5
7
  import { Board, BoardConfig, Project, ProjectFilter } from '../types.js';
6
8
  import { StorageContext } from './types.js';
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Project operations.
3
3
  * Board columns are now derived from workflow statuses (single source of truth).
4
+ *
5
+ * This module uses Drizzle ORM for type-safe database queries.
4
6
  */
5
- import { PMO_TABLES } from '../schema.js';
7
+ import { eq, and, like, or, asc, desc, sql, count } from 'drizzle-orm';
8
+ import { pmoProjects, pmoTickets, pmoWorkflows, pmoWorkflowStatuses, } from '../../database/drizzle-schema.js';
6
9
  import { PMOError, } from '../types.js';
7
10
  import { generateEntityId, slugify } from '../utils.js';
8
11
  import { generateBoardMarkdown } from '../markdown.js';
9
12
  import { rowToTicket, wrapSqliteError } from './helpers.js';
10
- const T = PMO_TABLES;
11
13
  export class ProjectStorage {
12
14
  ctx;
13
15
  constructor(ctx) {
@@ -29,34 +31,42 @@ export class ProjectStorage {
29
31
  if (!identifier)
30
32
  return null;
31
33
  // 1. Exact ID match
32
- const exactMatch = this.ctx.db.prepare(`
33
- SELECT id FROM ${T.projects} WHERE id = ?
34
- `).get(identifier);
34
+ const exactMatch = this.ctx.drizzle
35
+ .select({ id: pmoProjects.id })
36
+ .from(pmoProjects)
37
+ .where(eq(pmoProjects.id, identifier))
38
+ .get();
35
39
  if (exactMatch)
36
40
  return exactMatch.id;
37
41
  // 2. Case-insensitive ID match
38
- const caseInsensitiveId = this.ctx.db.prepare(`
39
- SELECT id FROM ${T.projects} WHERE LOWER(id) = LOWER(?)
40
- `).get(identifier);
42
+ const caseInsensitiveId = this.ctx.drizzle
43
+ .select({ id: pmoProjects.id })
44
+ .from(pmoProjects)
45
+ .where(sql `LOWER(${pmoProjects.id}) = LOWER(${identifier})`)
46
+ .get();
41
47
  if (caseInsensitiveId)
42
48
  return caseInsensitiveId.id;
43
49
  // 3. Exact name match
44
- const nameMatch = this.ctx.db.prepare(`
45
- SELECT id FROM ${T.projects} WHERE name = ?
46
- `).get(identifier);
50
+ const nameMatch = this.ctx.drizzle
51
+ .select({ id: pmoProjects.id })
52
+ .from(pmoProjects)
53
+ .where(eq(pmoProjects.name, identifier))
54
+ .get();
47
55
  if (nameMatch)
48
56
  return nameMatch.id;
49
57
  // 4. Case-insensitive name match
50
- const caseInsensitiveName = this.ctx.db.prepare(`
51
- SELECT id FROM ${T.projects} WHERE LOWER(name) = LOWER(?)
52
- `).get(identifier);
58
+ const caseInsensitiveName = this.ctx.drizzle
59
+ .select({ id: pmoProjects.id })
60
+ .from(pmoProjects)
61
+ .where(sql `LOWER(${pmoProjects.name}) = LOWER(${identifier})`)
62
+ .get();
53
63
  if (caseInsensitiveName)
54
64
  return caseInsensitiveName.id;
55
65
  // 5. Slugified name match - check if identifier is a slug of any project name
56
- // Get all projects and compare slugified names
57
- const allProjects = this.ctx.db.prepare(`
58
- SELECT id, name FROM ${T.projects}
59
- `).all();
66
+ const allProjects = this.ctx.drizzle
67
+ .select({ id: pmoProjects.id, name: pmoProjects.name })
68
+ .from(pmoProjects)
69
+ .all();
60
70
  const identifierLower = identifier.toLowerCase();
61
71
  for (const project of allProjects) {
62
72
  const projectSlug = slugify(project.name);
@@ -72,12 +82,27 @@ export class ProjectStorage {
72
82
  */
73
83
  async init(projectId, config) {
74
84
  const projectName = config.name || 'Project Board';
75
- const now = Date.now();
85
+ const now = String(Date.now());
76
86
  // Create or update project with default workflow
77
- this.ctx.db.prepare(`
78
- INSERT OR REPLACE INTO ${T.projects} (id, name, template, workflow_id, updated_at)
79
- VALUES (?, ?, ?, 'default', ?)
80
- `).run(projectId, projectName, 'kanban', now);
87
+ this.ctx.drizzle
88
+ .insert(pmoProjects)
89
+ .values({
90
+ id: projectId,
91
+ name: projectName,
92
+ template: 'kanban',
93
+ workflowId: 'default',
94
+ updatedAt: now,
95
+ })
96
+ .onConflictDoUpdate({
97
+ target: pmoProjects.id,
98
+ set: {
99
+ name: projectName,
100
+ template: 'kanban',
101
+ workflowId: 'default',
102
+ updatedAt: now,
103
+ },
104
+ })
105
+ .run();
81
106
  return this.getBoard(projectId);
82
107
  }
83
108
  /**
@@ -94,19 +119,27 @@ export class ProjectStorage {
94
119
  throw new PMOError('NOT_FOUND', `Project not found: ${projectIdOrName}. Run init() first.`);
95
120
  }
96
121
  // Get project metadata with workflow using resolved ID
97
- const projectRow = this.ctx.db.prepare(`
98
- SELECT id, name, workflow_id, updated_at FROM ${T.projects} WHERE id = ?
99
- `).get(resolvedId);
122
+ const projectRow = this.ctx.drizzle
123
+ .select({
124
+ id: pmoProjects.id,
125
+ name: pmoProjects.name,
126
+ workflowId: pmoProjects.workflowId,
127
+ updatedAt: pmoProjects.updatedAt,
128
+ })
129
+ .from(pmoProjects)
130
+ .where(eq(pmoProjects.id, resolvedId))
131
+ .get();
100
132
  if (!projectRow) {
101
133
  throw new PMOError('NOT_FOUND', `Project not found: ${projectIdOrName}. Run init() first.`);
102
134
  }
103
135
  // Get workflow statuses as columns
104
- const workflowId = projectRow.workflow_id || 'default';
105
- const statusRows = this.ctx.db.prepare(`
106
- SELECT * FROM ${T.workflow_statuses}
107
- WHERE workflow_id = ?
108
- ORDER BY position
109
- `).all(workflowId);
136
+ const workflowId = projectRow.workflowId || 'default';
137
+ const statusRows = this.ctx.drizzle
138
+ .select()
139
+ .from(pmoWorkflowStatuses)
140
+ .where(eq(pmoWorkflowStatuses.workflowId, workflowId))
141
+ .orderBy(asc(pmoWorkflowStatuses.position))
142
+ .all();
110
143
  // Build columns from statuses, with tickets sorted by priority then created_at
111
144
  const columns = await Promise.all(statusRows.map(async (status) => ({
112
145
  id: status.id,
@@ -119,7 +152,7 @@ export class ProjectStorage {
119
152
  id: projectRow.id,
120
153
  name: projectRow.name,
121
154
  columns,
122
- updatedAt: new Date(projectRow.updated_at),
155
+ updatedAt: new Date(projectRow.updatedAt),
123
156
  };
124
157
  }
125
158
  /**
@@ -138,17 +171,38 @@ export class ProjectStorage {
138
171
  const workflowId = project.template || 'default';
139
172
  const now = Date.now();
140
173
  // Try to find a workflow with matching ID
141
- const workflow = this.ctx.db.prepare(`
142
- SELECT id FROM ${T.workflows} WHERE id = ?
143
- `).get(workflowId);
174
+ const workflow = this.ctx.drizzle
175
+ .select({ id: pmoWorkflows.id })
176
+ .from(pmoWorkflows)
177
+ .where(eq(pmoWorkflows.id, workflowId))
178
+ .get();
144
179
  // Use the requested workflow if it exists, otherwise fall back to default
145
180
  const finalWorkflowId = workflow ? workflowId : 'default';
146
181
  // Insert project with workflow
147
182
  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);
183
+ this.ctx.drizzle
184
+ .insert(pmoProjects)
185
+ .values({
186
+ id,
187
+ name: project.name,
188
+ template: workflowId,
189
+ description: project.description || null,
190
+ workflowId: finalWorkflowId,
191
+ createdAt: String(now),
192
+ updatedAt: String(now),
193
+ })
194
+ .onConflictDoUpdate({
195
+ target: pmoProjects.id,
196
+ set: {
197
+ name: project.name,
198
+ template: workflowId,
199
+ description: project.description || null,
200
+ workflowId: finalWorkflowId,
201
+ createdAt: String(now),
202
+ updatedAt: String(now),
203
+ },
204
+ })
205
+ .run();
152
206
  }
153
207
  catch (err) {
154
208
  wrapSqliteError('Project', 'create', err);
@@ -164,19 +218,29 @@ export class ProjectStorage {
164
218
  if (!resolvedId) {
165
219
  return null;
166
220
  }
167
- const projectRow = this.ctx.db.prepare(`
168
- SELECT id, name, template, description, workflow_id, updated_at FROM ${T.projects} WHERE id = ?
169
- `).get(resolvedId);
221
+ const projectRow = this.ctx.drizzle
222
+ .select({
223
+ id: pmoProjects.id,
224
+ name: pmoProjects.name,
225
+ template: pmoProjects.template,
226
+ description: pmoProjects.description,
227
+ workflowId: pmoProjects.workflowId,
228
+ updatedAt: pmoProjects.updatedAt,
229
+ })
230
+ .from(pmoProjects)
231
+ .where(eq(pmoProjects.id, resolvedId))
232
+ .get();
170
233
  if (!projectRow) {
171
234
  return null;
172
235
  }
173
236
  // Get workflow statuses as columns
174
- const workflowId = projectRow.workflow_id || 'default';
175
- const statusRows = this.ctx.db.prepare(`
176
- SELECT * FROM ${T.workflow_statuses}
177
- WHERE workflow_id = ?
178
- ORDER BY position
179
- `).all(workflowId);
237
+ const workflowId = projectRow.workflowId || 'default';
238
+ const statusRows = this.ctx.drizzle
239
+ .select()
240
+ .from(pmoWorkflowStatuses)
241
+ .where(eq(pmoWorkflowStatuses.workflowId, workflowId))
242
+ .orderBy(asc(pmoWorkflowStatuses.position))
243
+ .all();
180
244
  const columns = await Promise.all(statusRows.map(async (status) => ({
181
245
  id: status.id,
182
246
  name: status.name,
@@ -188,7 +252,7 @@ export class ProjectStorage {
188
252
  id: projectRow.id,
189
253
  name: projectRow.name,
190
254
  columns,
191
- updatedAt: new Date(projectRow.updated_at),
255
+ updatedAt: new Date(projectRow.updatedAt),
192
256
  };
193
257
  }
194
258
  /**
@@ -196,34 +260,60 @@ export class ProjectStorage {
196
260
  * Tickets are sorted by position (force-ranked) then created_at as tiebreaker.
197
261
  */
198
262
  async getTicketsForStatus(statusId, projectId) {
199
- const ticketRows = this.ctx.db.prepare(`
200
- SELECT t.*,
201
- ws.name as status_name,
202
- ws.category as status_category
203
- FROM ${T.tickets} t
204
- LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
205
- WHERE t.status_id = ? AND t.project_id = ?
206
- ORDER BY t.position ASC, t.created_at ASC
207
- `).all(statusId, projectId);
208
- return Promise.all(ticketRows.map((row) => rowToTicket(this.ctx.db, row)));
263
+ const ticketRows = this.ctx.drizzle
264
+ .select({
265
+ id: pmoTickets.id,
266
+ project_id: pmoTickets.projectId,
267
+ title: pmoTickets.title,
268
+ description: pmoTickets.description,
269
+ priority: pmoTickets.priority,
270
+ category: pmoTickets.category,
271
+ status_id: pmoTickets.statusId,
272
+ owner: pmoTickets.owner,
273
+ assignee: pmoTickets.assignee,
274
+ branch: pmoTickets.branch,
275
+ spec_id: pmoTickets.specId,
276
+ epic_id: pmoTickets.epicId,
277
+ labels: pmoTickets.labels,
278
+ position: pmoTickets.position,
279
+ created_at: pmoTickets.createdAt,
280
+ updated_at: pmoTickets.updatedAt,
281
+ last_synced_from_spec: pmoTickets.lastSyncedFromSpec,
282
+ last_synced_from_board: pmoTickets.lastSyncedFromBoard,
283
+ column_id: sql `NULL`,
284
+ column_name: sql `NULL`,
285
+ project_name: sql `NULL`,
286
+ })
287
+ .from(pmoTickets)
288
+ .leftJoin(pmoWorkflowStatuses, eq(pmoTickets.statusId, pmoWorkflowStatuses.id))
289
+ .where(and(eq(pmoTickets.statusId, statusId), eq(pmoTickets.projectId, projectId)))
290
+ .orderBy(asc(pmoTickets.position), asc(pmoTickets.createdAt))
291
+ .all();
292
+ return Promise.all(ticketRows.map((row) => rowToTicket(this.ctx.drizzle, row)));
209
293
  }
210
294
  /**
211
295
  * List project summaries.
212
296
  */
213
297
  async listProjectSummaries() {
214
- const projects = this.ctx.db.prepare(`
215
- SELECT p.*, COUNT(t.id) as ticket_count
216
- FROM ${T.projects} p
217
- LEFT JOIN ${T.tickets} t ON p.id = t.project_id
218
- GROUP BY p.id
219
- ORDER BY p.created_at
220
- `).all();
298
+ const projects = this.ctx.drizzle
299
+ .select({
300
+ id: pmoProjects.id,
301
+ name: pmoProjects.name,
302
+ template: pmoProjects.template,
303
+ description: pmoProjects.description,
304
+ ticketCount: count(pmoTickets.id),
305
+ })
306
+ .from(pmoProjects)
307
+ .leftJoin(pmoTickets, eq(pmoProjects.id, pmoTickets.projectId))
308
+ .groupBy(pmoProjects.id)
309
+ .orderBy(asc(pmoProjects.createdAt))
310
+ .all();
221
311
  return projects.map((p) => ({
222
312
  id: p.id,
223
313
  name: p.name,
224
314
  template: p.template,
225
315
  description: p.description,
226
- ticketCount: p.ticket_count,
316
+ ticketCount: p.ticketCount,
227
317
  }));
228
318
  }
229
319
  /**
@@ -239,7 +329,10 @@ export class ProjectStorage {
239
329
  throw new PMOError('INVALID', 'Cannot delete the default project');
240
330
  }
241
331
  try {
242
- const result = this.ctx.db.prepare(`DELETE FROM ${T.projects} WHERE id = ?`).run(resolvedId);
332
+ const result = this.ctx.drizzle
333
+ .delete(pmoProjects)
334
+ .where(eq(pmoProjects.id, resolvedId))
335
+ .run();
243
336
  if (result.changes === 0) {
244
337
  throw new PMOError('NOT_FOUND', `Project not found: ${projectIdOrName}`);
245
338
  }
@@ -259,7 +352,11 @@ export class ProjectStorage {
259
352
  const resolvedId = this.resolveProjectId(idOrName);
260
353
  if (!resolvedId)
261
354
  return null;
262
- const row = this.ctx.db.prepare(`SELECT * FROM ${T.projects} WHERE id = ?`).get(resolvedId);
355
+ const row = this.ctx.drizzle
356
+ .select()
357
+ .from(pmoProjects)
358
+ .where(eq(pmoProjects.id, resolvedId))
359
+ .get();
263
360
  if (!row)
264
361
  return null;
265
362
  return this.rowToProject(row);
@@ -274,68 +371,59 @@ export class ProjectStorage {
274
371
  }
275
372
  // Use the resolved ID for the update
276
373
  const resolvedId = existing.id;
277
- const updates = ['updated_at = ?'];
278
- const params = [Date.now()];
279
- if (changes.name !== undefined) {
280
- updates.push('name = ?');
281
- params.push(changes.name);
282
- }
283
- if (changes.description !== undefined) {
284
- updates.push('description = ?');
285
- params.push(changes.description || null);
286
- }
287
- if (changes.status !== undefined) {
288
- updates.push('status = ?');
289
- params.push(changes.status);
290
- }
291
- if (changes.phaseId !== undefined) {
292
- updates.push('phase_id = ?');
293
- params.push(changes.phaseId || null);
294
- }
295
- if (changes.workflowId !== undefined) {
296
- updates.push('workflow_id = ?');
297
- params.push(changes.workflowId || null);
298
- }
299
- if (changes.isArchived !== undefined) {
300
- updates.push('is_archived = ?');
301
- params.push(changes.isArchived ? 1 : 0);
302
- }
374
+ const updates = {
375
+ updatedAt: String(Date.now()),
376
+ };
377
+ if (changes.name !== undefined)
378
+ updates.name = changes.name;
379
+ if (changes.description !== undefined)
380
+ updates.description = changes.description || null;
381
+ if (changes.status !== undefined)
382
+ updates.status = changes.status;
383
+ if (changes.phaseId !== undefined)
384
+ updates.phaseId = changes.phaseId || null;
385
+ if (changes.workflowId !== undefined)
386
+ updates.workflowId = changes.workflowId || null;
387
+ if (changes.isArchived !== undefined)
388
+ updates.isArchived = changes.isArchived;
303
389
  if (changes.targetDate !== undefined) {
304
- updates.push('target_date = ?');
305
- params.push(changes.targetDate ? changes.targetDate.toISOString() : null);
390
+ updates.targetDate = changes.targetDate ? changes.targetDate.toISOString() : null;
306
391
  }
307
- params.push(resolvedId);
308
- this.ctx.db.prepare(`UPDATE ${T.projects} SET ${updates.join(', ')} WHERE id = ?`).run(...params);
392
+ this.ctx.drizzle
393
+ .update(pmoProjects)
394
+ .set(updates)
395
+ .where(eq(pmoProjects.id, resolvedId))
396
+ .run();
309
397
  return (await this.getProject(resolvedId));
310
398
  }
311
399
  /**
312
400
  * List projects with optional filter.
313
401
  */
314
402
  async listProjects(filter) {
315
- let sql = `SELECT * FROM ${T.projects}`;
403
+ let query = this.ctx.drizzle
404
+ .select()
405
+ .from(pmoProjects)
406
+ .$dynamic();
316
407
  const conditions = [];
317
- const params = [];
318
408
  // Filter by archived status if explicitly specified
319
409
  if (filter?.isArchived === true) {
320
- conditions.push('is_archived = 1');
410
+ conditions.push(eq(pmoProjects.isArchived, true));
321
411
  }
322
412
  else if (filter?.isArchived === false) {
323
- conditions.push('is_archived = 0');
413
+ conditions.push(eq(pmoProjects.isArchived, false));
324
414
  }
325
415
  if (filter?.phaseId) {
326
- conditions.push('phase_id = ?');
327
- params.push(filter.phaseId);
416
+ conditions.push(eq(pmoProjects.phaseId, filter.phaseId));
328
417
  }
329
418
  if (filter?.search) {
330
- conditions.push('(name LIKE ? OR description LIKE ?)');
331
- const searchTerm = `%${filter.search}%`;
332
- params.push(searchTerm, searchTerm);
419
+ conditions.push(or(like(pmoProjects.name, `%${filter.search}%`), like(pmoProjects.description, `%${filter.search}%`)));
333
420
  }
334
421
  if (conditions.length > 0) {
335
- sql += ' WHERE ' + conditions.join(' AND ');
422
+ query = query.where(and(...conditions));
336
423
  }
337
- sql += ' ORDER BY updated_at DESC';
338
- const rows = this.ctx.db.prepare(sql).all(...params);
424
+ const rows = query
425
+ .orderBy(desc(pmoProjects.updatedAt))
426
+ .all();
339
427
  return rows.map((row) => this.rowToProject(row));
340
428
  }
341
429
  /**
@@ -371,13 +459,13 @@ export class ProjectStorage {
371
459
  template: row.template || undefined,
372
460
  description: row.description || undefined,
373
461
  status: (row.status || 'active'),
374
- phaseId: row.phase_id || undefined,
375
- workflowId: row.workflow_id || undefined,
376
- isArchived: row.is_archived === 1,
377
- targetDate: row.target_date ? new Date(row.target_date) : undefined,
378
- initiativeId: row.initiative_id || undefined,
379
- createdAt: new Date(row.created_at),
380
- updatedAt: new Date(row.updated_at),
462
+ phaseId: row.phaseId || undefined,
463
+ workflowId: row.workflowId || undefined,
464
+ isArchived: row.isArchived ?? false,
465
+ targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
466
+ initiativeId: row.initiativeId || undefined,
467
+ createdAt: new Date(row.createdAt || Date.now()),
468
+ updatedAt: new Date(row.updatedAt || Date.now()),
381
469
  };
382
470
  }
383
471
  }
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Spec operations for PMO.
3
+ *
4
+ * This module uses Drizzle ORM for type-safe database queries.
3
5
  */
4
6
  import { Project, Spec, SpecFilter, Ticket } from '../types.js';
5
7
  import { StorageContext } from './types.js';