@cliangdev/flux-plugin 0.2.0-dev.dc5e2c4 → 0.2.0-dev.f718bcf

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 (46) hide show
  1. package/README.md +3 -3
  2. package/agents/coder.md +150 -25
  3. package/commands/breakdown.md +47 -10
  4. package/commands/flux.md +92 -12
  5. package/commands/implement.md +166 -17
  6. package/commands/linear.md +6 -5
  7. package/commands/prd.md +996 -82
  8. package/manifest.json +2 -1
  9. package/package.json +4 -2
  10. package/skills/flux-orchestrator/SKILL.md +11 -3
  11. package/skills/prd-writer/SKILL.md +761 -0
  12. package/skills/ux-ui-design/SKILL.md +346 -0
  13. package/skills/ux-ui-design/references/design-tokens.md +359 -0
  14. package/src/dashboard/__tests__/api.test.ts +211 -0
  15. package/src/dashboard/browser.ts +35 -0
  16. package/src/dashboard/public/app.js +869 -0
  17. package/src/dashboard/public/index.html +90 -0
  18. package/src/dashboard/public/styles.css +807 -0
  19. package/src/dashboard/public/vendor/highlight.css +10 -0
  20. package/src/dashboard/public/vendor/highlight.min.js +8422 -0
  21. package/src/dashboard/public/vendor/marked.min.js +2210 -0
  22. package/src/dashboard/server.ts +296 -0
  23. package/src/dashboard/watchers.ts +83 -0
  24. package/src/server/adapters/__tests__/dependency-ops.test.ts +52 -18
  25. package/src/server/adapters/linear/adapter.ts +19 -14
  26. package/src/server/adapters/local-adapter.ts +48 -7
  27. package/src/server/db/__tests__/queries.test.ts +2 -1
  28. package/src/server/db/schema.ts +9 -0
  29. package/src/server/index.ts +0 -2
  30. package/src/server/tools/__tests__/crud.test.ts +111 -1
  31. package/src/server/tools/__tests__/mcp-interface.test.ts +100 -9
  32. package/src/server/tools/__tests__/query.test.ts +73 -21
  33. package/src/server/tools/__tests__/z-configure-linear.test.ts +1 -1
  34. package/src/server/tools/__tests__/z-get-linear-url.test.ts +1 -1
  35. package/src/server/tools/create-epic.ts +11 -2
  36. package/src/server/tools/create-prd.ts +11 -2
  37. package/src/server/tools/create-task.ts +11 -2
  38. package/src/server/tools/dependencies.ts +2 -2
  39. package/src/server/tools/get-entity.ts +12 -10
  40. package/src/server/tools/index.ts +53 -9
  41. package/src/server/tools/init-project.ts +1 -1
  42. package/src/server/tools/render-status.ts +38 -20
  43. package/src/status-line/__tests__/status-line.test.ts +1 -1
  44. package/src/utils/status-renderer.ts +32 -6
  45. package/skills/prd-template/SKILL.md +0 -242
  46. package/src/server/tools/get-project-context.ts +0 -33
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Flux Dashboard Server
3
+ *
4
+ * HTTP + WebSocket server for the Flux Dashboard.
5
+ * Serves static files and API endpoints for viewing PRD/Epic/Task data.
6
+ */
7
+
8
+ import { getAdapter } from "../server/adapters/index.js";
9
+ import type { Epic, Prd, Task } from "../server/adapters/types.js";
10
+ import { initDb } from "../server/db/index.js";
11
+ import { openBrowser } from "./browser.js";
12
+ import { startWatchers, stopWatchers } from "./watchers.js";
13
+
14
+ const DEFAULT_PORT = 3333;
15
+ const MAX_PORT_ATTEMPTS = 10;
16
+
17
+ interface DashboardServer {
18
+ server: ReturnType<typeof Bun.serve>;
19
+ port: number;
20
+ stop: () => void;
21
+ }
22
+
23
+ function getPort(): number {
24
+ const envPort = process.env.FLUX_DASHBOARD_PORT;
25
+ if (envPort) {
26
+ const parsed = Number.parseInt(envPort, 10);
27
+ if (!Number.isNaN(parsed) && parsed > 0 && parsed < 65536) {
28
+ return parsed;
29
+ }
30
+ }
31
+ return DEFAULT_PORT;
32
+ }
33
+
34
+ async function findAvailablePort(startPort: number): Promise<number> {
35
+ for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
36
+ try {
37
+ const testServer = Bun.serve({
38
+ port,
39
+ fetch: () => new Response("test"),
40
+ });
41
+ testServer.stop();
42
+ return port;
43
+ } catch {}
44
+ }
45
+ throw new Error(
46
+ `No available port found between ${startPort} and ${startPort + MAX_PORT_ATTEMPTS - 1}`,
47
+ );
48
+ }
49
+
50
+ function getContentType(path: string): string {
51
+ if (path.endsWith(".html")) return "text/html";
52
+ if (path.endsWith(".css")) return "text/css";
53
+ if (path.endsWith(".js")) return "application/javascript";
54
+ if (path.endsWith(".json")) return "application/json";
55
+ if (path.endsWith(".svg")) return "image/svg+xml";
56
+ return "application/octet-stream";
57
+ }
58
+
59
+ export async function startDashboard(): Promise<DashboardServer> {
60
+ const preferredPort = getPort();
61
+ const port = await findAvailablePort(preferredPort);
62
+
63
+ const publicDir = new URL("./public/", import.meta.url).pathname;
64
+
65
+ const server = Bun.serve({
66
+ port,
67
+
68
+ async fetch(req, server) {
69
+ const url = new URL(req.url);
70
+ const path = url.pathname;
71
+
72
+ // WebSocket upgrade
73
+ if (path === "/ws") {
74
+ const upgraded = server.upgrade(req);
75
+ return upgraded
76
+ ? undefined
77
+ : new Response("WebSocket upgrade failed", { status: 400 });
78
+ }
79
+
80
+ // API endpoints
81
+ if (path.startsWith("/api/")) {
82
+ return handleApiRequest(path, url);
83
+ }
84
+
85
+ // Static files
86
+ const filePath = path === "/" ? "/index.html" : path;
87
+ const fullPath = `${publicDir}${filePath}`;
88
+
89
+ try {
90
+ const file = Bun.file(fullPath);
91
+ if (await file.exists()) {
92
+ return new Response(file, {
93
+ headers: { "Content-Type": getContentType(fullPath) },
94
+ });
95
+ }
96
+ } catch {}
97
+
98
+ return new Response("Not found", { status: 404 });
99
+ },
100
+
101
+ websocket: {
102
+ open(ws) {
103
+ ws.subscribe("updates");
104
+ },
105
+ message(_ws, _message) {
106
+ // Client messages not needed for read-only dashboard
107
+ },
108
+ close(ws) {
109
+ ws.unsubscribe("updates");
110
+ },
111
+ },
112
+ });
113
+
114
+ // Start file watchers
115
+ startWatchers(server);
116
+
117
+ // Graceful shutdown
118
+ const cleanup = () => {
119
+ console.log("\nShutting down dashboard...");
120
+ stopWatchers();
121
+ server.stop();
122
+ process.exit(0);
123
+ };
124
+
125
+ process.on("SIGINT", cleanup);
126
+ process.on("SIGTERM", cleanup);
127
+
128
+ return {
129
+ server,
130
+ port,
131
+ stop: () => {
132
+ stopWatchers();
133
+ server.stop();
134
+ },
135
+ };
136
+ }
137
+
138
+ interface TreePrd extends Prd {
139
+ epics: TreeEpic[];
140
+ }
141
+
142
+ interface TreeEpic extends Epic {
143
+ tasks: Task[];
144
+ }
145
+
146
+ async function handleApiRequest(path: string, _url: URL): Promise<Response> {
147
+ try {
148
+ const adapter = getAdapter();
149
+
150
+ // GET /api/tree
151
+ if (path === "/api/tree") {
152
+ const prds = await adapter.listPrds({}, { limit: 100, offset: 0 });
153
+ const tree: TreePrd[] = await Promise.all(
154
+ prds.items.map(async (prd) => {
155
+ const epics = await adapter.listEpics(
156
+ { prdRef: prd.ref },
157
+ { limit: 100, offset: 0 },
158
+ );
159
+ const epicsWithTasks: TreeEpic[] = await Promise.all(
160
+ epics.items.map(async (epic) => {
161
+ const tasks = await adapter.listTasks(
162
+ { epicRef: epic.ref },
163
+ { limit: 100, offset: 0 },
164
+ );
165
+ return { ...epic, tasks: tasks.items };
166
+ }),
167
+ );
168
+ return { ...prd, epics: epicsWithTasks };
169
+ }),
170
+ );
171
+ return Response.json(tree);
172
+ }
173
+
174
+ // GET /api/prd/:ref
175
+ if (path.startsWith("/api/prd/")) {
176
+ const ref = path.slice("/api/prd/".length);
177
+ const prd = await adapter.getPrd(ref);
178
+ if (!prd) {
179
+ return Response.json({ error: "PRD not found" }, { status: 404 });
180
+ }
181
+
182
+ let markdown = prd.description || "";
183
+ if (prd.folderPath) {
184
+ try {
185
+ const prdPath = `${prd.folderPath}/prd.md`;
186
+ const file = Bun.file(prdPath);
187
+ if (await file.exists()) {
188
+ markdown = await file.text();
189
+ }
190
+ } catch {}
191
+ }
192
+
193
+ return Response.json({ ...prd, markdown });
194
+ }
195
+
196
+ // GET /api/epic/:ref
197
+ if (path.startsWith("/api/epic/")) {
198
+ const ref = path.slice("/api/epic/".length);
199
+ const epic = await adapter.getEpic(ref);
200
+ if (!epic) {
201
+ return Response.json({ error: "Epic not found" }, { status: 404 });
202
+ }
203
+ const criteria = await adapter.getCriteria(ref);
204
+ return Response.json({ ...epic, criteria });
205
+ }
206
+
207
+ // GET /api/task/:ref
208
+ if (path.startsWith("/api/task/")) {
209
+ const ref = path.slice("/api/task/".length);
210
+ const task = await adapter.getTask(ref);
211
+ if (!task) {
212
+ return Response.json({ error: "Task not found" }, { status: 404 });
213
+ }
214
+ const criteria = await adapter.getCriteria(ref);
215
+ const deps = await adapter.getDependencies(ref);
216
+ return Response.json({ ...task, criteria, dependencies: deps });
217
+ }
218
+
219
+ // GET /api/tags
220
+ if (path === "/api/tags") {
221
+ const prds = await adapter.listPrds({}, { limit: 1000, offset: 0 });
222
+ const tagCounts = new Map<string, number>();
223
+ let total = 0;
224
+
225
+ for (const prd of prds.items) {
226
+ total++;
227
+ if (prd.tag) {
228
+ tagCounts.set(prd.tag, (tagCounts.get(prd.tag) || 0) + 1);
229
+ }
230
+ }
231
+
232
+ const tags = [
233
+ { tag: "All", count: total },
234
+ ...Array.from(tagCounts.entries())
235
+ .map(([tag, count]) => ({ tag, count }))
236
+ .sort((a, b) => a.tag.localeCompare(b.tag)),
237
+ ];
238
+
239
+ return Response.json(tags);
240
+ }
241
+
242
+ // GET /api/dependencies
243
+ if (path === "/api/dependencies") {
244
+ const edges: Array<{ from: string; to: string; type: string }> = [];
245
+
246
+ const prds = await adapter.listPrds({}, { limit: 1000, offset: 0 });
247
+ for (const prd of prds.items) {
248
+ const deps = await adapter.getDependencies(prd.ref);
249
+ for (const depRef of deps) {
250
+ edges.push({ from: prd.ref, to: depRef, type: "prd" });
251
+ }
252
+ }
253
+
254
+ const epics = await adapter.listEpics({}, { limit: 1000, offset: 0 });
255
+ for (const epic of epics.items) {
256
+ const deps = await adapter.getDependencies(epic.ref);
257
+ for (const depRef of deps) {
258
+ edges.push({ from: epic.ref, to: depRef, type: "epic" });
259
+ }
260
+ }
261
+
262
+ const tasks = await adapter.listTasks({}, { limit: 1000, offset: 0 });
263
+ for (const task of tasks.items) {
264
+ const deps = await adapter.getDependencies(task.ref);
265
+ for (const depRef of deps) {
266
+ edges.push({ from: task.ref, to: depRef, type: "task" });
267
+ }
268
+ }
269
+
270
+ return Response.json({ edges });
271
+ }
272
+
273
+ return Response.json({ error: "Unknown endpoint" }, { status: 404 });
274
+ } catch (error) {
275
+ console.error("API error:", error);
276
+ return Response.json(
277
+ { error: error instanceof Error ? error.message : "Internal error" },
278
+ { status: 500 },
279
+ );
280
+ }
281
+ }
282
+
283
+ // Main entry point
284
+ if (import.meta.main) {
285
+ try {
286
+ // Initialize local database (no-op if using external adapter like Linear)
287
+ initDb();
288
+ const { port } = await startDashboard();
289
+ const url = `http://localhost:${port}`;
290
+ console.log(`\n Flux Dashboard running at ${url}\n`);
291
+ await openBrowser(url);
292
+ } catch (error) {
293
+ console.error("Failed to start dashboard:", error);
294
+ process.exit(1);
295
+ }
296
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * File Watchers for Real-time Updates
3
+ *
4
+ * Watches .flux/flux.db and .flux/prds/ for changes and broadcasts
5
+ * updates to connected WebSocket clients.
6
+ */
7
+
8
+ import { type FSWatcher, watch } from "node:fs";
9
+ import { resolve } from "node:path";
10
+
11
+ let dbWatcher: FSWatcher | null = null;
12
+ let prdsWatcher: FSWatcher | null = null;
13
+ let debounceTimer: Timer | null = null;
14
+
15
+ const DEBOUNCE_MS = 500;
16
+
17
+ export function startWatchers(server: ReturnType<typeof Bun.serve>): void {
18
+ const projectRoot = process.env.FLUX_PROJECT_ROOT || process.cwd();
19
+ const fluxDir = resolve(projectRoot, ".flux");
20
+ const dbPath = resolve(fluxDir, "flux.db");
21
+ const prdsDir = resolve(fluxDir, "prds");
22
+
23
+ const broadcast = (type: "db" | "file", path?: string) => {
24
+ if (debounceTimer) {
25
+ clearTimeout(debounceTimer);
26
+ }
27
+
28
+ debounceTimer = setTimeout(() => {
29
+ server.publish(
30
+ "updates",
31
+ JSON.stringify({
32
+ type: "update",
33
+ source: type,
34
+ path,
35
+ timestamp: Date.now(),
36
+ }),
37
+ );
38
+ }, DEBOUNCE_MS);
39
+ };
40
+
41
+ // Watch database file
42
+ try {
43
+ dbWatcher = watch(dbPath, (eventType) => {
44
+ if (eventType === "change") {
45
+ broadcast("db");
46
+ }
47
+ });
48
+ } catch (error) {
49
+ console.warn("Could not watch database file:", error);
50
+ }
51
+
52
+ // Watch PRDs directory recursively
53
+ try {
54
+ prdsWatcher = watch(
55
+ prdsDir,
56
+ { recursive: true },
57
+ (_eventType, filename) => {
58
+ if (filename?.endsWith(".md")) {
59
+ broadcast("file", filename);
60
+ }
61
+ },
62
+ );
63
+ } catch (error) {
64
+ console.warn("Could not watch PRDs directory:", error);
65
+ }
66
+ }
67
+
68
+ export function stopWatchers(): void {
69
+ if (debounceTimer) {
70
+ clearTimeout(debounceTimer);
71
+ debounceTimer = null;
72
+ }
73
+
74
+ if (dbWatcher) {
75
+ dbWatcher.close();
76
+ dbWatcher = null;
77
+ }
78
+
79
+ if (prdsWatcher) {
80
+ prdsWatcher.close();
81
+ prdsWatcher = null;
82
+ }
83
+ }
@@ -194,8 +194,12 @@ describe("LinearAdapter - Dependency Operations", () => {
194
194
  const mockRelation = {
195
195
  id: "relation_1",
196
196
  type: "blocks",
197
- issue: { id: "issue_epic_1", identifier: "ENG-42" },
198
- relatedIssue: { id: "issue_epic_2", identifier: "ENG-43" },
197
+ // Linear SDK returns promises for lazy-loaded related objects
198
+ issue: Promise.resolve({ id: "issue_epic_1", identifier: "ENG-42" }),
199
+ relatedIssue: Promise.resolve({
200
+ id: "issue_epic_2",
201
+ identifier: "ENG-43",
202
+ }),
199
203
  delete: mockDelete,
200
204
  };
201
205
 
@@ -208,7 +212,7 @@ describe("LinearAdapter - Dependency Operations", () => {
208
212
  id: "issue_epic_2",
209
213
  identifier: "ENG-43",
210
214
  _raw: {
211
- relations: mock(async () => ({
215
+ inverseRelations: mock(async () => ({
212
216
  nodes: [mockRelation],
213
217
  })),
214
218
  },
@@ -235,7 +239,7 @@ describe("LinearAdapter - Dependency Operations", () => {
235
239
  id: "issue_epic_1",
236
240
  identifier: "ENG-42",
237
241
  _raw: {
238
- relations: mock(async () => ({ nodes: [] })),
242
+ inverseRelations: mock(async () => ({ nodes: [] })),
239
243
  },
240
244
  });
241
245
 
@@ -265,7 +269,7 @@ describe("LinearAdapter - Dependency Operations", () => {
265
269
  id: "issue_epic_2",
266
270
  identifier: "ENG-43",
267
271
  _raw: {
268
- relations: mock(async () => ({
272
+ inverseRelations: mock(async () => ({
269
273
  nodes: [], // No relations
270
274
  })),
271
275
  },
@@ -294,19 +298,31 @@ describe("LinearAdapter - Dependency Operations", () => {
294
298
  id: "issue_epic_1",
295
299
  identifier: "ENG-43",
296
300
  _raw: {
297
- relations: mock(async () => ({
301
+ inverseRelations: mock(async () => ({
298
302
  nodes: [
299
303
  {
300
304
  id: "relation_1",
301
305
  type: "blocks",
302
- issue: { id: "issue_epic_2", identifier: "ENG-42" },
303
- relatedIssue: { id: "issue_epic_1", identifier: "ENG-43" },
306
+ issue: Promise.resolve({
307
+ id: "issue_epic_2",
308
+ identifier: "ENG-42",
309
+ }),
310
+ relatedIssue: Promise.resolve({
311
+ id: "issue_epic_1",
312
+ identifier: "ENG-43",
313
+ }),
304
314
  },
305
315
  {
306
316
  id: "relation_2",
307
317
  type: "blocks",
308
- issue: { id: "issue_epic_3", identifier: "ENG-44" },
309
- relatedIssue: { id: "issue_epic_1", identifier: "ENG-43" },
318
+ issue: Promise.resolve({
319
+ id: "issue_epic_3",
320
+ identifier: "ENG-44",
321
+ }),
322
+ relatedIssue: Promise.resolve({
323
+ id: "issue_epic_1",
324
+ identifier: "ENG-43",
325
+ }),
310
326
  },
311
327
  ],
312
328
  })),
@@ -328,7 +344,7 @@ describe("LinearAdapter - Dependency Operations", () => {
328
344
  id: "issue_epic_1",
329
345
  identifier: "ENG-42",
330
346
  _raw: {
331
- relations: mock(async () => ({
347
+ inverseRelations: mock(async () => ({
332
348
  nodes: [],
333
349
  })),
334
350
  },
@@ -349,25 +365,43 @@ describe("LinearAdapter - Dependency Operations", () => {
349
365
  id: "issue_epic_1",
350
366
  identifier: "ENG-43",
351
367
  _raw: {
352
- relations: mock(async () => ({
368
+ inverseRelations: mock(async () => ({
353
369
  nodes: [
354
370
  {
355
371
  id: "relation_1",
356
372
  type: "blocks",
357
- issue: { id: "issue_epic_2", identifier: "ENG-42" },
358
- relatedIssue: { id: "issue_epic_1", identifier: "ENG-43" },
373
+ issue: Promise.resolve({
374
+ id: "issue_epic_2",
375
+ identifier: "ENG-42",
376
+ }),
377
+ relatedIssue: Promise.resolve({
378
+ id: "issue_epic_1",
379
+ identifier: "ENG-43",
380
+ }),
359
381
  },
360
382
  {
361
383
  id: "relation_2",
362
384
  type: "duplicate",
363
- issue: { id: "issue_epic_3", identifier: "ENG-44" },
364
- relatedIssue: { id: "issue_epic_1", identifier: "ENG-43" },
385
+ issue: Promise.resolve({
386
+ id: "issue_epic_3",
387
+ identifier: "ENG-44",
388
+ }),
389
+ relatedIssue: Promise.resolve({
390
+ id: "issue_epic_1",
391
+ identifier: "ENG-43",
392
+ }),
365
393
  },
366
394
  {
367
395
  id: "relation_3",
368
396
  type: "related",
369
- issue: { id: "issue_epic_4", identifier: "ENG-45" },
370
- relatedIssue: { id: "issue_epic_1", identifier: "ENG-43" },
397
+ issue: Promise.resolve({
398
+ id: "issue_epic_4",
399
+ identifier: "ENG-45",
400
+ }),
401
+ relatedIssue: Promise.resolve({
402
+ id: "issue_epic_1",
403
+ identifier: "ENG-43",
404
+ }),
371
405
  },
372
406
  ],
373
407
  })),
@@ -887,16 +887,22 @@ export class LinearAdapter implements BackendAdapter {
887
887
  if (!blockedIssue) throw new Error(`Issue not found: ${ref}`);
888
888
  if (!blockerIssue) throw new Error(`Issue not found: ${dependsOnRef}`);
889
889
 
890
+ // Use inverseRelations to find relations where blockedIssue is the target
890
891
  const relations = await this.client.execute<any>(() =>
891
- blockedIssue._raw.relations(),
892
+ blockedIssue._raw.inverseRelations(),
892
893
  );
893
894
 
894
- const relationToDelete = relations.nodes.find(
895
- (rel: any) =>
896
- rel.type === "blocks" &&
897
- rel.issue.id === blockerIssue.id &&
898
- rel.relatedIssue.id === blockedIssue.id,
899
- );
895
+ // Find the relation to delete - need to await rel.issue since it's lazy-loaded
896
+ let relationToDelete: any = null;
897
+ for (const rel of relations.nodes) {
898
+ if (rel.type === "blocks") {
899
+ const relIssue = await rel.issue;
900
+ if (relIssue?.id === blockerIssue.id) {
901
+ relationToDelete = rel;
902
+ break;
903
+ }
904
+ }
905
+ }
900
906
 
901
907
  if (!relationToDelete) {
902
908
  throw new Error(
@@ -911,18 +917,17 @@ export class LinearAdapter implements BackendAdapter {
911
917
  const issue = await this.fetchIssue(ref);
912
918
  if (!issue) throw new Error(`Issue not found: ${ref}`);
913
919
 
920
+ // Use inverseRelations to get relations where this issue is the target (relatedIssueId)
921
+ // This finds issues that block this one
914
922
  const relations = await this.client.execute<any>(() =>
915
- issue._raw.relations(),
923
+ issue._raw.inverseRelations(),
916
924
  );
917
925
 
918
926
  const blockingRefs: string[] = [];
919
927
  for (const rel of relations.nodes) {
920
- if (
921
- rel.type === "blocks" &&
922
- rel.relatedIssue &&
923
- (rel.relatedIssue as any).id === issue.id
924
- ) {
925
- const blockerIssue = rel.issue as any;
928
+ if (rel.type === "blocks") {
929
+ // Linear SDK returns lazy-loaded promises for related objects
930
+ const blockerIssue = await rel.issue;
926
931
  if (blockerIssue?.identifier) {
927
932
  blockingRefs.push(blockerIssue.identifier);
928
933
  }
@@ -12,7 +12,7 @@ import {
12
12
  rmSync,
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
- import { join } from "node:path";
15
+ import { isAbsolute, join } from "node:path";
16
16
  import { config } from "../config.js";
17
17
  import {
18
18
  count,
@@ -108,6 +108,12 @@ interface ProjectRow {
108
108
  // =============================================================================
109
109
 
110
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
+
111
117
  return {
112
118
  id: row.id,
113
119
  projectId: row.project_id,
@@ -116,7 +122,7 @@ function toPrd(row: PrdRow): Prd {
116
122
  description: row.description ?? undefined,
117
123
  status: row.status as Prd["status"],
118
124
  tag: row.tag ?? undefined,
119
- folderPath: row.folder_path ?? undefined,
125
+ folderPath,
120
126
  createdAt: row.created_at,
121
127
  updatedAt: row.updated_at,
122
128
  };
@@ -312,6 +318,14 @@ export class LocalAdapter implements BackendAdapter {
312
318
  cascade.epics++;
313
319
  }
314
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
+
315
329
  remove(db, "prds", prd.id);
316
330
 
317
331
  return { deleted: ref, cascade };
@@ -710,7 +724,15 @@ export class LocalAdapter implements BackendAdapter {
710
724
  throw new Error("Dependencies must be between entities of the same type");
711
725
  }
712
726
 
713
- if (entityType === "E") {
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") {
714
736
  const epic = findByRef<EpicRow>(db, "epics", ref);
715
737
  const dependsOnEpic = findByRef<EpicRow>(db, "epics", dependsOnRef);
716
738
  if (!epic || !dependsOnEpic) throw new Error("Epic not found");
@@ -726,8 +748,6 @@ export class LocalAdapter implements BackendAdapter {
726
748
  db.query(
727
749
  "INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)",
728
750
  ).run(task.id, dependsOnTask.id);
729
- } else {
730
- throw new Error("Dependencies can only be added to Epics or Tasks");
731
751
  }
732
752
  }
733
753
 
@@ -735,7 +755,15 @@ export class LocalAdapter implements BackendAdapter {
735
755
  const db = getDb();
736
756
  const entityType = getEntityType(ref);
737
757
 
738
- if (entityType === "E") {
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") {
739
767
  const epic = findByRef<EpicRow>(db, "epics", ref);
740
768
  const dependsOnEpic = findByRef<EpicRow>(db, "epics", dependsOnRef);
741
769
  if (!epic || !dependsOnEpic) throw new Error("Epic not found");
@@ -758,7 +786,20 @@ export class LocalAdapter implements BackendAdapter {
758
786
  const db = getDb();
759
787
  const entityType = getEntityType(ref);
760
788
 
761
- if (entityType === "E") {
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") {
762
803
  const epic = findByRef<EpicRow>(db, "epics", ref);
763
804
  if (!epic) return [];
764
805
 
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Set up test environment BEFORE any imports
6
- const TEST_DIR = `/tmp/flux-test-db-${Date.now()}`;
6
+ const TEST_DIR = `/tmp/flux-test-db-${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
7
  const FLUX_DIR = join(TEST_DIR, ".flux");
8
8
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
9
9
 
@@ -28,6 +28,7 @@ describe("Database Queries", () => {
28
28
  let projectId: string;
29
29
 
30
30
  beforeEach(() => {
31
+ closeDb();
31
32
  config.clearCache();
32
33
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
33
34