trekoon 0.2.9 → 0.3.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.
Files changed (41) hide show
  1. package/.agents/skills/trekoon/SKILL.md +162 -26
  2. package/README.md +18 -15
  3. package/docs/ai-agents.md +49 -4
  4. package/docs/commands.md +90 -16
  5. package/docs/machine-contracts.md +120 -0
  6. package/docs/plans/r1-unified-skill-rewrite.md +290 -0
  7. package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
  8. package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
  9. package/docs/quickstart.md +41 -12
  10. package/package.json +23 -1
  11. package/src/board/assets/app.js +1 -0
  12. package/src/board/assets/components/EpicRow.js +21 -6
  13. package/src/board/assets/components/EpicsOverview.js +5 -1
  14. package/src/board/assets/components/Notice.js +19 -12
  15. package/src/board/assets/components/Workspace.js +16 -5
  16. package/src/board/assets/components/helpers.js +17 -0
  17. package/src/board/assets/runtime/clipboard.js +34 -0
  18. package/src/board/assets/runtime/delegation.js +33 -0
  19. package/src/board/assets/state/actions.js +68 -0
  20. package/src/board/assets/state/store.js +1 -0
  21. package/src/board/assets/styles/board.css +156 -36
  22. package/src/board/routes.ts +2 -0
  23. package/src/commands/epic.ts +74 -3
  24. package/src/commands/session.ts +7 -75
  25. package/src/commands/subtask.ts +7 -5
  26. package/src/commands/suggest.ts +283 -0
  27. package/src/commands/sync-helpers.ts +75 -0
  28. package/src/commands/task-readiness.ts +8 -20
  29. package/src/commands/task.ts +59 -3
  30. package/src/domain/mutation-service.ts +69 -42
  31. package/src/domain/tracker-domain.ts +151 -22
  32. package/src/domain/types.ts +12 -0
  33. package/src/index.ts +1 -1
  34. package/src/io/output.ts +4 -2
  35. package/src/runtime/cli-shell.ts +26 -3
  36. package/src/runtime/command-types.ts +1 -1
  37. package/src/storage/database.ts +43 -1
  38. package/src/storage/events-retention.ts +57 -8
  39. package/src/storage/migrations.ts +58 -3
  40. package/src/sync/service.ts +101 -24
  41. package/src/sync/types.ts +1 -0
@@ -1,8 +1,9 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
+ import { writeTransaction } from "../storage/database";
3
4
  import { appendEventWithGitContext } from "../sync/event-writes";
4
5
  import { ENTITY_OPERATIONS } from "./mutation-operations";
5
- import { TrackerDomain } from "./tracker-domain";
6
+ import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
6
7
  import {
7
8
  type CompactEpicCreateResult,
8
9
  type CompactEpicExpandResult,
@@ -104,7 +105,7 @@ export class MutationService {
104
105
  }
105
106
 
106
107
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
107
- return this.#db.transaction((): EpicRecord => {
108
+ return writeTransaction(this.#db, (): EpicRecord => {
108
109
  const epic = this.#domain.createEpic(input);
109
110
  this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
110
111
  title: epic.title,
@@ -112,7 +113,7 @@ export class MutationService {
112
113
  status: epic.status,
113
114
  });
114
115
  return epic;
115
- })();
116
+ });
116
117
  }
117
118
 
118
119
  createEpicGraph(input: {
@@ -123,7 +124,7 @@ export class MutationService {
123
124
  subtaskSpecs: readonly CompactSubtaskSpec[];
124
125
  dependencySpecs: readonly CompactDependencySpec[];
125
126
  }): CompactEpicCreateResult {
126
- return this.#db.transaction((): CompactEpicCreateResult => {
127
+ return writeTransaction(this.#db, (): CompactEpicCreateResult => {
127
128
  const epic = this.#domain.createEpic(input);
128
129
  const created = this.#domain.expandEpic({
129
130
  epicId: epic.id,
@@ -172,14 +173,18 @@ export class MutationService {
172
173
  dependencies: created.dependencies,
173
174
  result: created.result,
174
175
  };
175
- })();
176
+ });
176
177
  }
177
178
 
178
179
  updateEpic(
179
180
  id: string,
180
181
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
181
182
  ): EpicRecord {
182
- return this.#db.transaction((): EpicRecord => {
183
+ return writeTransaction(this.#db, (): EpicRecord => {
184
+ if (input.status !== undefined) {
185
+ const existing = this.#domain.getEpicOrThrow(id);
186
+ validateStatusTransition(existing.status, input.status, "epic", id);
187
+ }
183
188
  const epic = this.#domain.updateEpic(id, input);
184
189
  this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
185
190
  title: epic.title,
@@ -187,27 +192,27 @@ export class MutationService {
187
192
  status: epic.status,
188
193
  });
189
194
  return epic;
190
- })();
195
+ });
191
196
  }
192
197
 
193
198
  updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
194
- return this.#db.transaction((): StatusCascadePlan => {
199
+ return writeTransaction(this.#db, (): StatusCascadePlan => {
195
200
  const plan = this.#domain.planStatusCascade("epic", id, status);
196
201
  this.#assertCascadeNotBlocked(plan);
197
202
  this.#applyStatusCascadePlan(plan);
198
203
  return plan;
199
- })();
204
+ });
200
205
  }
201
206
 
202
207
  deleteEpic(id: string): void {
203
- this.#db.transaction((): void => {
208
+ writeTransaction(this.#db, (): void => {
204
209
  this.#domain.deleteEpic(id);
205
210
  this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
206
- })();
211
+ });
207
212
  }
208
213
 
209
214
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
210
- return this.#db.transaction((): TaskRecord => {
215
+ return writeTransaction(this.#db, (): TaskRecord => {
211
216
  const task = this.#domain.createTask(input);
212
217
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
213
218
  epic_id: task.epicId,
@@ -216,11 +221,11 @@ export class MutationService {
216
221
  status: task.status,
217
222
  });
218
223
  return task;
219
- })();
224
+ });
220
225
  }
221
226
 
222
227
  createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
223
- return this.#db.transaction((): CompactTaskBatchCreateResult => {
228
+ return writeTransaction(this.#db, (): CompactTaskBatchCreateResult => {
224
229
  const created = this.#domain.createTaskBatch(input);
225
230
  for (const task of created.tasks) {
226
231
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -231,7 +236,7 @@ export class MutationService {
231
236
  });
232
237
  }
233
238
  return created;
234
- })();
239
+ });
235
240
  }
236
241
 
237
242
  expandEpic(input: {
@@ -240,7 +245,7 @@ export class MutationService {
240
245
  subtaskSpecs: readonly CompactSubtaskSpec[];
241
246
  dependencySpecs: readonly CompactDependencySpec[];
242
247
  }): CompactEpicExpandResult {
243
- return this.#db.transaction((): CompactEpicExpandResult => {
248
+ return writeTransaction(this.#db, (): CompactEpicExpandResult => {
244
249
  const created = this.#domain.expandEpic(input);
245
250
  for (const task of created.tasks) {
246
251
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
@@ -270,39 +275,44 @@ export class MutationService {
270
275
  }
271
276
 
272
277
  return created;
273
- })();
278
+ });
274
279
  }
275
280
 
276
281
  updateTask(
277
282
  id: string,
278
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
283
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
279
284
  ): TaskRecord {
280
- return this.#db.transaction((): TaskRecord => {
285
+ return writeTransaction(this.#db, (): TaskRecord => {
286
+ if (input.status !== undefined) {
287
+ const existing = this.#domain.getTaskOrThrow(id);
288
+ validateStatusTransition(existing.status, input.status, "task", id);
289
+ }
281
290
  const task = this.#domain.updateTask(id, input);
282
291
  this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
283
292
  epic_id: task.epicId,
284
293
  title: task.title,
285
294
  description: task.description,
286
295
  status: task.status,
296
+ owner: task.owner,
287
297
  });
288
298
  return task;
289
- })();
299
+ });
290
300
  }
291
301
 
292
302
  updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
293
- return this.#db.transaction((): StatusCascadePlan => {
303
+ return writeTransaction(this.#db, (): StatusCascadePlan => {
294
304
  const plan = this.#domain.planStatusCascade("task", id, status);
295
305
  this.#assertCascadeNotBlocked(plan);
296
306
  this.#applyStatusCascadePlan(plan);
297
307
  return plan;
298
- })();
308
+ });
299
309
  }
300
310
 
301
311
  deleteTask(id: string): void {
302
- this.#db.transaction((): void => {
312
+ writeTransaction(this.#db, (): void => {
303
313
  this.#domain.deleteTask(id);
304
314
  this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
305
- })();
315
+ });
306
316
  }
307
317
 
308
318
  createSubtask(input: {
@@ -311,7 +321,7 @@ export class MutationService {
311
321
  description?: string | undefined;
312
322
  status?: string | undefined;
313
323
  }): SubtaskRecord {
314
- return this.#db.transaction((): SubtaskRecord => {
324
+ return writeTransaction(this.#db, (): SubtaskRecord => {
315
325
  const subtask = this.#domain.createSubtask(input);
316
326
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
317
327
  task_id: subtask.taskId,
@@ -320,11 +330,11 @@ export class MutationService {
320
330
  status: subtask.status,
321
331
  });
322
332
  return subtask;
323
- })();
333
+ });
324
334
  }
325
335
 
326
336
  createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
327
- return this.#db.transaction((): CompactSubtaskBatchCreateResult => {
337
+ return writeTransaction(this.#db, (): CompactSubtaskBatchCreateResult => {
328
338
  const created = this.#domain.createSubtaskBatch(input);
329
339
  for (const subtask of created.subtasks) {
330
340
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
@@ -335,34 +345,39 @@ export class MutationService {
335
345
  });
336
346
  }
337
347
  return created;
338
- })();
348
+ });
339
349
  }
340
350
 
341
351
  updateSubtask(
342
352
  id: string,
343
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
353
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
344
354
  ): SubtaskRecord {
345
- return this.#db.transaction((): SubtaskRecord => {
355
+ return writeTransaction(this.#db, (): SubtaskRecord => {
356
+ if (input.status !== undefined) {
357
+ const existing = this.#domain.getSubtaskOrThrow(id);
358
+ validateStatusTransition(existing.status, input.status, "subtask", id);
359
+ }
346
360
  const subtask = this.#domain.updateSubtask(id, input);
347
361
  this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
348
362
  task_id: subtask.taskId,
349
363
  title: subtask.title,
350
364
  description: subtask.description,
351
365
  status: subtask.status,
366
+ owner: subtask.owner,
352
367
  });
353
368
  return subtask;
354
- })();
369
+ });
355
370
  }
356
371
 
357
372
  deleteSubtask(id: string): void {
358
- this.#db.transaction((): void => {
373
+ writeTransaction(this.#db, (): void => {
359
374
  this.#domain.deleteSubtask(id);
360
375
  this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
361
- })();
376
+ });
362
377
  }
363
378
 
364
379
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
365
- return this.#db.transaction((): DependencyRecord => {
380
+ return writeTransaction(this.#db, (): DependencyRecord => {
366
381
  const dependency = this.#domain.addDependency(sourceId, dependsOnId);
367
382
  this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
368
383
  source_id: dependency.sourceId,
@@ -371,11 +386,11 @@ export class MutationService {
371
386
  depends_on_kind: dependency.dependsOnKind,
372
387
  });
373
388
  return dependency;
374
- })();
389
+ });
375
390
  }
376
391
 
377
392
  addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
378
- return this.#db.transaction((): CompactDependencyBatchAddResult => {
393
+ return writeTransaction(this.#db, (): CompactDependencyBatchAddResult => {
379
394
  const created = this.#domain.addDependencyBatch(input);
380
395
  for (const dependency of created.dependencies) {
381
396
  this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
@@ -386,11 +401,11 @@ export class MutationService {
386
401
  });
387
402
  }
388
403
  return created;
389
- })();
404
+ });
390
405
  }
391
406
 
392
407
  removeDependency(sourceId: string, dependsOnId: string): number {
393
- return this.#db.transaction((): number => {
408
+ return writeTransaction(this.#db, (): number => {
394
409
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
395
410
  if (removed > 0) {
396
411
  this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
@@ -399,11 +414,19 @@ export class MutationService {
399
414
  });
400
415
  }
401
416
  return removed;
402
- })();
417
+ });
403
418
  }
404
419
 
405
420
  describeError(error: unknown): string | undefined {
406
- if (!(error instanceof DomainError) || error.code !== "dependency_blocked") {
421
+ if (!(error instanceof DomainError)) {
422
+ return undefined;
423
+ }
424
+
425
+ if (error.code === "status_transition_invalid") {
426
+ return error.message;
427
+ }
428
+
429
+ if (error.code !== "dependency_blocked") {
407
430
  return undefined;
408
431
  }
409
432
 
@@ -560,6 +583,7 @@ export class MutationService {
560
583
  title: task.title,
561
584
  description: task.description,
562
585
  status: task.status,
586
+ owner: task.owner,
563
587
  });
564
588
  continue;
565
589
  }
@@ -570,6 +594,7 @@ export class MutationService {
570
594
  title: subtask.title,
571
595
  description: subtask.description,
572
596
  status: subtask.status,
597
+ owner: subtask.owner,
573
598
  });
574
599
  }
575
600
  }
@@ -582,7 +607,7 @@ export class MutationService {
582
607
  ): ScopeReplacementResult {
583
608
  const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
584
609
 
585
- this.#db.transaction((): void => {
610
+ writeTransaction(this.#db, (): void => {
586
611
  for (const node of nodes) {
587
612
  const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
588
613
  const nextDescription = fields.includes("description")
@@ -610,6 +635,7 @@ export class MutationService {
610
635
  title: task.title,
611
636
  description: task.description,
612
637
  status: task.status,
638
+ owner: task.owner,
613
639
  });
614
640
  continue;
615
641
  }
@@ -620,9 +646,10 @@ export class MutationService {
620
646
  title: subtask.title,
621
647
  description: subtask.description,
622
648
  status: subtask.status,
649
+ owner: subtask.owner,
623
650
  });
624
651
  }
625
- })();
652
+ });
626
653
 
627
654
  return result;
628
655
  }
@@ -32,10 +32,13 @@ import {
32
32
  type SubtaskRecord,
33
33
  type TaskTreeDetailed,
34
34
  type TaskRecord,
35
+ VALID_STATUSES,
36
+ VALID_TRANSITIONS,
37
+ type ValidStatus,
35
38
  } from "./types";
36
39
 
37
40
  const DEFAULT_STATUS = "todo";
38
- const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "in-progress", "done"]);
41
+ const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
39
42
 
40
43
  interface EpicRow {
41
44
  id: string;
@@ -48,10 +51,12 @@ interface EpicRow {
48
51
 
49
52
  interface TaskRow extends EpicRow {
50
53
  epic_id: string;
54
+ owner: string | null;
51
55
  }
52
56
 
53
57
  interface SubtaskRow extends EpicRow {
54
58
  task_id: string;
59
+ owner: string | null;
55
60
  }
56
61
 
57
62
  interface DependencyRow {
@@ -146,6 +151,45 @@ function normalizeSubtaskDescription(value: string | undefined): string {
146
151
  return value.trim();
147
152
  }
148
153
 
154
+ function isValidStatus(status: string): status is ValidStatus {
155
+ return (VALID_STATUSES as readonly string[]).includes(status);
156
+ }
157
+
158
+ export function validateStatusTransition(fromStatus: string, toStatus: string, entityKind: string, entityId: string): void {
159
+ if (fromStatus === toStatus) {
160
+ return;
161
+ }
162
+
163
+ if (!isValidStatus(toStatus)) {
164
+ throw new DomainError({
165
+ code: "status_transition_invalid",
166
+ message: `invalid status '${toStatus}' for ${entityKind} ${entityId}; allowed statuses: ${VALID_STATUSES.join(", ")}`,
167
+ details: { entity: entityKind, id: entityId, fromStatus, toStatus, allowedStatuses: [...VALID_STATUSES] },
168
+ });
169
+ }
170
+
171
+ if (!isValidStatus(fromStatus)) {
172
+ // Legacy/custom status from pre-0.3.1 data; allow transition to any valid
173
+ // status so existing databases can migrate forward without manual fixups.
174
+ return;
175
+ }
176
+
177
+ const allowed = VALID_TRANSITIONS.get(fromStatus);
178
+ if (!allowed || !allowed.has(toStatus)) {
179
+ throw new DomainError({
180
+ code: "status_transition_invalid",
181
+ message: `cannot transition ${entityKind} ${entityId} from '${fromStatus}' to '${toStatus}'`,
182
+ details: {
183
+ entity: entityKind,
184
+ id: entityId,
185
+ fromStatus,
186
+ toStatus,
187
+ allowedTransitions: allowed ? [...allowed] : [],
188
+ },
189
+ });
190
+ }
191
+ }
192
+
149
193
  function mapEpic(row: EpicRow): EpicRecord {
150
194
  return {
151
195
  id: row.id,
@@ -164,6 +208,7 @@ function mapTask(row: TaskRow): TaskRecord {
164
208
  title: row.title,
165
209
  description: row.description,
166
210
  status: row.status,
211
+ owner: row.owner ?? null,
167
212
  createdAt: row.created_at,
168
213
  updatedAt: row.updated_at,
169
214
  };
@@ -176,6 +221,7 @@ function mapSubtask(row: SubtaskRow): SubtaskRecord {
176
221
  title: row.title,
177
222
  description: row.description,
178
223
  status: row.status,
224
+ owner: row.owner ?? null,
179
225
  createdAt: row.created_at,
180
226
  updatedAt: row.updated_at,
181
227
  };
@@ -376,21 +422,21 @@ export class TrackerDomain {
376
422
  this.getEpicOrThrow(epicId);
377
423
  const rows = this.#db
378
424
  .query(
379
- "SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
425
+ "SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
380
426
  )
381
427
  .all(epicId) as TaskRow[];
382
428
  return rows.map(mapTask);
383
429
  }
384
430
 
385
431
  const rows = this.#db
386
- .query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
432
+ .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
387
433
  .all() as TaskRow[];
388
434
  return rows.map(mapTask);
389
435
  }
390
436
 
391
437
  getTask(id: string): TaskRecord | null {
392
438
  const row = this.#db
393
- .query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE id = ?;")
439
+ .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id = ?;")
394
440
  .get(id) as TaskRow | null;
395
441
  return row ? mapTask(row) : null;
396
442
  }
@@ -410,19 +456,20 @@ export class TrackerDomain {
410
456
 
411
457
  updateTask(
412
458
  id: string,
413
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
459
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
414
460
  ): TaskRecord {
415
461
  const existing: TaskRecord = this.getTaskOrThrow(id);
416
462
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
417
463
  const nextDescription: string =
418
464
  input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
419
465
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
466
+ const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
420
467
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
421
468
  const now: number = Date.now();
422
469
 
423
470
  this.#db
424
- .query("UPDATE tasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
425
- .run(nextTitle, nextDescription, nextStatus, now, id);
471
+ .query("UPDATE tasks SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
472
+ .run(nextTitle, nextDescription, nextStatus, nextOwner, now, id);
426
473
 
427
474
  return this.getTaskOrThrow(id);
428
475
  }
@@ -541,7 +588,7 @@ export class TrackerDomain {
541
588
  this.getTaskOrThrow(taskId);
542
589
  const rows = this.#db
543
590
  .query(
544
- "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
591
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
545
592
  )
546
593
  .all(taskId) as SubtaskRow[];
547
594
  return rows.map(mapSubtask);
@@ -549,15 +596,25 @@ export class TrackerDomain {
549
596
 
550
597
  const rows = this.#db
551
598
  .query(
552
- "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
599
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
553
600
  )
554
601
  .all() as SubtaskRow[];
555
602
  return rows.map(mapSubtask);
556
603
  }
557
604
 
605
+ getOpenSubtasks(taskId: string): readonly SubtaskRecord[] {
606
+ this.getTaskOrThrow(taskId);
607
+ const rows = this.#db
608
+ .query(
609
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? AND status != 'done' ORDER BY created_at ASC, id ASC;",
610
+ )
611
+ .all(taskId) as SubtaskRow[];
612
+ return rows.map(mapSubtask);
613
+ }
614
+
558
615
  getSubtask(id: string): SubtaskRecord | null {
559
616
  const row = this.#db
560
- .query("SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE id = ?;")
617
+ .query("SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id = ?;")
561
618
  .get(id) as SubtaskRow | null;
562
619
  return row ? mapSubtask(row) : null;
563
620
  }
@@ -577,19 +634,20 @@ export class TrackerDomain {
577
634
 
578
635
  updateSubtask(
579
636
  id: string,
580
- input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
637
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
581
638
  ): SubtaskRecord {
582
639
  const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
583
640
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
584
641
  const nextDescription: string =
585
642
  input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
586
643
  const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
644
+ const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
587
645
  this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
588
646
  const now: number = Date.now();
589
647
 
590
648
  this.#db
591
- .query("UPDATE subtasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
592
- .run(nextTitle, nextDescription, nextStatus, now, id);
649
+ .query("UPDATE subtasks SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
650
+ .run(nextTitle, nextDescription, nextStatus, nextOwner, now, id);
593
651
 
594
652
  return this.getSubtaskOrThrow(id);
595
653
  }
@@ -605,7 +663,7 @@ export class TrackerDomain {
605
663
  const taskIds = new Set(tasks.map((task) => task.id));
606
664
  const subtasks = this.#db
607
665
  .query(
608
- "SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
666
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
609
667
  )
610
668
  .all(epicId) as SubtaskRow[];
611
669
 
@@ -921,6 +979,65 @@ export class TrackerDomain {
921
979
  return rows.map(mapDependency);
922
980
  }
923
981
 
982
+ /**
983
+ * Resolves dependency statuses for multiple tasks using a single prepared
984
+ * statement executed once per task ID. This avoids the previous N+1 pattern
985
+ * where each task required separate getTaskOrThrow/getSubtaskOrThrow calls
986
+ * per dependency.
987
+ */
988
+ batchResolveDependencyStatuses(
989
+ taskIds: readonly string[],
990
+ ): Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }> {
991
+ const result = new Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }>();
992
+
993
+ if (taskIds.length === 0) {
994
+ return result;
995
+ }
996
+
997
+ // Use a static parameterised query per task ID rather than interpolating
998
+ // a dynamic IN-list into the SQL string. This is consistent with every
999
+ // other query in TrackerDomain and avoids any placeholder-count confusion.
1000
+ const stmt = this.#db.query(
1001
+ `SELECT d.source_id, d.depends_on_id, d.depends_on_kind, COALESCE(t.status, s.status) AS dep_status
1002
+ FROM dependencies d
1003
+ LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1004
+ LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1005
+ WHERE d.source_id = ?
1006
+ ORDER BY d.created_at ASC, d.id ASC;`,
1007
+ );
1008
+
1009
+ for (const taskId of taskIds) {
1010
+ const entry = { totalDependencies: 0, blockers: [] as Array<{ id: string; kind: "task" | "subtask"; status: string }> };
1011
+ result.set(taskId, entry);
1012
+
1013
+ const rows = stmt.all(taskId) as Array<{
1014
+ source_id: string;
1015
+ depends_on_id: string;
1016
+ depends_on_kind: "task" | "subtask";
1017
+ dep_status: string | null;
1018
+ }>;
1019
+
1020
+ for (const row of rows) {
1021
+ entry.totalDependencies += 1;
1022
+
1023
+ // Skip orphaned dependency rows (target deleted).
1024
+ if (row.dep_status === null) {
1025
+ continue;
1026
+ }
1027
+
1028
+ if (row.dep_status !== "done") {
1029
+ entry.blockers.push({
1030
+ id: row.depends_on_id,
1031
+ kind: row.depends_on_kind,
1032
+ status: row.dep_status,
1033
+ });
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ return result;
1039
+ }
1040
+
924
1041
  listReverseDependencies(nodeId: string): readonly ReverseDependencyNode[] {
925
1042
  const normalizedNodeId: string = assertNonEmpty("nodeId", nodeId);
926
1043
  this.resolveNodeKind(normalizedNodeId);
@@ -1492,10 +1609,17 @@ export class TrackerDomain {
1492
1609
  }
1493
1610
 
1494
1611
  for (const dependency of this.listDependencies(change.id)) {
1495
- const dependencyStatus =
1612
+ const dependencyNode =
1496
1613
  dependency.dependsOnKind === "task"
1497
- ? this.getTaskOrThrow(dependency.dependsOnId).status
1498
- : this.getSubtaskOrThrow(dependency.dependsOnId).status;
1614
+ ? this.getTask(dependency.dependsOnId)
1615
+ : this.getSubtask(dependency.dependsOnId);
1616
+
1617
+ // Skip orphaned dependency rows where the referenced node no longer exists.
1618
+ if (!dependencyNode) {
1619
+ continue;
1620
+ }
1621
+
1622
+ const dependencyStatus = dependencyNode.status;
1499
1623
  const inScope = scopeIdSet.has(dependency.dependsOnId);
1500
1624
  const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
1501
1625
  if (dependencyStatus === "done" || willCascade) {
@@ -1560,19 +1684,24 @@ export class TrackerDomain {
1560
1684
  const unresolved: UnresolvedDependencyBlocker[] = [];
1561
1685
 
1562
1686
  for (const dependency of dependencies) {
1563
- const dependencyStatus =
1687
+ const dependencyNode =
1564
1688
  dependency.dependsOnKind === "task"
1565
- ? this.getTaskOrThrow(dependency.dependsOnId).status
1566
- : this.getSubtaskOrThrow(dependency.dependsOnId).status;
1689
+ ? this.getTask(dependency.dependsOnId)
1690
+ : this.getSubtask(dependency.dependsOnId);
1691
+
1692
+ // Skip orphaned dependency rows where the referenced node no longer exists.
1693
+ if (!dependencyNode) {
1694
+ continue;
1695
+ }
1567
1696
 
1568
- if (dependencyStatus === "done") {
1697
+ if (dependencyNode.status === "done") {
1569
1698
  continue;
1570
1699
  }
1571
1700
 
1572
1701
  unresolved.push({
1573
1702
  id: dependency.dependsOnId,
1574
1703
  kind: dependency.dependsOnKind,
1575
- status: dependencyStatus,
1704
+ status: dependencyNode.status,
1576
1705
  });
1577
1706
  }
1578
1707
 
@@ -1,5 +1,15 @@
1
1
  export type NodeKind = "epic" | "task" | "subtask";
2
2
 
3
+ export const VALID_STATUSES = ["todo", "in_progress", "done", "blocked"] as const;
4
+ export type ValidStatus = (typeof VALID_STATUSES)[number];
5
+
6
+ export const VALID_TRANSITIONS: ReadonlyMap<ValidStatus, ReadonlySet<ValidStatus>> = new Map<ValidStatus, ReadonlySet<ValidStatus>>([
7
+ ["todo", new Set<ValidStatus>(["in_progress", "blocked"])],
8
+ ["in_progress", new Set<ValidStatus>(["done", "blocked"])],
9
+ ["blocked", new Set<ValidStatus>(["in_progress", "todo"])],
10
+ ["done", new Set<ValidStatus>(["in_progress"])],
11
+ ]);
12
+
3
13
  export const COMPACT_TEMP_KEY_PREFIX = "@";
4
14
 
5
15
  export type CompactTempKey = string;
@@ -95,6 +105,7 @@ export interface TaskRecord {
95
105
  readonly title: string;
96
106
  readonly description: string;
97
107
  readonly status: string;
108
+ readonly owner: string | null;
98
109
  readonly createdAt: number;
99
110
  readonly updatedAt: number;
100
111
  }
@@ -105,6 +116,7 @@ export interface SubtaskRecord {
105
116
  readonly title: string;
106
117
  readonly description: string;
107
118
  readonly status: string;
119
+ readonly owner: string | null;
108
120
  readonly createdAt: number;
109
121
  readonly updatedAt: number;
110
122
  }