trekoon 0.4.1 → 0.4.3

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 (58) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -765
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
  3. package/.agents/skills/trekoon/reference/execution.md +188 -159
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +213 -213
  6. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  7. package/.agents/skills/trekoon/reference/sync.md +82 -0
  8. package/README.md +29 -8
  9. package/docs/ai-agents.md +65 -6
  10. package/docs/commands.md +149 -5
  11. package/docs/machine-contracts.md +123 -0
  12. package/docs/quickstart.md +55 -3
  13. package/package.json +1 -1
  14. package/src/board/assets/app.js +47 -13
  15. package/src/board/assets/components/Component.js +20 -8
  16. package/src/board/assets/components/Workspace.js +9 -3
  17. package/src/board/assets/components/helpers.js +4 -0
  18. package/src/board/assets/runtime/delegation.js +8 -0
  19. package/src/board/assets/runtime/focus-trap.js +48 -0
  20. package/src/board/assets/state/actions.js +45 -4
  21. package/src/board/assets/state/api.js +304 -17
  22. package/src/board/assets/state/store.js +82 -11
  23. package/src/board/assets/state/url.js +10 -0
  24. package/src/board/assets/state/utils.js +2 -1
  25. package/src/board/event-bus.ts +81 -0
  26. package/src/board/routes.ts +430 -40
  27. package/src/board/server.ts +86 -10
  28. package/src/board/snapshot.ts +6 -0
  29. package/src/board/wal-watcher.ts +313 -0
  30. package/src/commands/board.ts +52 -17
  31. package/src/commands/epic.ts +7 -9
  32. package/src/commands/error-utils.ts +54 -1
  33. package/src/commands/help.ts +75 -10
  34. package/src/commands/migrate.ts +153 -24
  35. package/src/commands/quickstart.ts +7 -0
  36. package/src/commands/skills.ts +17 -5
  37. package/src/commands/subtask.ts +71 -10
  38. package/src/commands/suggest.ts +6 -13
  39. package/src/commands/task.ts +137 -88
  40. package/src/domain/batch-validation.ts +329 -0
  41. package/src/domain/cascade-planner.ts +412 -0
  42. package/src/domain/dependency-rules.ts +15 -0
  43. package/src/domain/mutation-service.ts +842 -187
  44. package/src/domain/search.ts +113 -0
  45. package/src/domain/tracker-domain.ts +167 -693
  46. package/src/domain/types.ts +56 -2
  47. package/src/export/render-markdown.ts +1 -2
  48. package/src/index.ts +37 -0
  49. package/src/runtime/cli-shell.ts +44 -0
  50. package/src/runtime/daemon.ts +700 -0
  51. package/src/storage/backup.ts +166 -0
  52. package/src/storage/database.ts +268 -4
  53. package/src/storage/migrations.ts +441 -22
  54. package/src/storage/path.ts +8 -0
  55. package/src/storage/schema.ts +5 -1
  56. package/src/sync/event-writes.ts +38 -11
  57. package/src/sync/git-context.ts +226 -8
  58. package/src/sync/service.ts +679 -156
@@ -6,7 +6,6 @@ import {
6
6
  type CompactEpicExpandResult,
7
7
  type CompactDependencyBatchAddResult,
8
8
  type CompactDependencySpec,
9
- type CompactEntityRef,
10
9
  type CompactSubtaskBatchCreateResult,
11
10
  type CompactSubtaskSpec,
12
11
  type CompactTaskBatchCreateResult,
@@ -21,14 +20,10 @@ import {
21
20
  type ReverseDependencyNode,
22
21
  type SearchEntityMatch,
23
22
  type SearchField,
24
- type SearchFieldMatch,
25
23
  type SearchNode,
26
24
  type SearchSummary,
27
- type StatusCascadeBlocker,
28
- type StatusCascadeChange,
29
25
  type StatusCascadePlan,
30
26
  type StatusCascadeRootKind,
31
- type StatusCascadeScopeNode,
32
27
  type SubtaskRecord,
33
28
  type TaskTreeDetailed,
34
29
  type TaskRecord,
@@ -36,9 +31,22 @@ import {
36
31
  VALID_TRANSITIONS,
37
32
  type ValidStatus,
38
33
  } from "./types";
34
+ import { buildMatchSnippet, collectSearchMatches, countMatches, summarizeMatches } from "./search";
35
+ import { loadCascadeDependencyTargetStatuses, planStatusCascade as planStatusCascadeImpl } from "./cascade-planner";
36
+ import { DEPENDENCY_GATED_STATUSES } from "./dependency-rules";
37
+ import {
38
+ type BatchValidationReader,
39
+ type DependencyBatchResolution,
40
+ type DependencyBatchValidationIssue,
41
+ type ResolvedDependencyBatchSpec,
42
+ buildDependencyAdjacency as buildDependencyAdjacencyFn,
43
+ collectDependencyBatchIssues as collectDependencyBatchIssuesFn,
44
+ resolveDependencyBatchSpec as resolveDependencyBatchSpecFn,
45
+ resolveEpicExpandDependencySpecs as resolveEpicExpandDependencySpecsFn,
46
+ resolveEpicExpandSubtaskSpecs as resolveEpicExpandSubtaskSpecsFn,
47
+ } from "./batch-validation";
39
48
 
40
49
  const DEFAULT_STATUS = "todo";
41
- const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
42
50
  const SQLITE_MAX_VARIABLES = 999;
43
51
 
44
52
  interface EpicRow {
@@ -48,6 +56,7 @@ interface EpicRow {
48
56
  status: string;
49
57
  created_at: number;
50
58
  updated_at: number;
59
+ version: number;
51
60
  }
52
61
 
53
62
  interface TaskRow extends EpicRow {
@@ -97,32 +106,6 @@ interface ValidatedSubtaskBatchSpec {
97
106
  readonly status: string;
98
107
  }
99
108
 
100
- interface ResolvedDependencyBatchSpec {
101
- readonly index: number;
102
- readonly sourceId: string;
103
- readonly sourceKind: "task" | "subtask";
104
- readonly dependsOnId: string;
105
- readonly dependsOnKind: "task" | "subtask";
106
- }
107
-
108
- interface DependencyBatchValidationIssue {
109
- readonly index: number;
110
- readonly type: "missing_id" | "duplicate" | "cycle";
111
- readonly sourceId: string;
112
- readonly dependsOnId: string;
113
- readonly details: Record<string, unknown>;
114
- }
115
-
116
- interface DependencyBatchResolution {
117
- readonly spec?: ResolvedDependencyBatchSpec;
118
- readonly issues: readonly DependencyBatchValidationIssue[];
119
- }
120
-
121
- interface ResolvedCompactEntity {
122
- readonly id: string;
123
- readonly kind: "task" | "subtask";
124
- }
125
-
126
109
  interface TaskDeletionPlan {
127
110
  readonly subtaskIds: readonly string[];
128
111
  readonly touchingDependencies: readonly DependencyRecord[];
@@ -226,6 +209,7 @@ function mapEpic(row: EpicRow): EpicRecord {
226
209
  status: row.status,
227
210
  createdAt: row.created_at,
228
211
  updatedAt: row.updated_at,
212
+ version: row.version,
229
213
  };
230
214
  }
231
215
 
@@ -239,6 +223,7 @@ function mapTask(row: TaskRow): TaskRecord {
239
223
  owner: row.owner ?? null,
240
224
  createdAt: row.created_at,
241
225
  updatedAt: row.updated_at,
226
+ version: row.version,
242
227
  };
243
228
  }
244
229
 
@@ -252,6 +237,7 @@ function mapSubtask(row: SubtaskRow): SubtaskRecord {
252
237
  owner: row.owner ?? null,
253
238
  createdAt: row.created_at,
254
239
  updatedAt: row.updated_at,
240
+ version: row.version,
255
241
  };
256
242
  }
257
243
 
@@ -267,55 +253,6 @@ function mapDependency(row: DependencyRow): DependencyRecord {
267
253
  };
268
254
  }
269
255
 
270
- function countMatches(value: string, searchText: string): number {
271
- if (searchText.length === 0) {
272
- return 0;
273
- }
274
-
275
- let count = 0;
276
- let offset = 0;
277
- while (offset <= value.length - searchText.length) {
278
- const nextIndex = value.indexOf(searchText, offset);
279
- if (nextIndex === -1) {
280
- return count;
281
- }
282
-
283
- count += 1;
284
- offset = nextIndex + searchText.length;
285
- }
286
-
287
- return count;
288
- }
289
-
290
- function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
291
- if (searchText.length === 0) {
292
- return "";
293
- }
294
-
295
- const matchIndex = value.indexOf(searchText);
296
- if (matchIndex === -1) {
297
- return "";
298
- }
299
-
300
- const start = Math.max(0, matchIndex - contextSize);
301
- const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
302
- const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
303
- const prefix = start > 0 ? "…" : "";
304
- const suffix = end < value.length ? "…" : "";
305
- return `${prefix}${rawSnippet}${suffix}`;
306
- }
307
-
308
- function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
309
- return {
310
- matchedEntities: matches.length,
311
- matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
312
- totalMatches: matches.reduce(
313
- (total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
314
- 0,
315
- ),
316
- };
317
- }
318
-
319
256
  export class TrackerDomain {
320
257
  readonly #db: Database;
321
258
 
@@ -323,7 +260,32 @@ export class TrackerDomain {
323
260
  this.#db = db;
324
261
  }
325
262
 
263
+ #assertInTransaction(opName: string): void {
264
+ if (!this.#db.inTransaction) {
265
+ throw new DomainError({
266
+ code: "invalid_state",
267
+ message: `${opName} must be called inside a writeTransaction`,
268
+ details: { op: opName },
269
+ });
270
+ }
271
+ }
272
+
273
+ #makeBatchValidationReader(): BatchValidationReader {
274
+ return {
275
+ getTask: (id) => this.getTask(id),
276
+ getSubtask: (id) => this.getSubtask(id),
277
+ getDependencyByEdge: (sourceId, dependsOnId) => this.#getDependencyByEdge(sourceId, dependsOnId),
278
+ buildDependencyAdjacency: () => {
279
+ const rows = this.#db
280
+ .query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;")
281
+ .all() as Array<{ source_id: string; depends_on_id: string }>;
282
+ return buildDependencyAdjacencyFn(rows);
283
+ },
284
+ };
285
+ }
286
+
326
287
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
288
+ this.#assertInTransaction("createEpic");
327
289
  const now: number = Date.now();
328
290
  const id: string = randomUUID();
329
291
  const title: string = assertNonEmpty("title", input.title);
@@ -341,14 +303,61 @@ export class TrackerDomain {
341
303
 
342
304
  listEpics(): readonly EpicRecord[] {
343
305
  const rows = this.#db
344
- .query("SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC, id ASC;")
306
+ .query("SELECT id, title, description, status, created_at, updated_at, version FROM epics ORDER BY created_at ASC, id ASC;")
345
307
  .all() as EpicRow[];
346
308
  return rows.map(mapEpic);
347
309
  }
348
310
 
311
+ /**
312
+ * Returns the count of all epics without fetching rows.
313
+ */
314
+ countEpics(): number {
315
+ const row = this.#db.query("SELECT COUNT(*) AS n FROM epics;").get() as { n: number };
316
+ return row.n;
317
+ }
318
+
319
+ /**
320
+ * Returns the single "active" epic without fetching all rows:
321
+ * 1. First in_progress epic (any order — there should be at most one).
322
+ * 2. Fallback: most-recently-updated todo epic.
323
+ * 3. Fallback: oldest epic by created_at / id (matches epics[0] from listEpics).
324
+ * 4. null when the table is empty.
325
+ *
326
+ * Note: no index on (status, updated_at) exists on the epics table as of this
327
+ * writing, so the query uses a table scan. For typical epic counts this is
328
+ * negligible; add idx_epics_status_updated_at if it becomes a concern.
329
+ */
330
+ findActiveEpic(): EpicRecord | null {
331
+ const inProgress = this.#db
332
+ .query(
333
+ "SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE status = 'in_progress' LIMIT 1;",
334
+ )
335
+ .get() as EpicRow | null;
336
+ if (inProgress) {
337
+ return mapEpic(inProgress);
338
+ }
339
+
340
+ const todo = this.#db
341
+ .query(
342
+ "SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
343
+ )
344
+ .get() as EpicRow | null;
345
+ if (todo) {
346
+ return mapEpic(todo);
347
+ }
348
+
349
+ // Fallback: oldest epic regardless of status (mirrors epics[0] from listEpics).
350
+ const oldest = this.#db
351
+ .query(
352
+ "SELECT id, title, description, status, created_at, updated_at, version FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
353
+ )
354
+ .get() as EpicRow | null;
355
+ return oldest ? mapEpic(oldest) : null;
356
+ }
357
+
349
358
  getEpic(id: string): EpicRecord | null {
350
359
  const row = this.#db
351
- .query("SELECT id, title, description, status, created_at, updated_at FROM epics WHERE id = ?;")
360
+ .query("SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE id = ?;")
352
361
  .get(id) as EpicRow | null;
353
362
  return row ? mapEpic(row) : null;
354
363
  }
@@ -370,6 +379,7 @@ export class TrackerDomain {
370
379
  id: string,
371
380
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
372
381
  ): EpicRecord {
382
+ this.#assertInTransaction("updateEpic");
373
383
  const existing: EpicRecord = this.getEpicOrThrow(id);
374
384
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
375
385
  const nextDescription: string =
@@ -385,11 +395,13 @@ export class TrackerDomain {
385
395
  }
386
396
 
387
397
  deleteEpic(id: string): void {
398
+ this.#assertInTransaction("deleteEpic");
388
399
  this.getEpicOrThrow(id);
389
400
  this.#db.query("DELETE FROM epics WHERE id = ?;").run(id);
390
401
  }
391
402
 
392
403
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
404
+ this.#assertInTransaction("createTask");
393
405
  const now: number = Date.now();
394
406
  const id: string = randomUUID();
395
407
  const epicId: string = assertNonEmpty("epicId", input.epicId);
@@ -428,13 +440,7 @@ export class TrackerDomain {
428
440
  };
429
441
  }
430
442
 
431
- if (!this.#db.inTransaction) {
432
- throw new DomainError({
433
- code: "invalid_state",
434
- message: "createTaskBatch must be called inside a writeTransaction",
435
- details: { entity: "task" },
436
- });
437
- }
443
+ this.#assertInTransaction("createTaskBatch");
438
444
 
439
445
  const TASK_COLS_PER_ROW = 7; // id, epic_id, title, description, status, created_at, updated_at (version is literal 1)
440
446
  const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / TASK_COLS_PER_ROW);
@@ -467,7 +473,7 @@ export class TrackerDomain {
467
473
  const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
468
474
  const chunkRows = this.#db
469
475
  .query(
470
- `SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id IN (${inPlaceholders});`,
476
+ `SELECT id, epic_id, title, description, status, owner, created_at, updated_at, version FROM tasks WHERE id IN (${inPlaceholders});`,
471
477
  )
472
478
  .all(...chunkIds) as TaskRow[];
473
479
  fetchedRows.push(...chunkRows);
@@ -499,21 +505,21 @@ export class TrackerDomain {
499
505
  this.getEpicOrThrow(epicId);
500
506
  const rows = this.#db
501
507
  .query(
502
- "SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
508
+ "SELECT id, epic_id, title, description, status, owner, created_at, updated_at, version FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
503
509
  )
504
510
  .all(epicId) as TaskRow[];
505
511
  return rows.map(mapTask);
506
512
  }
507
513
 
508
514
  const rows = this.#db
509
- .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
515
+ .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at, version FROM tasks ORDER BY created_at ASC, id ASC;")
510
516
  .all() as TaskRow[];
511
517
  return rows.map(mapTask);
512
518
  }
513
519
 
514
520
  getTask(id: string): TaskRecord | null {
515
521
  const row = this.#db
516
- .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id = ?;")
522
+ .query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at, version FROM tasks WHERE id = ?;")
517
523
  .get(id) as TaskRow | null;
518
524
  return row ? mapTask(row) : null;
519
525
  }
@@ -535,6 +541,7 @@ export class TrackerDomain {
535
541
  id: string,
536
542
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
537
543
  ): TaskRecord {
544
+ this.#assertInTransaction("updateTask");
538
545
  const existing: TaskRecord = this.getTaskOrThrow(id);
539
546
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
540
547
  const nextDescription: string =
@@ -552,6 +559,7 @@ export class TrackerDomain {
552
559
  }
553
560
 
554
561
  deleteTask(id: string): void {
562
+ this.#assertInTransaction("deleteTask");
555
563
  const normalizedTaskId: string = assertNonEmpty("id", id);
556
564
  this.getTaskOrThrow(normalizedTaskId);
557
565
 
@@ -575,6 +583,7 @@ export class TrackerDomain {
575
583
  createSubtask(
576
584
  input: { taskId: string; title: string; description?: string | undefined; status?: string | undefined },
577
585
  ): SubtaskRecord {
586
+ this.#assertInTransaction("createSubtask");
578
587
  const now: number = Date.now();
579
588
  const id: string = randomUUID();
580
589
  const taskId: string = assertNonEmpty("taskId", input.taskId);
@@ -623,13 +632,7 @@ export class TrackerDomain {
623
632
  };
624
633
  }
625
634
 
626
- if (!this.#db.inTransaction) {
627
- throw new DomainError({
628
- code: "invalid_state",
629
- message: "createSubtaskBatch must be called inside a writeTransaction",
630
- details: { entity: "subtask" },
631
- });
632
- }
635
+ this.#assertInTransaction("createSubtaskBatch");
633
636
 
634
637
  const SUBTASK_COLS_PER_ROW = 7; // id, task_id, title, description, status, created_at, updated_at (version is literal 1)
635
638
  const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / SUBTASK_COLS_PER_ROW);
@@ -662,7 +665,7 @@ export class TrackerDomain {
662
665
  const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
663
666
  const chunkRows = this.#db
664
667
  .query(
665
- `SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id IN (${inPlaceholders});`,
668
+ `SELECT id, task_id, title, description, status, owner, created_at, updated_at, version FROM subtasks WHERE id IN (${inPlaceholders});`,
666
669
  )
667
670
  .all(...chunkIds) as SubtaskRow[];
668
671
  fetchedRows.push(...chunkRows);
@@ -738,7 +741,7 @@ export class TrackerDomain {
738
741
  this.getTaskOrThrow(taskId);
739
742
  const rows = this.#db
740
743
  .query(
741
- "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
744
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at, version FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
742
745
  )
743
746
  .all(taskId) as SubtaskRow[];
744
747
  return rows.map(mapSubtask);
@@ -746,7 +749,7 @@ export class TrackerDomain {
746
749
 
747
750
  const rows = this.#db
748
751
  .query(
749
- "SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
752
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at, version FROM subtasks ORDER BY created_at ASC, id ASC;",
750
753
  )
751
754
  .all() as SubtaskRow[];
752
755
  return rows.map(mapSubtask);
@@ -756,7 +759,7 @@ export class TrackerDomain {
756
759
  this.getTaskOrThrow(taskId);
757
760
  const rows = this.#db
758
761
  .query(
759
- "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;",
762
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at, version FROM subtasks WHERE task_id = ? AND status != 'done' ORDER BY created_at ASC, id ASC;",
760
763
  )
761
764
  .all(taskId) as SubtaskRow[];
762
765
  return rows.map(mapSubtask);
@@ -764,7 +767,7 @@ export class TrackerDomain {
764
767
 
765
768
  getSubtask(id: string): SubtaskRecord | null {
766
769
  const row = this.#db
767
- .query("SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id = ?;")
770
+ .query("SELECT id, task_id, title, description, status, owner, created_at, updated_at, version FROM subtasks WHERE id = ?;")
768
771
  .get(id) as SubtaskRow | null;
769
772
  return row ? mapSubtask(row) : null;
770
773
  }
@@ -786,6 +789,7 @@ export class TrackerDomain {
786
789
  id: string,
787
790
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
788
791
  ): SubtaskRecord {
792
+ this.#assertInTransaction("updateSubtask");
789
793
  const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
790
794
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
791
795
  const nextDescription: string =
@@ -803,6 +807,7 @@ export class TrackerDomain {
803
807
  }
804
808
 
805
809
  deleteSubtask(id: string): void {
810
+ this.#assertInTransaction("deleteSubtask");
806
811
  this.getSubtaskOrThrow(id);
807
812
  this.#db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(id, id);
808
813
  this.#db.query("DELETE FROM subtasks WHERE id = ?;").run(id);
@@ -861,7 +866,7 @@ export class TrackerDomain {
861
866
  const taskIds = new Set(tasks.map((task) => task.id));
862
867
  const subtasks = this.#db
863
868
  .query(
864
- "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;",
869
+ "SELECT id, task_id, title, description, status, owner, created_at, updated_at, version FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
865
870
  )
866
871
  .all(epicId) as SubtaskRow[];
867
872
 
@@ -941,37 +946,17 @@ export class TrackerDomain {
941
946
  }
942
947
 
943
948
  planStatusCascade(rootKind: StatusCascadeRootKind, rootId: string, targetStatus: string): StatusCascadePlan {
944
- const normalizedTargetStatus = assertNonEmpty("status", targetStatus);
945
- const scope = this.#collectStatusCascadeScope(rootKind, rootId);
946
- const scopeIdSet = new Set(scope.map((node) => node.id));
947
- const orderedChanges = this.#orderStatusCascadeChanges(scope, normalizedTargetStatus);
948
- const changedIds = orderedChanges.map((change) => change.id);
949
- const changedIdSet = new Set(changedIds);
950
- const unchangedIds = scope
951
- .filter((node) => !changedIdSet.has(node.id))
952
- .map((node) => node.id);
953
- const blockers = this.#collectStatusCascadeBlockers(orderedChanges, scopeIdSet, changedIdSet, normalizedTargetStatus);
954
-
955
- return {
949
+ return planStatusCascadeImpl(
950
+ {
951
+ buildEpicTreeDetailed: (id) => this.buildEpicTreeDetailed(id),
952
+ buildTaskTreeDetailed: (id) => this.buildTaskTreeDetailed(id),
953
+ listDependenciesBySourceIds: (ids) => this.listDependenciesBySourceIds(ids),
954
+ loadDependencyTargetStatuses: (ids) => loadCascadeDependencyTargetStatuses(this.#db, ids),
955
+ },
956
956
  rootKind,
957
957
  rootId,
958
- targetStatus: normalizedTargetStatus,
959
- atomic: true,
960
- scope,
961
- orderedChanges,
962
- changedIds,
963
- unchangedIds,
964
- blockers,
965
- counts: {
966
- scope: scope.length,
967
- changed: orderedChanges.length,
968
- unchanged: unchangedIds.length,
969
- blockers: blockers.length,
970
- changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
971
- changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
972
- changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
973
- },
974
- };
958
+ targetStatus,
959
+ );
975
960
  }
976
961
 
977
962
  collectEpicSearchScope(epicId: string): readonly SearchNode[] {
@@ -1076,6 +1061,7 @@ export class TrackerDomain {
1076
1061
  }
1077
1062
 
1078
1063
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
1064
+ this.#assertInTransaction("addDependency");
1079
1065
  const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
1080
1066
  const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
1081
1067
 
@@ -1121,6 +1107,7 @@ export class TrackerDomain {
1121
1107
  }
1122
1108
 
1123
1109
  addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
1110
+ this.#assertInTransaction("addDependencyBatch");
1124
1111
  const resolutions = input.specs.map((spec, index) => this.#resolveDependencyBatchSpec(index, spec));
1125
1112
  const resolvedSpecs = resolutions.flatMap((resolution) => (resolution.spec === undefined ? [] : [resolution.spec]));
1126
1113
  const issues = resolutions.flatMap((resolution) => resolution.issues).concat(this.#collectDependencyBatchIssues(resolvedSpecs));
@@ -1207,6 +1194,7 @@ export class TrackerDomain {
1207
1194
  }
1208
1195
 
1209
1196
  removeDependency(sourceId: string, dependsOnId: string): number {
1197
+ this.#assertInTransaction("removeDependency");
1210
1198
  const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
1211
1199
  const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
1212
1200
  const result = this.#db
@@ -1282,7 +1270,7 @@ export class TrackerDomain {
1282
1270
  const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1283
1271
  const rows = this.#db
1284
1272
  .query(
1285
- `SELECT id, task_id, title, description, status, owner, created_at, updated_at
1273
+ `SELECT id, task_id, title, description, status, owner, created_at, updated_at, version
1286
1274
  FROM subtasks
1287
1275
  WHERE task_id IN (${inPlaceholders})
1288
1276
  ORDER BY created_at ASC, id ASC;`,
@@ -1300,44 +1288,56 @@ export class TrackerDomain {
1300
1288
  }
1301
1289
 
1302
1290
  /**
1303
- * Resolves dependency statuses for multiple tasks using a single prepared
1304
- * statement executed once per task ID. This avoids the previous N+1 pattern
1305
- * where each task required separate getTaskOrThrow/getSubtaskOrThrow calls
1306
- * per dependency.
1291
+ * Resolves dependency statuses for multiple tasks using chunked
1292
+ * WHERE source_id IN (?...) queries the same pattern as
1293
+ * #collectStatusCascadeBlockers. This reduces N queries to
1294
+ * ceil(N/999), eliminating the previous per-ID N+1 loop.
1307
1295
  */
1308
1296
  batchResolveDependencyStatuses(
1309
1297
  taskIds: readonly string[],
1310
1298
  ): Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }> {
1299
+ type DepStatusRow = {
1300
+ source_id: string;
1301
+ depends_on_id: string;
1302
+ depends_on_kind: "task" | "subtask";
1303
+ dep_status: string | null;
1304
+ };
1305
+
1311
1306
  const result = new Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }>();
1312
1307
 
1313
1308
  if (taskIds.length === 0) {
1314
1309
  return result;
1315
1310
  }
1316
1311
 
1317
- // Use a static parameterised query per task ID rather than interpolating
1318
- // a dynamic IN-list into the SQL string. This is consistent with every
1319
- // other query in TrackerDomain and avoids any placeholder-count confusion.
1320
- const stmt = this.#db.query(
1321
- `SELECT d.source_id, d.depends_on_id, d.depends_on_kind, COALESCE(t.status, s.status) AS dep_status
1322
- FROM dependencies d
1323
- LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1324
- LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1325
- WHERE d.source_id = ?
1326
- ORDER BY d.created_at ASC, d.id ASC;`,
1327
- );
1328
-
1312
+ // Pre-populate so every requested ID has an entry, even with no deps.
1329
1313
  for (const taskId of taskIds) {
1330
- const entry = { totalDependencies: 0, blockers: [] as Array<{ id: string; kind: "task" | "subtask"; status: string }> };
1331
- result.set(taskId, entry);
1314
+ result.set(taskId, { totalDependencies: 0, blockers: [] });
1315
+ }
1332
1316
 
1333
- const rows = stmt.all(taskId) as Array<{
1334
- source_id: string;
1335
- depends_on_id: string;
1336
- depends_on_kind: "task" | "subtask";
1337
- dep_status: string | null;
1338
- }>;
1317
+ // Batch-fetch all dependency rows with their target statuses using chunked
1318
+ // IN queries with JOINs. ceil(N/999) queries instead of N.
1319
+ for (let offset = 0; offset < taskIds.length; offset += SQLITE_MAX_VARIABLES) {
1320
+ const chunkIds = taskIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1321
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1322
+
1323
+ const rows = this.#db
1324
+ .query(
1325
+ `SELECT d.source_id, d.depends_on_id, d.depends_on_kind,
1326
+ COALESCE(t.status, s.status) AS dep_status
1327
+ FROM dependencies d
1328
+ LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1329
+ LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1330
+ WHERE d.source_id IN (${inPlaceholders})
1331
+ ORDER BY d.created_at ASC, d.id ASC;`,
1332
+ )
1333
+ .all(...chunkIds) as DepStatusRow[];
1339
1334
 
1340
1335
  for (const row of rows) {
1336
+ const entry = result.get(row.source_id);
1337
+ if (entry === undefined) {
1338
+ continue;
1339
+ }
1340
+
1341
1341
  entry.totalDependencies += 1;
1342
1342
 
1343
1343
  // Skip orphaned dependency rows (target deleted).
@@ -1420,279 +1420,25 @@ export class TrackerDomain {
1420
1420
  }
1421
1421
 
1422
1422
  #resolveDependencyBatchSpec(index: number, spec: CompactDependencySpec): DependencyBatchResolution {
1423
- const sourceResolution = this.#resolveDependencyBatchId(spec.source, "source", index);
1424
- const dependsOnResolution = this.#resolveDependencyBatchId(spec.dependsOn, "dependsOn", index);
1425
- const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
1426
- const sourceId = sourceResolution.id;
1427
- const dependsOnId = dependsOnResolution.id;
1428
-
1429
- if (sourceId === undefined || dependsOnId === undefined) {
1430
- return {
1431
- issues,
1432
- };
1433
- }
1434
-
1435
- if (sourceId === dependsOnId) {
1436
- return {
1437
- issues: [
1438
- ...issues,
1439
- {
1440
- index,
1441
- type: "cycle",
1442
- sourceId,
1443
- dependsOnId,
1444
- details: { sourceId, dependsOnId, reason: "self_reference" },
1445
- },
1446
- ],
1447
- };
1448
- }
1449
-
1450
- return {
1451
- spec: {
1452
- index,
1453
- sourceId,
1454
- sourceKind: this.resolveNodeKind(sourceId),
1455
- dependsOnId,
1456
- dependsOnKind: this.resolveNodeKind(dependsOnId),
1457
- },
1458
- issues,
1459
- };
1460
- }
1461
-
1462
- #resolveDependencyBatchId(
1463
- reference: CompactEntityRef,
1464
- field: "source" | "dependsOn",
1465
- index: number,
1466
- ): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
1467
- if (reference.kind === "temp_key") {
1468
- return {
1469
- issues: [
1470
- {
1471
- index,
1472
- type: "missing_id",
1473
- sourceId: field === "source" ? `@${reference.tempKey}` : "",
1474
- dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
1475
- details: {
1476
- field,
1477
- tempKey: reference.tempKey,
1478
- message: `Unresolved temp key @${reference.tempKey}`,
1479
- },
1480
- },
1481
- ],
1482
- };
1483
- }
1484
-
1485
- const id = assertNonEmpty(field === "source" ? "sourceId" : "dependsOnId", reference.id);
1486
- const task = this.getTask(id);
1487
- const subtask = this.getSubtask(id);
1488
- if (!task && !subtask) {
1489
- return {
1490
- issues: [
1491
- {
1492
- index,
1493
- type: "missing_id",
1494
- sourceId: field === "source" ? id : "",
1495
- dependsOnId: field === "dependsOn" ? id : "",
1496
- details: {
1497
- field,
1498
- id,
1499
- message: `Node not found: ${id}`,
1500
- },
1501
- },
1502
- ],
1503
- };
1504
- }
1505
-
1506
- return { id, issues: [] };
1423
+ return resolveDependencyBatchSpecFn(index, spec, this.#makeBatchValidationReader());
1507
1424
  }
1508
1425
 
1509
1426
  #resolveEpicExpandSubtaskSpecs(
1510
1427
  specs: readonly CompactSubtaskSpec[],
1511
1428
  mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1512
1429
  ): CompactSubtaskSpec[] {
1513
- return specs.map((spec, index) => {
1514
- const parent = this.#resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent");
1515
- if (parent.kind !== "task") {
1516
- throw new DomainError({
1517
- code: "invalid_input",
1518
- message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
1519
- details: {
1520
- index,
1521
- field: "parent",
1522
- kind: parent.kind,
1523
- id: parent.id,
1524
- },
1525
- });
1526
- }
1527
-
1528
- return {
1529
- ...spec,
1530
- parent: {
1531
- kind: "id",
1532
- id: parent.id,
1533
- },
1534
- };
1535
- });
1430
+ return resolveEpicExpandSubtaskSpecsFn(specs, mappings, this.#makeBatchValidationReader());
1536
1431
  }
1537
1432
 
1538
1433
  #resolveEpicExpandDependencySpecs(
1539
1434
  specs: readonly CompactDependencySpec[],
1540
1435
  mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1541
1436
  ): CompactDependencySpec[] {
1542
- return specs.map((spec, index) => ({
1543
- source: {
1544
- kind: "id",
1545
- id: this.#resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source").id,
1546
- },
1547
- dependsOn: {
1548
- kind: "id",
1549
- id: this.#resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn").id,
1550
- },
1551
- }));
1552
- }
1553
-
1554
- #resolveEpicExpandEntityRef(
1555
- reference: CompactEntityRef,
1556
- mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1557
- option: "subtask" | "dep",
1558
- index: number,
1559
- field: "parent" | "source" | "dependsOn",
1560
- ): ResolvedCompactEntity {
1561
- if (reference.kind === "temp_key") {
1562
- const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
1563
- if (mapping === undefined) {
1564
- throw new DomainError({
1565
- code: "invalid_input",
1566
- message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
1567
- details: {
1568
- index,
1569
- field,
1570
- tempKey: reference.tempKey,
1571
- option,
1572
- },
1573
- });
1574
- }
1575
-
1576
- return {
1577
- id: mapping.id,
1578
- kind: mapping.kind,
1579
- };
1580
- }
1581
-
1582
- const id = assertNonEmpty(field === "parent" ? "taskId" : `${field}Id`, reference.id);
1583
- return {
1584
- id,
1585
- kind: this.resolveNodeKind(id),
1586
- };
1437
+ return resolveEpicExpandDependencySpecsFn(specs, mappings, this.#makeBatchValidationReader());
1587
1438
  }
1588
1439
 
1589
1440
  #collectDependencyBatchIssues(specs: readonly ResolvedDependencyBatchSpec[]): DependencyBatchValidationIssue[] {
1590
- const issues: DependencyBatchValidationIssue[] = [];
1591
- const seenEdges = new Map<string, number>();
1592
- const adjacency = this.#buildDependencyAdjacency();
1593
-
1594
- for (const spec of specs) {
1595
- const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
1596
- const existingIndex = seenEdges.get(edgeKey);
1597
- if (existingIndex !== undefined) {
1598
- issues.push({
1599
- index: spec.index,
1600
- type: "duplicate",
1601
- sourceId: spec.sourceId,
1602
- dependsOnId: spec.dependsOnId,
1603
- details: {
1604
- sourceId: spec.sourceId,
1605
- dependsOnId: spec.dependsOnId,
1606
- firstIndex: existingIndex,
1607
- duplicateIndex: spec.index,
1608
- duplicateKind: "batch",
1609
- },
1610
- });
1611
- continue;
1612
- }
1613
-
1614
- if (this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
1615
- issues.push({
1616
- index: spec.index,
1617
- type: "duplicate",
1618
- sourceId: spec.sourceId,
1619
- dependsOnId: spec.dependsOnId,
1620
- details: {
1621
- sourceId: spec.sourceId,
1622
- dependsOnId: spec.dependsOnId,
1623
- duplicateKind: "existing",
1624
- },
1625
- });
1626
- continue;
1627
- }
1628
-
1629
- if (this.#wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
1630
- issues.push({
1631
- index: spec.index,
1632
- type: "cycle",
1633
- sourceId: spec.sourceId,
1634
- dependsOnId: spec.dependsOnId,
1635
- details: {
1636
- sourceId: spec.sourceId,
1637
- dependsOnId: spec.dependsOnId,
1638
- },
1639
- });
1640
- continue;
1641
- }
1642
-
1643
- const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
1644
- nextNeighbors.add(spec.dependsOnId);
1645
- adjacency.set(spec.sourceId, nextNeighbors);
1646
- seenEdges.set(edgeKey, spec.index);
1647
- }
1648
-
1649
- return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
1650
- }
1651
-
1652
- #buildDependencyAdjacency(): Map<string, Set<string>> {
1653
- const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;").all() as Array<{
1654
- source_id: string;
1655
- depends_on_id: string;
1656
- }>;
1657
- const adjacency = new Map<string, Set<string>>();
1658
-
1659
- for (const row of rows) {
1660
- const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
1661
- neighbors.add(row.depends_on_id);
1662
- adjacency.set(row.source_id, neighbors);
1663
- }
1664
-
1665
- return adjacency;
1666
- }
1667
-
1668
- #wouldCreateCycleInAdjacency(adjacency: ReadonlyMap<string, ReadonlySet<string>>, sourceId: string, dependsOnId: string): boolean {
1669
- const visited = new Set<string>();
1670
- const queue: string[] = [dependsOnId];
1671
-
1672
- while (queue.length > 0) {
1673
- const current = queue.shift();
1674
- if (current === undefined || visited.has(current)) {
1675
- continue;
1676
- }
1677
-
1678
- if (current === sourceId) {
1679
- return true;
1680
- }
1681
-
1682
- visited.add(current);
1683
- const neighbors = adjacency.get(current);
1684
- if (neighbors === undefined) {
1685
- continue;
1686
- }
1687
-
1688
- for (const neighbor of neighbors) {
1689
- if (!visited.has(neighbor)) {
1690
- queue.push(neighbor);
1691
- }
1692
- }
1693
- }
1694
-
1695
- return false;
1441
+ return collectDependencyBatchIssuesFn(specs, this.#makeBatchValidationReader());
1696
1442
  }
1697
1443
 
1698
1444
  private collectSearchMatches(
@@ -1700,33 +1446,7 @@ export class TrackerDomain {
1700
1446
  searchText: string,
1701
1447
  fields: readonly SearchField[],
1702
1448
  ): readonly SearchEntityMatch[] {
1703
- const matches: SearchEntityMatch[] = [];
1704
-
1705
- for (const node of nodes) {
1706
- const matchedFields: SearchFieldMatch[] = [];
1707
- for (const field of fields) {
1708
- const count = countMatches(node[field], searchText);
1709
- if (count > 0) {
1710
- matchedFields.push({
1711
- field,
1712
- count,
1713
- snippet: buildMatchSnippet(node[field], searchText),
1714
- });
1715
- }
1716
- }
1717
-
1718
- if (matchedFields.length === 0) {
1719
- continue;
1720
- }
1721
-
1722
- matches.push({
1723
- kind: node.kind,
1724
- id: node.id,
1725
- fields: matchedFields,
1726
- });
1727
- }
1728
-
1729
- return matches;
1449
+ return collectSearchMatches(nodes, searchText, fields);
1730
1450
  }
1731
1451
 
1732
1452
  private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
@@ -1751,253 +1471,7 @@ export class TrackerDomain {
1751
1471
  return row !== null;
1752
1472
  }
1753
1473
 
1754
- #collectStatusCascadeScope(rootKind: StatusCascadeRootKind, rootId: string): StatusCascadeScopeNode[] {
1755
- if (rootKind === "task") {
1756
- const tree = this.buildTaskTreeDetailed(rootId);
1757
- return [
1758
- {
1759
- kind: "task",
1760
- id: tree.id,
1761
- parentId: tree.epicId,
1762
- status: tree.status,
1763
- },
1764
- ...tree.subtasks.map((subtask) => ({
1765
- kind: "subtask" as const,
1766
- id: subtask.id,
1767
- parentId: subtask.taskId,
1768
- status: subtask.status,
1769
- })),
1770
- ];
1771
- }
1772
-
1773
- const tree = this.buildEpicTreeDetailed(rootId);
1774
- return [
1775
- {
1776
- kind: "epic",
1777
- id: tree.id,
1778
- status: tree.status,
1779
- },
1780
- ...tree.tasks.flatMap((task) => [
1781
- {
1782
- kind: "task" as const,
1783
- id: task.id,
1784
- parentId: task.epicId,
1785
- status: task.status,
1786
- },
1787
- ...task.subtasks.map((subtask) => ({
1788
- kind: "subtask" as const,
1789
- id: subtask.id,
1790
- parentId: subtask.taskId,
1791
- status: subtask.status,
1792
- })),
1793
- ]),
1794
- ];
1795
- }
1796
-
1797
- #orderStatusCascadeChanges(scope: readonly StatusCascadeScopeNode[], targetStatus: string): StatusCascadeChange[] {
1798
- const changes = scope
1799
- .filter((node) => node.status !== targetStatus)
1800
- .map((node) => {
1801
- const change: StatusCascadeChange = {
1802
- kind: node.kind,
1803
- id: node.id,
1804
- previousStatus: node.status,
1805
- nextStatus: targetStatus,
1806
- ...(node.parentId === undefined ? {} : { parentId: node.parentId }),
1807
- };
1808
- return change;
1809
- });
1810
-
1811
- if (targetStatus !== "done") {
1812
- return changes;
1813
- }
1814
-
1815
- return this.#topologicallyOrderDoneCascadeChanges(changes);
1816
- }
1817
-
1818
- #topologicallyOrderDoneCascadeChanges(changes: readonly StatusCascadeChange[]): StatusCascadeChange[] {
1819
- const indexById = new Map<string, number>();
1820
- const changeById = new Map<string, StatusCascadeChange>();
1821
- const dependencyTargetsBySource = new Map<string, Set<string>>();
1822
- const dependents = new Map<string, Set<string>>();
1823
- const indegree = new Map<string, number>();
1824
- const dependencyMap = this.listDependenciesBySourceIds(
1825
- changes.filter((change) => change.kind === "task" || change.kind === "subtask").map((change) => change.id),
1826
- );
1827
-
1828
- changes.forEach((change, index) => {
1829
- indexById.set(change.id, index);
1830
- changeById.set(change.id, change);
1831
- indegree.set(change.id, 0);
1832
-
1833
- if (change.kind !== "task" && change.kind !== "subtask") {
1834
- return;
1835
- }
1836
-
1837
- const dependencyTargets = new Set((dependencyMap.get(change.id) ?? []).map((dependency) => dependency.dependsOnId));
1838
- dependencyTargetsBySource.set(change.id, dependencyTargets);
1839
- });
1840
-
1841
- const addEdge = (fromId: string, toId: string): void => {
1842
- if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
1843
- return;
1844
- }
1845
-
1846
- const neighbors = dependents.get(fromId) ?? new Set<string>();
1847
- if (neighbors.has(toId)) {
1848
- return;
1849
- }
1850
-
1851
- neighbors.add(toId);
1852
- dependents.set(fromId, neighbors);
1853
- indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
1854
- };
1855
-
1856
- for (const change of changes) {
1857
- const dependencyTargets = dependencyTargetsBySource.get(change.id);
1858
-
1859
- if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
1860
- addEdge(change.id, change.parentId);
1861
- }
1862
-
1863
- if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
1864
- addEdge(change.id, change.parentId);
1865
- }
1866
-
1867
- if (change.kind !== "task" && change.kind !== "subtask") {
1868
- continue;
1869
- }
1870
-
1871
- for (const dependencyTargetId of dependencyTargets ?? []) {
1872
- addEdge(dependencyTargetId, change.id);
1873
- }
1874
- }
1875
-
1876
- const ordered: StatusCascadeChange[] = [];
1877
- const ready = changes
1878
- .filter((change) => (indegree.get(change.id) ?? 0) === 0)
1879
- .sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
1880
-
1881
- while (ready.length > 0) {
1882
- const next = ready.shift();
1883
- if (next === undefined) {
1884
- continue;
1885
- }
1886
-
1887
- ordered.push(next);
1888
- for (const dependentId of dependents.get(next.id) ?? []) {
1889
- const remaining = (indegree.get(dependentId) ?? 0) - 1;
1890
- indegree.set(dependentId, remaining);
1891
- if (remaining !== 0) {
1892
- continue;
1893
- }
1894
-
1895
- const dependent = changeById.get(dependentId);
1896
- if (dependent === undefined) {
1897
- continue;
1898
- }
1899
-
1900
- ready.push(dependent);
1901
- ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
1902
- }
1903
- }
1904
-
1905
- if (ordered.length !== changes.length) {
1906
- throw new DomainError({
1907
- code: "invalid_dependency",
1908
- message: "unable to determine dependency-safe cascade order",
1909
- details: {
1910
- changedIds: changes.map((change) => change.id),
1911
- },
1912
- });
1913
- }
1914
-
1915
- return ordered;
1916
- }
1917
-
1918
- #collectStatusCascadeBlockers(
1919
- changes: readonly StatusCascadeChange[],
1920
- scopeIdSet: ReadonlySet<string>,
1921
- changedIdSet: ReadonlySet<string>,
1922
- targetStatus: string,
1923
- ): StatusCascadeBlocker[] {
1924
- if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
1925
- return [];
1926
- }
1927
-
1928
- // Collect all dependency-eligible change IDs upfront.
1929
- const eligibleIds: string[] = [];
1930
- for (const change of changes) {
1931
- if (change.kind === "task" || change.kind === "subtask") {
1932
- eligibleIds.push(change.id);
1933
- }
1934
- }
1935
-
1936
- if (eligibleIds.length === 0) {
1937
- return [];
1938
- }
1939
-
1940
- // Batch-fetch all dependency rows with their target statuses using a
1941
- // chunked IN query with JOINs. This replaces the previous per-ID
1942
- // prepared statement approach, reducing N queries to ceil(N/999).
1943
- type DepStatusRow = {
1944
- source_id: string;
1945
- source_kind: "task" | "subtask";
1946
- depends_on_id: string;
1947
- depends_on_kind: "task" | "subtask";
1948
- dep_status: string | null;
1949
- };
1950
-
1951
- const blockers: StatusCascadeBlocker[] = [];
1952
-
1953
- for (let offset = 0; offset < eligibleIds.length; offset += SQLITE_MAX_VARIABLES) {
1954
- const chunkIds = eligibleIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1955
- const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1956
- const rows = this.#db
1957
- .query(
1958
- `SELECT d.source_id, d.source_kind, d.depends_on_id, d.depends_on_kind,
1959
- COALESCE(t.status, s.status) AS dep_status
1960
- FROM dependencies d
1961
- LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1962
- LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1963
- WHERE d.source_id IN (${inPlaceholders})
1964
- ORDER BY d.created_at ASC, d.id ASC;`,
1965
- )
1966
- .all(...chunkIds) as DepStatusRow[];
1967
-
1968
- for (const row of rows) {
1969
- // Skip orphaned dependency rows where the referenced node no longer exists.
1970
- if (row.dep_status === null) {
1971
- continue;
1972
- }
1973
-
1974
- const inScope = scopeIdSet.has(row.depends_on_id);
1975
- const willCascade = targetStatus === "done" && changedIdSet.has(row.depends_on_id);
1976
- if (row.dep_status === "done" || willCascade) {
1977
- continue;
1978
- }
1979
-
1980
- blockers.push({
1981
- sourceId: row.source_id,
1982
- sourceKind: row.source_kind,
1983
- dependsOnId: row.depends_on_id,
1984
- dependsOnKind: row.depends_on_kind,
1985
- dependsOnStatus: row.dep_status,
1986
- inScope,
1987
- willCascade,
1988
- });
1989
- }
1990
- }
1991
-
1992
- return blockers.sort(
1993
- (left, right) =>
1994
- left.sourceId.localeCompare(right.sourceId) ||
1995
- left.dependsOnId.localeCompare(right.dependsOnId) ||
1996
- left.dependsOnKind.localeCompare(right.dependsOnKind),
1997
- );
1998
- }
1999
-
2000
- private assertNoUnresolvedDependenciesForStatusTransition(
1474
+ assertNoUnresolvedDependenciesForStatusTransition(
2001
1475
  id: string,
2002
1476
  kind: DependencyNodeKind,
2003
1477
  existingStatus: string,