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