@cliangdev/flux-plugin 0.0.0-dev.cbdf207 → 0.0.0-dev.df3e9bb

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 (86) hide show
  1. package/README.md +8 -4
  2. package/bin/install.cjs +150 -16
  3. package/package.json +7 -11
  4. package/src/__tests__/version.test.ts +37 -0
  5. package/src/adapters/local/.gitkeep +0 -0
  6. package/src/server/__tests__/config.test.ts +163 -0
  7. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  8. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  9. package/src/server/adapters/__tests__/dependency-ops.test.ts +395 -0
  10. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  11. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  12. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  13. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  14. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  15. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  16. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  17. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  18. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  19. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  20. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  21. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  22. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  23. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  24. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  25. package/src/server/adapters/factory.ts +90 -0
  26. package/src/server/adapters/index.ts +9 -0
  27. package/src/server/adapters/linear/adapter.ts +1136 -0
  28. package/src/server/adapters/linear/client.ts +169 -0
  29. package/src/server/adapters/linear/config.ts +152 -0
  30. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  31. package/src/server/adapters/linear/helpers/index.ts +7 -0
  32. package/src/server/adapters/linear/index.ts +16 -0
  33. package/src/server/adapters/linear/mappers/description.ts +136 -0
  34. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  35. package/src/server/adapters/linear/mappers/index.ts +27 -0
  36. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  37. package/src/server/adapters/linear/mappers/task.ts +82 -0
  38. package/src/server/adapters/linear/types.ts +264 -0
  39. package/src/server/adapters/local-adapter.ts +968 -0
  40. package/src/server/adapters/types.ts +293 -0
  41. package/src/server/config.ts +73 -0
  42. package/src/server/db/__tests__/queries.test.ts +472 -0
  43. package/src/server/db/ids.ts +17 -0
  44. package/src/server/db/index.ts +69 -0
  45. package/src/server/db/queries.ts +142 -0
  46. package/src/server/db/refs.ts +60 -0
  47. package/src/server/db/schema.ts +88 -0
  48. package/src/server/db/sqlite.ts +10 -0
  49. package/src/server/index.ts +83 -0
  50. package/src/server/tools/__tests__/crud.test.ts +301 -0
  51. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  52. package/src/server/tools/__tests__/mcp-interface.test.ts +388 -0
  53. package/src/server/tools/__tests__/query.test.ts +353 -0
  54. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  55. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  56. package/src/server/tools/configure-linear.ts +373 -0
  57. package/src/server/tools/create-epic.ts +35 -0
  58. package/src/server/tools/create-prd.ts +31 -0
  59. package/src/server/tools/create-task.ts +38 -0
  60. package/src/server/tools/criteria.ts +50 -0
  61. package/src/server/tools/delete-entity.ts +76 -0
  62. package/src/server/tools/dependencies.ts +55 -0
  63. package/src/server/tools/get-entity.ts +238 -0
  64. package/src/server/tools/get-linear-url.ts +28 -0
  65. package/src/server/tools/get-project-context.ts +33 -0
  66. package/src/server/tools/get-stats.ts +52 -0
  67. package/src/server/tools/get-version.ts +20 -0
  68. package/src/server/tools/index.ts +114 -0
  69. package/src/server/tools/init-project.ts +108 -0
  70. package/src/server/tools/query-entities.ts +167 -0
  71. package/src/server/tools/render-status.ts +201 -0
  72. package/src/server/tools/update-entity.ts +140 -0
  73. package/src/server/tools/update-status.ts +166 -0
  74. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  75. package/src/server/utils/logger.ts +9 -0
  76. package/src/server/utils/mcp-response.ts +254 -0
  77. package/src/server/utils/status-transitions.ts +160 -0
  78. package/src/status-line/__tests__/status-line.test.ts +215 -0
  79. package/src/status-line/index.ts +147 -0
  80. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  81. package/src/utils/__tests__/display.test.ts +97 -0
  82. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  83. package/src/utils/display.ts +62 -0
  84. package/src/utils/status-renderer.ts +188 -0
  85. package/src/version.ts +5 -0
  86. package/dist/server/index.js +0 -87063
@@ -0,0 +1,968 @@
1
+ /**
2
+ * LocalAdapter - SQLite + filesystem implementation of BackendAdapter
3
+ *
4
+ * Wraps the existing db layer to provide the BackendAdapter interface.
5
+ * This is the default adapter for local development.
6
+ */
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { config } from "../config.js";
17
+ import {
18
+ count,
19
+ findAll,
20
+ findById,
21
+ findByRef,
22
+ generateId,
23
+ generateRef,
24
+ getDb,
25
+ insert,
26
+ remove,
27
+ update,
28
+ } from "../db/index.js";
29
+ import type {
30
+ AcceptanceCriterion,
31
+ AddCriterionInput,
32
+ BackendAdapter,
33
+ CascadeResult,
34
+ CreateEpicInput,
35
+ CreatePrdInput,
36
+ CreateTaskInput,
37
+ Document,
38
+ Epic,
39
+ EpicFilters,
40
+ PaginatedResult,
41
+ PaginationOptions,
42
+ Prd,
43
+ PrdFilters,
44
+ Stats,
45
+ Task,
46
+ TaskFilters,
47
+ UpdateEpicInput,
48
+ UpdatePrdInput,
49
+ UpdateTaskInput,
50
+ } from "./types.js";
51
+
52
+ // =============================================================================
53
+ // Database Row Types (snake_case from SQLite)
54
+ // =============================================================================
55
+
56
+ interface PrdRow {
57
+ id: string;
58
+ project_id: string;
59
+ ref: string;
60
+ title: string;
61
+ description: string | null;
62
+ status: string;
63
+ tag: string | null;
64
+ folder_path: string | null;
65
+ created_at: string;
66
+ updated_at: string;
67
+ }
68
+
69
+ interface EpicRow {
70
+ id: string;
71
+ prd_id: string;
72
+ ref: string;
73
+ title: string;
74
+ description: string | null;
75
+ status: string;
76
+ created_at: string;
77
+ updated_at: string;
78
+ }
79
+
80
+ interface TaskRow {
81
+ id: string;
82
+ epic_id: string;
83
+ ref: string;
84
+ title: string;
85
+ description: string | null;
86
+ status: string;
87
+ priority: string;
88
+ created_at: string;
89
+ updated_at: string;
90
+ }
91
+
92
+ interface CriterionRow {
93
+ id: string;
94
+ parent_type: string;
95
+ parent_id: string;
96
+ criteria: string;
97
+ is_met: number;
98
+ created_at: string;
99
+ }
100
+
101
+ interface ProjectRow {
102
+ id: string;
103
+ ref_prefix: string;
104
+ }
105
+
106
+ // =============================================================================
107
+ // Helper Functions
108
+ // =============================================================================
109
+
110
+ function toPrd(row: PrdRow): Prd {
111
+ return {
112
+ id: row.id,
113
+ projectId: row.project_id,
114
+ ref: row.ref,
115
+ title: row.title,
116
+ description: row.description ?? undefined,
117
+ status: row.status as Prd["status"],
118
+ tag: row.tag ?? undefined,
119
+ folderPath: row.folder_path ?? undefined,
120
+ createdAt: row.created_at,
121
+ updatedAt: row.updated_at,
122
+ };
123
+ }
124
+
125
+ function toEpic(row: EpicRow): Epic {
126
+ return {
127
+ id: row.id,
128
+ prdId: row.prd_id,
129
+ ref: row.ref,
130
+ title: row.title,
131
+ description: row.description ?? undefined,
132
+ status: row.status as Epic["status"],
133
+ createdAt: row.created_at,
134
+ updatedAt: row.updated_at,
135
+ };
136
+ }
137
+
138
+ function toTask(row: TaskRow): Task {
139
+ return {
140
+ id: row.id,
141
+ epicId: row.epic_id,
142
+ ref: row.ref,
143
+ title: row.title,
144
+ description: row.description ?? undefined,
145
+ status: row.status as Task["status"],
146
+ priority: row.priority as Task["priority"],
147
+ createdAt: row.created_at,
148
+ updatedAt: row.updated_at,
149
+ };
150
+ }
151
+
152
+ function toCriterion(row: CriterionRow): AcceptanceCriterion {
153
+ return {
154
+ id: row.id,
155
+ parentType: row.parent_type as "epic" | "task",
156
+ parentId: row.parent_id,
157
+ criteria: row.criteria,
158
+ isMet: Boolean(row.is_met),
159
+ createdAt: row.created_at,
160
+ };
161
+ }
162
+
163
+ function getEntityType(ref: string): "P" | "E" | "T" {
164
+ const match = ref.match(/-([PET])\d+$/);
165
+ if (!match) {
166
+ throw new Error(`Invalid reference format: ${ref}`);
167
+ }
168
+ return match[1] as "P" | "E" | "T";
169
+ }
170
+
171
+ function getProject(): ProjectRow {
172
+ const db = getDb();
173
+ let project = db
174
+ .query("SELECT * FROM projects LIMIT 1")
175
+ .get() as ProjectRow | null;
176
+
177
+ if (!project) {
178
+ // Create default project if none exists
179
+ const projectId = generateId("proj");
180
+ const prefix = getProjectPrefix();
181
+ insert(db, "projects", {
182
+ id: projectId,
183
+ name: "default",
184
+ ref_prefix: prefix,
185
+ });
186
+ project = { id: projectId, ref_prefix: prefix };
187
+ }
188
+
189
+ return project;
190
+ }
191
+
192
+ function getProjectPrefix(): string {
193
+ const projectJsonPath = config.projectJsonPath;
194
+ if (existsSync(projectJsonPath)) {
195
+ try {
196
+ const content = readFileSync(projectJsonPath, "utf-8");
197
+ const project = JSON.parse(content) as { ref_prefix?: string };
198
+ return project.ref_prefix || "FLUX";
199
+ } catch {
200
+ return "FLUX";
201
+ }
202
+ }
203
+ return "FLUX";
204
+ }
205
+
206
+ // =============================================================================
207
+ // LocalAdapter Implementation
208
+ // =============================================================================
209
+
210
+ export class LocalAdapter implements BackendAdapter {
211
+ // ---------------------------------------------------------------------------
212
+ // PRD Operations
213
+ // ---------------------------------------------------------------------------
214
+
215
+ async createPrd(input: CreatePrdInput): Promise<Prd> {
216
+ const db = getDb();
217
+ const project = getProject();
218
+
219
+ const id = generateId("prd");
220
+ const ref = generateRef(db, "P", project.ref_prefix);
221
+
222
+ insert(db, "prds", {
223
+ id,
224
+ project_id: project.id,
225
+ ref,
226
+ title: input.title,
227
+ description: input.description ?? null,
228
+ status: "DRAFT",
229
+ tag: input.tag ?? null,
230
+ folder_path: input.folderPath ?? null,
231
+ });
232
+
233
+ const row = findById<PrdRow>(db, "prds", id);
234
+ if (!row) throw new Error("Failed to create PRD");
235
+ return toPrd(row);
236
+ }
237
+
238
+ async updatePrd(ref: string, input: UpdatePrdInput): Promise<Prd> {
239
+ const db = getDb();
240
+ const existing = findByRef<PrdRow>(db, "prds", ref);
241
+ if (!existing) throw new Error(`PRD not found: ${ref}`);
242
+
243
+ const updates: Record<string, unknown> = {};
244
+ if (input.title !== undefined) updates.title = input.title;
245
+ if (input.description !== undefined)
246
+ updates.description = input.description;
247
+ if (input.status !== undefined) updates.status = input.status;
248
+ if (input.tag !== undefined) updates.tag = input.tag;
249
+ if (input.folderPath !== undefined) updates.folder_path = input.folderPath;
250
+
251
+ if (Object.keys(updates).length > 0) {
252
+ update(db, "prds", existing.id, updates);
253
+ }
254
+
255
+ const row = findById<PrdRow>(db, "prds", existing.id);
256
+ if (!row) throw new Error("Failed to update PRD");
257
+ return toPrd(row);
258
+ }
259
+
260
+ async getPrd(ref: string): Promise<Prd | null> {
261
+ const db = getDb();
262
+ const row = findByRef<PrdRow>(db, "prds", ref);
263
+ return row ? toPrd(row) : null;
264
+ }
265
+
266
+ async listPrds(
267
+ filters?: PrdFilters,
268
+ pagination?: PaginationOptions,
269
+ ): Promise<PaginatedResult<Prd>> {
270
+ const db = getDb();
271
+ const where: Record<string, unknown> = {};
272
+
273
+ if (filters?.status) where.status = filters.status;
274
+ if (filters?.tag) where.tag = filters.tag;
275
+
276
+ const limit = pagination?.limit ?? 50;
277
+ const offset = pagination?.offset ?? 0;
278
+
279
+ const total = count(db, "prds", where);
280
+ const rows = findAll<PrdRow>(db, "prds", { where, limit, offset });
281
+
282
+ return {
283
+ items: rows.map(toPrd),
284
+ total,
285
+ limit,
286
+ offset,
287
+ hasMore: offset + rows.length < total,
288
+ };
289
+ }
290
+
291
+ async deletePrd(
292
+ ref: string,
293
+ ): Promise<{ deleted: string; cascade: CascadeResult }> {
294
+ const db = getDb();
295
+ const prd = findByRef<PrdRow>(db, "prds", ref);
296
+ if (!prd) throw new Error(`PRD not found: ${ref}`);
297
+
298
+ const cascade: CascadeResult = {
299
+ criteria: 0,
300
+ tasks: 0,
301
+ epics: 0,
302
+ dependencies: 0,
303
+ };
304
+
305
+ // Delete all epics under this PRD
306
+ const epics = findAll<EpicRow>(db, "epics", { prd_id: prd.id });
307
+ for (const epic of epics) {
308
+ const epicResult = await this.deleteEpic(epic.ref);
309
+ cascade.criteria += epicResult.cascade.criteria;
310
+ cascade.tasks += epicResult.cascade.tasks;
311
+ cascade.dependencies += epicResult.cascade.dependencies;
312
+ cascade.epics++;
313
+ }
314
+
315
+ remove(db, "prds", prd.id);
316
+
317
+ return { deleted: ref, cascade };
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Epic Operations
322
+ // ---------------------------------------------------------------------------
323
+
324
+ async createEpic(input: CreateEpicInput): Promise<Epic> {
325
+ const db = getDb();
326
+ const project = getProject();
327
+
328
+ const prd = findByRef<PrdRow>(db, "prds", input.prdRef);
329
+ if (!prd) throw new Error(`PRD not found: ${input.prdRef}`);
330
+
331
+ const id = generateId("epic");
332
+ const ref = generateRef(db, "E", project.ref_prefix);
333
+
334
+ insert(db, "epics", {
335
+ id,
336
+ prd_id: prd.id,
337
+ ref,
338
+ title: input.title,
339
+ description: input.description ?? null,
340
+ status: "PENDING",
341
+ });
342
+
343
+ // Add acceptance criteria if provided
344
+ if (input.acceptanceCriteria?.length) {
345
+ for (const criteria of input.acceptanceCriteria) {
346
+ const criteriaId = generateId("ac");
347
+ insert(db, "acceptance_criteria", {
348
+ id: criteriaId,
349
+ parent_type: "epic",
350
+ parent_id: id,
351
+ criteria,
352
+ is_met: 0,
353
+ });
354
+ }
355
+ }
356
+
357
+ const row = findById<EpicRow>(db, "epics", id);
358
+ if (!row) throw new Error("Failed to create Epic");
359
+ return toEpic(row);
360
+ }
361
+
362
+ async updateEpic(ref: string, input: UpdateEpicInput): Promise<Epic> {
363
+ const db = getDb();
364
+ const existing = findByRef<EpicRow>(db, "epics", ref);
365
+ if (!existing) throw new Error(`Epic not found: ${ref}`);
366
+
367
+ const updates: Record<string, unknown> = {};
368
+ if (input.title !== undefined) updates.title = input.title;
369
+ if (input.description !== undefined)
370
+ updates.description = input.description;
371
+ if (input.status !== undefined) updates.status = input.status;
372
+
373
+ if (Object.keys(updates).length > 0) {
374
+ update(db, "epics", existing.id, updates);
375
+ }
376
+
377
+ const row = findById<EpicRow>(db, "epics", existing.id);
378
+ if (!row) throw new Error("Failed to update Epic");
379
+ return toEpic(row);
380
+ }
381
+
382
+ async getEpic(ref: string): Promise<Epic | null> {
383
+ const db = getDb();
384
+ const row = findByRef<EpicRow>(db, "epics", ref);
385
+ return row ? toEpic(row) : null;
386
+ }
387
+
388
+ async listEpics(
389
+ filters?: EpicFilters,
390
+ pagination?: PaginationOptions,
391
+ ): Promise<PaginatedResult<Epic>> {
392
+ const db = getDb();
393
+ const where: Record<string, unknown> = {};
394
+
395
+ if (filters?.status) where.status = filters.status;
396
+ if (filters?.prdRef) {
397
+ const prd = findByRef<PrdRow>(db, "prds", filters.prdRef);
398
+ if (!prd)
399
+ return {
400
+ items: [],
401
+ total: 0,
402
+ limit: pagination?.limit ?? 50,
403
+ offset: pagination?.offset ?? 0,
404
+ hasMore: false,
405
+ };
406
+ where.prd_id = prd.id;
407
+ }
408
+
409
+ const limit = pagination?.limit ?? 50;
410
+ const offset = pagination?.offset ?? 0;
411
+
412
+ const total = count(db, "epics", where);
413
+ const rows = findAll<EpicRow>(db, "epics", { where, limit, offset });
414
+
415
+ return {
416
+ items: rows.map(toEpic),
417
+ total,
418
+ limit,
419
+ offset,
420
+ hasMore: offset + rows.length < total,
421
+ };
422
+ }
423
+
424
+ async deleteEpic(
425
+ ref: string,
426
+ ): Promise<{ deleted: string; cascade: CascadeResult }> {
427
+ const db = getDb();
428
+ const epic = findByRef<EpicRow>(db, "epics", ref);
429
+ if (!epic) throw new Error(`Epic not found: ${ref}`);
430
+
431
+ const cascade: CascadeResult = {
432
+ criteria: 0,
433
+ tasks: 0,
434
+ epics: 0,
435
+ dependencies: 0,
436
+ };
437
+
438
+ // Delete all tasks under this epic
439
+ const tasks = findAll<TaskRow>(db, "tasks", { epic_id: epic.id });
440
+ for (const task of tasks) {
441
+ const taskResult = await this.deleteTask(task.ref);
442
+ cascade.criteria += taskResult.cascade.criteria;
443
+ cascade.tasks++;
444
+ }
445
+
446
+ // Delete epic criteria
447
+ const epicCriteria = findAll<CriterionRow>(db, "acceptance_criteria", {
448
+ parent_type: "epic",
449
+ parent_id: epic.id,
450
+ });
451
+ for (const c of epicCriteria) {
452
+ remove(db, "acceptance_criteria", c.id);
453
+ cascade.criteria++;
454
+ }
455
+
456
+ // Delete epic dependencies
457
+ db.query(
458
+ "DELETE FROM epic_dependencies WHERE epic_id = ? OR depends_on_epic_id = ?",
459
+ ).run(epic.id, epic.id);
460
+
461
+ remove(db, "epics", epic.id);
462
+
463
+ return { deleted: ref, cascade };
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Task Operations
468
+ // ---------------------------------------------------------------------------
469
+
470
+ async createTask(input: CreateTaskInput): Promise<Task> {
471
+ const db = getDb();
472
+ const project = getProject();
473
+
474
+ const epic = findByRef<EpicRow>(db, "epics", input.epicRef);
475
+ if (!epic) throw new Error(`Epic not found: ${input.epicRef}`);
476
+
477
+ const id = generateId("task");
478
+ const ref = generateRef(db, "T", project.ref_prefix);
479
+
480
+ insert(db, "tasks", {
481
+ id,
482
+ epic_id: epic.id,
483
+ ref,
484
+ title: input.title,
485
+ description: input.description ?? null,
486
+ status: "PENDING",
487
+ priority: input.priority ?? "MEDIUM",
488
+ });
489
+
490
+ // Add acceptance criteria if provided
491
+ if (input.acceptanceCriteria?.length) {
492
+ for (const criteria of input.acceptanceCriteria) {
493
+ const criteriaId = generateId("ac");
494
+ insert(db, "acceptance_criteria", {
495
+ id: criteriaId,
496
+ parent_type: "task",
497
+ parent_id: id,
498
+ criteria,
499
+ is_met: 0,
500
+ });
501
+ }
502
+ }
503
+
504
+ const row = findById<TaskRow>(db, "tasks", id);
505
+ if (!row) throw new Error("Failed to create Task");
506
+ return toTask(row);
507
+ }
508
+
509
+ async updateTask(ref: string, input: UpdateTaskInput): Promise<Task> {
510
+ const db = getDb();
511
+ const existing = findByRef<TaskRow>(db, "tasks", ref);
512
+ if (!existing) throw new Error(`Task not found: ${ref}`);
513
+
514
+ const updates: Record<string, unknown> = {};
515
+ if (input.title !== undefined) updates.title = input.title;
516
+ if (input.description !== undefined)
517
+ updates.description = input.description;
518
+ if (input.status !== undefined) updates.status = input.status;
519
+ if (input.priority !== undefined) updates.priority = input.priority;
520
+
521
+ if (Object.keys(updates).length > 0) {
522
+ update(db, "tasks", existing.id, updates);
523
+ }
524
+
525
+ const row = findById<TaskRow>(db, "tasks", existing.id);
526
+ if (!row) throw new Error("Failed to update Task");
527
+ return toTask(row);
528
+ }
529
+
530
+ async getTask(ref: string): Promise<Task | null> {
531
+ const db = getDb();
532
+ const row = findByRef<TaskRow>(db, "tasks", ref);
533
+ return row ? toTask(row) : null;
534
+ }
535
+
536
+ async listTasks(
537
+ filters?: TaskFilters,
538
+ pagination?: PaginationOptions,
539
+ ): Promise<PaginatedResult<Task>> {
540
+ const db = getDb();
541
+ const where: Record<string, unknown> = {};
542
+
543
+ if (filters?.status) where.status = filters.status;
544
+ if (filters?.priority) where.priority = filters.priority;
545
+ if (filters?.epicRef) {
546
+ const epic = findByRef<EpicRow>(db, "epics", filters.epicRef);
547
+ if (!epic)
548
+ return {
549
+ items: [],
550
+ total: 0,
551
+ limit: pagination?.limit ?? 50,
552
+ offset: pagination?.offset ?? 0,
553
+ hasMore: false,
554
+ };
555
+ where.epic_id = epic.id;
556
+ }
557
+
558
+ const limit = pagination?.limit ?? 50;
559
+ const offset = pagination?.offset ?? 0;
560
+
561
+ const total = count(db, "tasks", where);
562
+ const rows = findAll<TaskRow>(db, "tasks", { where, limit, offset });
563
+
564
+ return {
565
+ items: rows.map(toTask),
566
+ total,
567
+ limit,
568
+ offset,
569
+ hasMore: offset + rows.length < total,
570
+ };
571
+ }
572
+
573
+ async deleteTask(
574
+ ref: string,
575
+ ): Promise<{ deleted: string; cascade: CascadeResult }> {
576
+ const db = getDb();
577
+ const task = findByRef<TaskRow>(db, "tasks", ref);
578
+ if (!task) throw new Error(`Task not found: ${ref}`);
579
+
580
+ const cascade: CascadeResult = {
581
+ criteria: 0,
582
+ tasks: 0,
583
+ epics: 0,
584
+ dependencies: 0,
585
+ };
586
+
587
+ // Delete task criteria
588
+ const taskCriteria = findAll<CriterionRow>(db, "acceptance_criteria", {
589
+ parent_type: "task",
590
+ parent_id: task.id,
591
+ });
592
+ for (const c of taskCriteria) {
593
+ remove(db, "acceptance_criteria", c.id);
594
+ cascade.criteria++;
595
+ }
596
+
597
+ // Delete task dependencies
598
+ db.query(
599
+ "DELETE FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?",
600
+ ).run(task.id, task.id);
601
+
602
+ remove(db, "tasks", task.id);
603
+
604
+ return { deleted: ref, cascade };
605
+ }
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // Acceptance Criteria Operations
609
+ // ---------------------------------------------------------------------------
610
+
611
+ async addCriterion(input: AddCriterionInput): Promise<AcceptanceCriterion> {
612
+ const db = getDb();
613
+ const entityType = getEntityType(input.parentRef);
614
+
615
+ let parentId: string;
616
+ let parentType: "epic" | "task";
617
+
618
+ if (entityType === "E") {
619
+ const epic = findByRef<EpicRow>(db, "epics", input.parentRef);
620
+ if (!epic) throw new Error(`Epic not found: ${input.parentRef}`);
621
+ parentId = epic.id;
622
+ parentType = "epic";
623
+ } else if (entityType === "T") {
624
+ const task = findByRef<TaskRow>(db, "tasks", input.parentRef);
625
+ if (!task) throw new Error(`Task not found: ${input.parentRef}`);
626
+ parentId = task.id;
627
+ parentType = "task";
628
+ } else {
629
+ throw new Error("Criteria can only be added to Epics or Tasks");
630
+ }
631
+
632
+ const id = generateId("ac");
633
+ insert(db, "acceptance_criteria", {
634
+ id,
635
+ parent_type: parentType,
636
+ parent_id: parentId,
637
+ criteria: input.criteria,
638
+ is_met: 0,
639
+ });
640
+
641
+ const row = findById<CriterionRow>(db, "acceptance_criteria", id);
642
+ if (!row) throw new Error("Failed to add criterion");
643
+ return toCriterion(row);
644
+ }
645
+
646
+ async markCriterionMet(criterionId: string): Promise<AcceptanceCriterion> {
647
+ const db = getDb();
648
+ const existing = findById<CriterionRow>(
649
+ db,
650
+ "acceptance_criteria",
651
+ criterionId,
652
+ );
653
+ if (!existing) throw new Error(`Criterion not found: ${criterionId}`);
654
+
655
+ // Use direct query since acceptance_criteria table has no updated_at column
656
+ db.query("UPDATE acceptance_criteria SET is_met = 1 WHERE id = ?").run(
657
+ criterionId,
658
+ );
659
+
660
+ const row = findById<CriterionRow>(db, "acceptance_criteria", criterionId);
661
+ if (!row) throw new Error("Failed to update criterion");
662
+ return toCriterion(row);
663
+ }
664
+
665
+ async getCriteria(parentRef: string): Promise<AcceptanceCriterion[]> {
666
+ const db = getDb();
667
+ const entityType = getEntityType(parentRef);
668
+
669
+ let parentId: string;
670
+ let parentType: "epic" | "task";
671
+
672
+ if (entityType === "E") {
673
+ const epic = findByRef<EpicRow>(db, "epics", parentRef);
674
+ if (!epic) return [];
675
+ parentId = epic.id;
676
+ parentType = "epic";
677
+ } else if (entityType === "T") {
678
+ const task = findByRef<TaskRow>(db, "tasks", parentRef);
679
+ if (!task) return [];
680
+ parentId = task.id;
681
+ parentType = "task";
682
+ } else {
683
+ return [];
684
+ }
685
+
686
+ const rows = findAll<CriterionRow>(db, "acceptance_criteria", {
687
+ parent_type: parentType,
688
+ parent_id: parentId,
689
+ });
690
+
691
+ return rows.map(toCriterion);
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // Dependency Operations
696
+ // ---------------------------------------------------------------------------
697
+
698
+ async addDependency(ref: string, dependsOnRef: string): Promise<void> {
699
+ const db = getDb();
700
+
701
+ // Check for self-dependency
702
+ if (ref === dependsOnRef) {
703
+ throw new Error("Entity cannot depend on itself");
704
+ }
705
+
706
+ const entityType = getEntityType(ref);
707
+ const dependsOnType = getEntityType(dependsOnRef);
708
+
709
+ if (entityType !== dependsOnType) {
710
+ throw new Error("Dependencies must be between entities of the same type");
711
+ }
712
+
713
+ if (entityType === "E") {
714
+ const epic = findByRef<EpicRow>(db, "epics", ref);
715
+ const dependsOnEpic = findByRef<EpicRow>(db, "epics", dependsOnRef);
716
+ if (!epic || !dependsOnEpic) throw new Error("Epic not found");
717
+
718
+ db.query(
719
+ "INSERT OR IGNORE INTO epic_dependencies (epic_id, depends_on_epic_id) VALUES (?, ?)",
720
+ ).run(epic.id, dependsOnEpic.id);
721
+ } else if (entityType === "T") {
722
+ const task = findByRef<TaskRow>(db, "tasks", ref);
723
+ const dependsOnTask = findByRef<TaskRow>(db, "tasks", dependsOnRef);
724
+ if (!task || !dependsOnTask) throw new Error("Task not found");
725
+
726
+ db.query(
727
+ "INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)",
728
+ ).run(task.id, dependsOnTask.id);
729
+ } else {
730
+ throw new Error("Dependencies can only be added to Epics or Tasks");
731
+ }
732
+ }
733
+
734
+ async removeDependency(ref: string, dependsOnRef: string): Promise<void> {
735
+ const db = getDb();
736
+ const entityType = getEntityType(ref);
737
+
738
+ if (entityType === "E") {
739
+ const epic = findByRef<EpicRow>(db, "epics", ref);
740
+ const dependsOnEpic = findByRef<EpicRow>(db, "epics", dependsOnRef);
741
+ if (!epic || !dependsOnEpic) throw new Error("Epic not found");
742
+
743
+ db.query(
744
+ "DELETE FROM epic_dependencies WHERE epic_id = ? AND depends_on_epic_id = ?",
745
+ ).run(epic.id, dependsOnEpic.id);
746
+ } else if (entityType === "T") {
747
+ const task = findByRef<TaskRow>(db, "tasks", ref);
748
+ const dependsOnTask = findByRef<TaskRow>(db, "tasks", dependsOnRef);
749
+ if (!task || !dependsOnTask) throw new Error("Task not found");
750
+
751
+ db.query(
752
+ "DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?",
753
+ ).run(task.id, dependsOnTask.id);
754
+ }
755
+ }
756
+
757
+ async getDependencies(ref: string): Promise<string[]> {
758
+ const db = getDb();
759
+ const entityType = getEntityType(ref);
760
+
761
+ if (entityType === "E") {
762
+ const epic = findByRef<EpicRow>(db, "epics", ref);
763
+ if (!epic) return [];
764
+
765
+ const deps = db
766
+ .query(
767
+ `SELECT e.ref FROM epic_dependencies ed
768
+ JOIN epics e ON ed.depends_on_epic_id = e.id
769
+ WHERE ed.epic_id = ?`,
770
+ )
771
+ .all(epic.id) as { ref: string }[];
772
+
773
+ return deps.map((d) => d.ref);
774
+ } else if (entityType === "T") {
775
+ const task = findByRef<TaskRow>(db, "tasks", ref);
776
+ if (!task) return [];
777
+
778
+ const deps = db
779
+ .query(
780
+ `SELECT t.ref FROM task_dependencies td
781
+ JOIN tasks t ON td.depends_on_task_id = t.id
782
+ WHERE td.task_id = ?`,
783
+ )
784
+ .all(task.id) as { ref: string }[];
785
+
786
+ return deps.map((d) => d.ref);
787
+ }
788
+
789
+ return [];
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // Document Operations
794
+ // ---------------------------------------------------------------------------
795
+
796
+ async saveDocument(doc: Document): Promise<Document> {
797
+ const prd = await this.getPrd(doc.prdRef);
798
+ if (!prd) throw new Error(`PRD not found: ${doc.prdRef}`);
799
+
800
+ // Ensure folder exists
801
+ const folderPath =
802
+ prd.folderPath || join(config.fluxPath, "prds", doc.prdRef.toLowerCase());
803
+ if (!existsSync(folderPath)) {
804
+ mkdirSync(folderPath, { recursive: true });
805
+ }
806
+
807
+ // Update PRD folder_path if not set
808
+ if (!prd.folderPath) {
809
+ await this.updatePrd(doc.prdRef, { folderPath });
810
+ }
811
+
812
+ const filePath = join(folderPath, doc.filename);
813
+ writeFileSync(filePath, doc.content, "utf-8");
814
+
815
+ return { ...doc, url: filePath };
816
+ }
817
+
818
+ async getDocuments(prdRef: string): Promise<Document[]> {
819
+ const prd = await this.getPrd(prdRef);
820
+ if (!prd || !prd.folderPath) return [];
821
+
822
+ const folderPath = prd.folderPath;
823
+ if (!existsSync(folderPath)) return [];
824
+
825
+ const { readdirSync } = await import("node:fs");
826
+ const files = readdirSync(folderPath);
827
+
828
+ return files
829
+ .filter((f) => f.endsWith(".md"))
830
+ .map((filename) => ({
831
+ prdRef,
832
+ filename,
833
+ content: readFileSync(join(folderPath, filename), "utf-8"),
834
+ url: join(folderPath, filename),
835
+ }));
836
+ }
837
+
838
+ async deleteDocument(prdRef: string, filename: string): Promise<void> {
839
+ const prd = await this.getPrd(prdRef);
840
+ if (!prd || !prd.folderPath) return;
841
+
842
+ const filePath = join(prd.folderPath, filename);
843
+ if (existsSync(filePath)) {
844
+ rmSync(filePath);
845
+ }
846
+ }
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // Query Operations
850
+ // ---------------------------------------------------------------------------
851
+
852
+ async getStats(): Promise<Stats> {
853
+ const db = getDb();
854
+
855
+ // PRD stats (exclude archived from totals)
856
+ const prdArchived = count(db, "prds", { status: "ARCHIVED" });
857
+ const prdTotal = (
858
+ db
859
+ .query("SELECT COUNT(*) as count FROM prds WHERE status != 'ARCHIVED'")
860
+ .get() as { count: number }
861
+ ).count;
862
+
863
+ // Epic stats (exclude epics belonging to archived PRDs)
864
+ const epicTotal = (
865
+ db
866
+ .query(`
867
+ SELECT COUNT(*) as count FROM epics e
868
+ JOIN prds p ON e.prd_id = p.id
869
+ WHERE p.status != 'ARCHIVED'
870
+ `)
871
+ .get() as { count: number }
872
+ ).count;
873
+ const epicPending = (
874
+ db
875
+ .query(`
876
+ SELECT COUNT(*) as count FROM epics e
877
+ JOIN prds p ON e.prd_id = p.id
878
+ WHERE p.status != 'ARCHIVED' AND e.status = 'PENDING'
879
+ `)
880
+ .get() as { count: number }
881
+ ).count;
882
+ const epicInProgress = (
883
+ db
884
+ .query(`
885
+ SELECT COUNT(*) as count FROM epics e
886
+ JOIN prds p ON e.prd_id = p.id
887
+ WHERE p.status != 'ARCHIVED' AND e.status = 'IN_PROGRESS'
888
+ `)
889
+ .get() as { count: number }
890
+ ).count;
891
+ const epicCompleted = (
892
+ db
893
+ .query(`
894
+ SELECT COUNT(*) as count FROM epics e
895
+ JOIN prds p ON e.prd_id = p.id
896
+ WHERE p.status != 'ARCHIVED' AND e.status = 'COMPLETED'
897
+ `)
898
+ .get() as { count: number }
899
+ ).count;
900
+
901
+ // Task stats (exclude tasks belonging to epics of archived PRDs)
902
+ const taskTotal = (
903
+ db
904
+ .query(`
905
+ SELECT COUNT(*) as count FROM tasks t
906
+ JOIN epics e ON t.epic_id = e.id
907
+ JOIN prds p ON e.prd_id = p.id
908
+ WHERE p.status != 'ARCHIVED'
909
+ `)
910
+ .get() as { count: number }
911
+ ).count;
912
+ const taskPending = (
913
+ db
914
+ .query(`
915
+ SELECT COUNT(*) as count FROM tasks t
916
+ JOIN epics e ON t.epic_id = e.id
917
+ JOIN prds p ON e.prd_id = p.id
918
+ WHERE p.status != 'ARCHIVED' AND t.status = 'PENDING'
919
+ `)
920
+ .get() as { count: number }
921
+ ).count;
922
+ const taskInProgress = (
923
+ db
924
+ .query(`
925
+ SELECT COUNT(*) as count FROM tasks t
926
+ JOIN epics e ON t.epic_id = e.id
927
+ JOIN prds p ON e.prd_id = p.id
928
+ WHERE p.status != 'ARCHIVED' AND t.status = 'IN_PROGRESS'
929
+ `)
930
+ .get() as { count: number }
931
+ ).count;
932
+ const taskCompleted = (
933
+ db
934
+ .query(`
935
+ SELECT COUNT(*) as count FROM tasks t
936
+ JOIN epics e ON t.epic_id = e.id
937
+ JOIN prds p ON e.prd_id = p.id
938
+ WHERE p.status != 'ARCHIVED' AND t.status = 'COMPLETED'
939
+ `)
940
+ .get() as { count: number }
941
+ ).count;
942
+
943
+ return {
944
+ prds: {
945
+ total: prdTotal,
946
+ draft: count(db, "prds", { status: "DRAFT" }),
947
+ pendingReview: count(db, "prds", { status: "PENDING_REVIEW" }),
948
+ reviewed: count(db, "prds", { status: "REVIEWED" }),
949
+ approved: count(db, "prds", { status: "APPROVED" }),
950
+ breakdownReady: count(db, "prds", { status: "BREAKDOWN_READY" }),
951
+ completed: count(db, "prds", { status: "COMPLETED" }),
952
+ archived: prdArchived,
953
+ },
954
+ epics: {
955
+ total: epicTotal,
956
+ pending: epicPending,
957
+ inProgress: epicInProgress,
958
+ completed: epicCompleted,
959
+ },
960
+ tasks: {
961
+ total: taskTotal,
962
+ pending: taskPending,
963
+ inProgress: taskInProgress,
964
+ completed: taskCompleted,
965
+ },
966
+ };
967
+ }
968
+ }