@allpepper/task-orchestrator 1.1.2 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allpepper/task-orchestrator",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for hierarchical task orchestration with SQLite persistence",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db/migrate.ts CHANGED
@@ -19,7 +19,7 @@ interface Migration {
19
19
 
20
20
  function loadMigrations(): Migration[] {
21
21
  const migrationsDir = join(dirname(import.meta.path), 'migrations');
22
- const files = ['001_initial_schema.sql'];
22
+ const files = ['001_initial_schema.sql', '002_generalize_dependencies.sql'];
23
23
 
24
24
  return files.map((file, i) => ({
25
25
  version: i + 1,
@@ -0,0 +1,27 @@
1
+ -- Migration 002: Generalize dependencies table for polymorphic entity references
2
+ -- Renames task-specific columns to entity-generic ones and adds entity_type discriminator
3
+
4
+ -- Step 1: Create new table with generalized schema
5
+ CREATE TABLE IF NOT EXISTS dependencies_new (
6
+ id TEXT PRIMARY KEY,
7
+ from_entity_id TEXT NOT NULL,
8
+ to_entity_id TEXT NOT NULL,
9
+ entity_type TEXT NOT NULL DEFAULT 'task' CHECK (entity_type IN ('task', 'feature')),
10
+ type VARCHAR(20) NOT NULL CHECK (type IN ('BLOCKS', 'IS_BLOCKED_BY', 'RELATES_TO')),
11
+ created_at TEXT NOT NULL
12
+ );
13
+
14
+ -- Step 2: Copy existing data, mapping old columns to new and defaulting entity_type to 'task'
15
+ INSERT INTO dependencies_new (id, from_entity_id, to_entity_id, entity_type, type, created_at)
16
+ SELECT id, from_task_id, to_task_id, 'task', type, created_at
17
+ FROM dependencies;
18
+
19
+ -- Step 3: Drop old table and rename new one
20
+ DROP TABLE IF EXISTS dependencies;
21
+ ALTER TABLE dependencies_new RENAME TO dependencies;
22
+
23
+ -- Step 4: Recreate indexes with new column names
24
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_unique ON dependencies(from_entity_id, to_entity_id, type, entity_type);
25
+ CREATE INDEX IF NOT EXISTS idx_dependencies_from_entity_id ON dependencies(from_entity_id);
26
+ CREATE INDEX IF NOT EXISTS idx_dependencies_to_entity_id ON dependencies(to_entity_id);
27
+ CREATE INDEX IF NOT EXISTS idx_dependencies_entity_type ON dependencies(entity_type);
@@ -76,6 +76,11 @@ export enum DependencyType {
76
76
  RELATES_TO = 'RELATES_TO'
77
77
  }
78
78
 
79
+ export enum DependencyEntityType {
80
+ TASK = 'task',
81
+ FEATURE = 'feature'
82
+ }
83
+
79
84
  export enum LockStatus {
80
85
  UNLOCKED = 'UNLOCKED',
81
86
  LOCKED_EXCLUSIVE = 'LOCKED_EXCLUSIVE',
@@ -177,8 +182,9 @@ export interface TemplateSection {
177
182
 
178
183
  export interface Dependency {
179
184
  id: string;
180
- fromTaskId: string;
181
- toTaskId: string;
185
+ fromEntityId: string;
186
+ toEntityId: string;
187
+ entityType: DependencyEntityType;
182
188
  type: DependencyType;
183
189
  createdAt: Date;
184
190
  }
@@ -9,7 +9,7 @@ import {
9
9
  err,
10
10
  toDate
11
11
  } from './base';
12
- import type { Result, Dependency, DependencyType, Task } from '../domain/types';
12
+ import type { Result, Dependency, DependencyType, DependencyEntityType, Task, Feature } from '../domain/types';
13
13
  import { ValidationError, NotFoundError, ConflictError } from '../domain/types';
14
14
 
15
15
  // ============================================================================
@@ -18,17 +18,17 @@ import { ValidationError, NotFoundError, ConflictError } from '../domain/types';
18
18
 
19
19
  /**
20
20
  * Check if adding a dependency would create a circular dependency.
21
- * Uses BFS to traverse the dependency graph from toTaskId following BLOCKS dependencies.
22
- * If we reach fromTaskId, it means adding fromTaskId -> toTaskId would create a cycle.
21
+ * Uses BFS to traverse the dependency graph from toEntityId following BLOCKS dependencies.
22
+ * If we reach fromEntityId, it means adding fromEntityId -> toEntityId would create a cycle.
23
23
  */
24
- function hasCircularDependency(fromTaskId: string, toTaskId: string): boolean {
24
+ function hasCircularDependency(fromEntityId: string, toEntityId: string, entityType: DependencyEntityType): boolean {
25
25
  const visited = new Set<string>();
26
- const queue = [toTaskId];
26
+ const queue = [toEntityId];
27
27
 
28
28
  while (queue.length > 0) {
29
29
  const current = queue.shift()!;
30
30
 
31
- if (current === fromTaskId) {
31
+ if (current === fromEntityId) {
32
32
  return true;
33
33
  }
34
34
 
@@ -38,14 +38,13 @@ function hasCircularDependency(fromTaskId: string, toTaskId: string): boolean {
38
38
 
39
39
  visited.add(current);
40
40
 
41
- // Follow BLOCKS dependencies from current task
42
- const deps = queryAll<{ to_task_id: string }>(
43
- "SELECT to_task_id FROM dependencies WHERE from_task_id = ? AND type = 'BLOCKS'",
44
- [current]
41
+ const deps = queryAll<{ to_entity_id: string }>(
42
+ "SELECT to_entity_id FROM dependencies WHERE from_entity_id = ? AND type = 'BLOCKS' AND entity_type = ?",
43
+ [current, entityType]
45
44
  );
46
45
 
47
46
  for (const dep of deps) {
48
- queue.push(dep.to_task_id);
47
+ queue.push(dep.to_entity_id);
49
48
  }
50
49
  }
51
50
 
@@ -60,8 +59,9 @@ function hasCircularDependency(fromTaskId: string, toTaskId: string): boolean {
60
59
  function mapRowToDependency(row: any): Dependency {
61
60
  return {
62
61
  id: row.id,
63
- fromTaskId: row.from_task_id,
64
- toTaskId: row.to_task_id,
62
+ fromEntityId: row.from_entity_id,
63
+ toEntityId: row.to_entity_id,
64
+ entityType: row.entity_type as DependencyEntityType,
65
65
  type: row.type as DependencyType,
66
66
  createdAt: toDate(row.created_at)
67
67
  };
@@ -87,52 +87,76 @@ function mapRowToTask(row: any): Task {
87
87
  };
88
88
  }
89
89
 
90
+ /** Map database row to Feature domain object */
91
+ function mapRowToFeature(row: any): Feature {
92
+ return {
93
+ id: row.id,
94
+ projectId: row.project_id ?? undefined,
95
+ name: row.name,
96
+ summary: row.summary,
97
+ description: row.description ?? undefined,
98
+ status: row.status,
99
+ priority: row.priority,
100
+ version: row.version,
101
+ createdAt: toDate(row.created_at),
102
+ modifiedAt: toDate(row.modified_at)
103
+ };
104
+ }
105
+
90
106
  // ============================================================================
91
107
  // Repository Functions
92
108
  // ============================================================================
93
109
 
94
110
  /**
95
- * Create a new dependency between two tasks.
111
+ * Create a new dependency between two entities of the same type.
96
112
  *
97
113
  * Validates:
98
- * - fromTaskId != toTaskId (no self-dependency)
99
- * - Both tasks exist
114
+ * - fromEntityId != toEntityId (no self-dependency)
115
+ * - Both entities exist
100
116
  * - No circular dependencies (if A blocks B, B cannot block A)
101
117
  * - No duplicate dependencies
102
118
  */
103
119
  export function createDependency(params: {
104
- fromTaskId: string;
105
- toTaskId: string;
120
+ fromEntityId: string;
121
+ toEntityId: string;
106
122
  type: DependencyType;
123
+ entityType: DependencyEntityType;
107
124
  }): Result<Dependency> {
108
- const { fromTaskId, toTaskId, type } = params;
125
+ const { fromEntityId, toEntityId, type, entityType } = params;
109
126
 
110
127
  // Validate: no self-dependency
111
- if (fromTaskId === toTaskId) {
112
- return err('Cannot create a dependency from a task to itself', 'SELF_DEPENDENCY');
128
+ if (fromEntityId === toEntityId) {
129
+ return err('Cannot create a dependency from an entity to itself', 'SELF_DEPENDENCY');
113
130
  }
114
131
 
115
- // Validate: both tasks exist
116
- const fromTask = queryOne<{ id: string }>(
117
- 'SELECT id FROM tasks WHERE id = ?',
118
- [fromTaskId]
132
+ // Validate: both entities exist
133
+ const table = entityType === 'task' ? 'tasks' : 'features';
134
+ const fromEntity = queryOne<{ id: string }>(
135
+ `SELECT id FROM ${table} WHERE id = ?`,
136
+ [fromEntityId]
119
137
  );
120
138
 
121
- if (!fromTask) {
122
- return err(`Task not found: ${fromTaskId}`, 'NOT_FOUND');
139
+ if (!fromEntity) {
140
+ return err(`${entityType} not found: ${fromEntityId}`, 'NOT_FOUND');
123
141
  }
124
142
 
125
- const toTask = queryOne<{ id: string }>(
126
- 'SELECT id FROM tasks WHERE id = ?',
127
- [toTaskId]
143
+ const toEntity = queryOne<{ id: string }>(
144
+ `SELECT id FROM ${table} WHERE id = ?`,
145
+ [toEntityId]
128
146
  );
129
147
 
130
- if (!toTask) {
131
- return err(`Task not found: ${toTaskId}`, 'NOT_FOUND');
148
+ if (!toEntity) {
149
+ return err(`${entityType} not found: ${toEntityId}`, 'NOT_FOUND');
132
150
  }
133
151
 
134
- // Validate: no circular dependencies for BLOCKS type
135
- if (type === 'BLOCKS' && hasCircularDependency(fromTaskId, toTaskId)) {
152
+ // Validate: no circular dependencies for BLOCKS and IS_BLOCKED_BY types
153
+ const isCircular = type === 'BLOCKS'
154
+ ? hasCircularDependency(fromEntityId, toEntityId, entityType)
155
+ : type === 'IS_BLOCKED_BY'
156
+ ? hasCircularDependency(toEntityId, fromEntityId, entityType)
157
+ : false;
158
+
159
+ if (isCircular) {
136
160
  return err(
137
161
  'Cannot create dependency: would create a circular dependency',
138
162
  'CIRCULAR_DEPENDENCY'
@@ -141,13 +165,13 @@ export function createDependency(params: {
141
165
 
142
166
  // Check for duplicate
143
167
  const existing = queryOne<{ id: string }>(
144
- 'SELECT id FROM dependencies WHERE from_task_id = ? AND to_task_id = ? AND type = ?',
145
- [fromTaskId, toTaskId, type]
168
+ 'SELECT id FROM dependencies WHERE from_entity_id = ? AND to_entity_id = ? AND type = ? AND entity_type = ?',
169
+ [fromEntityId, toEntityId, type, entityType]
146
170
  );
147
171
 
148
172
  if (existing) {
149
173
  return err(
150
- 'Dependency already exists between these tasks with this type',
174
+ 'Dependency already exists between these entities with this type',
151
175
  'DUPLICATE_DEPENDENCY'
152
176
  );
153
177
  }
@@ -158,14 +182,15 @@ export function createDependency(params: {
158
182
 
159
183
  try {
160
184
  execute(
161
- 'INSERT INTO dependencies (id, from_task_id, to_task_id, type, created_at) VALUES (?, ?, ?, ?, ?)',
162
- [id, fromTaskId, toTaskId, type, createdAt]
185
+ 'INSERT INTO dependencies (id, from_entity_id, to_entity_id, entity_type, type, created_at) VALUES (?, ?, ?, ?, ?, ?)',
186
+ [id, fromEntityId, toEntityId, entityType, type, createdAt]
163
187
  );
164
188
 
165
189
  const dependency: Dependency = {
166
190
  id,
167
- fromTaskId,
168
- toTaskId,
191
+ fromEntityId,
192
+ toEntityId,
193
+ entityType,
169
194
  type,
170
195
  createdAt: toDate(createdAt)
171
196
  };
@@ -177,33 +202,37 @@ export function createDependency(params: {
177
202
  }
178
203
 
179
204
  /**
180
- * Get dependencies for a task.
205
+ * Get dependencies for an entity.
181
206
  *
182
- * @param taskId - The task ID to query
207
+ * @param entityId - The entity ID to query
183
208
  * @param direction - Filter by direction:
184
- * - 'dependencies': tasks that this task depends on (from_task_id = taskId)
185
- * - 'dependents': tasks that depend on this task (to_task_id = taskId)
209
+ * - 'dependencies': entities that this entity depends on (from_entity_id = entityId)
210
+ * - 'dependents': entities that depend on this entity (to_entity_id = entityId)
186
211
  * - 'both': union of above (default)
212
+ * @param entityType - Optional filter by entity type
187
213
  */
188
214
  export function getDependencies(
189
- taskId: string,
190
- direction: 'dependencies' | 'dependents' | 'both' = 'both'
215
+ entityId: string,
216
+ direction: 'dependencies' | 'dependents' | 'both' = 'both',
217
+ entityType?: DependencyEntityType
191
218
  ): Result<Dependency[]> {
192
219
  try {
193
220
  let dependencies: Dependency[] = [];
221
+ const typeFilter = entityType ? ' AND entity_type = ?' : '';
222
+ const typeParam = entityType ? [entityType] : [];
194
223
 
195
224
  if (direction === 'dependencies' || direction === 'both') {
196
225
  const rows = queryAll<any>(
197
- 'SELECT * FROM dependencies WHERE from_task_id = ? ORDER BY created_at',
198
- [taskId]
226
+ `SELECT * FROM dependencies WHERE from_entity_id = ?${typeFilter} ORDER BY created_at`,
227
+ [entityId, ...typeParam]
199
228
  );
200
229
  dependencies.push(...rows.map(mapRowToDependency));
201
230
  }
202
231
 
203
232
  if (direction === 'dependents' || direction === 'both') {
204
233
  const rows = queryAll<any>(
205
- 'SELECT * FROM dependencies WHERE to_task_id = ? ORDER BY created_at',
206
- [taskId]
234
+ `SELECT * FROM dependencies WHERE to_entity_id = ?${typeFilter} ORDER BY created_at`,
235
+ [entityId, ...typeParam]
207
236
  );
208
237
  dependencies.push(...rows.map(mapRowToDependency));
209
238
  }
@@ -232,125 +261,217 @@ export function deleteDependency(id: string): Result<boolean> {
232
261
  }
233
262
 
234
263
  /**
235
- * Get all blocked tasks.
264
+ * Get all blocked entities of a given type.
236
265
  *
237
- * Returns tasks that either:
266
+ * Returns entities that either:
238
267
  * - Have status = 'BLOCKED', OR
239
- * - Have incomplete blocking dependencies (tasks that block them but are not completed)
268
+ * - Have incomplete blocking dependencies (blockers that are not completed/resolved)
240
269
  *
241
- * @param params - Optional filters for projectId and/or featureId
270
+ * @param params - Entity type and optional filters
242
271
  */
243
- export function getBlockedTasks(params?: {
272
+ export function getBlocked(params: {
273
+ entityType: DependencyEntityType;
244
274
  projectId?: string;
245
275
  featureId?: string;
246
- }): Result<Task[]> {
276
+ }): Result<(Task | Feature)[]> {
247
277
  try {
248
- let sql = `
249
- SELECT DISTINCT t.*
250
- FROM tasks t
251
- WHERE (
252
- t.status = 'BLOCKED'
253
- OR EXISTS (
254
- SELECT 1
255
- FROM dependencies d
256
- JOIN tasks blocker ON blocker.id = d.from_task_id
257
- WHERE d.to_task_id = t.id
258
- AND d.type = 'BLOCKS'
259
- AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
278
+ const { entityType } = params;
279
+
280
+ if (entityType === 'task') {
281
+ let sql = `
282
+ SELECT DISTINCT t.*
283
+ FROM tasks t
284
+ WHERE (
285
+ t.status = 'BLOCKED'
286
+ OR EXISTS (
287
+ SELECT 1
288
+ FROM dependencies d
289
+ JOIN tasks blocker ON blocker.id = d.from_entity_id
290
+ WHERE d.to_entity_id = t.id
291
+ AND d.type = 'BLOCKS'
292
+ AND d.entity_type = 'task'
293
+ AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
294
+ )
260
295
  )
261
- )
262
- `;
263
-
264
- const sqlParams: string[] = [];
265
-
266
- if (params?.projectId) {
267
- sql += ' AND t.project_id = ?';
268
- sqlParams.push(params.projectId);
269
- }
270
-
271
- if (params?.featureId) {
272
- sql += ' AND t.feature_id = ?';
273
- sqlParams.push(params.featureId);
296
+ `;
297
+
298
+ const sqlParams: string[] = [];
299
+
300
+ if (params.projectId) {
301
+ sql += ' AND t.project_id = ?';
302
+ sqlParams.push(params.projectId);
303
+ }
304
+
305
+ if (params.featureId) {
306
+ sql += ' AND t.feature_id = ?';
307
+ sqlParams.push(params.featureId);
308
+ }
309
+
310
+ sql += `
311
+ ORDER BY
312
+ CASE t.priority
313
+ WHEN 'HIGH' THEN 1
314
+ WHEN 'MEDIUM' THEN 2
315
+ WHEN 'LOW' THEN 3
316
+ END,
317
+ t.created_at ASC
318
+ `;
319
+
320
+ const rows = queryAll<any>(sql, sqlParams);
321
+ return ok(rows.map(mapRowToTask));
322
+ } else {
323
+ let sql = `
324
+ SELECT DISTINCT f.*
325
+ FROM features f
326
+ WHERE (
327
+ f.status = 'BLOCKED'
328
+ OR EXISTS (
329
+ SELECT 1
330
+ FROM dependencies d
331
+ JOIN features blocker ON blocker.id = d.from_entity_id
332
+ WHERE d.to_entity_id = f.id
333
+ AND d.type = 'BLOCKS'
334
+ AND d.entity_type = 'feature'
335
+ AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
336
+ )
337
+ )
338
+ `;
339
+
340
+ const sqlParams: string[] = [];
341
+
342
+ if (params.projectId) {
343
+ sql += ' AND f.project_id = ?';
344
+ sqlParams.push(params.projectId);
345
+ }
346
+
347
+ sql += `
348
+ ORDER BY
349
+ CASE f.priority
350
+ WHEN 'HIGH' THEN 1
351
+ WHEN 'MEDIUM' THEN 2
352
+ WHEN 'LOW' THEN 3
353
+ END,
354
+ f.created_at ASC
355
+ `;
356
+
357
+ const rows = queryAll<any>(sql, sqlParams);
358
+ return ok(rows.map(mapRowToFeature));
274
359
  }
275
-
276
- sql += ' ORDER BY t.priority DESC, t.created_at ASC';
277
-
278
- const rows = queryAll<any>(sql, sqlParams);
279
- const tasks = rows.map(mapRowToTask);
280
-
281
- return ok(tasks);
282
360
  } catch (error: any) {
283
- return err(`Failed to get blocked tasks: ${error.message}`, 'QUERY_FAILED');
361
+ return err(`Failed to get blocked entities: ${error.message}`, 'QUERY_FAILED');
284
362
  }
285
363
  }
286
364
 
287
365
  /**
288
- * Get the next task to work on.
366
+ * Get the next entity to work on.
367
+ *
368
+ * For tasks: returns the highest priority PENDING task with no incomplete blockers.
369
+ * Ordered by priority, complexity (simpler first), then creation time.
289
370
  *
290
- * Returns the highest priority PENDING task that has no incomplete blocking dependencies.
291
- * Considers priority and complexity.
371
+ * For features: returns the highest priority DRAFT/PLANNING feature with no incomplete blockers.
372
+ * Ordered by priority, then creation time.
292
373
  *
293
- * @param params - Optional filters and priority preference
374
+ * @param params - Entity type, optional filters, and priority preference
294
375
  */
295
- export function getNextTask(params?: {
376
+ export function getNext(params: {
377
+ entityType: DependencyEntityType;
296
378
  projectId?: string;
297
379
  featureId?: string;
298
380
  priority?: string;
299
- }): Result<Task | null> {
381
+ }): Result<Task | Feature | null> {
300
382
  try {
301
- let sql = `
302
- SELECT t.*
303
- FROM tasks t
304
- WHERE t.status = 'PENDING'
305
- AND NOT EXISTS (
306
- SELECT 1
307
- FROM dependencies d
308
- JOIN tasks blocker ON blocker.id = d.from_task_id
309
- WHERE d.to_task_id = t.id
310
- AND d.type = 'BLOCKS'
311
- AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
312
- )
313
- `;
314
-
315
- const sqlParams: string[] = [];
316
-
317
- if (params?.projectId) {
318
- sql += ' AND t.project_id = ?';
319
- sqlParams.push(params.projectId);
320
- }
321
-
322
- if (params?.featureId) {
323
- sql += ' AND t.feature_id = ?';
324
- sqlParams.push(params.featureId);
325
- }
326
-
327
- if (params?.priority) {
328
- sql += ' AND t.priority = ?';
329
- sqlParams.push(params.priority);
330
- }
331
-
332
- // Order by priority (HIGH > MEDIUM > LOW), then by complexity (simpler first), then by creation time
333
- sql += `
334
- ORDER BY
335
- CASE t.priority
336
- WHEN 'HIGH' THEN 1
337
- WHEN 'MEDIUM' THEN 2
338
- WHEN 'LOW' THEN 3
339
- END,
340
- t.complexity ASC,
341
- t.created_at ASC
342
- LIMIT 1
343
- `;
344
-
345
- const row = queryOne<any>(sql, sqlParams);
346
-
347
- if (!row) {
348
- return ok(null);
383
+ const { entityType } = params;
384
+
385
+ if (entityType === 'task') {
386
+ let sql = `
387
+ SELECT t.*
388
+ FROM tasks t
389
+ WHERE t.status = 'PENDING'
390
+ AND NOT EXISTS (
391
+ SELECT 1
392
+ FROM dependencies d
393
+ JOIN tasks blocker ON blocker.id = d.from_entity_id
394
+ WHERE d.to_entity_id = t.id
395
+ AND d.type = 'BLOCKS'
396
+ AND d.entity_type = 'task'
397
+ AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
398
+ )
399
+ `;
400
+
401
+ const sqlParams: string[] = [];
402
+
403
+ if (params.projectId) {
404
+ sql += ' AND t.project_id = ?';
405
+ sqlParams.push(params.projectId);
406
+ }
407
+
408
+ if (params.featureId) {
409
+ sql += ' AND t.feature_id = ?';
410
+ sqlParams.push(params.featureId);
411
+ }
412
+
413
+ if (params.priority) {
414
+ sql += ' AND t.priority = ?';
415
+ sqlParams.push(params.priority);
416
+ }
417
+
418
+ sql += `
419
+ ORDER BY
420
+ CASE t.priority
421
+ WHEN 'HIGH' THEN 1
422
+ WHEN 'MEDIUM' THEN 2
423
+ WHEN 'LOW' THEN 3
424
+ END,
425
+ t.complexity ASC,
426
+ t.created_at ASC
427
+ LIMIT 1
428
+ `;
429
+
430
+ const row = queryOne<any>(sql, sqlParams);
431
+ return ok(row ? mapRowToTask(row) : null);
432
+ } else {
433
+ let sql = `
434
+ SELECT f.*
435
+ FROM features f
436
+ WHERE f.status IN ('DRAFT', 'PLANNING')
437
+ AND NOT EXISTS (
438
+ SELECT 1
439
+ FROM dependencies d
440
+ JOIN features blocker ON blocker.id = d.from_entity_id
441
+ WHERE d.to_entity_id = f.id
442
+ AND d.type = 'BLOCKS'
443
+ AND d.entity_type = 'feature'
444
+ AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
445
+ )
446
+ `;
447
+
448
+ const sqlParams: string[] = [];
449
+
450
+ if (params.projectId) {
451
+ sql += ' AND f.project_id = ?';
452
+ sqlParams.push(params.projectId);
453
+ }
454
+
455
+ if (params.priority) {
456
+ sql += ' AND f.priority = ?';
457
+ sqlParams.push(params.priority);
458
+ }
459
+
460
+ sql += `
461
+ ORDER BY
462
+ CASE f.priority
463
+ WHEN 'HIGH' THEN 1
464
+ WHEN 'MEDIUM' THEN 2
465
+ WHEN 'LOW' THEN 3
466
+ END,
467
+ f.created_at ASC
468
+ LIMIT 1
469
+ `;
470
+
471
+ const row = queryOne<any>(sql, sqlParams);
472
+ return ok(row ? mapRowToFeature(row) : null);
349
473
  }
350
-
351
- const task = mapRowToTask(row);
352
- return ok(task);
353
474
  } catch (error: any) {
354
- return err(`Failed to get next task: ${error.message}`, 'QUERY_FAILED');
475
+ return err(`Failed to get next entity: ${error.message}`, 'QUERY_FAILED');
355
476
  }
356
477
  }
@@ -376,7 +376,7 @@ export function deleteFeature(id: string, options?: { cascade?: boolean }): Resu
376
376
 
377
377
  // Delete each task's dependencies, sections, and tags
378
378
  for (const task of taskIds) {
379
- execute('DELETE FROM dependencies WHERE from_task_id = ? OR to_task_id = ?', [task.id, task.id]);
379
+ execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [task.id, task.id, 'task']);
380
380
  execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.TASK, task.id]);
381
381
  deleteTags(task.id, EntityType.TASK);
382
382
  }
@@ -385,6 +385,9 @@ export function deleteFeature(id: string, options?: { cascade?: boolean }): Resu
385
385
  execute('DELETE FROM tasks WHERE feature_id = ?', [id]);
386
386
  }
387
387
 
388
+ // Delete feature-level dependencies
389
+ execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [id, id, 'feature']);
390
+
388
391
  // Delete feature sections
389
392
  execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.FEATURE, id]);
390
393
 
@@ -268,7 +268,7 @@ export function deleteProject(id: string, options?: { cascade?: boolean }): Resu
268
268
 
269
269
  // Delete each task's dependencies, sections, and tags
270
270
  for (const task of taskIds) {
271
- execute('DELETE FROM dependencies WHERE from_task_id = ? OR to_task_id = ?', [task.id, task.id]);
271
+ execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [task.id, task.id, 'task']);
272
272
  execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.TASK, task.id]);
273
273
  deleteTags(task.id, EntityType.TASK);
274
274
  }
@@ -280,8 +280,9 @@ export function deleteProject(id: string, options?: { cascade?: boolean }): Resu
280
280
  [id, id]
281
281
  );
282
282
 
283
- // Delete each feature's sections and tags
283
+ // Delete each feature's dependencies, sections, and tags
284
284
  for (const feature of featureIds) {
285
+ execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [feature.id, feature.id, 'feature']);
285
286
  execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.FEATURE, feature.id]);
286
287
  deleteTags(feature.id, EntityType.FEATURE);
287
288
  }