@nijaru/tk 0.0.1 → 0.0.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.
package/src/db/storage.ts CHANGED
@@ -7,9 +7,11 @@ import {
7
7
  writeFileSync,
8
8
  realpathSync,
9
9
  lstatSync,
10
+ renameSync,
10
11
  } from "fs";
11
12
  import { join, resolve, basename } from "path";
12
13
  import { getTasksDir, getWorkingDir } from "../lib/root";
14
+ import { isTaskOverdue } from "../lib/time";
13
15
  import type { Task, Config, Status, Priority, TaskWithMeta, LogEntry } from "../types";
14
16
  import { DEFAULT_CONFIG, taskId, parseId, generateRef } from "../types";
15
17
 
@@ -31,6 +33,22 @@ function getConfigPath(tasksDir: string): string {
31
33
  return join(tasksDir, "config.json");
32
34
  }
33
35
 
36
+ /**
37
+ * Write a file atomically by writing to a temporary file and renaming it.
38
+ */
39
+ function atomicWrite(path: string, content: string): void {
40
+ const tempPath = `${path}.tmp.${generateRef()}`;
41
+ try {
42
+ writeFileSync(tempPath, content);
43
+ renameSync(tempPath, path);
44
+ } catch (e) {
45
+ if (existsSync(tempPath)) {
46
+ unlinkSync(tempPath);
47
+ }
48
+ throw e;
49
+ }
50
+ }
51
+
34
52
  function validatePathSafety(path: string, tasksDir: string): boolean {
35
53
  try {
36
54
  if (existsSync(path)) {
@@ -45,8 +63,50 @@ function validatePathSafety(path: string, tasksDir: string): boolean {
45
63
  }
46
64
  return true;
47
65
  } catch {
48
- return true;
66
+ // Fail closed: deny access on any error
67
+ return false;
68
+ }
69
+ }
70
+
71
+ const STATUS_ORDER: Record<Status, number> = { active: 0, open: 1, done: 2 };
72
+
73
+ /**
74
+ * Compare two tasks for sorting:
75
+ * 1. Status (active > open > done)
76
+ * 2. If active/open: overdue > priority (1-4, 0) > due date > created_at
77
+ * 3. If done: completed_at (newest first)
78
+ */
79
+ export function compareTasks(a: Task, b: Task): number {
80
+ const sA = STATUS_ORDER[a.status] ?? 99;
81
+ const sB = STATUS_ORDER[b.status] ?? 99;
82
+ if (sA !== sB) return sA - sB;
83
+
84
+ if (a.status !== "done") {
85
+ // Overdue hoist
86
+ const overdueA = isTaskOverdue(a.due_date, a.status) ? 0 : 1;
87
+ const overdueB = isTaskOverdue(b.due_date, b.status) ? 0 : 1;
88
+ if (overdueA !== overdueB) return overdueA - overdueB;
89
+
90
+ // Priority (1-4, then 0/none)
91
+ const pA = a.priority === 0 ? 5 : a.priority;
92
+ const pB = b.priority === 0 ? 5 : b.priority;
93
+ if (pA !== pB) return pA - pB;
94
+
95
+ // Due date (soonest first, nulls last)
96
+ if (a.due_date !== b.due_date) {
97
+ if (!a.due_date) return 1;
98
+ if (!b.due_date) return -1;
99
+ return a.due_date.localeCompare(b.due_date);
100
+ }
101
+
102
+ // Created at (newest first)
103
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
49
104
  }
105
+
106
+ // Done tasks: newest completion first
107
+ const timeA = a.completed_at ? new Date(a.completed_at).getTime() : 0;
108
+ const timeB = b.completed_at ? new Date(b.completed_at).getTime() : 0;
109
+ return timeB - timeA;
50
110
  }
51
111
 
52
112
  // --- Config Operations ---
@@ -68,7 +128,7 @@ export function getConfig(): Config {
68
128
  export function saveConfig(config: Config): void {
69
129
  const tasksDir = ensureTasksDir();
70
130
  const configPath = getConfigPath(tasksDir);
71
- writeFileSync(configPath, JSON.stringify(config, null, 2));
131
+ atomicWrite(configPath, JSON.stringify(config, null, 2));
72
132
  }
73
133
 
74
134
  export function updateConfig(updates: Partial<Config>): Config {
@@ -153,12 +213,12 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
153
213
  const newPath = getTaskPath(tasksDir, taskId(task));
154
214
 
155
215
  // Write to new path, delete old
156
- writeFileSync(newPath, JSON.stringify(task, null, 2));
216
+ atomicWrite(newPath, JSON.stringify(task, null, 2));
157
217
  unlinkSync(oldPath);
158
218
  } else if (modified) {
159
219
  // Just update references in non-renamed task
160
220
  const path = getTaskPath(tasksDir, taskId(task));
161
- writeFileSync(path, JSON.stringify(task, null, 2));
221
+ atomicWrite(path, JSON.stringify(task, null, 2));
162
222
  }
163
223
  }
164
224
 
@@ -197,14 +257,145 @@ function readTaskFile(path: string, tasksDir?: string): Task | null {
197
257
  }
198
258
  try {
199
259
  const text = readFileSync(path, "utf-8");
200
- return JSON.parse(text);
260
+ const parsed = JSON.parse(text);
261
+ // Validate structure to avoid crashes on malformed data
262
+ if (!isValidTaskStructure(parsed)) return null;
263
+ return parsed;
201
264
  } catch {
202
265
  return null;
203
266
  }
204
267
  }
205
268
 
206
269
  function writeTaskFile(path: string, task: Task): void {
207
- writeFileSync(path, JSON.stringify(task, null, 2));
270
+ atomicWrite(path, JSON.stringify(task, null, 2));
271
+ }
272
+
273
+ /**
274
+ * Validate that a parsed object has the minimum required Task structure.
275
+ * Returns true if valid, false otherwise.
276
+ */
277
+ function isValidTaskStructure(obj: unknown): obj is Task {
278
+ if (!obj || typeof obj !== "object") return false;
279
+ const t = obj as Record<string, unknown>;
280
+ return (
281
+ typeof t.project === "string" &&
282
+ typeof t.ref === "string" &&
283
+ typeof t.title === "string" &&
284
+ typeof t.status === "string" &&
285
+ Array.isArray(t.blocked_by)
286
+ );
287
+ }
288
+
289
+ /**
290
+ * Remove references to specific IDs from a task's blocked_by and parent fields.
291
+ * Returns true if the task was modified.
292
+ */
293
+ function removeRefsFromTask(task: Task, shouldRemove: (id: string) => boolean): boolean {
294
+ let modified = false;
295
+
296
+ const originalLen = task.blocked_by.length;
297
+ task.blocked_by = task.blocked_by.filter((id) => !shouldRemove(id));
298
+ if (task.blocked_by.length !== originalLen) {
299
+ modified = true;
300
+ }
301
+
302
+ if (task.parent && shouldRemove(task.parent)) {
303
+ task.parent = null;
304
+ modified = true;
305
+ }
306
+
307
+ return modified;
308
+ }
309
+
310
+ // --- Auto-cleanup ---
311
+
312
+ export interface CleanupInfo {
313
+ orphanedBlockers: string[];
314
+ orphanedParent: string | null;
315
+ idMismatch: { was: string; fixed: string } | null;
316
+ }
317
+
318
+ /**
319
+ * Clean orphaned references from a task. Returns info about what was cleaned.
320
+ * Writes to disk if any changes were made.
321
+ */
322
+ function cleanTaskOrphans(
323
+ task: Task,
324
+ path: string,
325
+ expectedId: string,
326
+ tasksDir: string,
327
+ ): CleanupInfo {
328
+ const cleanup: CleanupInfo = {
329
+ orphanedBlockers: [],
330
+ orphanedParent: null,
331
+ idMismatch: null,
332
+ };
333
+
334
+ let modified = false;
335
+
336
+ // Check ID mismatch (filename vs content)
337
+ const actualId = taskId(task);
338
+ if (actualId !== expectedId) {
339
+ const parsed = parseId(expectedId);
340
+ if (parsed) {
341
+ cleanup.idMismatch = { was: actualId, fixed: expectedId };
342
+ task.project = parsed.project;
343
+ task.ref = parsed.ref;
344
+ modified = true;
345
+ }
346
+ }
347
+
348
+ // Check orphaned blocked_by references
349
+ const validBlockers: string[] = [];
350
+ for (const blockerId of task.blocked_by) {
351
+ if (existsSync(getTaskPath(tasksDir, blockerId))) {
352
+ validBlockers.push(blockerId);
353
+ } else {
354
+ cleanup.orphanedBlockers.push(blockerId);
355
+ modified = true;
356
+ }
357
+ }
358
+ if (cleanup.orphanedBlockers.length > 0) {
359
+ task.blocked_by = validBlockers;
360
+ }
361
+
362
+ // Check orphaned parent reference
363
+ if (task.parent && !existsSync(getTaskPath(tasksDir, task.parent))) {
364
+ cleanup.orphanedParent = task.parent;
365
+ task.parent = null;
366
+ modified = true;
367
+ }
368
+
369
+ // Write back if modified
370
+ if (modified) {
371
+ task.updated_at = new Date().toISOString();
372
+ atomicWrite(path, JSON.stringify(task, null, 2));
373
+ }
374
+
375
+ return cleanup;
376
+ }
377
+
378
+ function hasCleanup(info: CleanupInfo): boolean {
379
+ return (
380
+ info.orphanedBlockers.length > 0 || info.orphanedParent !== null || info.idMismatch !== null
381
+ );
382
+ }
383
+
384
+ export function formatCleanupMessage(taskId: string, info: CleanupInfo): string {
385
+ const parts: string[] = [];
386
+
387
+ if (info.orphanedBlockers.length > 0) {
388
+ const s = info.orphanedBlockers.length === 1 ? "reference" : "references";
389
+ parts.push(`${info.orphanedBlockers.length} orphaned ${s}`);
390
+ }
391
+ if (info.orphanedParent) {
392
+ parts.push("orphaned parent");
393
+ }
394
+ if (info.idMismatch) {
395
+ parts.push(`ID mismatch (was ${info.idMismatch.was})`);
396
+ }
397
+
398
+ return `(cleaned ${parts.join(", ")} from ${taskId})`;
208
399
  }
209
400
 
210
401
  function writeTaskFileExclusive(path: string, task: Task): boolean {
@@ -295,6 +486,36 @@ export function validateParent(
295
486
  return { ok: true };
296
487
  }
297
488
 
489
+ // --- Task Enrichment ---
490
+
491
+ /**
492
+ * Enriches a Task with computed metadata.
493
+ * Can optionally take a status map for efficient bulk processing of blockers.
494
+ */
495
+ export function enrichTask(task: Task, statusMap?: Map<string, Status>): TaskWithMeta {
496
+ const tasksDir = getTasksDir();
497
+ let blockedByIncomplete = false;
498
+
499
+ for (const blockerId of task.blocked_by) {
500
+ let status = statusMap?.get(blockerId);
501
+ if (status === undefined) {
502
+ status = readTaskFile(getTaskPath(tasksDir, blockerId), tasksDir)?.status;
503
+ }
504
+
505
+ if (status && status !== "done") {
506
+ blockedByIncomplete = true;
507
+ break;
508
+ }
509
+ }
510
+
511
+ return {
512
+ ...task,
513
+ id: taskId(task),
514
+ is_overdue: isTaskOverdue(task.due_date, task.status),
515
+ blocked_by_incomplete: blockedByIncomplete,
516
+ };
517
+ }
518
+
298
519
  // --- Task CRUD ---
299
520
 
300
521
  export interface CreateTaskOptions {
@@ -343,7 +564,7 @@ export function createTask(options: CreateTaskOptions): Task & { id: string } {
343
564
 
344
565
  const path = getTaskPath(tasksDir, id);
345
566
  if (writeTaskFileExclusive(path, task)) {
346
- return { ...task, id };
567
+ return enrichTask(task);
347
568
  }
348
569
  // Collision (extremely rare with 4 chars) - retry with new ref
349
570
  }
@@ -351,18 +572,12 @@ export function createTask(options: CreateTaskOptions): Task & { id: string } {
351
572
  throw new Error(`Failed to create task after ${maxRetries} attempts`);
352
573
  }
353
574
 
354
- export function getTask(id: string): (Task & { id: string }) | null {
355
- const tasksDir = getTasksDir();
356
- if (!existsSync(tasksDir)) return null;
357
-
358
- const path = getTaskPath(tasksDir, id);
359
- const task = readTaskFile(path, tasksDir);
360
- if (!task) return null;
361
-
362
- return { ...task, id: taskId(task) };
575
+ export interface TaskResult {
576
+ task: TaskWithMeta;
577
+ cleanup: CleanupInfo | null;
363
578
  }
364
579
 
365
- export function getTaskWithMeta(id: string): TaskWithMeta | null {
580
+ export function getTask(id: string): TaskResult | null {
366
581
  const tasksDir = getTasksDir();
367
582
  if (!existsSync(tasksDir)) return null;
368
583
 
@@ -370,40 +585,18 @@ export function getTaskWithMeta(id: string): TaskWithMeta | null {
370
585
  const task = readTaskFile(path, tasksDir);
371
586
  if (!task) return null;
372
587
 
373
- // Check if any blockers are incomplete
374
- let blockedByIncomplete = false;
375
- for (const blockerId of task.blocked_by) {
376
- const blockerTask = readTaskFile(getTaskPath(tasksDir, blockerId), tasksDir);
377
- if (!blockerTask) continue;
378
- if (blockerTask.status !== "done") {
379
- blockedByIncomplete = true;
380
- break;
381
- }
382
- }
383
-
384
- // Check if overdue (due date is before today, not including today)
385
- let isOverdue = false;
386
- if (task.due_date && task.status !== "done") {
387
- const today = new Date();
388
- today.setHours(0, 0, 0, 0);
389
- const parts = task.due_date.split("-").map(Number);
390
- const year = parts[0];
391
- const month = parts[1];
392
- const day = parts[2];
393
- if (year && month && day) {
394
- const dueDate = new Date(year, month - 1, day);
395
- isOverdue = dueDate < today;
396
- }
397
- }
588
+ // Auto-clean orphaned references
589
+ const cleanup = cleanTaskOrphans(task, path, id, tasksDir);
398
590
 
399
591
  return {
400
- ...task,
401
- id: taskId(task),
402
- blocked_by_incomplete: blockedByIncomplete,
403
- is_overdue: isOverdue,
592
+ task: enrichTask(task),
593
+ cleanup: hasCleanup(cleanup) ? cleanup : null,
404
594
  };
405
595
  }
406
596
 
597
+ // Deprecated alias for getTask
598
+ export const getTaskWithMeta = getTask;
599
+
407
600
  export interface ListOptions {
408
601
  status?: Status;
409
602
  priority?: Priority;
@@ -416,80 +609,63 @@ export interface ListOptions {
416
609
  limit?: number;
417
610
  }
418
611
 
419
- export function listTasks(options?: ListOptions): (Task & { id: string })[] {
612
+ export function listTasks(options?: ListOptions): TaskWithMeta[] {
420
613
  const tasksDir = getTasksDir();
421
614
  if (!existsSync(tasksDir)) return [];
422
615
 
423
- let tasks = getAllTasks(tasksDir);
424
-
425
- // Apply filters
426
- if (options?.status) {
427
- tasks = tasks.filter((t) => t.status === options.status);
428
- }
429
- if (options?.priority !== undefined) {
430
- tasks = tasks.filter((t) => t.priority === options.priority);
431
- }
432
- if (options?.project) {
433
- tasks = tasks.filter((t) => t.project === options.project);
434
- }
435
- if (options?.label) {
436
- tasks = tasks.filter((t) => t.labels.includes(options.label!));
437
- }
438
- if (options?.assignee) {
439
- tasks = tasks.filter((t) => t.assignees.includes(options.assignee!));
440
- }
441
- if (options?.parent) {
442
- tasks = tasks.filter((t) => t.parent === options.parent);
443
- }
444
- if (options?.roots) {
445
- tasks = tasks.filter((t) => t.parent === null);
446
- }
447
- if (options?.overdue) {
448
- const today = new Date();
449
- today.setHours(0, 0, 0, 0);
450
- tasks = tasks.filter((t) => {
451
- if (!t.due_date || t.status === "done") return false;
452
- const parts = t.due_date.split("-").map(Number);
453
- const year = parts[0];
454
- const month = parts[1];
455
- const day = parts[2];
456
- if (!year || !month || !day) return false;
457
- const dueDate = new Date(year, month - 1, day);
458
- return dueDate < today;
459
- });
616
+ const allTasks = getAllTasks(tasksDir);
617
+ // Build status map from all tasks once for efficient blocker lookups during enrichment
618
+ const statusMap = new Map<string, Status>(allTasks.map((t) => [taskId(t), t.status]));
619
+
620
+ let tasks = allTasks;
621
+
622
+ // Apply filters declaratively
623
+ if (options) {
624
+ const filters: ((t: Task) => boolean)[] = [];
625
+
626
+ if (options.status) filters.push((t) => t.status === options.status);
627
+ if (options.priority !== undefined) filters.push((t) => t.priority === options.priority);
628
+ if (options.project) filters.push((t) => t.project === options.project);
629
+ if (options.label) filters.push((t) => t.labels.includes(options.label!));
630
+ if (options.assignee) filters.push((t) => t.assignees.includes(options.assignee!));
631
+ if (options.parent) filters.push((t) => t.parent === options.parent);
632
+ if (options.roots) filters.push((t) => t.parent === null);
633
+ if (options.overdue) filters.push((t) => isTaskOverdue(t.due_date, t.status));
634
+
635
+ if (filters.length > 0) {
636
+ tasks = tasks.filter((t) => filters.every((f) => f(t)));
637
+ }
460
638
  }
461
639
 
462
- // Sort by priority (lower = more urgent), then created_at (newer first)
463
- tasks.sort((a, b) => {
464
- if (a.priority !== b.priority) return a.priority - b.priority;
465
- return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
466
- });
640
+ tasks.sort(compareTasks);
467
641
 
468
642
  if (options?.limit) {
469
643
  tasks = tasks.slice(0, options.limit);
470
644
  }
471
645
 
472
- return tasks.map((t) => ({ ...t, id: taskId(t) }));
646
+ return tasks.map((t) => enrichTask(t, statusMap));
473
647
  }
474
648
 
475
- export function listReadyTasks(): (Task & { id: string })[] {
649
+ export function listReadyTasks(): TaskWithMeta[] {
476
650
  const tasksDir = getTasksDir();
477
651
  if (!existsSync(tasksDir)) return [];
478
652
 
479
- const openTasks = listTasks({ status: "open" });
480
-
481
- const readyTasks = openTasks.filter((task) => {
482
- for (const blockerId of task.blocked_by) {
483
- const blockerTask = readTaskFile(getTaskPath(tasksDir, blockerId), tasksDir);
484
- if (!blockerTask) continue;
485
- if (blockerTask.status !== "done") {
486
- return false;
653
+ const allTasks = getAllTasks(tasksDir);
654
+ const statusMap = new Map<string, Status>(allTasks.map((t) => [taskId(t), t.status]));
655
+
656
+ const readyTasks = allTasks
657
+ .filter((task) => task.status === "active" || task.status === "open")
658
+ .filter((task) => {
659
+ for (const blockerId of task.blocked_by) {
660
+ const blockerStatus = statusMap.get(blockerId);
661
+ if (blockerStatus && blockerStatus !== "done") return false;
487
662
  }
488
- }
489
- return true;
490
- });
663
+ return true;
664
+ });
665
+
666
+ readyTasks.sort(compareTasks);
491
667
 
492
- return readyTasks;
668
+ return readyTasks.map((t) => enrichTask(t, statusMap));
493
669
  }
494
670
 
495
671
  export function updateTaskStatus(id: string, status: Status): (Task & { id: string }) | null {
@@ -504,7 +680,7 @@ export function updateTaskStatus(id: string, status: Status): (Task & { id: stri
504
680
  task.completed_at = status === "done" ? now : null;
505
681
 
506
682
  writeTaskFile(path, task);
507
- return { ...task, id: taskId(task) };
683
+ return enrichTask(task);
508
684
  }
509
685
 
510
686
  export interface UpdateTaskOptions {
@@ -536,7 +712,7 @@ export function updateTask(id: string, updates: UpdateTaskOptions): (Task & { id
536
712
  task.updated_at = now;
537
713
 
538
714
  writeTaskFile(path, task);
539
- return { ...task, id: taskId(task) };
715
+ return enrichTask(task);
540
716
  }
541
717
 
542
718
  export function deleteTask(id: string): boolean {
@@ -546,12 +722,12 @@ export function deleteTask(id: string): boolean {
546
722
 
547
723
  unlinkSync(path);
548
724
 
549
- // Remove this task from all blocked_by arrays
725
+ // Remove this task from all blocked_by arrays and parent references
550
726
  const allTasks = getAllTasks(tasksDir);
551
727
  for (const task of allTasks) {
552
- if (task.blocked_by.includes(id)) {
553
- task.blocked_by = task.blocked_by.filter((b) => b !== id);
554
- writeTaskFile(getTaskPath(tasksDir, taskId(task)), task);
728
+ const modified = removeRefsFromTask(task, (ref) => ref === id);
729
+ if (modified) {
730
+ atomicWrite(getTaskPath(tasksDir, taskId(task)), JSON.stringify(task, null, 2));
555
731
  }
556
732
  }
557
733
 
@@ -560,8 +736,7 @@ export function deleteTask(id: string): boolean {
560
736
 
561
737
  export interface CleanOptions {
562
738
  olderThanMs?: number;
563
- status?: Status;
564
- all?: boolean;
739
+ force?: boolean; // ignore age threshold
565
740
  }
566
741
 
567
742
  export function cleanTasks(options: CleanOptions): number {
@@ -569,16 +744,15 @@ export function cleanTasks(options: CleanOptions): number {
569
744
  if (!existsSync(tasksDir)) return 0;
570
745
 
571
746
  const tasks = getAllTasks(tasksDir);
572
- const cutoff = options.olderThanMs ? Date.now() - options.olderThanMs : 0;
747
+ // Clamp cutoff to prevent negative values from very large olderThanMs
748
+ const cutoff = options.olderThanMs ? Math.max(0, Date.now() - options.olderThanMs) : 0;
573
749
 
574
750
  const toDelete: string[] = [];
575
751
 
576
752
  for (const task of tasks) {
577
- const validStatus = options.status ? task.status === options.status : task.status === "done";
753
+ if (task.status !== "done") continue;
578
754
 
579
- if (!validStatus) continue;
580
-
581
- if (!options.all && cutoff > 0) {
755
+ if (!options.force && cutoff > 0) {
582
756
  if (!task.completed_at) continue;
583
757
  if (new Date(task.completed_at).getTime() >= cutoff) continue;
584
758
  }
@@ -590,14 +764,14 @@ export function cleanTasks(options: CleanOptions): number {
590
764
  unlinkSync(getTaskPath(tasksDir, id));
591
765
  }
592
766
 
593
- // Clean up dangling blocked_by references
767
+ // Clean up dangling blocked_by and parent references
594
768
  if (toDelete.length > 0) {
769
+ const toDeleteSet = new Set(toDelete);
595
770
  const remaining = getAllTasks(tasksDir);
596
771
  for (const task of remaining) {
597
- const original = task.blocked_by.length;
598
- task.blocked_by = task.blocked_by.filter((b) => !toDelete.includes(b));
599
- if (task.blocked_by.length !== original) {
600
- writeTaskFile(getTaskPath(tasksDir, taskId(task)), task);
772
+ const modified = removeRefsFromTask(task, (id) => toDeleteSet.has(id));
773
+ if (modified) {
774
+ atomicWrite(getTaskPath(tasksDir, taskId(task)), JSON.stringify(task, null, 2));
601
775
  }
602
776
  }
603
777
  }
@@ -623,18 +797,11 @@ export function addLogEntry(taskIdStr: string, message: string): LogEntry {
623
797
 
624
798
  task.logs.push(entry);
625
799
  task.updated_at = now;
626
- writeTaskFile(path, task);
800
+ atomicWrite(path, JSON.stringify(task, null, 2));
627
801
 
628
802
  return entry;
629
803
  }
630
804
 
631
- export function getLogEntries(taskIdStr: string): LogEntry[] {
632
- const tasksDir = getTasksDir();
633
- const path = getTaskPath(tasksDir, taskIdStr);
634
- const task = readTaskFile(path, tasksDir);
635
- return task?.logs ?? [];
636
- }
637
-
638
805
  // --- Block Operations ---
639
806
 
640
807
  export function addBlock(taskIdStr: string, blockedBy: string): { ok: boolean; error?: string } {
@@ -661,7 +828,7 @@ export function addBlock(taskIdStr: string, blockedBy: string): { ok: boolean; e
661
828
 
662
829
  task.blocked_by.push(blockedBy);
663
830
  task.updated_at = new Date().toISOString();
664
- writeTaskFile(taskPath, task);
831
+ atomicWrite(taskPath, JSON.stringify(task, null, 2));
665
832
 
666
833
  return { ok: true };
667
834
  }
@@ -677,30 +844,13 @@ export function removeBlock(taskIdStr: string, blockedBy: string): boolean {
677
844
 
678
845
  task.blocked_by.splice(idx, 1);
679
846
  task.updated_at = new Date().toISOString();
680
- writeTaskFile(taskPath, task);
847
+ atomicWrite(taskPath, JSON.stringify(task, null, 2));
681
848
 
682
849
  return true;
683
850
  }
684
851
 
685
- export function getBlockers(taskIdStr: string): string[] {
686
- const tasksDir = getTasksDir();
687
- const task = readTaskFile(getTaskPath(tasksDir, taskIdStr), tasksDir);
688
- return task?.blocked_by ?? [];
689
- }
690
-
691
- export function getBlocking(taskIdStr: string): string[] {
692
- const tasksDir = getTasksDir();
693
- const allTasks = getAllTasks(tasksDir);
694
- return allTasks.filter((t) => t.blocked_by.includes(taskIdStr)).map((t) => taskId(t));
695
- }
696
-
697
852
  // --- Utility ---
698
853
 
699
- export function tasksExist(): boolean {
700
- const tasksDir = getTasksDir();
701
- return existsSync(tasksDir);
702
- }
703
-
704
854
  export function initTasks(project?: string): string {
705
855
  const tasksDir = ensureTasksDir();
706
856
  const config = getConfig();
@@ -765,13 +915,100 @@ export function resolveId(input: string): string | null {
765
915
  return matches[0];
766
916
  }
767
917
 
768
- // No match or ambiguous
769
918
  return null;
770
919
  }
771
920
 
921
+ export function findMatchingIds(input: string): string[] {
922
+ const tasksDir = getTasksDir();
923
+ if (!existsSync(tasksDir)) return [];
924
+
925
+ const inputLower = input.toLowerCase();
926
+ const files = readdirSync(tasksDir);
927
+ const matches: string[] = [];
928
+
929
+ for (const file of files) {
930
+ if (!file.endsWith(".json") || file === "config.json") continue;
931
+ const id = file.slice(0, -5);
932
+ if (!parseId(id)) continue;
933
+ const ref = id.split("-")[1] ?? "";
934
+ if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
935
+ matches.push(id);
936
+ }
937
+ }
938
+
939
+ return matches;
940
+ }
941
+
942
+ // --- Health Check ---
943
+
944
+ export interface CheckResult {
945
+ totalTasks: number;
946
+ cleaned: { task: string; info: CleanupInfo }[];
947
+ unfixable: { file: string; error: string }[];
948
+ }
949
+
772
950
  /**
773
- * Validate that an ID is in the correct format.
951
+ * Run health check on all tasks.
952
+ * Auto-fixes orphaned refs/ID mismatches and reports unfixable issues.
774
953
  */
775
- export function isValidId(id: string): boolean {
776
- return parseId(id) !== null;
954
+ export function checkTasks(): CheckResult {
955
+ const tasksDir = getTasksDir();
956
+ const result: CheckResult = {
957
+ totalTasks: 0,
958
+ cleaned: [],
959
+ unfixable: [],
960
+ };
961
+
962
+ if (!existsSync(tasksDir)) return result;
963
+
964
+ const files = readdirSync(tasksDir).filter((f) => f.endsWith(".json") && f !== "config.json");
965
+
966
+ for (const file of files) {
967
+ const expectedId = file.slice(0, -5);
968
+
969
+ // Warn about unexpected files (non-task JSON)
970
+ if (!parseId(expectedId)) {
971
+ result.unfixable.push({
972
+ file,
973
+ error: "Invalid task ID format in filename",
974
+ });
975
+ continue;
976
+ }
977
+
978
+ const path = join(tasksDir, file);
979
+
980
+ // Try to read and parse
981
+ let parsed: unknown;
982
+ try {
983
+ const text = readFileSync(path, "utf-8");
984
+ parsed = JSON.parse(text);
985
+ } catch (e) {
986
+ // Corrupted JSON - unfixable
987
+ result.unfixable.push({
988
+ file,
989
+ error: e instanceof Error ? e.message : "Invalid JSON",
990
+ });
991
+ continue;
992
+ }
993
+
994
+ // Validate task structure
995
+ if (!isValidTaskStructure(parsed)) {
996
+ result.unfixable.push({
997
+ file,
998
+ error: "Invalid task structure (missing required fields)",
999
+ });
1000
+ continue;
1001
+ }
1002
+
1003
+ const task = parsed;
1004
+ result.totalTasks++;
1005
+
1006
+ // Auto-clean (same as getTask does)
1007
+ const cleanup = cleanTaskOrphans(task, path, expectedId, tasksDir);
1008
+ if (hasCleanup(cleanup)) {
1009
+ result.cleaned.push({ task: expectedId, info: cleanup });
1010
+ }
1011
+ }
1012
+
1013
+ return result;
777
1014
  }