trekoon 0.4.2 → 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.
@@ -71,32 +71,32 @@ function normalizeOwnerInput(owner: string | null | undefined): string | null |
71
71
 
72
72
  /**
73
73
  * Thrown by the *WithIfMatch CAS variants when the supplied `If-Match`
74
- * `updatedAt` does not match the row currently in the database.
74
+ * version token does not match the row currently in the database.
75
75
  *
76
76
  * The error is **not** a `DomainError` so the generic `toBoardRouteError`
77
77
  * fall-through doesn't accidentally surface it as a 400 — route handlers
78
78
  * catch it explicitly and emit the canonical 409 `precondition_failed`
79
- * payload (with `currentUpdatedAt` fetched inside the same transaction
79
+ * payload (with `currentVersion` fetched inside the same transaction
80
80
  * that observed the mismatch).
81
81
  */
82
82
  export class PreconditionFailedError extends Error {
83
83
  readonly entityKind: "epic" | "task" | "subtask";
84
84
  readonly entityId: string;
85
- readonly currentUpdatedAt: number;
86
- readonly providedUpdatedAt: number;
85
+ readonly currentVersion: number;
86
+ readonly providedVersion: number;
87
87
 
88
88
  constructor(input: {
89
89
  entityKind: "epic" | "task" | "subtask";
90
90
  entityId: string;
91
- currentUpdatedAt: number;
92
- providedUpdatedAt: number;
91
+ currentVersion: number;
92
+ providedVersion: number;
93
93
  }) {
94
- super("If-Match version does not match current updatedAt");
94
+ super("If-Match version does not match current version");
95
95
  this.name = "PreconditionFailedError";
96
96
  this.entityKind = input.entityKind;
97
97
  this.entityId = input.entityId;
98
- this.currentUpdatedAt = input.currentUpdatedAt;
99
- this.providedUpdatedAt = input.providedUpdatedAt;
98
+ this.currentVersion = input.currentVersion;
99
+ this.providedVersion = input.providedVersion;
100
100
  }
101
101
  }
102
102
 
@@ -132,11 +132,9 @@ const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
132
132
  // than keeps up with expiration.
133
133
  const BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 1000;
134
134
 
135
- // Module-level timestamp guards the prune sweep. Per-process (not
136
- // per-MutationService) so that a long-lived process making frequent claims
137
- // does not re-run the DELETE on every transaction. Reset to 0 by tests via
138
- // `__resetIdempotencyPruneThrottleForTests`.
139
- let lastIdempotencyPruneAt = 0;
135
+ const idempotencyPruneByDatabase = new Map<string, number>();
136
+ let memoryDatabasePruneKeys = new WeakMap<Database, string>();
137
+ let nextMemoryDatabasePruneId = 0;
140
138
 
141
139
  /**
142
140
  * Test hook: resets the module-level prune throttle so a fresh test run
@@ -144,7 +142,26 @@ let lastIdempotencyPruneAt = 0;
144
142
  * never invoke this.
145
143
  */
146
144
  export function __resetIdempotencyPruneThrottleForTests(): void {
147
- lastIdempotencyPruneAt = 0;
145
+ idempotencyPruneByDatabase.clear();
146
+ memoryDatabasePruneKeys = new WeakMap<Database, string>();
147
+ nextMemoryDatabasePruneId = 0;
148
+ }
149
+
150
+ function idempotencyPruneKeyForDatabase(db: Database): string {
151
+ const rows = db.query("PRAGMA database_list;").all() as Array<{ name: string; file: string }>;
152
+ const main = rows.find((row) => row.name === "main");
153
+ if (main?.file) {
154
+ return main.file;
155
+ }
156
+
157
+ const existing = memoryDatabasePruneKeys.get(db);
158
+ if (existing) {
159
+ return existing;
160
+ }
161
+ nextMemoryDatabasePruneId += 1;
162
+ const next = `:memory:${nextMemoryDatabasePruneId}`;
163
+ memoryDatabasePruneKeys.set(db, next);
164
+ return next;
148
165
  }
149
166
 
150
167
  interface ScopeReplacementResult {
@@ -296,10 +313,10 @@ export class MutationService {
296
313
  * Atomic If-Match CAS variant of {@link updateEpic}.
297
314
  *
298
315
  * The `If-Match` precondition is enforced INSIDE the write transaction
299
- * via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND updated_at = ?`).
316
+ * via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND version = ?`).
300
317
  * If zero rows are affected we determine whether the row is missing
301
318
  * (→ `DomainError(not_found)`) or merely stale (→ {@link PreconditionFailedError}
302
- * with the freshly-fetched `currentUpdatedAt`).
319
+ * with the freshly-fetched `currentVersion`).
303
320
  *
304
321
  * This eliminates the read-check-then-write race the previous route-level
305
322
  * check had: a concurrent writer could land between `parseIfMatchHeader`'s
@@ -312,7 +329,7 @@ export class MutationService {
312
329
  */
313
330
  updateEpicWithIfMatch(
314
331
  id: string,
315
- ifMatchUpdatedAt: number,
332
+ ifMatchVersion: number,
316
333
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
317
334
  ): EpicRecord {
318
335
  return this.#writeTransaction((): EpicRecord => {
@@ -343,22 +360,22 @@ export class MutationService {
343
360
  `UPDATE epics
344
361
  SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1
345
362
  WHERE id = ?
346
- AND updated_at = ?
363
+ AND version = ?
347
364
  RETURNING id`,
348
365
  )
349
- .get(nextTitle, nextDescription, nextStatus, now, id, ifMatchUpdatedAt) as { id: string } | null;
366
+ .get(nextTitle, nextDescription, nextStatus, now, id, ifMatchVersion) as { id: string } | null;
350
367
 
351
368
  if (result === null) {
352
369
  // Zero rows changed. We already proved the row exists via
353
370
  // getEpicOrThrow, so the only remaining failure mode is a stale
354
- // precondition. Re-fetch updatedAt inside the same tx so the
371
+ // precondition. Re-fetch version inside the same tx so the
355
372
  // caller's 409 carries the freshest value.
356
373
  const current = this.#domain.getEpicOrThrow(id);
357
374
  throw new PreconditionFailedError({
358
375
  entityKind: "epic",
359
376
  entityId: id,
360
- currentUpdatedAt: current.updatedAt,
361
- providedUpdatedAt: ifMatchUpdatedAt,
377
+ currentVersion: current.version,
378
+ providedVersion: ifMatchVersion,
362
379
  });
363
380
  }
364
381
 
@@ -389,17 +406,17 @@ export class MutationService {
389
406
  */
390
407
  updateEpicStatusCascadeWithIfMatch(
391
408
  id: string,
392
- ifMatchUpdatedAt: number,
409
+ ifMatchVersion: number,
393
410
  status: string,
394
411
  ): StatusCascadePlan {
395
412
  return this.#writeTransaction((): StatusCascadePlan => {
396
413
  const existing = this.#domain.getEpicOrThrow(id);
397
- if (existing.updatedAt !== ifMatchUpdatedAt) {
414
+ if (existing.version !== ifMatchVersion) {
398
415
  throw new PreconditionFailedError({
399
416
  entityKind: "epic",
400
417
  entityId: id,
401
- currentUpdatedAt: existing.updatedAt,
402
- providedUpdatedAt: ifMatchUpdatedAt,
418
+ currentVersion: existing.version,
419
+ providedVersion: ifMatchVersion,
403
420
  });
404
421
  }
405
422
  const plan = this.#domain.planStatusCascade("epic", id, status);
@@ -513,7 +530,7 @@ export class MutationService {
513
530
  */
514
531
  updateTaskWithIfMatch(
515
532
  id: string,
516
- ifMatchUpdatedAt: number,
533
+ ifMatchVersion: number,
517
534
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
518
535
  ): TaskRecord {
519
536
  return this.#writeTransaction((): TaskRecord => {
@@ -546,18 +563,18 @@ export class MutationService {
546
563
  `UPDATE tasks
547
564
  SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
548
565
  WHERE id = ?
549
- AND updated_at = ?
566
+ AND version = ?
550
567
  RETURNING id`,
551
568
  )
552
- .get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchUpdatedAt) as { id: string } | null;
569
+ .get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchVersion) as { id: string } | null;
553
570
 
554
571
  if (result === null) {
555
572
  const current = this.#domain.getTaskOrThrow(id);
556
573
  throw new PreconditionFailedError({
557
574
  entityKind: "task",
558
575
  entityId: id,
559
- currentUpdatedAt: current.updatedAt,
560
- providedUpdatedAt: ifMatchUpdatedAt,
576
+ currentVersion: current.version,
577
+ providedVersion: ifMatchVersion,
561
578
  });
562
579
  }
563
580
 
@@ -686,7 +703,7 @@ export class MutationService {
686
703
  const nextStatus = input.status ?? existing.status;
687
704
  this.#db
688
705
  .query(
689
- "UPDATE epics SET description = description || ?, status = ?, updated_at = ? WHERE id = ?;",
706
+ "UPDATE epics SET description = description || ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
690
707
  )
691
708
  .run(separator + input.append, nextStatus, now, input.epicId);
692
709
  const epic = this.#domain.getEpicOrThrow(input.epicId);
@@ -1032,7 +1049,7 @@ export class MutationService {
1032
1049
  */
1033
1050
  updateSubtaskWithIfMatch(
1034
1051
  id: string,
1035
- ifMatchUpdatedAt: number,
1052
+ ifMatchVersion: number,
1036
1053
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
1037
1054
  ): SubtaskRecord {
1038
1055
  return this.#writeTransaction((): SubtaskRecord => {
@@ -1063,18 +1080,18 @@ export class MutationService {
1063
1080
  `UPDATE subtasks
1064
1081
  SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
1065
1082
  WHERE id = ?
1066
- AND updated_at = ?
1083
+ AND version = ?
1067
1084
  RETURNING id`,
1068
1085
  )
1069
- .get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchUpdatedAt) as { id: string } | null;
1086
+ .get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchVersion) as { id: string } | null;
1070
1087
 
1071
1088
  if (result === null) {
1072
1089
  const current = this.#domain.getSubtaskOrThrow(id);
1073
1090
  throw new PreconditionFailedError({
1074
1091
  entityKind: "subtask",
1075
1092
  entityId: id,
1076
- currentUpdatedAt: current.updatedAt,
1077
- providedUpdatedAt: ifMatchUpdatedAt,
1093
+ currentVersion: current.version,
1094
+ providedVersion: ifMatchVersion,
1078
1095
  });
1079
1096
  }
1080
1097
 
@@ -1581,12 +1598,13 @@ export class MutationService {
1581
1598
 
1582
1599
  #pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
1583
1600
  // Skip if we swept recently — see BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS
1584
- // for rationale. Module-level throttle so it applies per-process even
1585
- // across MutationService instances.
1586
- if (now - lastIdempotencyPruneAt < BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS) {
1601
+ // for rationale. The throttle is per database file so daemon processes
1602
+ // serving several workspaces do not suppress each other's cleanup.
1603
+ const pruneKey = idempotencyPruneKeyForDatabase(this.#db);
1604
+ const lastPrunedAt = idempotencyPruneByDatabase.get(pruneKey) ?? 0;
1605
+ if (now - lastPrunedAt < BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS) {
1587
1606
  return;
1588
1607
  }
1589
- lastIdempotencyPruneAt = now;
1590
1608
 
1591
1609
  const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
1592
1610
  this.#db.query(
@@ -1596,6 +1614,7 @@ export class MutationService {
1596
1614
  AND created_at < ?;
1597
1615
  `,
1598
1616
  ).run(cutoff);
1617
+ idempotencyPruneByDatabase.set(pruneKey, now);
1599
1618
  }
1600
1619
 
1601
1620
  #previewScopeReplacement(
@@ -56,6 +56,7 @@ interface EpicRow {
56
56
  status: string;
57
57
  created_at: number;
58
58
  updated_at: number;
59
+ version: number;
59
60
  }
60
61
 
61
62
  interface TaskRow extends EpicRow {
@@ -208,6 +209,7 @@ function mapEpic(row: EpicRow): EpicRecord {
208
209
  status: row.status,
209
210
  createdAt: row.created_at,
210
211
  updatedAt: row.updated_at,
212
+ version: row.version,
211
213
  };
212
214
  }
213
215
 
@@ -221,6 +223,7 @@ function mapTask(row: TaskRow): TaskRecord {
221
223
  owner: row.owner ?? null,
222
224
  createdAt: row.created_at,
223
225
  updatedAt: row.updated_at,
226
+ version: row.version,
224
227
  };
225
228
  }
226
229
 
@@ -234,6 +237,7 @@ function mapSubtask(row: SubtaskRow): SubtaskRecord {
234
237
  owner: row.owner ?? null,
235
238
  createdAt: row.created_at,
236
239
  updatedAt: row.updated_at,
240
+ version: row.version,
237
241
  };
238
242
  }
239
243
 
@@ -299,7 +303,7 @@ export class TrackerDomain {
299
303
 
300
304
  listEpics(): readonly EpicRecord[] {
301
305
  const rows = this.#db
302
- .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;")
303
307
  .all() as EpicRow[];
304
308
  return rows.map(mapEpic);
305
309
  }
@@ -326,7 +330,7 @@ export class TrackerDomain {
326
330
  findActiveEpic(): EpicRecord | null {
327
331
  const inProgress = this.#db
328
332
  .query(
329
- "SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'in_progress' LIMIT 1;",
333
+ "SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE status = 'in_progress' LIMIT 1;",
330
334
  )
331
335
  .get() as EpicRow | null;
332
336
  if (inProgress) {
@@ -335,7 +339,7 @@ export class TrackerDomain {
335
339
 
336
340
  const todo = this.#db
337
341
  .query(
338
- "SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
342
+ "SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
339
343
  )
340
344
  .get() as EpicRow | null;
341
345
  if (todo) {
@@ -345,7 +349,7 @@ export class TrackerDomain {
345
349
  // Fallback: oldest epic regardless of status (mirrors epics[0] from listEpics).
346
350
  const oldest = this.#db
347
351
  .query(
348
- "SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
352
+ "SELECT id, title, description, status, created_at, updated_at, version FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
349
353
  )
350
354
  .get() as EpicRow | null;
351
355
  return oldest ? mapEpic(oldest) : null;
@@ -353,7 +357,7 @@ export class TrackerDomain {
353
357
 
354
358
  getEpic(id: string): EpicRecord | null {
355
359
  const row = this.#db
356
- .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 = ?;")
357
361
  .get(id) as EpicRow | null;
358
362
  return row ? mapEpic(row) : null;
359
363
  }
@@ -469,7 +473,7 @@ export class TrackerDomain {
469
473
  const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
470
474
  const chunkRows = this.#db
471
475
  .query(
472
- `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});`,
473
477
  )
474
478
  .all(...chunkIds) as TaskRow[];
475
479
  fetchedRows.push(...chunkRows);
@@ -501,21 +505,21 @@ export class TrackerDomain {
501
505
  this.getEpicOrThrow(epicId);
502
506
  const rows = this.#db
503
507
  .query(
504
- "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;",
505
509
  )
506
510
  .all(epicId) as TaskRow[];
507
511
  return rows.map(mapTask);
508
512
  }
509
513
 
510
514
  const rows = this.#db
511
- .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;")
512
516
  .all() as TaskRow[];
513
517
  return rows.map(mapTask);
514
518
  }
515
519
 
516
520
  getTask(id: string): TaskRecord | null {
517
521
  const row = this.#db
518
- .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 = ?;")
519
523
  .get(id) as TaskRow | null;
520
524
  return row ? mapTask(row) : null;
521
525
  }
@@ -661,7 +665,7 @@ export class TrackerDomain {
661
665
  const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
662
666
  const chunkRows = this.#db
663
667
  .query(
664
- `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});`,
665
669
  )
666
670
  .all(...chunkIds) as SubtaskRow[];
667
671
  fetchedRows.push(...chunkRows);
@@ -737,7 +741,7 @@ export class TrackerDomain {
737
741
  this.getTaskOrThrow(taskId);
738
742
  const rows = this.#db
739
743
  .query(
740
- "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;",
741
745
  )
742
746
  .all(taskId) as SubtaskRow[];
743
747
  return rows.map(mapSubtask);
@@ -745,7 +749,7 @@ export class TrackerDomain {
745
749
 
746
750
  const rows = this.#db
747
751
  .query(
748
- "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;",
749
753
  )
750
754
  .all() as SubtaskRow[];
751
755
  return rows.map(mapSubtask);
@@ -755,7 +759,7 @@ export class TrackerDomain {
755
759
  this.getTaskOrThrow(taskId);
756
760
  const rows = this.#db
757
761
  .query(
758
- "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;",
759
763
  )
760
764
  .all(taskId) as SubtaskRow[];
761
765
  return rows.map(mapSubtask);
@@ -763,7 +767,7 @@ export class TrackerDomain {
763
767
 
764
768
  getSubtask(id: string): SubtaskRecord | null {
765
769
  const row = this.#db
766
- .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 = ?;")
767
771
  .get(id) as SubtaskRow | null;
768
772
  return row ? mapSubtask(row) : null;
769
773
  }
@@ -862,7 +866,7 @@ export class TrackerDomain {
862
866
  const taskIds = new Set(tasks.map((task) => task.id));
863
867
  const subtasks = this.#db
864
868
  .query(
865
- "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;",
866
870
  )
867
871
  .all(epicId) as SubtaskRow[];
868
872
 
@@ -1266,7 +1270,7 @@ export class TrackerDomain {
1266
1270
  const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
1267
1271
  const rows = this.#db
1268
1272
  .query(
1269
- `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
1270
1274
  FROM subtasks
1271
1275
  WHERE task_id IN (${inPlaceholders})
1272
1276
  ORDER BY created_at ASC, id ASC;`,
@@ -97,6 +97,7 @@ export interface EpicRecord {
97
97
  readonly status: string;
98
98
  readonly createdAt: number;
99
99
  readonly updatedAt: number;
100
+ readonly version: number;
100
101
  }
101
102
 
102
103
  export interface TaskRecord {
@@ -108,6 +109,7 @@ export interface TaskRecord {
108
109
  readonly owner: string | null;
109
110
  readonly createdAt: number;
110
111
  readonly updatedAt: number;
112
+ readonly version: number;
111
113
  }
112
114
 
113
115
  export interface SubtaskRecord {
@@ -119,6 +121,7 @@ export interface SubtaskRecord {
119
121
  readonly owner: string | null;
120
122
  readonly createdAt: number;
121
123
  readonly updatedAt: number;
124
+ readonly version: number;
122
125
  }
123
126
 
124
127
  export interface DependencyRecord {
@@ -124,8 +124,7 @@ function renderTaskIndex(lines: string[], bundle: ExportBundle): void {
124
124
  lines.push("| # | Title | Status | Subtasks |");
125
125
  lines.push("|---|-------|--------|----------|");
126
126
 
127
- for (let i = 0; i < bundle.tasks.length; i++) {
128
- const task = bundle.tasks[i];
127
+ for (const [i, task] of bundle.tasks.entries()) {
129
128
  const subtaskCount = bundle.subtasks.filter((s) => s.taskId === task.id).length;
130
129
  const anchor = taskAnchor(task);
131
130
  lines.push(`| ${i + 1} | [${escapeTableCell(escapeInlineText(task.title))}](#${anchor}) | ${task.status} | ${subtaskCount} |`);