@proletariat/cli 0.3.36 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/branch/where.js +6 -17
  4. package/dist/commands/epic/ticket.js +7 -24
  5. package/dist/commands/execution/config.js +4 -14
  6. package/dist/commands/execution/logs.js +6 -0
  7. package/dist/commands/execution/view.js +8 -0
  8. package/dist/commands/init.js +4 -8
  9. package/dist/commands/mcp-server.js +2 -1
  10. package/dist/commands/pmo/init.js +12 -40
  11. package/dist/commands/qa/index.d.ts +54 -0
  12. package/dist/commands/qa/index.js +762 -0
  13. package/dist/commands/repo/view.js +2 -8
  14. package/dist/commands/session/attach.js +4 -4
  15. package/dist/commands/session/health.js +4 -4
  16. package/dist/commands/session/list.js +1 -19
  17. package/dist/commands/session/peek.js +6 -6
  18. package/dist/commands/session/poke.js +2 -2
  19. package/dist/commands/ticket/epic.js +17 -43
  20. package/dist/commands/work/spawn-all.js +1 -1
  21. package/dist/commands/work/spawn.js +15 -4
  22. package/dist/commands/work/start.js +17 -9
  23. package/dist/commands/work/watch.js +1 -1
  24. package/dist/commands/workspace/prune.js +3 -3
  25. package/dist/hooks/init.js +21 -10
  26. package/dist/lib/agents/commands.d.ts +5 -0
  27. package/dist/lib/agents/commands.js +143 -97
  28. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  29. package/dist/lib/database/drizzle-schema.js +53 -0
  30. package/dist/lib/database/index.d.ts +47 -1
  31. package/dist/lib/database/index.js +138 -20
  32. package/dist/lib/execution/runners.d.ts +34 -0
  33. package/dist/lib/execution/runners.js +134 -7
  34. package/dist/lib/execution/session-utils.d.ts +5 -0
  35. package/dist/lib/execution/session-utils.js +45 -3
  36. package/dist/lib/execution/spawner.js +15 -2
  37. package/dist/lib/execution/storage.d.ts +1 -1
  38. package/dist/lib/execution/storage.js +17 -2
  39. package/dist/lib/execution/types.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.d.ts +1 -0
  41. package/dist/lib/mcp/tools/index.js +1 -0
  42. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  43. package/dist/lib/mcp/tools/tmux.js +182 -0
  44. package/dist/lib/mcp/tools/work.js +52 -0
  45. package/dist/lib/pmo/schema.d.ts +1 -1
  46. package/dist/lib/pmo/schema.js +1 -0
  47. package/dist/lib/pmo/storage/base.js +207 -0
  48. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  49. package/dist/lib/pmo/storage/dependencies.js +11 -3
  50. package/dist/lib/pmo/storage/epics.js +1 -1
  51. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  52. package/dist/lib/pmo/storage/helpers.js +36 -26
  53. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  54. package/dist/lib/pmo/storage/projects.js +207 -119
  55. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  56. package/dist/lib/pmo/storage/specs.js +274 -188
  57. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  58. package/dist/lib/pmo/storage/tickets.js +350 -290
  59. package/dist/lib/pmo/storage/views.d.ts +2 -0
  60. package/dist/lib/pmo/storage/views.js +183 -130
  61. package/dist/lib/prompt-json.d.ts +5 -0
  62. package/dist/lib/prompt-json.js +9 -0
  63. package/oclif.manifest.json +3293 -3190
  64. package/package.json +11 -6
  65. package/LICENSE +0 -190
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Spec operations for PMO.
3
+ *
4
+ * This module uses Drizzle ORM for type-safe database queries.
3
5
  */
4
- import { PMO_TABLES } from '../schema.js';
6
+ import { eq, and, like, or, asc, sql } from 'drizzle-orm';
7
+ import { pmoSpecs, pmoProjects, pmoTickets, pmoWorkflowStatuses, pmoProjectSpecs, pmoSpecDependencies, } from '../../database/drizzle-schema.js';
5
8
  import { PMOError } from '../types.js';
6
9
  import { generateEntityId } from '../utils.js';
7
10
  import { rowToSpec, rowToTicket, wrapSqliteError } from './helpers.js';
8
- const T = PMO_TABLES;
9
11
  export class SpecStorage {
10
12
  ctx;
11
13
  constructor(ctx) {
@@ -18,16 +20,25 @@ export class SpecStorage {
18
20
  const id = spec.id || generateEntityId(this.ctx.db, 'spec');
19
21
  const now = Date.now();
20
22
  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);
23
+ this.ctx.drizzle.insert(pmoSpecs).values({
24
+ id,
25
+ title: spec.title || 'Untitled Spec',
26
+ status: spec.status || 'draft',
27
+ type: spec.type || null,
28
+ tags: spec.tags ? JSON.stringify(spec.tags) : null,
29
+ problem: spec.problem || null,
30
+ solution: spec.solution || null,
31
+ decisions: spec.decisions || null,
32
+ notNow: spec.notNow || null,
33
+ uiUx: spec.uiUx || null,
34
+ acceptanceCriteria: spec.acceptanceCriteria || null,
35
+ openQuestions: spec.openQuestions || null,
36
+ requirementsFunctional: spec.requirementsFunctional || null,
37
+ requirementsTechnical: spec.requirementsTechnical || null,
38
+ context: spec.context || null,
39
+ createdAt: String(now),
40
+ updatedAt: String(now),
41
+ }).run();
31
42
  }
32
43
  catch (err) {
33
44
  wrapSqliteError('Spec', 'create', err);
@@ -58,14 +69,29 @@ export class SpecStorage {
58
69
  * Get a spec by ID.
59
70
  */
60
71
  async getSpec(id) {
61
- const row = this.ctx.db.prepare(`
62
- SELECT id, title, status, type, tags,
63
- problem, solution, decisions, not_now, ui_ux,
64
- acceptance_criteria, open_questions,
65
- requirements_functional, requirements_technical,
66
- context, created_at, updated_at
67
- FROM ${T.specs} WHERE id = ?
68
- `).get(id);
72
+ const row = this.ctx.drizzle
73
+ .select({
74
+ id: pmoSpecs.id,
75
+ title: pmoSpecs.title,
76
+ status: pmoSpecs.status,
77
+ type: pmoSpecs.type,
78
+ tags: pmoSpecs.tags,
79
+ problem: pmoSpecs.problem,
80
+ solution: pmoSpecs.solution,
81
+ decisions: pmoSpecs.decisions,
82
+ not_now: pmoSpecs.notNow,
83
+ ui_ux: pmoSpecs.uiUx,
84
+ acceptance_criteria: pmoSpecs.acceptanceCriteria,
85
+ open_questions: pmoSpecs.openQuestions,
86
+ requirements_functional: pmoSpecs.requirementsFunctional,
87
+ requirements_technical: pmoSpecs.requirementsTechnical,
88
+ context: pmoSpecs.context,
89
+ created_at: pmoSpecs.createdAt,
90
+ updated_at: pmoSpecs.updatedAt,
91
+ })
92
+ .from(pmoSpecs)
93
+ .where(eq(pmoSpecs.id, id))
94
+ .get();
69
95
  if (!row)
70
96
  return null;
71
97
  return rowToSpec(row);
@@ -74,30 +100,45 @@ export class SpecStorage {
74
100
  * List specs with optional filters.
75
101
  */
76
102
  async listSpecs(filter) {
77
- let query = `
78
- SELECT id, title, status, type, tags,
79
- problem, solution, decisions, not_now, ui_ux,
80
- acceptance_criteria, open_questions,
81
- requirements_functional, requirements_technical,
82
- context, created_at, updated_at
83
- FROM ${T.specs} WHERE 1=1
84
- `;
85
- const params = [];
103
+ let query = this.ctx.drizzle
104
+ .select({
105
+ id: pmoSpecs.id,
106
+ title: pmoSpecs.title,
107
+ status: pmoSpecs.status,
108
+ type: pmoSpecs.type,
109
+ tags: pmoSpecs.tags,
110
+ problem: pmoSpecs.problem,
111
+ solution: pmoSpecs.solution,
112
+ decisions: pmoSpecs.decisions,
113
+ not_now: pmoSpecs.notNow,
114
+ ui_ux: pmoSpecs.uiUx,
115
+ acceptance_criteria: pmoSpecs.acceptanceCriteria,
116
+ open_questions: pmoSpecs.openQuestions,
117
+ requirements_functional: pmoSpecs.requirementsFunctional,
118
+ requirements_technical: pmoSpecs.requirementsTechnical,
119
+ context: pmoSpecs.context,
120
+ created_at: pmoSpecs.createdAt,
121
+ updated_at: pmoSpecs.updatedAt,
122
+ })
123
+ .from(pmoSpecs)
124
+ .$dynamic();
125
+ const conditions = [];
86
126
  if (filter?.status) {
87
- query += ' AND status = ?';
88
- params.push(filter.status);
127
+ conditions.push(eq(pmoSpecs.status, filter.status));
89
128
  }
90
129
  if (filter?.type) {
91
- query += ' AND type = ?';
92
- params.push(filter.type);
130
+ conditions.push(eq(pmoSpecs.type, filter.type));
93
131
  }
94
132
  if (filter?.search) {
95
- query += ' AND (id LIKE ? OR title LIKE ? OR problem LIKE ? OR solution LIKE ?)';
96
- params.push(`%${filter.search}%`, `%${filter.search}%`, `%${filter.search}%`, `%${filter.search}%`);
133
+ conditions.push(or(like(pmoSpecs.id, `%${filter.search}%`), like(pmoSpecs.title, `%${filter.search}%`), like(pmoSpecs.problem, `%${filter.search}%`), like(pmoSpecs.solution, `%${filter.search}%`)));
97
134
  }
98
- query += ' ORDER BY title';
99
- const rows = this.ctx.db.prepare(query).all(...params);
100
- return rows.map(rowToSpec);
135
+ if (conditions.length > 0) {
136
+ query = query.where(and(...conditions));
137
+ }
138
+ const rows = query
139
+ .orderBy(asc(pmoSpecs.title))
140
+ .all();
141
+ return rows.map((row) => rowToSpec(row));
101
142
  }
102
143
  /**
103
144
  * Update a spec.
@@ -107,69 +148,42 @@ export class SpecStorage {
107
148
  if (!existing) {
108
149
  throw new PMOError('NOT_FOUND', `Spec not found: ${id}`);
109
150
  }
110
- const updates = [];
111
- const params = [];
112
- if (changes.title !== undefined) {
113
- updates.push('title = ?');
114
- params.push(changes.title);
115
- }
116
- if (changes.status !== undefined) {
117
- updates.push('status = ?');
118
- params.push(changes.status);
119
- }
120
- if (changes.type !== undefined) {
121
- updates.push('type = ?');
122
- params.push(changes.type);
123
- }
124
- if (changes.tags !== undefined) {
125
- updates.push('tags = ?');
126
- params.push(JSON.stringify(changes.tags));
127
- }
128
- if (changes.problem !== undefined) {
129
- updates.push('problem = ?');
130
- params.push(changes.problem);
131
- }
132
- if (changes.solution !== undefined) {
133
- updates.push('solution = ?');
134
- params.push(changes.solution);
135
- }
136
- if (changes.decisions !== undefined) {
137
- updates.push('decisions = ?');
138
- params.push(changes.decisions);
139
- }
140
- if (changes.notNow !== undefined) {
141
- updates.push('not_now = ?');
142
- params.push(changes.notNow);
143
- }
144
- if (changes.uiUx !== undefined) {
145
- updates.push('ui_ux = ?');
146
- params.push(changes.uiUx);
147
- }
148
- if (changes.acceptanceCriteria !== undefined) {
149
- updates.push('acceptance_criteria = ?');
150
- params.push(changes.acceptanceCriteria);
151
- }
152
- if (changes.openQuestions !== undefined) {
153
- updates.push('open_questions = ?');
154
- params.push(changes.openQuestions);
155
- }
156
- if (changes.requirementsFunctional !== undefined) {
157
- updates.push('requirements_functional = ?');
158
- params.push(changes.requirementsFunctional);
159
- }
160
- if (changes.requirementsTechnical !== undefined) {
161
- updates.push('requirements_technical = ?');
162
- params.push(changes.requirementsTechnical);
163
- }
164
- if (changes.context !== undefined) {
165
- updates.push('context = ?');
166
- params.push(changes.context);
167
- }
168
- if (updates.length > 0) {
169
- updates.push('updated_at = ?');
170
- params.push(Date.now());
171
- params.push(id);
172
- this.ctx.db.prepare(`UPDATE ${T.specs} SET ${updates.join(', ')} WHERE id = ?`).run(...params);
151
+ const updates = {};
152
+ if (changes.title !== undefined)
153
+ updates.title = changes.title;
154
+ if (changes.status !== undefined)
155
+ updates.status = changes.status;
156
+ if (changes.type !== undefined)
157
+ updates.type = changes.type;
158
+ if (changes.tags !== undefined)
159
+ updates.tags = JSON.stringify(changes.tags);
160
+ if (changes.problem !== undefined)
161
+ updates.problem = changes.problem;
162
+ if (changes.solution !== undefined)
163
+ updates.solution = changes.solution;
164
+ if (changes.decisions !== undefined)
165
+ updates.decisions = changes.decisions;
166
+ if (changes.notNow !== undefined)
167
+ updates.notNow = changes.notNow;
168
+ if (changes.uiUx !== undefined)
169
+ updates.uiUx = changes.uiUx;
170
+ if (changes.acceptanceCriteria !== undefined)
171
+ updates.acceptanceCriteria = changes.acceptanceCriteria;
172
+ if (changes.openQuestions !== undefined)
173
+ updates.openQuestions = changes.openQuestions;
174
+ if (changes.requirementsFunctional !== undefined)
175
+ updates.requirementsFunctional = changes.requirementsFunctional;
176
+ if (changes.requirementsTechnical !== undefined)
177
+ updates.requirementsTechnical = changes.requirementsTechnical;
178
+ if (changes.context !== undefined)
179
+ updates.context = changes.context;
180
+ if (Object.keys(updates).length > 0) {
181
+ updates.updatedAt = String(Date.now());
182
+ this.ctx.drizzle
183
+ .update(pmoSpecs)
184
+ .set(updates)
185
+ .where(eq(pmoSpecs.id, id))
186
+ .run();
173
187
  }
174
188
  return (await this.getSpec(id));
175
189
  }
@@ -182,8 +196,12 @@ export class SpecStorage {
182
196
  throw new PMOError('NOT_FOUND', `Spec not found: ${id}`);
183
197
  }
184
198
  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);
199
+ this.ctx.drizzle.delete(pmoSpecs).where(eq(pmoSpecs.id, id)).run();
200
+ this.ctx.drizzle
201
+ .update(pmoTickets)
202
+ .set({ specId: null })
203
+ .where(eq(pmoTickets.specId, id))
204
+ .run();
187
205
  }
188
206
  catch (err) {
189
207
  wrapSqliteError('Spec', 'delete', err);
@@ -194,9 +212,11 @@ export class SpecStorage {
194
212
  */
195
213
  async linkTicketToSpec(ticketId, specId) {
196
214
  // Verify ticket exists and get its project
197
- const ticket = this.ctx.db.prepare(`
198
- SELECT id, project_id FROM ${T.tickets} WHERE id = ?
199
- `).get(ticketId);
215
+ const ticket = this.ctx.drizzle
216
+ .select({ id: pmoTickets.id, projectId: pmoTickets.projectId })
217
+ .from(pmoTickets)
218
+ .where(eq(pmoTickets.id, ticketId))
219
+ .get();
200
220
  if (!ticket) {
201
221
  throw new PMOError('NOT_FOUND', `Ticket not found: ${ticketId}`, ticketId);
202
222
  }
@@ -205,57 +225,80 @@ export class SpecStorage {
205
225
  if (!spec) {
206
226
  throw new PMOError('NOT_FOUND', `Spec not found: ${specId}`);
207
227
  }
208
- this.ctx.db.prepare(`
209
- UPDATE ${T.tickets}
210
- SET spec_id = ?, updated_at = ?
211
- WHERE id = ?
212
- `).run(specId, Date.now(), ticketId);
213
- this.ctx.updateBoardTimestamp(ticket.project_id);
228
+ this.ctx.drizzle
229
+ .update(pmoTickets)
230
+ .set({ specId, updatedAt: String(Date.now()) })
231
+ .where(eq(pmoTickets.id, ticketId))
232
+ .run();
233
+ this.ctx.updateBoardTimestamp(ticket.projectId);
214
234
  }
215
235
  /**
216
236
  * Unlink a ticket from a spec.
217
237
  */
218
238
  async unlinkTicketFromSpec(ticketId, specId) {
219
239
  // Get ticket's project for board timestamp update
220
- const ticket = this.ctx.db.prepare(`
221
- SELECT project_id FROM ${T.tickets} WHERE id = ?
222
- `).get(ticketId);
223
- this.ctx.db.prepare(`
224
- UPDATE ${T.tickets}
225
- SET spec_id = NULL, updated_at = ?
226
- WHERE id = ? AND spec_id = ?
227
- `).run(Date.now(), ticketId, specId);
240
+ const ticket = this.ctx.drizzle
241
+ .select({ projectId: pmoTickets.projectId })
242
+ .from(pmoTickets)
243
+ .where(eq(pmoTickets.id, ticketId))
244
+ .get();
245
+ this.ctx.drizzle
246
+ .update(pmoTickets)
247
+ .set({ specId: null, updatedAt: String(Date.now()) })
248
+ .where(and(eq(pmoTickets.id, ticketId), eq(pmoTickets.specId, specId)))
249
+ .run();
228
250
  if (ticket) {
229
- this.ctx.updateBoardTimestamp(ticket.project_id);
251
+ this.ctx.updateBoardTimestamp(ticket.projectId);
230
252
  }
231
253
  }
232
254
  /**
233
255
  * Get tickets for a spec.
234
256
  */
235
257
  async getTicketsForSpec(projectId, specId) {
236
- const rows = this.ctx.db.prepare(`
237
- SELECT t.*,
238
- ws.id as column_id,
239
- t.position as position,
240
- ws.name as column_name
241
- FROM ${T.tickets} t
242
- LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
243
- WHERE t.project_id = ? AND t.spec_id = ?
244
- ORDER BY ws.position, t.position ASC, t.created_at ASC
245
- `).all(projectId, specId);
246
- return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
258
+ const rows = this.ctx.drizzle
259
+ .select({
260
+ id: pmoTickets.id,
261
+ project_id: pmoTickets.projectId,
262
+ title: pmoTickets.title,
263
+ description: pmoTickets.description,
264
+ priority: pmoTickets.priority,
265
+ category: pmoTickets.category,
266
+ status_id: pmoTickets.statusId,
267
+ owner: pmoTickets.owner,
268
+ assignee: pmoTickets.assignee,
269
+ branch: pmoTickets.branch,
270
+ spec_id: pmoTickets.specId,
271
+ epic_id: pmoTickets.epicId,
272
+ labels: pmoTickets.labels,
273
+ position: pmoTickets.position,
274
+ created_at: pmoTickets.createdAt,
275
+ updated_at: pmoTickets.updatedAt,
276
+ last_synced_from_spec: pmoTickets.lastSyncedFromSpec,
277
+ last_synced_from_board: pmoTickets.lastSyncedFromBoard,
278
+ column_id: pmoWorkflowStatuses.id,
279
+ column_name: pmoWorkflowStatuses.name,
280
+ project_name: sql `NULL`,
281
+ })
282
+ .from(pmoTickets)
283
+ .leftJoin(pmoWorkflowStatuses, eq(pmoTickets.statusId, pmoWorkflowStatuses.id))
284
+ .where(and(eq(pmoTickets.projectId, projectId), eq(pmoTickets.specId, specId)))
285
+ .orderBy(asc(pmoWorkflowStatuses.position), asc(pmoTickets.position), asc(pmoTickets.createdAt))
286
+ .all();
287
+ return Promise.all(rows.map((row) => rowToTicket(this.ctx.drizzle, row)));
247
288
  }
248
289
  /**
249
290
  * Get specs for a ticket.
250
291
  */
251
292
  async getSpecsForTicket(ticketId) {
252
- const ticket = this.ctx.db.prepare(`
253
- SELECT spec_id FROM ${T.tickets} WHERE id = ?
254
- `).get(ticketId);
255
- if (!ticket || !ticket.spec_id) {
293
+ const ticket = this.ctx.drizzle
294
+ .select({ specId: pmoTickets.specId })
295
+ .from(pmoTickets)
296
+ .where(eq(pmoTickets.id, ticketId))
297
+ .get();
298
+ if (!ticket || !ticket.specId) {
256
299
  return [];
257
300
  }
258
- const spec = await this.getSpec(ticket.spec_id);
301
+ const spec = await this.getSpec(ticket.specId);
259
302
  return spec ? [spec] : [];
260
303
  }
261
304
  /**
@@ -270,31 +313,38 @@ export class SpecStorage {
270
313
  if (!dependsOnSpec) {
271
314
  throw new PMOError('NOT_FOUND', `Spec not found: ${dependsOnId}`);
272
315
  }
273
- this.ctx.db.prepare(`
274
- INSERT OR IGNORE INTO ${T.spec_dependencies} (spec_id, depends_on_spec_id, created_at)
275
- VALUES (?, ?, ?)
276
- `).run(specId, dependsOnId, Date.now());
316
+ this.ctx.drizzle
317
+ .insert(pmoSpecDependencies)
318
+ .values({
319
+ specId,
320
+ dependsOnSpecId: dependsOnId,
321
+ createdAt: String(Date.now()),
322
+ })
323
+ .onConflictDoNothing()
324
+ .run();
277
325
  }
278
326
  /**
279
327
  * Remove a spec dependency.
280
328
  */
281
329
  async removeSpecDependency(specId, dependsOnId) {
282
- this.ctx.db.prepare(`
283
- DELETE FROM ${T.spec_dependencies}
284
- WHERE spec_id = ? AND depends_on_spec_id = ?
285
- `).run(specId, dependsOnId);
330
+ this.ctx.drizzle
331
+ .delete(pmoSpecDependencies)
332
+ .where(and(eq(pmoSpecDependencies.specId, specId), eq(pmoSpecDependencies.dependsOnSpecId, dependsOnId)))
333
+ .run();
286
334
  }
287
335
  /**
288
336
  * Get dependencies for a spec.
289
337
  */
290
338
  async getSpecDependencies(specId) {
291
- const rows = this.ctx.db.prepare(`
292
- SELECT depends_on_spec_id FROM ${T.spec_dependencies} WHERE spec_id = ?
293
- `).all(specId);
339
+ const rows = this.ctx.drizzle
340
+ .select({ dependsOnSpecId: pmoSpecDependencies.dependsOnSpecId })
341
+ .from(pmoSpecDependencies)
342
+ .where(eq(pmoSpecDependencies.specId, specId))
343
+ .all();
294
344
  const specs = [];
295
345
  for (const row of rows) {
296
346
  // eslint-disable-next-line no-await-in-loop -- Sequential lookup for relationship chain
297
- const spec = await this.getSpec(row.depends_on_spec_id);
347
+ const spec = await this.getSpec(row.dependsOnSpecId);
298
348
  if (spec)
299
349
  specs.push(spec);
300
350
  }
@@ -304,13 +354,15 @@ export class SpecStorage {
304
354
  * Get specs that depend on a spec.
305
355
  */
306
356
  async getSpecDependents(specId) {
307
- const rows = this.ctx.db.prepare(`
308
- SELECT spec_id FROM ${T.spec_dependencies} WHERE depends_on_spec_id = ?
309
- `).all(specId);
357
+ const rows = this.ctx.drizzle
358
+ .select({ specId: pmoSpecDependencies.specId })
359
+ .from(pmoSpecDependencies)
360
+ .where(eq(pmoSpecDependencies.dependsOnSpecId, specId))
361
+ .all();
310
362
  const specs = [];
311
363
  for (const row of rows) {
312
364
  // eslint-disable-next-line no-await-in-loop -- Sequential lookup for relationship chain
313
- const spec = await this.getSpec(row.spec_id);
365
+ const spec = await this.getSpec(row.specId);
314
366
  if (spec)
315
367
  specs.push(spec);
316
368
  }
@@ -321,9 +373,11 @@ export class SpecStorage {
321
373
  */
322
374
  async linkProjectToSpec(projectId, specId) {
323
375
  // Verify project exists
324
- const project = this.ctx.db.prepare(`
325
- SELECT id FROM ${T.projects} WHERE id = ?
326
- `).get(projectId);
376
+ const project = this.ctx.drizzle
377
+ .select({ id: pmoProjects.id })
378
+ .from(pmoProjects)
379
+ .where(eq(pmoProjects.id, projectId))
380
+ .get();
327
381
  if (!project) {
328
382
  throw new PMOError('NOT_FOUND', `Project "${projectId}" not found`);
329
383
  }
@@ -332,59 +386,91 @@ export class SpecStorage {
332
386
  if (!spec) {
333
387
  throw new PMOError('NOT_FOUND', `Spec "${specId}" not found`);
334
388
  }
335
- this.ctx.db.prepare(`
336
- INSERT OR IGNORE INTO ${T.project_specs} (project_id, spec_id, created_at)
337
- VALUES (?, ?, ?)
338
- `).run(projectId, specId, Date.now());
389
+ this.ctx.drizzle
390
+ .insert(pmoProjectSpecs)
391
+ .values({
392
+ projectId,
393
+ specId,
394
+ createdAt: String(Date.now()),
395
+ })
396
+ .onConflictDoNothing()
397
+ .run();
339
398
  }
340
399
  /**
341
400
  * Unlink a project from a spec.
342
401
  */
343
402
  async unlinkProjectFromSpec(projectId, specId) {
344
- this.ctx.db.prepare(`
345
- DELETE FROM ${T.project_specs}
346
- WHERE project_id = ? AND spec_id = ?
347
- `).run(projectId, specId);
403
+ this.ctx.drizzle
404
+ .delete(pmoProjectSpecs)
405
+ .where(and(eq(pmoProjectSpecs.projectId, projectId), eq(pmoProjectSpecs.specId, specId)))
406
+ .run();
348
407
  }
349
408
  /**
350
409
  * Get specs for a project.
351
410
  */
352
411
  async getSpecsForProject(projectId) {
353
- const rows = this.ctx.db.prepare(`
354
- SELECT s.id, s.title, s.status, s.type, s.tags,
355
- s.problem, s.solution, s.decisions, s.not_now, s.ui_ux,
356
- s.acceptance_criteria, s.open_questions,
357
- s.requirements_functional, s.requirements_technical,
358
- s.context, s.created_at, s.updated_at
359
- FROM ${T.specs} s
360
- JOIN ${T.project_specs} ps ON s.id = ps.spec_id
361
- WHERE ps.project_id = ?
362
- ORDER BY s.title
363
- `).all(projectId);
364
- return rows.map(rowToSpec);
412
+ const rows = this.ctx.drizzle
413
+ .select({
414
+ id: pmoSpecs.id,
415
+ title: pmoSpecs.title,
416
+ status: pmoSpecs.status,
417
+ type: pmoSpecs.type,
418
+ tags: pmoSpecs.tags,
419
+ problem: pmoSpecs.problem,
420
+ solution: pmoSpecs.solution,
421
+ decisions: pmoSpecs.decisions,
422
+ not_now: pmoSpecs.notNow,
423
+ ui_ux: pmoSpecs.uiUx,
424
+ acceptance_criteria: pmoSpecs.acceptanceCriteria,
425
+ open_questions: pmoSpecs.openQuestions,
426
+ requirements_functional: pmoSpecs.requirementsFunctional,
427
+ requirements_technical: pmoSpecs.requirementsTechnical,
428
+ context: pmoSpecs.context,
429
+ created_at: pmoSpecs.createdAt,
430
+ updated_at: pmoSpecs.updatedAt,
431
+ })
432
+ .from(pmoSpecs)
433
+ .innerJoin(pmoProjectSpecs, eq(pmoSpecs.id, pmoProjectSpecs.specId))
434
+ .where(eq(pmoProjectSpecs.projectId, projectId))
435
+ .orderBy(asc(pmoSpecs.title))
436
+ .all();
437
+ return rows.map((row) => rowToSpec(row));
365
438
  }
366
439
  /**
367
440
  * Get projects for a spec.
368
441
  */
369
442
  async getProjectsForSpec(specId) {
370
- const rows = this.ctx.db.prepare(`
371
- SELECT p.*
372
- FROM ${T.projects} p
373
- JOIN ${T.project_specs} ps ON p.id = ps.project_id
374
- WHERE ps.spec_id = ?
375
- ORDER BY p.name
376
- `).all(specId);
443
+ const rows = this.ctx.drizzle
444
+ .select({
445
+ id: pmoProjects.id,
446
+ name: pmoProjects.name,
447
+ template: pmoProjects.template,
448
+ description: pmoProjects.description,
449
+ status: pmoProjects.status,
450
+ phaseId: pmoProjects.phaseId,
451
+ workflowId: pmoProjects.workflowId,
452
+ isArchived: pmoProjects.isArchived,
453
+ targetDate: pmoProjects.targetDate,
454
+ initiativeId: pmoProjects.initiativeId,
455
+ createdAt: pmoProjects.createdAt,
456
+ updatedAt: pmoProjects.updatedAt,
457
+ })
458
+ .from(pmoProjects)
459
+ .innerJoin(pmoProjectSpecs, eq(pmoProjects.id, pmoProjectSpecs.projectId))
460
+ .where(eq(pmoProjectSpecs.specId, specId))
461
+ .orderBy(asc(pmoProjects.name))
462
+ .all();
377
463
  return rows.map((row) => ({
378
464
  id: row.id,
379
465
  name: row.name,
380
466
  template: row.template || undefined,
381
467
  description: row.description || undefined,
382
468
  status: (row.status || 'active'),
383
- phaseId: row.phase_id || undefined,
384
- isArchived: row.is_archived === 1,
385
- targetDate: row.target_date ? new Date(row.target_date) : undefined,
386
- createdAt: new Date(row.created_at),
387
- updatedAt: new Date(row.updated_at),
469
+ phaseId: row.phaseId || undefined,
470
+ isArchived: row.isArchived ?? false,
471
+ targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
472
+ createdAt: new Date(row.createdAt || Date.now()),
473
+ updatedAt: new Date(row.updatedAt || Date.now()),
388
474
  }));
389
475
  }
390
476
  }
@@ -3,6 +3,8 @@
3
3
  * Tickets reference workflow statuses directly via status_id.
4
4
  * Tickets have a position column for force-ranked ordering within a status.
5
5
  * Positions use gapped integers (1000, 2000, 3000...) for stable reordering.
6
+ *
7
+ * This module uses Drizzle ORM for type-safe database queries.
6
8
  */
7
9
  import { CreateTicketInput, Ticket, TicketFilter } from '../types.js';
8
10
  import { StorageContext } from './types.js';