@allpepper/task-orchestrator 0.1.0

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 (42) hide show
  1. package/README.md +15 -0
  2. package/package.json +51 -0
  3. package/src/db/client.ts +34 -0
  4. package/src/db/index.ts +1 -0
  5. package/src/db/migrate.ts +51 -0
  6. package/src/db/migrations/001_initial_schema.sql +160 -0
  7. package/src/domain/index.ts +1 -0
  8. package/src/domain/types.ts +225 -0
  9. package/src/index.ts +7 -0
  10. package/src/repos/base.ts +151 -0
  11. package/src/repos/dependencies.ts +356 -0
  12. package/src/repos/features.ts +507 -0
  13. package/src/repos/index.ts +4 -0
  14. package/src/repos/projects.ts +350 -0
  15. package/src/repos/sections.ts +505 -0
  16. package/src/repos/tags.example.ts +125 -0
  17. package/src/repos/tags.ts +175 -0
  18. package/src/repos/tasks.ts +581 -0
  19. package/src/repos/templates.ts +649 -0
  20. package/src/server.ts +121 -0
  21. package/src/services/index.ts +2 -0
  22. package/src/services/status-validator.ts +100 -0
  23. package/src/services/workflow.ts +104 -0
  24. package/src/tools/apply-template.ts +129 -0
  25. package/src/tools/get-blocked-tasks.ts +63 -0
  26. package/src/tools/get-next-status.ts +183 -0
  27. package/src/tools/get-next-task.ts +75 -0
  28. package/src/tools/get-tag-usage.ts +54 -0
  29. package/src/tools/index.ts +30 -0
  30. package/src/tools/list-tags.ts +56 -0
  31. package/src/tools/manage-container.ts +333 -0
  32. package/src/tools/manage-dependency.ts +198 -0
  33. package/src/tools/manage-sections.ts +388 -0
  34. package/src/tools/manage-template.ts +313 -0
  35. package/src/tools/query-container.ts +296 -0
  36. package/src/tools/query-dependencies.ts +68 -0
  37. package/src/tools/query-sections.ts +70 -0
  38. package/src/tools/query-templates.ts +137 -0
  39. package/src/tools/query-workflow-state.ts +198 -0
  40. package/src/tools/registry.ts +180 -0
  41. package/src/tools/rename-tag.ts +64 -0
  42. package/src/tools/setup-project.ts +189 -0
@@ -0,0 +1,505 @@
1
+ import {
2
+ db,
3
+ generateId,
4
+ now,
5
+ queryOne,
6
+ queryAll,
7
+ execute,
8
+ ok,
9
+ err,
10
+ toDate,
11
+ } from './base';
12
+ import type { Result, Section } from '../domain/types';
13
+ import { ContentFormat, EntityType, NotFoundError, ValidationError } from '../domain/types';
14
+
15
+ // ============================================================================
16
+ // Type Definitions
17
+ // ============================================================================
18
+
19
+ interface SectionRow {
20
+ id: string;
21
+ entity_type: string;
22
+ entity_id: string;
23
+ title: string;
24
+ usage_description: string;
25
+ content: string;
26
+ content_format: string;
27
+ ordinal: number;
28
+ tags: string;
29
+ version: number;
30
+ created_at: string;
31
+ modified_at: string;
32
+ }
33
+
34
+ interface AddSectionParams {
35
+ entityType: string;
36
+ entityId: string;
37
+ title: string;
38
+ usageDescription: string;
39
+ content: string;
40
+ contentFormat?: string;
41
+ ordinal?: number;
42
+ tags?: string;
43
+ }
44
+
45
+ interface GetSectionsParams {
46
+ includeContent?: boolean;
47
+ tags?: string;
48
+ sectionIds?: string[];
49
+ }
50
+
51
+ interface UpdateSectionParams {
52
+ title?: string;
53
+ usageDescription?: string;
54
+ content?: string;
55
+ contentFormat?: string;
56
+ tags?: string;
57
+ version: number;
58
+ }
59
+
60
+ interface BulkCreateSectionParams {
61
+ entityType: string;
62
+ entityId: string;
63
+ title: string;
64
+ usageDescription: string;
65
+ content: string;
66
+ contentFormat?: string;
67
+ tags?: string;
68
+ }
69
+
70
+ // ============================================================================
71
+ // Mapper Functions
72
+ // ============================================================================
73
+
74
+ function rowToSection(row: SectionRow): Section {
75
+ return {
76
+ id: row.id,
77
+ entityType: row.entity_type as EntityType,
78
+ entityId: row.entity_id,
79
+ title: row.title,
80
+ usageDescription: row.usage_description,
81
+ content: row.content,
82
+ contentFormat: row.content_format as ContentFormat,
83
+ ordinal: row.ordinal,
84
+ tags: row.tags,
85
+ version: row.version,
86
+ createdAt: toDate(row.created_at),
87
+ modifiedAt: toDate(row.modified_at),
88
+ };
89
+ }
90
+
91
+ // ============================================================================
92
+ // Helper Functions
93
+ // ============================================================================
94
+
95
+ function getNextOrdinal(entityId: string, entityType: string): number {
96
+ const result = queryOne<{ max_ordinal: number | null }>(
97
+ 'SELECT MAX(ordinal) as max_ordinal FROM sections WHERE entity_id = ? AND entity_type = ?',
98
+ [entityId, entityType]
99
+ );
100
+ return (result?.max_ordinal ?? -1) + 1;
101
+ }
102
+
103
+ function validateContentFormat(format: string): boolean {
104
+ return Object.values(ContentFormat).includes(format as ContentFormat);
105
+ }
106
+
107
+ // ============================================================================
108
+ // Repository Functions
109
+ // ============================================================================
110
+
111
+ /**
112
+ * Add a new section to an entity
113
+ */
114
+ export function addSection(params: AddSectionParams): Result<Section> {
115
+ try {
116
+ // Validate content format if provided
117
+ const contentFormat = params.contentFormat ?? ContentFormat.MARKDOWN;
118
+ if (!validateContentFormat(contentFormat)) {
119
+ return err(`Invalid content format: ${contentFormat}`);
120
+ }
121
+
122
+ // Get ordinal
123
+ const ordinal = params.ordinal ?? getNextOrdinal(params.entityId, params.entityType);
124
+
125
+ // Check for ordinal conflict
126
+ const existing = queryOne<{ id: string }>(
127
+ 'SELECT id FROM sections WHERE entity_type = ? AND entity_id = ? AND ordinal = ?',
128
+ [params.entityType, params.entityId, ordinal]
129
+ );
130
+
131
+ if (existing) {
132
+ return err(`Section with ordinal ${ordinal} already exists for this entity`, 'CONFLICT');
133
+ }
134
+
135
+ const id = generateId();
136
+ const timestamp = now();
137
+ const tags = params.tags ?? '';
138
+
139
+ execute(
140
+ `INSERT INTO sections (
141
+ id, entity_type, entity_id, title, usage_description,
142
+ content, content_format, ordinal, tags, version,
143
+ created_at, modified_at
144
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
145
+ [
146
+ id,
147
+ params.entityType,
148
+ params.entityId,
149
+ params.title,
150
+ params.usageDescription,
151
+ params.content,
152
+ contentFormat,
153
+ ordinal,
154
+ tags,
155
+ timestamp,
156
+ timestamp,
157
+ ]
158
+ );
159
+
160
+ const row = queryOne<SectionRow>('SELECT * FROM sections WHERE id = ?', [id]);
161
+ if (!row) {
162
+ return err('Failed to create section');
163
+ }
164
+
165
+ return ok(rowToSection(row));
166
+ } catch (error) {
167
+ return err(`Failed to add section: ${error}`);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get sections for an entity
173
+ */
174
+ export function getSections(
175
+ entityId: string,
176
+ entityType: string,
177
+ params?: GetSectionsParams
178
+ ): Result<Section[]> {
179
+ try {
180
+ let sql = 'SELECT * FROM sections WHERE entity_id = ? AND entity_type = ?';
181
+ const sqlParams: any[] = [entityId, entityType];
182
+
183
+ // Filter by tags if provided
184
+ if (params?.tags) {
185
+ const tagList = params.tags.split(',').map(t => t.trim());
186
+ const tagConditions = tagList.map(() => 'tags LIKE ?').join(' OR ');
187
+ sql += ` AND (${tagConditions})`;
188
+ tagList.forEach(tag => sqlParams.push(`%${tag}%`));
189
+ }
190
+
191
+ // Filter by section IDs if provided
192
+ if (params?.sectionIds && params.sectionIds.length > 0) {
193
+ const placeholders = params.sectionIds.map(() => '?').join(',');
194
+ sql += ` AND id IN (${placeholders})`;
195
+ sqlParams.push(...params.sectionIds);
196
+ }
197
+
198
+ sql += ' ORDER BY ordinal ASC';
199
+
200
+ const rows = queryAll<SectionRow>(sql, sqlParams);
201
+
202
+ // If includeContent is false, clear content field for token savings
203
+ if (params?.includeContent === false) {
204
+ const sections = rows.map(row => rowToSection({ ...row, content: '' }));
205
+ return ok(sections);
206
+ }
207
+
208
+ const sections = rows.map(rowToSection);
209
+ return ok(sections);
210
+ } catch (error) {
211
+ return err(`Failed to get sections: ${error}`);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Update a section
217
+ */
218
+ export function updateSection(id: string, params: UpdateSectionParams): Result<Section> {
219
+ try {
220
+ // Validate version
221
+ const existing = queryOne<SectionRow>('SELECT * FROM sections WHERE id = ?', [id]);
222
+ if (!existing) {
223
+ return err(`Section not found: ${id}`, 'NOT_FOUND');
224
+ }
225
+
226
+ if (existing.version !== params.version) {
227
+ return err(
228
+ `Version mismatch: expected ${existing.version}, got ${params.version}`,
229
+ 'VERSION_CONFLICT'
230
+ );
231
+ }
232
+
233
+ // Validate content format if provided
234
+ if (params.contentFormat && !validateContentFormat(params.contentFormat)) {
235
+ return err(`Invalid content format: ${params.contentFormat}`);
236
+ }
237
+
238
+ // Build update query dynamically
239
+ const updates: string[] = [];
240
+ const sqlParams: any[] = [];
241
+
242
+ if (params.title !== undefined) {
243
+ updates.push('title = ?');
244
+ sqlParams.push(params.title);
245
+ }
246
+ if (params.usageDescription !== undefined) {
247
+ updates.push('usage_description = ?');
248
+ sqlParams.push(params.usageDescription);
249
+ }
250
+ if (params.content !== undefined) {
251
+ updates.push('content = ?');
252
+ sqlParams.push(params.content);
253
+ }
254
+ if (params.contentFormat !== undefined) {
255
+ updates.push('content_format = ?');
256
+ sqlParams.push(params.contentFormat);
257
+ }
258
+ if (params.tags !== undefined) {
259
+ updates.push('tags = ?');
260
+ sqlParams.push(params.tags);
261
+ }
262
+
263
+ if (updates.length === 0) {
264
+ return err('No fields to update');
265
+ }
266
+
267
+ // Always update version and modified_at
268
+ updates.push('version = version + 1');
269
+ updates.push('modified_at = ?');
270
+ sqlParams.push(now());
271
+
272
+ // Add id for WHERE clause
273
+ sqlParams.push(id);
274
+
275
+ const sql = `UPDATE sections SET ${updates.join(', ')} WHERE id = ?`;
276
+ const changes = execute(sql, sqlParams);
277
+
278
+ if (changes === 0) {
279
+ return err(`Section not found: ${id}`, 'NOT_FOUND');
280
+ }
281
+
282
+ const updated = queryOne<SectionRow>('SELECT * FROM sections WHERE id = ?', [id]);
283
+ if (!updated) {
284
+ return err('Failed to retrieve updated section');
285
+ }
286
+
287
+ return ok(rowToSection(updated));
288
+ } catch (error) {
289
+ return err(`Failed to update section: ${error}`);
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Update only the text content of a section
295
+ */
296
+ export function updateSectionText(id: string, content: string, version: number): Result<Section> {
297
+ try {
298
+ // Validate version
299
+ const existing = queryOne<SectionRow>('SELECT * FROM sections WHERE id = ?', [id]);
300
+ if (!existing) {
301
+ return err(`Section not found: ${id}`, 'NOT_FOUND');
302
+ }
303
+
304
+ if (existing.version !== version) {
305
+ return err(
306
+ `Version mismatch: expected ${existing.version}, got ${version}`,
307
+ 'VERSION_CONFLICT'
308
+ );
309
+ }
310
+
311
+ const changes = execute(
312
+ 'UPDATE sections SET content = ?, version = version + 1, modified_at = ? WHERE id = ?',
313
+ [content, now(), id]
314
+ );
315
+
316
+ if (changes === 0) {
317
+ return err(`Section not found: ${id}`, 'NOT_FOUND');
318
+ }
319
+
320
+ const updated = queryOne<SectionRow>('SELECT * FROM sections WHERE id = ?', [id]);
321
+ if (!updated) {
322
+ return err('Failed to retrieve updated section');
323
+ }
324
+
325
+ return ok(rowToSection(updated));
326
+ } catch (error) {
327
+ return err(`Failed to update section text: ${error}`);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Delete a section
333
+ */
334
+ export function deleteSection(id: string): Result<boolean> {
335
+ try {
336
+ // Check if section exists
337
+ const existing = queryOne<{ id: string }>('SELECT id FROM sections WHERE id = ?', [id]);
338
+ if (!existing) {
339
+ return err(`Section not found: ${id}`, 'NOT_FOUND');
340
+ }
341
+
342
+ const changes = execute('DELETE FROM sections WHERE id = ?', [id]);
343
+ return ok(changes > 0);
344
+ } catch (error) {
345
+ return err(`Failed to delete section: ${error}`);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Reorder sections for an entity
351
+ *
352
+ * Uses a two-phase approach to avoid UNIQUE constraint violations:
353
+ * 1. First, set all ordinals to temporary negative values
354
+ * 2. Then update them to their final values
355
+ */
356
+ export function reorderSections(
357
+ entityId: string,
358
+ entityType: string,
359
+ orderedIds: string[]
360
+ ): Result<boolean> {
361
+ try {
362
+ // Use transaction for atomic updates
363
+ db.run('BEGIN TRANSACTION');
364
+
365
+ try {
366
+ const timestamp = now();
367
+
368
+ // Phase 1: Set all sections to temporary negative ordinals to avoid UNIQUE constraint conflicts
369
+ for (let i = 0; i < orderedIds.length; i++) {
370
+ const changes = execute(
371
+ 'UPDATE sections SET ordinal = ?, modified_at = ? WHERE id = ? AND entity_id = ? AND entity_type = ?',
372
+ [-(i + 1), timestamp, orderedIds[i], entityId, entityType]
373
+ );
374
+
375
+ if (changes === 0) {
376
+ throw new Error(`Section not found or does not belong to entity: ${orderedIds[i]}`);
377
+ }
378
+ }
379
+
380
+ // Phase 2: Set final ordinal values
381
+ for (let i = 0; i < orderedIds.length; i++) {
382
+ execute(
383
+ 'UPDATE sections SET ordinal = ? WHERE id = ? AND entity_id = ? AND entity_type = ?',
384
+ [i, orderedIds[i], entityId, entityType]
385
+ );
386
+ }
387
+
388
+ db.run('COMMIT');
389
+ return ok(true);
390
+ } catch (error) {
391
+ db.run('ROLLBACK');
392
+ throw error;
393
+ }
394
+ } catch (error) {
395
+ return err(`Failed to reorder sections: ${error}`);
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Bulk create sections
401
+ */
402
+ export function bulkCreateSections(
403
+ sections: BulkCreateSectionParams[]
404
+ ): Result<Section[]> {
405
+ try {
406
+ if (sections.length === 0) {
407
+ return ok([]);
408
+ }
409
+
410
+ // Use transaction for atomic bulk insert
411
+ db.run('BEGIN TRANSACTION');
412
+
413
+ try {
414
+ const created: Section[] = [];
415
+
416
+ // Group by entity to get proper ordinals
417
+ const entityMap = new Map<string, number>();
418
+
419
+ for (const section of sections) {
420
+ const entityKey = `${section.entityType}:${section.entityId}`;
421
+
422
+ // Get or calculate starting ordinal for this entity
423
+ if (!entityMap.has(entityKey)) {
424
+ const startOrdinal = getNextOrdinal(section.entityId, section.entityType);
425
+ entityMap.set(entityKey, startOrdinal);
426
+ }
427
+
428
+ const ordinal = entityMap.get(entityKey)!;
429
+ entityMap.set(entityKey, ordinal + 1);
430
+
431
+ const contentFormat = section.contentFormat ?? ContentFormat.MARKDOWN;
432
+ if (!validateContentFormat(contentFormat)) {
433
+ throw new Error(`Invalid content format: ${contentFormat}`);
434
+ }
435
+
436
+ const id = generateId();
437
+ const timestamp = now();
438
+ const tags = section.tags ?? '';
439
+
440
+ execute(
441
+ `INSERT INTO sections (
442
+ id, entity_type, entity_id, title, usage_description,
443
+ content, content_format, ordinal, tags, version,
444
+ created_at, modified_at
445
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
446
+ [
447
+ id,
448
+ section.entityType,
449
+ section.entityId,
450
+ section.title,
451
+ section.usageDescription,
452
+ section.content,
453
+ contentFormat,
454
+ ordinal,
455
+ tags,
456
+ timestamp,
457
+ timestamp,
458
+ ]
459
+ );
460
+
461
+ const row = queryOne<SectionRow>('SELECT * FROM sections WHERE id = ?', [id]);
462
+ if (!row) {
463
+ throw new Error('Failed to retrieve created section');
464
+ }
465
+
466
+ created.push(rowToSection(row));
467
+ }
468
+
469
+ db.run('COMMIT');
470
+ return ok(created);
471
+ } catch (error) {
472
+ db.run('ROLLBACK');
473
+ throw error;
474
+ }
475
+ } catch (error) {
476
+ return err(`Failed to bulk create sections: ${error}`);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Bulk delete sections
482
+ */
483
+ export function bulkDeleteSections(ids: string[]): Result<number> {
484
+ try {
485
+ if (ids.length === 0) {
486
+ return ok(0);
487
+ }
488
+
489
+ // Use transaction for atomic bulk delete
490
+ db.run('BEGIN TRANSACTION');
491
+
492
+ try {
493
+ const placeholders = ids.map(() => '?').join(',');
494
+ const changes = execute(`DELETE FROM sections WHERE id IN (${placeholders})`, ids);
495
+
496
+ db.run('COMMIT');
497
+ return ok(changes);
498
+ } catch (error) {
499
+ db.run('ROLLBACK');
500
+ throw error;
501
+ }
502
+ } catch (error) {
503
+ return err(`Failed to bulk delete sections: ${error}`);
504
+ }
505
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Example usage of the tags repository
3
+ *
4
+ * Run with: bun run src/repos/tags.example.ts
5
+ */
6
+
7
+ import { runMigrations } from '../db/migrate';
8
+ import { db, generateId, now } from '../db/client';
9
+ import { listTags, getTagUsage, renameTag } from './tags';
10
+
11
+ // Initialize the database
12
+ runMigrations();
13
+
14
+ // Clean up any existing test data
15
+ db.run('DELETE FROM entity_tags');
16
+
17
+ console.log('=== Tags Repository Examples ===\n');
18
+
19
+ // Example 1: Add some tags
20
+ console.log('1. Adding tags to entities...');
21
+ const projectId = generateId();
22
+ const featureId = generateId();
23
+ const taskId = generateId();
24
+
25
+ db.run(
26
+ 'INSERT INTO entity_tags (id, entity_id, entity_type, tag, created_at) VALUES (?, ?, ?, ?, ?)',
27
+ [generateId(), projectId, 'PROJECT', 'backend', now()]
28
+ );
29
+ db.run(
30
+ 'INSERT INTO entity_tags (id, entity_id, entity_type, tag, created_at) VALUES (?, ?, ?, ?, ?)',
31
+ [generateId(), projectId, 'PROJECT', 'api', now()]
32
+ );
33
+ db.run(
34
+ 'INSERT INTO entity_tags (id, entity_id, entity_type, tag, created_at) VALUES (?, ?, ?, ?, ?)',
35
+ [generateId(), featureId, 'FEATURE', 'authentication', now()]
36
+ );
37
+ db.run(
38
+ 'INSERT INTO entity_tags (id, entity_id, entity_type, tag, created_at) VALUES (?, ?, ?, ?, ?)',
39
+ [generateId(), featureId, 'FEATURE', 'api', now()]
40
+ );
41
+ db.run(
42
+ 'INSERT INTO entity_tags (id, entity_id, entity_type, tag, created_at) VALUES (?, ?, ?, ?, ?)',
43
+ [generateId(), taskId, 'TASK', 'bugfix', now()]
44
+ );
45
+ console.log('Added 5 tags across 3 entities\n');
46
+
47
+ // Example 2: List all tags
48
+ console.log('2. Listing all tags with counts...');
49
+ const allTags = listTags();
50
+ if (allTags.success) {
51
+ console.table(allTags.data);
52
+ } else {
53
+ console.error('Error:', allTags.error);
54
+ }
55
+
56
+ // Example 3: List tags filtered by entity type
57
+ console.log('\n3. Listing tags for PROJECT entities only...');
58
+ const projectTags = listTags({ entityType: 'PROJECT' });
59
+ if (projectTags.success) {
60
+ console.table(projectTags.data);
61
+ } else {
62
+ console.error('Error:', projectTags.error);
63
+ }
64
+
65
+ // Example 4: Get usage of a specific tag
66
+ console.log('\n4. Getting usage of "api" tag...');
67
+ const apiUsage = getTagUsage('api');
68
+ if (apiUsage.success) {
69
+ console.table(apiUsage.data);
70
+ } else {
71
+ console.error('Error:', apiUsage.error);
72
+ }
73
+
74
+ // Example 5: Rename a tag (dry run)
75
+ console.log('\n5. Dry run: Renaming "api" to "rest-api"...');
76
+ const dryRunResult = renameTag('api', 'rest-api', { dryRun: true });
77
+ if (dryRunResult.success) {
78
+ console.log(`Would affect ${dryRunResult.data.affected} entities:`);
79
+ console.table(dryRunResult.data.entities);
80
+ } else {
81
+ console.error('Error:', dryRunResult.error);
82
+ }
83
+
84
+ // Example 6: Rename a tag (actual)
85
+ console.log('\n6. Actually renaming "api" to "rest-api"...');
86
+ const renameResult = renameTag('api', 'rest-api');
87
+ if (renameResult.success) {
88
+ console.log(`Renamed tag on ${renameResult.data.affected} entities`);
89
+ } else {
90
+ console.error('Error:', renameResult.error);
91
+ }
92
+
93
+ // Example 7: Verify the rename
94
+ console.log('\n7. Verifying the rename...');
95
+ const afterRename = listTags();
96
+ if (afterRename.success) {
97
+ console.table(afterRename.data);
98
+ } else {
99
+ console.error('Error:', afterRename.error);
100
+ }
101
+
102
+ // Example 8: Handle conflict scenario
103
+ console.log('\n8. Testing conflict handling...');
104
+ db.run(
105
+ 'INSERT INTO entity_tags (id, entity_id, entity_type, tag, created_at) VALUES (?, ?, ?, ?, ?)',
106
+ [generateId(), projectId, 'PROJECT', 'server', now()]
107
+ );
108
+ console.log('Added "server" tag to project that has "backend"');
109
+
110
+ const conflictRename = renameTag('backend', 'server');
111
+ if (conflictRename.success) {
112
+ console.log(`Handled conflict, affected ${conflictRename.data.affected} entities`);
113
+ console.log('(Deleted old tag instead of creating duplicate)');
114
+ } else {
115
+ console.error('Error:', conflictRename.error);
116
+ }
117
+
118
+ // Verify no duplicates
119
+ const finalTags = db
120
+ .query('SELECT * FROM entity_tags WHERE entity_id = ? AND entity_type = ?')
121
+ .all(projectId, 'PROJECT') as any[];
122
+ console.log(`\nProject now has ${finalTags.length} tags (no duplicates):`);
123
+ console.table(finalTags.map(t => ({ tag: t.tag })));
124
+
125
+ console.log('\n=== Examples Complete ===');