@allpepper/task-orchestrator 1.1.3 → 1.2.1

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.3",
3
+ "version": "1.2.1",
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,83 @@ 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
+ let { fromEntityId, toEntityId, type, entityType } = params;
126
+
127
+ // Normalize IS_BLOCKED_BY → BLOCKS by swapping from/to.
128
+ // This keeps a single canonical form in the DB so all downstream
129
+ // queries only need to handle BLOCKS direction.
130
+ if (type === 'IS_BLOCKED_BY') {
131
+ [fromEntityId, toEntityId] = [toEntityId, fromEntityId];
132
+ type = 'BLOCKS' as DependencyType;
133
+ }
109
134
 
110
135
  // Validate: no self-dependency
111
- if (fromTaskId === toTaskId) {
112
- return err('Cannot create a dependency from a task to itself', 'SELF_DEPENDENCY');
136
+ if (fromEntityId === toEntityId) {
137
+ return err('Cannot create a dependency from an entity to itself', 'SELF_DEPENDENCY');
113
138
  }
114
139
 
115
- // Validate: both tasks exist
116
- const fromTask = queryOne<{ id: string }>(
117
- 'SELECT id FROM tasks WHERE id = ?',
118
- [fromTaskId]
140
+ // Validate: both entities exist
141
+ const table = entityType === 'task' ? 'tasks' : 'features';
142
+ const fromEntity = queryOne<{ id: string }>(
143
+ `SELECT id FROM ${table} WHERE id = ?`,
144
+ [fromEntityId]
119
145
  );
120
146
 
121
- if (!fromTask) {
122
- return err(`Task not found: ${fromTaskId}`, 'NOT_FOUND');
147
+ if (!fromEntity) {
148
+ return err(`${entityType} not found: ${fromEntityId}`, 'NOT_FOUND');
123
149
  }
124
150
 
125
- const toTask = queryOne<{ id: string }>(
126
- 'SELECT id FROM tasks WHERE id = ?',
127
- [toTaskId]
151
+ const toEntity = queryOne<{ id: string }>(
152
+ `SELECT id FROM ${table} WHERE id = ?`,
153
+ [toEntityId]
128
154
  );
129
155
 
130
- if (!toTask) {
131
- return err(`Task not found: ${toTaskId}`, 'NOT_FOUND');
156
+ if (!toEntity) {
157
+ return err(`${entityType} not found: ${toEntityId}`, 'NOT_FOUND');
132
158
  }
133
159
 
134
160
  // Validate: no circular dependencies for BLOCKS type
135
- if (type === 'BLOCKS' && hasCircularDependency(fromTaskId, toTaskId)) {
161
+ // (IS_BLOCKED_BY is already normalized to BLOCKS above, RELATES_TO has no direction)
162
+ const isCircular = type === 'BLOCKS'
163
+ ? hasCircularDependency(fromEntityId, toEntityId, entityType)
164
+ : false;
165
+
166
+ if (isCircular) {
136
167
  return err(
137
168
  'Cannot create dependency: would create a circular dependency',
138
169
  'CIRCULAR_DEPENDENCY'
@@ -141,13 +172,13 @@ export function createDependency(params: {
141
172
 
142
173
  // Check for duplicate
143
174
  const existing = queryOne<{ id: string }>(
144
- 'SELECT id FROM dependencies WHERE from_task_id = ? AND to_task_id = ? AND type = ?',
145
- [fromTaskId, toTaskId, type]
175
+ 'SELECT id FROM dependencies WHERE from_entity_id = ? AND to_entity_id = ? AND type = ? AND entity_type = ?',
176
+ [fromEntityId, toEntityId, type, entityType]
146
177
  );
147
178
 
148
179
  if (existing) {
149
180
  return err(
150
- 'Dependency already exists between these tasks with this type',
181
+ 'Dependency already exists between these entities with this type',
151
182
  'DUPLICATE_DEPENDENCY'
152
183
  );
153
184
  }
@@ -158,14 +189,15 @@ export function createDependency(params: {
158
189
 
159
190
  try {
160
191
  execute(
161
- 'INSERT INTO dependencies (id, from_task_id, to_task_id, type, created_at) VALUES (?, ?, ?, ?, ?)',
162
- [id, fromTaskId, toTaskId, type, createdAt]
192
+ 'INSERT INTO dependencies (id, from_entity_id, to_entity_id, entity_type, type, created_at) VALUES (?, ?, ?, ?, ?, ?)',
193
+ [id, fromEntityId, toEntityId, entityType, type, createdAt]
163
194
  );
164
195
 
165
196
  const dependency: Dependency = {
166
197
  id,
167
- fromTaskId,
168
- toTaskId,
198
+ fromEntityId,
199
+ toEntityId,
200
+ entityType,
169
201
  type,
170
202
  createdAt: toDate(createdAt)
171
203
  };
@@ -177,33 +209,37 @@ export function createDependency(params: {
177
209
  }
178
210
 
179
211
  /**
180
- * Get dependencies for a task.
212
+ * Get dependencies for an entity.
181
213
  *
182
- * @param taskId - The task ID to query
214
+ * @param entityId - The entity ID to query
183
215
  * @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)
216
+ * - 'dependencies': entities that this entity depends on (from_entity_id = entityId)
217
+ * - 'dependents': entities that depend on this entity (to_entity_id = entityId)
186
218
  * - 'both': union of above (default)
219
+ * @param entityType - Optional filter by entity type
187
220
  */
188
221
  export function getDependencies(
189
- taskId: string,
190
- direction: 'dependencies' | 'dependents' | 'both' = 'both'
222
+ entityId: string,
223
+ direction: 'dependencies' | 'dependents' | 'both' = 'both',
224
+ entityType?: DependencyEntityType
191
225
  ): Result<Dependency[]> {
192
226
  try {
193
227
  let dependencies: Dependency[] = [];
228
+ const typeFilter = entityType ? ' AND entity_type = ?' : '';
229
+ const typeParam = entityType ? [entityType] : [];
194
230
 
195
231
  if (direction === 'dependencies' || direction === 'both') {
196
232
  const rows = queryAll<any>(
197
- 'SELECT * FROM dependencies WHERE from_task_id = ? ORDER BY created_at',
198
- [taskId]
233
+ `SELECT * FROM dependencies WHERE from_entity_id = ?${typeFilter} ORDER BY created_at`,
234
+ [entityId, ...typeParam]
199
235
  );
200
236
  dependencies.push(...rows.map(mapRowToDependency));
201
237
  }
202
238
 
203
239
  if (direction === 'dependents' || direction === 'both') {
204
240
  const rows = queryAll<any>(
205
- 'SELECT * FROM dependencies WHERE to_task_id = ? ORDER BY created_at',
206
- [taskId]
241
+ `SELECT * FROM dependencies WHERE to_entity_id = ?${typeFilter} ORDER BY created_at`,
242
+ [entityId, ...typeParam]
207
243
  );
208
244
  dependencies.push(...rows.map(mapRowToDependency));
209
245
  }
@@ -232,125 +268,217 @@ export function deleteDependency(id: string): Result<boolean> {
232
268
  }
233
269
 
234
270
  /**
235
- * Get all blocked tasks.
271
+ * Get all blocked entities of a given type.
236
272
  *
237
- * Returns tasks that either:
273
+ * Returns entities that either:
238
274
  * - Have status = 'BLOCKED', OR
239
- * - Have incomplete blocking dependencies (tasks that block them but are not completed)
275
+ * - Have incomplete blocking dependencies (blockers that are not completed/resolved)
240
276
  *
241
- * @param params - Optional filters for projectId and/or featureId
277
+ * @param params - Entity type and optional filters
242
278
  */
243
- export function getBlockedTasks(params?: {
279
+ export function getBlocked(params: {
280
+ entityType: DependencyEntityType;
244
281
  projectId?: string;
245
282
  featureId?: string;
246
- }): Result<Task[]> {
283
+ }): Result<(Task | Feature)[]> {
247
284
  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')
285
+ const { entityType } = params;
286
+
287
+ if (entityType === 'task') {
288
+ let sql = `
289
+ SELECT DISTINCT t.*
290
+ FROM tasks t
291
+ WHERE (
292
+ t.status = 'BLOCKED'
293
+ OR EXISTS (
294
+ SELECT 1
295
+ FROM dependencies d
296
+ JOIN tasks blocker ON blocker.id = d.from_entity_id
297
+ WHERE d.to_entity_id = t.id
298
+ AND d.type = 'BLOCKS'
299
+ AND d.entity_type = 'task'
300
+ AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
301
+ )
260
302
  )
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);
303
+ `;
304
+
305
+ const sqlParams: string[] = [];
306
+
307
+ if (params.projectId) {
308
+ sql += ' AND t.project_id = ?';
309
+ sqlParams.push(params.projectId);
310
+ }
311
+
312
+ if (params.featureId) {
313
+ sql += ' AND t.feature_id = ?';
314
+ sqlParams.push(params.featureId);
315
+ }
316
+
317
+ sql += `
318
+ ORDER BY
319
+ CASE t.priority
320
+ WHEN 'HIGH' THEN 1
321
+ WHEN 'MEDIUM' THEN 2
322
+ WHEN 'LOW' THEN 3
323
+ END,
324
+ t.created_at ASC
325
+ `;
326
+
327
+ const rows = queryAll<any>(sql, sqlParams);
328
+ return ok(rows.map(mapRowToTask));
329
+ } else {
330
+ let sql = `
331
+ SELECT DISTINCT f.*
332
+ FROM features f
333
+ WHERE (
334
+ f.status = 'BLOCKED'
335
+ OR EXISTS (
336
+ SELECT 1
337
+ FROM dependencies d
338
+ JOIN features blocker ON blocker.id = d.from_entity_id
339
+ WHERE d.to_entity_id = f.id
340
+ AND d.type = 'BLOCKS'
341
+ AND d.entity_type = 'feature'
342
+ AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
343
+ )
344
+ )
345
+ `;
346
+
347
+ const sqlParams: string[] = [];
348
+
349
+ if (params.projectId) {
350
+ sql += ' AND f.project_id = ?';
351
+ sqlParams.push(params.projectId);
352
+ }
353
+
354
+ sql += `
355
+ ORDER BY
356
+ CASE f.priority
357
+ WHEN 'HIGH' THEN 1
358
+ WHEN 'MEDIUM' THEN 2
359
+ WHEN 'LOW' THEN 3
360
+ END,
361
+ f.created_at ASC
362
+ `;
363
+
364
+ const rows = queryAll<any>(sql, sqlParams);
365
+ return ok(rows.map(mapRowToFeature));
274
366
  }
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
367
  } catch (error: any) {
283
- return err(`Failed to get blocked tasks: ${error.message}`, 'QUERY_FAILED');
368
+ return err(`Failed to get blocked entities: ${error.message}`, 'QUERY_FAILED');
284
369
  }
285
370
  }
286
371
 
287
372
  /**
288
- * Get the next task to work on.
373
+ * Get the next entity to work on.
374
+ *
375
+ * For tasks: returns the highest priority PENDING task with no incomplete blockers.
376
+ * Ordered by priority, complexity (simpler first), then creation time.
289
377
  *
290
- * Returns the highest priority PENDING task that has no incomplete blocking dependencies.
291
- * Considers priority and complexity.
378
+ * For features: returns the highest priority DRAFT/PLANNING feature with no incomplete blockers.
379
+ * Ordered by priority, then creation time.
292
380
  *
293
- * @param params - Optional filters and priority preference
381
+ * @param params - Entity type, optional filters, and priority preference
294
382
  */
295
- export function getNextTask(params?: {
383
+ export function getNext(params: {
384
+ entityType: DependencyEntityType;
296
385
  projectId?: string;
297
386
  featureId?: string;
298
387
  priority?: string;
299
- }): Result<Task | null> {
388
+ }): Result<Task | Feature | null> {
300
389
  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);
390
+ const { entityType } = params;
391
+
392
+ if (entityType === 'task') {
393
+ let sql = `
394
+ SELECT t.*
395
+ FROM tasks t
396
+ WHERE t.status = 'PENDING'
397
+ AND NOT EXISTS (
398
+ SELECT 1
399
+ FROM dependencies d
400
+ JOIN tasks blocker ON blocker.id = d.from_entity_id
401
+ WHERE d.to_entity_id = t.id
402
+ AND d.type = 'BLOCKS'
403
+ AND d.entity_type = 'task'
404
+ AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
405
+ )
406
+ `;
407
+
408
+ const sqlParams: string[] = [];
409
+
410
+ if (params.projectId) {
411
+ sql += ' AND t.project_id = ?';
412
+ sqlParams.push(params.projectId);
413
+ }
414
+
415
+ if (params.featureId) {
416
+ sql += ' AND t.feature_id = ?';
417
+ sqlParams.push(params.featureId);
418
+ }
419
+
420
+ if (params.priority) {
421
+ sql += ' AND t.priority = ?';
422
+ sqlParams.push(params.priority);
423
+ }
424
+
425
+ sql += `
426
+ ORDER BY
427
+ CASE t.priority
428
+ WHEN 'HIGH' THEN 1
429
+ WHEN 'MEDIUM' THEN 2
430
+ WHEN 'LOW' THEN 3
431
+ END,
432
+ t.complexity ASC,
433
+ t.created_at ASC
434
+ LIMIT 1
435
+ `;
436
+
437
+ const row = queryOne<any>(sql, sqlParams);
438
+ return ok(row ? mapRowToTask(row) : null);
439
+ } else {
440
+ let sql = `
441
+ SELECT f.*
442
+ FROM features f
443
+ WHERE f.status IN ('DRAFT', 'PLANNING')
444
+ AND NOT EXISTS (
445
+ SELECT 1
446
+ FROM dependencies d
447
+ JOIN features blocker ON blocker.id = d.from_entity_id
448
+ WHERE d.to_entity_id = f.id
449
+ AND d.type = 'BLOCKS'
450
+ AND d.entity_type = 'feature'
451
+ AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
452
+ )
453
+ `;
454
+
455
+ const sqlParams: string[] = [];
456
+
457
+ if (params.projectId) {
458
+ sql += ' AND f.project_id = ?';
459
+ sqlParams.push(params.projectId);
460
+ }
461
+
462
+ if (params.priority) {
463
+ sql += ' AND f.priority = ?';
464
+ sqlParams.push(params.priority);
465
+ }
466
+
467
+ sql += `
468
+ ORDER BY
469
+ CASE f.priority
470
+ WHEN 'HIGH' THEN 1
471
+ WHEN 'MEDIUM' THEN 2
472
+ WHEN 'LOW' THEN 3
473
+ END,
474
+ f.created_at ASC
475
+ LIMIT 1
476
+ `;
477
+
478
+ const row = queryOne<any>(sql, sqlParams);
479
+ return ok(row ? mapRowToFeature(row) : null);
349
480
  }
350
-
351
- const task = mapRowToTask(row);
352
- return ok(task);
353
481
  } catch (error: any) {
354
- return err(`Failed to get next task: ${error.message}`, 'QUERY_FAILED');
482
+ return err(`Failed to get next entity: ${error.message}`, 'QUERY_FAILED');
355
483
  }
356
484
  }
@@ -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