trekoon 0.4.1 → 0.4.2

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 (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -1
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +45 -13
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +4 -0
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +42 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +79 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -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 {
@@ -97,32 +105,6 @@ interface ValidatedSubtaskBatchSpec {
97
105
  readonly status: string;
98
106
  }
99
107
 
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
108
  interface TaskDeletionPlan {
127
109
  readonly subtaskIds: readonly string[];
128
110
  readonly touchingDependencies: readonly DependencyRecord[];
@@ -267,55 +249,6 @@ function mapDependency(row: DependencyRow): DependencyRecord {
267
249
  };
268
250
  }
269
251
 
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
252
  export class TrackerDomain {
320
253
  readonly #db: Database;
321
254
 
@@ -323,7 +256,32 @@ export class TrackerDomain {
323
256
  this.#db = db;
324
257
  }
325
258
 
259
+ #assertInTransaction(opName: string): void {
260
+ if (!this.#db.inTransaction) {
261
+ throw new DomainError({
262
+ code: "invalid_state",
263
+ message: `${opName} must be called inside a writeTransaction`,
264
+ details: { op: opName },
265
+ });
266
+ }
267
+ }
268
+
269
+ #makeBatchValidationReader(): BatchValidationReader {
270
+ return {
271
+ getTask: (id) => this.getTask(id),
272
+ getSubtask: (id) => this.getSubtask(id),
273
+ getDependencyByEdge: (sourceId, dependsOnId) => this.#getDependencyByEdge(sourceId, dependsOnId),
274
+ buildDependencyAdjacency: () => {
275
+ const rows = this.#db
276
+ .query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;")
277
+ .all() as Array<{ source_id: string; depends_on_id: string }>;
278
+ return buildDependencyAdjacencyFn(rows);
279
+ },
280
+ };
281
+ }
282
+
326
283
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
284
+ this.#assertInTransaction("createEpic");
327
285
  const now: number = Date.now();
328
286
  const id: string = randomUUID();
329
287
  const title: string = assertNonEmpty("title", input.title);
@@ -346,6 +304,53 @@ export class TrackerDomain {
346
304
  return rows.map(mapEpic);
347
305
  }
348
306
 
307
+ /**
308
+ * Returns the count of all epics without fetching rows.
309
+ */
310
+ countEpics(): number {
311
+ const row = this.#db.query("SELECT COUNT(*) AS n FROM epics;").get() as { n: number };
312
+ return row.n;
313
+ }
314
+
315
+ /**
316
+ * Returns the single "active" epic without fetching all rows:
317
+ * 1. First in_progress epic (any order — there should be at most one).
318
+ * 2. Fallback: most-recently-updated todo epic.
319
+ * 3. Fallback: oldest epic by created_at / id (matches epics[0] from listEpics).
320
+ * 4. null when the table is empty.
321
+ *
322
+ * Note: no index on (status, updated_at) exists on the epics table as of this
323
+ * writing, so the query uses a table scan. For typical epic counts this is
324
+ * negligible; add idx_epics_status_updated_at if it becomes a concern.
325
+ */
326
+ findActiveEpic(): EpicRecord | null {
327
+ const inProgress = this.#db
328
+ .query(
329
+ "SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'in_progress' LIMIT 1;",
330
+ )
331
+ .get() as EpicRow | null;
332
+ if (inProgress) {
333
+ return mapEpic(inProgress);
334
+ }
335
+
336
+ const todo = this.#db
337
+ .query(
338
+ "SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
339
+ )
340
+ .get() as EpicRow | null;
341
+ if (todo) {
342
+ return mapEpic(todo);
343
+ }
344
+
345
+ // Fallback: oldest epic regardless of status (mirrors epics[0] from listEpics).
346
+ const oldest = this.#db
347
+ .query(
348
+ "SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
349
+ )
350
+ .get() as EpicRow | null;
351
+ return oldest ? mapEpic(oldest) : null;
352
+ }
353
+
349
354
  getEpic(id: string): EpicRecord | null {
350
355
  const row = this.#db
351
356
  .query("SELECT id, title, description, status, created_at, updated_at FROM epics WHERE id = ?;")
@@ -370,6 +375,7 @@ export class TrackerDomain {
370
375
  id: string,
371
376
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
372
377
  ): EpicRecord {
378
+ this.#assertInTransaction("updateEpic");
373
379
  const existing: EpicRecord = this.getEpicOrThrow(id);
374
380
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
375
381
  const nextDescription: string =
@@ -385,11 +391,13 @@ export class TrackerDomain {
385
391
  }
386
392
 
387
393
  deleteEpic(id: string): void {
394
+ this.#assertInTransaction("deleteEpic");
388
395
  this.getEpicOrThrow(id);
389
396
  this.#db.query("DELETE FROM epics WHERE id = ?;").run(id);
390
397
  }
391
398
 
392
399
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
400
+ this.#assertInTransaction("createTask");
393
401
  const now: number = Date.now();
394
402
  const id: string = randomUUID();
395
403
  const epicId: string = assertNonEmpty("epicId", input.epicId);
@@ -428,13 +436,7 @@ export class TrackerDomain {
428
436
  };
429
437
  }
430
438
 
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
- }
439
+ this.#assertInTransaction("createTaskBatch");
438
440
 
439
441
  const TASK_COLS_PER_ROW = 7; // id, epic_id, title, description, status, created_at, updated_at (version is literal 1)
440
442
  const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / TASK_COLS_PER_ROW);
@@ -535,6 +537,7 @@ export class TrackerDomain {
535
537
  id: string,
536
538
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
537
539
  ): TaskRecord {
540
+ this.#assertInTransaction("updateTask");
538
541
  const existing: TaskRecord = this.getTaskOrThrow(id);
539
542
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
540
543
  const nextDescription: string =
@@ -552,6 +555,7 @@ export class TrackerDomain {
552
555
  }
553
556
 
554
557
  deleteTask(id: string): void {
558
+ this.#assertInTransaction("deleteTask");
555
559
  const normalizedTaskId: string = assertNonEmpty("id", id);
556
560
  this.getTaskOrThrow(normalizedTaskId);
557
561
 
@@ -575,6 +579,7 @@ export class TrackerDomain {
575
579
  createSubtask(
576
580
  input: { taskId: string; title: string; description?: string | undefined; status?: string | undefined },
577
581
  ): SubtaskRecord {
582
+ this.#assertInTransaction("createSubtask");
578
583
  const now: number = Date.now();
579
584
  const id: string = randomUUID();
580
585
  const taskId: string = assertNonEmpty("taskId", input.taskId);
@@ -623,13 +628,7 @@ export class TrackerDomain {
623
628
  };
624
629
  }
625
630
 
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
- }
631
+ this.#assertInTransaction("createSubtaskBatch");
633
632
 
634
633
  const SUBTASK_COLS_PER_ROW = 7; // id, task_id, title, description, status, created_at, updated_at (version is literal 1)
635
634
  const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / SUBTASK_COLS_PER_ROW);
@@ -786,6 +785,7 @@ export class TrackerDomain {
786
785
  id: string,
787
786
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
788
787
  ): SubtaskRecord {
788
+ this.#assertInTransaction("updateSubtask");
789
789
  const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
790
790
  const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
791
791
  const nextDescription: string =
@@ -803,6 +803,7 @@ export class TrackerDomain {
803
803
  }
804
804
 
805
805
  deleteSubtask(id: string): void {
806
+ this.#assertInTransaction("deleteSubtask");
806
807
  this.getSubtaskOrThrow(id);
807
808
  this.#db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(id, id);
808
809
  this.#db.query("DELETE FROM subtasks WHERE id = ?;").run(id);
@@ -941,37 +942,17 @@ export class TrackerDomain {
941
942
  }
942
943
 
943
944
  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 {
945
+ return planStatusCascadeImpl(
946
+ {
947
+ buildEpicTreeDetailed: (id) => this.buildEpicTreeDetailed(id),
948
+ buildTaskTreeDetailed: (id) => this.buildTaskTreeDetailed(id),
949
+ listDependenciesBySourceIds: (ids) => this.listDependenciesBySourceIds(ids),
950
+ loadDependencyTargetStatuses: (ids) => loadCascadeDependencyTargetStatuses(this.#db, ids),
951
+ },
956
952
  rootKind,
957
953
  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
- };
954
+ targetStatus,
955
+ );
975
956
  }
976
957
 
977
958
  collectEpicSearchScope(epicId: string): readonly SearchNode[] {
@@ -1076,6 +1057,7 @@ export class TrackerDomain {
1076
1057
  }
1077
1058
 
1078
1059
  addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
1060
+ this.#assertInTransaction("addDependency");
1079
1061
  const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
1080
1062
  const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
1081
1063
 
@@ -1121,6 +1103,7 @@ export class TrackerDomain {
1121
1103
  }
1122
1104
 
1123
1105
  addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
1106
+ this.#assertInTransaction("addDependencyBatch");
1124
1107
  const resolutions = input.specs.map((spec, index) => this.#resolveDependencyBatchSpec(index, spec));
1125
1108
  const resolvedSpecs = resolutions.flatMap((resolution) => (resolution.spec === undefined ? [] : [resolution.spec]));
1126
1109
  const issues = resolutions.flatMap((resolution) => resolution.issues).concat(this.#collectDependencyBatchIssues(resolvedSpecs));
@@ -1207,6 +1190,7 @@ export class TrackerDomain {
1207
1190
  }
1208
1191
 
1209
1192
  removeDependency(sourceId: string, dependsOnId: string): number {
1193
+ this.#assertInTransaction("removeDependency");
1210
1194
  const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
1211
1195
  const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
1212
1196
  const result = this.#db
@@ -1300,44 +1284,56 @@ export class TrackerDomain {
1300
1284
  }
1301
1285
 
1302
1286
  /**
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.
1287
+ * Resolves dependency statuses for multiple tasks using chunked
1288
+ * WHERE source_id IN (?...) queries the same pattern as
1289
+ * #collectStatusCascadeBlockers. This reduces N queries to
1290
+ * ceil(N/999), eliminating the previous per-ID N+1 loop.
1307
1291
  */
1308
1292
  batchResolveDependencyStatuses(
1309
1293
  taskIds: readonly string[],
1310
1294
  ): Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }> {
1295
+ type DepStatusRow = {
1296
+ source_id: string;
1297
+ depends_on_id: string;
1298
+ depends_on_kind: "task" | "subtask";
1299
+ dep_status: string | null;
1300
+ };
1301
+
1311
1302
  const result = new Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }>();
1312
1303
 
1313
1304
  if (taskIds.length === 0) {
1314
1305
  return result;
1315
1306
  }
1316
1307
 
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
-
1308
+ // Pre-populate so every requested ID has an entry, even with no deps.
1329
1309
  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);
1310
+ result.set(taskId, { totalDependencies: 0, blockers: [] });
1311
+ }
1332
1312
 
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
- }>;
1313
+ // Batch-fetch all dependency rows with their target statuses using chunked
1314
+ // IN queries with JOINs. ceil(N/999) queries instead of N.
1315
+ for (let offset = 0; offset < taskIds.length; offset += SQLITE_MAX_VARIABLES) {
1316
+ const chunkIds = taskIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
1317
+ const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1318
+
1319
+ const rows = this.#db
1320
+ .query(
1321
+ `SELECT d.source_id, d.depends_on_id, d.depends_on_kind,
1322
+ COALESCE(t.status, s.status) AS dep_status
1323
+ FROM dependencies d
1324
+ LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
1325
+ LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
1326
+ WHERE d.source_id IN (${inPlaceholders})
1327
+ ORDER BY d.created_at ASC, d.id ASC;`,
1328
+ )
1329
+ .all(...chunkIds) as DepStatusRow[];
1339
1330
 
1340
1331
  for (const row of rows) {
1332
+ const entry = result.get(row.source_id);
1333
+ if (entry === undefined) {
1334
+ continue;
1335
+ }
1336
+
1341
1337
  entry.totalDependencies += 1;
1342
1338
 
1343
1339
  // Skip orphaned dependency rows (target deleted).
@@ -1420,279 +1416,25 @@ export class TrackerDomain {
1420
1416
  }
1421
1417
 
1422
1418
  #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: [] };
1419
+ return resolveDependencyBatchSpecFn(index, spec, this.#makeBatchValidationReader());
1507
1420
  }
1508
1421
 
1509
1422
  #resolveEpicExpandSubtaskSpecs(
1510
1423
  specs: readonly CompactSubtaskSpec[],
1511
1424
  mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1512
1425
  ): 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
- });
1426
+ return resolveEpicExpandSubtaskSpecsFn(specs, mappings, this.#makeBatchValidationReader());
1536
1427
  }
1537
1428
 
1538
1429
  #resolveEpicExpandDependencySpecs(
1539
1430
  specs: readonly CompactDependencySpec[],
1540
1431
  mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
1541
1432
  ): 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
- };
1433
+ return resolveEpicExpandDependencySpecsFn(specs, mappings, this.#makeBatchValidationReader());
1587
1434
  }
1588
1435
 
1589
1436
  #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;
1437
+ return collectDependencyBatchIssuesFn(specs, this.#makeBatchValidationReader());
1696
1438
  }
1697
1439
 
1698
1440
  private collectSearchMatches(
@@ -1700,33 +1442,7 @@ export class TrackerDomain {
1700
1442
  searchText: string,
1701
1443
  fields: readonly SearchField[],
1702
1444
  ): 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;
1445
+ return collectSearchMatches(nodes, searchText, fields);
1730
1446
  }
1731
1447
 
1732
1448
  private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
@@ -1751,253 +1467,7 @@ export class TrackerDomain {
1751
1467
  return row !== null;
1752
1468
  }
1753
1469
 
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(
1470
+ assertNoUnresolvedDependenciesForStatusTransition(
2001
1471
  id: string,
2002
1472
  kind: DependencyNodeKind,
2003
1473
  existingStatus: string,