@nijaru/tk 0.0.5 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db/storage.ts DELETED
@@ -1,1050 +0,0 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- readdirSync,
5
- unlinkSync,
6
- readFileSync,
7
- writeFileSync,
8
- realpathSync,
9
- lstatSync,
10
- renameSync,
11
- } from "fs";
12
- import { join, resolve, basename } from "path";
13
- import { getTasksDir, getWorkingDir } from "../lib/root";
14
- import { isTaskOverdue, daysUntilDue } from "../lib/time";
15
- import type { Task, Config, Status, Priority, TaskWithMeta, LogEntry } from "../types";
16
- import { DEFAULT_CONFIG, taskId, parseId, generateRef } from "../types";
17
-
18
- // --- Path Utilities ---
19
-
20
- function ensureTasksDir(): string {
21
- const tasksDir = getTasksDir();
22
- if (!existsSync(tasksDir)) {
23
- mkdirSync(tasksDir, { recursive: true });
24
- }
25
- return tasksDir;
26
- }
27
-
28
- function getTaskPath(tasksDir: string, id: string): string {
29
- return join(tasksDir, `${id}.json`);
30
- }
31
-
32
- function getConfigPath(tasksDir: string): string {
33
- return join(tasksDir, "config.json");
34
- }
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
-
52
- function validatePathSafety(path: string, tasksDir: string): boolean {
53
- try {
54
- if (existsSync(path)) {
55
- const stats = lstatSync(path);
56
- if (stats.isSymbolicLink()) {
57
- const realPath = realpathSync(path);
58
- const resolvedTasksDir = resolve(tasksDir);
59
- if (!realPath.startsWith(resolvedTasksDir)) {
60
- return false;
61
- }
62
- }
63
- }
64
- return true;
65
- } catch {
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];
81
- const sB = STATUS_ORDER[b.status];
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();
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;
110
- }
111
-
112
- // --- Config Operations ---
113
-
114
- export function getConfig(): Config {
115
- const tasksDir = getTasksDir();
116
- const configPath = getConfigPath(tasksDir);
117
- if (!existsSync(configPath)) {
118
- return { ...DEFAULT_CONFIG };
119
- }
120
- try {
121
- const text = readFileSync(configPath, "utf-8");
122
- const parsed = JSON.parse(text);
123
- return {
124
- ...DEFAULT_CONFIG,
125
- ...parsed,
126
- defaults: { ...DEFAULT_CONFIG.defaults, ...parsed.defaults },
127
- };
128
- } catch {
129
- return { ...DEFAULT_CONFIG };
130
- }
131
- }
132
-
133
- export function saveConfig(config: Config): void {
134
- const tasksDir = ensureTasksDir();
135
- const configPath = getConfigPath(tasksDir);
136
- atomicWrite(configPath, JSON.stringify(config, null, 2));
137
- }
138
-
139
- export function updateConfig(updates: Partial<Config>): Config {
140
- const config = getConfig();
141
- Object.assign(config, updates);
142
- saveConfig(config);
143
- return config;
144
- }
145
-
146
- export function setDefaultProject(project: string): Config {
147
- return updateConfig({ project });
148
- }
149
-
150
- export interface RenameResult {
151
- renamed: string[];
152
- referencesUpdated: number;
153
- }
154
-
155
- export function renameProject(oldProject: string, newProject: string): RenameResult {
156
- const tasksDir = getTasksDir();
157
- if (!existsSync(tasksDir)) {
158
- throw new Error("No .tasks/ directory found");
159
- }
160
-
161
- const allTasks = getAllTasks(tasksDir);
162
-
163
- // Find tasks to rename
164
- const toRename = allTasks.filter((t) => t.project === oldProject);
165
- if (toRename.length === 0) {
166
- throw new Error(`No tasks found with project "${oldProject}". Run 'tk ls' to see projects.`);
167
- }
168
-
169
- // Check for collisions
170
- const existingIds = new Set(allTasks.map((t) => taskId(t)));
171
- for (const task of toRename) {
172
- const newId = `${newProject}-${task.ref}`;
173
- if (existingIds.has(newId)) {
174
- throw new Error(`Cannot rename: "${newId}" already exists. Choose a different project name.`);
175
- }
176
- }
177
-
178
- // Build old→new ID mapping
179
- const idMap = new Map<string, string>();
180
- for (const task of toRename) {
181
- const oldId = taskId(task);
182
- const newId = `${newProject}-${task.ref}`;
183
- idMap.set(oldId, newId);
184
- }
185
-
186
- // Update references in ALL tasks and rename files
187
- let referencesUpdated = 0;
188
- for (const task of allTasks) {
189
- let modified = false;
190
-
191
- // Update blocked_by references
192
- if (task.blocked_by.length > 0) {
193
- const newBlockedBy = task.blocked_by.map((id) => {
194
- const newId = idMap.get(id);
195
- if (newId) {
196
- referencesUpdated++;
197
- return newId;
198
- }
199
- return id;
200
- });
201
- if (newBlockedBy.some((id, i) => id !== task.blocked_by[i])) {
202
- task.blocked_by = newBlockedBy;
203
- modified = true;
204
- }
205
- }
206
-
207
- // Update parent reference
208
- if (task.parent && idMap.has(task.parent)) {
209
- task.parent = idMap.get(task.parent)!;
210
- referencesUpdated++;
211
- modified = true;
212
- }
213
-
214
- // If this task is being renamed
215
- if (task.project === oldProject) {
216
- const oldPath = getTaskPath(tasksDir, taskId(task));
217
- task.project = newProject;
218
- const newPath = getTaskPath(tasksDir, taskId(task));
219
-
220
- // Write to new path, delete old
221
- atomicWrite(newPath, JSON.stringify(task, null, 2));
222
- unlinkSync(oldPath);
223
- } else if (modified) {
224
- // Just update references in non-renamed task
225
- const path = getTaskPath(tasksDir, taskId(task));
226
- atomicWrite(path, JSON.stringify(task, null, 2));
227
- }
228
- }
229
-
230
- // Update default project in config
231
- const config = getConfig();
232
- if (config.project === oldProject) {
233
- updateConfig({ project: newProject });
234
- }
235
-
236
- return {
237
- renamed: toRename.map((t) => `${newProject}-${t.ref}`),
238
- referencesUpdated,
239
- };
240
- }
241
-
242
- export interface MoveResult {
243
- old_id: string;
244
- new_id: string;
245
- referencesUpdated: number;
246
- }
247
-
248
- export function moveTask(oldId: string, newProject: string): MoveResult {
249
- const tasksDir = getTasksDir();
250
- if (!existsSync(tasksDir)) {
251
- throw new Error("No .tasks/ directory found");
252
- }
253
-
254
- const parsed = parseId(oldId);
255
- if (!parsed) {
256
- throw new Error(`Invalid task ID: ${oldId}`);
257
- }
258
-
259
- if (parsed.project === newProject) {
260
- throw new Error(`Task ${oldId} is already in project "${newProject}"`);
261
- }
262
-
263
- const oldPath = getTaskPath(tasksDir, oldId);
264
- if (!existsSync(oldPath)) {
265
- throw new Error(`Task not found: ${oldId}`);
266
- }
267
-
268
- const newId = `${newProject}-${parsed.ref}`;
269
- const newPath = getTaskPath(tasksDir, newId);
270
- if (existsSync(newPath)) {
271
- throw new Error(
272
- `Cannot move: "${newId}" already exists. The ref conflicts with an existing task.`,
273
- );
274
- }
275
-
276
- const task = readTaskFile(oldPath, tasksDir);
277
- if (!task) {
278
- throw new Error(`Failed to read task: ${oldId}`);
279
- }
280
-
281
- // Write new file with updated project
282
- task.project = newProject;
283
- task.updated_at = new Date().toISOString();
284
- atomicWrite(newPath, JSON.stringify(task, null, 2));
285
- unlinkSync(oldPath);
286
-
287
- // Update all blocked_by and parent refs in other tasks
288
- let referencesUpdated = 0;
289
- const allTasks = getAllTasks(tasksDir);
290
- for (const other of allTasks) {
291
- let modified = false;
292
-
293
- if (other.blocked_by.includes(oldId)) {
294
- other.blocked_by = other.blocked_by.map((id) => (id === oldId ? newId : id));
295
- referencesUpdated++;
296
- modified = true;
297
- }
298
-
299
- if (other.parent === oldId) {
300
- other.parent = newId;
301
- referencesUpdated++;
302
- modified = true;
303
- }
304
-
305
- if (modified) {
306
- atomicWrite(getTaskPath(tasksDir, taskId(other)), JSON.stringify(other, null, 2));
307
- }
308
- }
309
-
310
- return { old_id: oldId, new_id: newId, referencesUpdated };
311
- }
312
-
313
- // --- Task File Operations ---
314
-
315
- function readTaskFile(path: string, tasksDir?: string): Task | null {
316
- if (!existsSync(path)) return null;
317
- if (tasksDir && !validatePathSafety(path, tasksDir)) {
318
- return null;
319
- }
320
- try {
321
- const text = readFileSync(path, "utf-8");
322
- const parsed = JSON.parse(text);
323
- // Validate structure to avoid crashes on malformed data
324
- if (!isValidTaskStructure(parsed)) return null;
325
- return parsed;
326
- } catch {
327
- return null;
328
- }
329
- }
330
-
331
- function writeTaskFile(path: string, task: Task): void {
332
- atomicWrite(path, JSON.stringify(task, null, 2));
333
- }
334
-
335
- /**
336
- * Validate that a parsed object has the minimum required Task structure.
337
- * Returns true if valid, false otherwise.
338
- */
339
- function isValidTaskStructure(obj: unknown): obj is Task {
340
- if (!obj || typeof obj !== "object") return false;
341
- const t = obj as Record<string, unknown>;
342
- return (
343
- typeof t.project === "string" &&
344
- typeof t.ref === "string" &&
345
- typeof t.title === "string" &&
346
- typeof t.status === "string" &&
347
- Array.isArray(t.blocked_by) &&
348
- Array.isArray(t.logs)
349
- );
350
- }
351
-
352
- /**
353
- * Remove references to specific IDs from a task's blocked_by and parent fields.
354
- * Returns true if the task was modified.
355
- */
356
- function removeRefsFromTask(task: Task, shouldRemove: (id: string) => boolean): boolean {
357
- let modified = false;
358
-
359
- const originalLen = task.blocked_by.length;
360
- task.blocked_by = task.blocked_by.filter((id) => !shouldRemove(id));
361
- if (task.blocked_by.length !== originalLen) {
362
- modified = true;
363
- }
364
-
365
- if (task.parent && shouldRemove(task.parent)) {
366
- task.parent = null;
367
- modified = true;
368
- }
369
-
370
- return modified;
371
- }
372
-
373
- // --- Auto-cleanup ---
374
-
375
- export interface CleanupInfo {
376
- orphanedBlockers: string[];
377
- orphanedParent: string | null;
378
- idMismatch: { was: string; fixed: string } | null;
379
- }
380
-
381
- /**
382
- * Clean orphaned references from a task. Returns info about what was cleaned.
383
- * Writes to disk if any changes were made.
384
- */
385
- function cleanTaskOrphans(
386
- task: Task,
387
- path: string,
388
- expectedId: string,
389
- tasksDir: string,
390
- ): CleanupInfo {
391
- const cleanup: CleanupInfo = {
392
- orphanedBlockers: [],
393
- orphanedParent: null,
394
- idMismatch: null,
395
- };
396
-
397
- let modified = false;
398
-
399
- // Check ID mismatch (filename vs content)
400
- const actualId = taskId(task);
401
- if (actualId !== expectedId) {
402
- const parsed = parseId(expectedId);
403
- if (parsed) {
404
- cleanup.idMismatch = { was: actualId, fixed: expectedId };
405
- task.project = parsed.project;
406
- task.ref = parsed.ref;
407
- modified = true;
408
- }
409
- }
410
-
411
- // Check orphaned blocked_by references
412
- const validBlockers: string[] = [];
413
- for (const blockerId of task.blocked_by) {
414
- if (existsSync(getTaskPath(tasksDir, blockerId))) {
415
- validBlockers.push(blockerId);
416
- } else {
417
- cleanup.orphanedBlockers.push(blockerId);
418
- modified = true;
419
- }
420
- }
421
- if (cleanup.orphanedBlockers.length > 0) {
422
- task.blocked_by = validBlockers;
423
- }
424
-
425
- // Check orphaned parent reference
426
- if (task.parent && !existsSync(getTaskPath(tasksDir, task.parent))) {
427
- cleanup.orphanedParent = task.parent;
428
- task.parent = null;
429
- modified = true;
430
- }
431
-
432
- // Write back if modified
433
- if (modified) {
434
- task.updated_at = new Date().toISOString();
435
- atomicWrite(path, JSON.stringify(task, null, 2));
436
- }
437
-
438
- return cleanup;
439
- }
440
-
441
- function hasCleanup(info: CleanupInfo): boolean {
442
- return (
443
- info.orphanedBlockers.length > 0 || info.orphanedParent !== null || info.idMismatch !== null
444
- );
445
- }
446
-
447
- export function formatCleanupMessage(taskId: string, info: CleanupInfo): string {
448
- const parts: string[] = [];
449
-
450
- if (info.orphanedBlockers.length > 0) {
451
- const s = info.orphanedBlockers.length === 1 ? "reference" : "references";
452
- parts.push(`${info.orphanedBlockers.length} orphaned ${s}`);
453
- }
454
- if (info.orphanedParent) {
455
- parts.push("orphaned parent");
456
- }
457
- if (info.idMismatch) {
458
- parts.push(`ID mismatch (was ${info.idMismatch.was})`);
459
- }
460
-
461
- return `(cleaned ${parts.join(", ")} from ${taskId})`;
462
- }
463
-
464
- function writeTaskFileExclusive(path: string, task: Task): boolean {
465
- try {
466
- writeFileSync(path, JSON.stringify(task, null, 2), { flag: "wx" });
467
- return true;
468
- } catch (e) {
469
- if (e instanceof Error && "code" in e && e.code === "EEXIST") {
470
- return false;
471
- }
472
- throw e;
473
- }
474
- }
475
-
476
- function getAllTasks(tasksDir: string): Task[] {
477
- if (!existsSync(tasksDir)) return [];
478
- const files = readdirSync(tasksDir).filter((f) => f.endsWith(".json") && f !== "config.json");
479
- const tasks: Task[] = [];
480
- for (const file of files) {
481
- const task = readTaskFile(join(tasksDir, file), tasksDir);
482
- if (task) tasks.push(task);
483
- }
484
- return tasks;
485
- }
486
-
487
- // --- Cycle Detection ---
488
-
489
- function wouldCreateBlockCycle(tasksDir: string, taskIdStr: string, blockerId: string): boolean {
490
- const visited = new Set<string>();
491
- const stack = [blockerId];
492
-
493
- while (stack.length > 0) {
494
- const current = stack.pop()!;
495
- if (current === taskIdStr) return true;
496
- if (visited.has(current)) continue;
497
- visited.add(current);
498
-
499
- const task = readTaskFile(getTaskPath(tasksDir, current), tasksDir);
500
- if (task) {
501
- for (const dep of task.blocked_by) {
502
- stack.push(dep);
503
- }
504
- }
505
- }
506
- return false;
507
- }
508
-
509
- function wouldCreateParentCycle(tasksDir: string, taskIdStr: string, parentId: string): boolean {
510
- const visited = new Set<string>();
511
- let current: string | null = parentId;
512
-
513
- while (current) {
514
- if (current === taskIdStr) return true;
515
- if (visited.has(current)) return true;
516
- visited.add(current);
517
-
518
- const task = readTaskFile(getTaskPath(tasksDir, current), tasksDir);
519
- current = task?.parent ?? null;
520
- }
521
- return false;
522
- }
523
-
524
- // --- Parent Validation ---
525
-
526
- export function validateParent(
527
- parentId: string,
528
- currentTaskId?: string,
529
- ): { ok: boolean; error?: string } {
530
- if (!parseId(parentId)) {
531
- return { ok: false, error: `Invalid parent ID format: ${parentId}` };
532
- }
533
-
534
- if (currentTaskId && parentId === currentTaskId) {
535
- return { ok: false, error: "Task cannot be its own parent" };
536
- }
537
-
538
- const tasksDir = getTasksDir();
539
-
540
- const parentPath = getTaskPath(tasksDir, parentId);
541
- if (!existsSync(parentPath)) {
542
- return { ok: false, error: `Parent task not found: ${parentId}` };
543
- }
544
-
545
- if (currentTaskId && wouldCreateParentCycle(tasksDir, currentTaskId, parentId)) {
546
- return { ok: false, error: "Would create circular parent relationship" };
547
- }
548
-
549
- return { ok: true };
550
- }
551
-
552
- // --- Task Enrichment ---
553
-
554
- /**
555
- * Enriches a Task with computed metadata.
556
- * Can optionally take a status map for efficient bulk processing of blockers.
557
- */
558
- export function enrichTask(task: Task, statusMap?: Map<string, Status>): TaskWithMeta {
559
- const tasksDir = getTasksDir();
560
- let blockedByIncomplete = false;
561
-
562
- for (const blockerId of task.blocked_by) {
563
- let status = statusMap?.get(blockerId);
564
- if (status === undefined) {
565
- status = readTaskFile(getTaskPath(tasksDir, blockerId), tasksDir)?.status;
566
- }
567
-
568
- if (status && status !== "done") {
569
- blockedByIncomplete = true;
570
- break;
571
- }
572
- }
573
-
574
- return {
575
- ...task,
576
- id: taskId(task),
577
- is_overdue: isTaskOverdue(task.due_date, task.status),
578
- blocked_by_incomplete: blockedByIncomplete,
579
- days_until_due: daysUntilDue(task.due_date, task.status),
580
- };
581
- }
582
-
583
- // --- Task CRUD ---
584
-
585
- export interface CreateTaskOptions {
586
- title: string;
587
- description?: string;
588
- priority?: Priority;
589
- project?: string;
590
- labels?: string[];
591
- assignees?: string[];
592
- parent?: string;
593
- estimate?: number;
594
- due_date?: string;
595
- }
596
-
597
- export function createTask(options: CreateTaskOptions): Task & { id: string } {
598
- const tasksDir = ensureTasksDir();
599
- const config = getConfig();
600
- const project = options.project ?? config.project;
601
- const now = new Date().toISOString();
602
-
603
- // Generate random ref with collision detection
604
- const maxRetries = 10;
605
- for (let attempt = 0; attempt < maxRetries; attempt++) {
606
- const ref = generateRef();
607
- const id = `${project}-${ref}`;
608
-
609
- const task: Task = {
610
- project,
611
- ref,
612
- title: options.title,
613
- description: options.description ?? null,
614
- status: "open",
615
- priority: options.priority ?? config.defaults.priority,
616
- labels: options.labels ?? [...config.defaults.labels],
617
- assignees: options.assignees ?? [...config.defaults.assignees],
618
- parent: options.parent ?? null,
619
- blocked_by: [],
620
- estimate: options.estimate ?? null,
621
- due_date: options.due_date ?? null,
622
- logs: [],
623
- created_at: now,
624
- updated_at: now,
625
- completed_at: null,
626
- external: {},
627
- };
628
-
629
- const path = getTaskPath(tasksDir, id);
630
- if (writeTaskFileExclusive(path, task)) {
631
- return enrichTask(task);
632
- }
633
- // Collision (extremely rare with 4 chars) - retry with new ref
634
- }
635
-
636
- throw new Error(`Failed to create task after ${maxRetries} attempts`);
637
- }
638
-
639
- export interface TaskResult {
640
- task: TaskWithMeta;
641
- cleanup: CleanupInfo | null;
642
- }
643
-
644
- export function getTask(id: string): TaskResult | null {
645
- const tasksDir = getTasksDir();
646
- if (!existsSync(tasksDir)) return null;
647
-
648
- const path = getTaskPath(tasksDir, id);
649
- const task = readTaskFile(path, tasksDir);
650
- if (!task) return null;
651
-
652
- // Auto-clean orphaned references
653
- const cleanup = cleanTaskOrphans(task, path, id, tasksDir);
654
-
655
- return {
656
- task: enrichTask(task),
657
- cleanup: hasCleanup(cleanup) ? cleanup : null,
658
- };
659
- }
660
-
661
- export interface ListOptions {
662
- status?: Status;
663
- priority?: Priority;
664
- project?: string;
665
- label?: string;
666
- assignee?: string;
667
- parent?: string;
668
- roots?: boolean;
669
- overdue?: boolean;
670
- limit?: number;
671
- }
672
-
673
- export function listTasks(options?: ListOptions): TaskWithMeta[] {
674
- const tasksDir = getTasksDir();
675
- if (!existsSync(tasksDir)) return [];
676
-
677
- const allTasks = getAllTasks(tasksDir);
678
- // Build status map from all tasks once for efficient blocker lookups during enrichment
679
- const statusMap = new Map<string, Status>(allTasks.map((t) => [taskId(t), t.status]));
680
-
681
- let tasks = allTasks;
682
-
683
- // Apply filters declaratively
684
- if (options) {
685
- const filters: ((t: Task) => boolean)[] = [];
686
-
687
- if (options.status) filters.push((t) => t.status === options.status);
688
- if (options.priority !== undefined) filters.push((t) => t.priority === options.priority);
689
- if (options.project) filters.push((t) => t.project === options.project);
690
- if (options.label) filters.push((t) => t.labels.includes(options.label!));
691
- if (options.assignee) filters.push((t) => t.assignees.includes(options.assignee!));
692
- if (options.parent) filters.push((t) => t.parent === options.parent);
693
- if (options.roots) filters.push((t) => t.parent === null);
694
- if (options.overdue) filters.push((t) => isTaskOverdue(t.due_date, t.status));
695
-
696
- if (filters.length > 0) {
697
- tasks = tasks.filter((t) => filters.every((f) => f(t)));
698
- }
699
- }
700
-
701
- tasks.sort(compareTasks);
702
-
703
- if (options?.limit) {
704
- tasks = tasks.slice(0, options.limit);
705
- }
706
-
707
- return tasks.map((t) => enrichTask(t, statusMap));
708
- }
709
-
710
- export function listReadyTasks(): TaskWithMeta[] {
711
- const tasksDir = getTasksDir();
712
- if (!existsSync(tasksDir)) return [];
713
-
714
- const allTasks = getAllTasks(tasksDir);
715
- const statusMap = new Map<string, Status>(allTasks.map((t) => [taskId(t), t.status]));
716
-
717
- const readyTasks = allTasks
718
- .filter((task) => task.status === "active" || task.status === "open")
719
- .filter((task) => {
720
- for (const blockerId of task.blocked_by) {
721
- const blockerStatus = statusMap.get(blockerId);
722
- if (blockerStatus && blockerStatus !== "done") return false;
723
- }
724
- return true;
725
- });
726
-
727
- readyTasks.sort(compareTasks);
728
-
729
- return readyTasks.map((t) => enrichTask(t, statusMap));
730
- }
731
-
732
- export function updateTaskStatus(id: string, status: Status): (Task & { id: string }) | null {
733
- const tasksDir = getTasksDir();
734
- const path = getTaskPath(tasksDir, id);
735
- const task = readTaskFile(path, tasksDir);
736
- if (!task) return null;
737
-
738
- const now = new Date().toISOString();
739
- task.status = status;
740
- task.updated_at = now;
741
- task.completed_at = status === "done" ? now : null;
742
-
743
- writeTaskFile(path, task);
744
- return enrichTask(task);
745
- }
746
-
747
- export interface UpdateTaskOptions {
748
- title?: string;
749
- description?: string | null;
750
- priority?: Priority;
751
- labels?: string[];
752
- assignees?: string[];
753
- parent?: string | null;
754
- estimate?: number | null;
755
- due_date?: string | null;
756
- }
757
-
758
- export function updateTask(id: string, updates: UpdateTaskOptions): (Task & { id: string }) | null {
759
- const tasksDir = getTasksDir();
760
- const path = getTaskPath(tasksDir, id);
761
- const task = readTaskFile(path, tasksDir);
762
- if (!task) return null;
763
-
764
- const now = new Date().toISOString();
765
- if (updates.title !== undefined) task.title = updates.title;
766
- if (updates.description !== undefined) task.description = updates.description;
767
- if (updates.priority !== undefined) task.priority = updates.priority;
768
- if (updates.labels !== undefined) task.labels = updates.labels;
769
- if (updates.assignees !== undefined) task.assignees = updates.assignees;
770
- if (updates.parent !== undefined) task.parent = updates.parent;
771
- if (updates.estimate !== undefined) task.estimate = updates.estimate;
772
- if (updates.due_date !== undefined) task.due_date = updates.due_date;
773
- task.updated_at = now;
774
-
775
- writeTaskFile(path, task);
776
- return enrichTask(task);
777
- }
778
-
779
- export function deleteTask(id: string): boolean {
780
- const tasksDir = getTasksDir();
781
- const path = getTaskPath(tasksDir, id);
782
- if (!existsSync(path)) return false;
783
-
784
- unlinkSync(path);
785
-
786
- // Remove this task from all blocked_by arrays and parent references
787
- const allTasks = getAllTasks(tasksDir);
788
- for (const task of allTasks) {
789
- const modified = removeRefsFromTask(task, (ref) => ref === id);
790
- if (modified) {
791
- atomicWrite(getTaskPath(tasksDir, taskId(task)), JSON.stringify(task, null, 2));
792
- }
793
- }
794
-
795
- return true;
796
- }
797
-
798
- export interface CleanOptions {
799
- olderThanMs?: number;
800
- force?: boolean; // ignore age threshold
801
- }
802
-
803
- export function cleanTasks(options: CleanOptions): number {
804
- const tasksDir = getTasksDir();
805
- if (!existsSync(tasksDir)) return 0;
806
-
807
- const tasks = getAllTasks(tasksDir);
808
- // Clamp cutoff to prevent negative values from very large olderThanMs
809
- const cutoff = options.olderThanMs ? Math.max(0, Date.now() - options.olderThanMs) : 0;
810
-
811
- const toDelete: string[] = [];
812
-
813
- for (const task of tasks) {
814
- if (task.status !== "done") continue;
815
-
816
- if (!options.force && cutoff > 0) {
817
- if (!task.completed_at) continue;
818
- if (new Date(task.completed_at).getTime() >= cutoff) continue;
819
- }
820
-
821
- toDelete.push(taskId(task));
822
- }
823
-
824
- for (const id of toDelete) {
825
- unlinkSync(getTaskPath(tasksDir, id));
826
- }
827
-
828
- // Clean up dangling blocked_by and parent references
829
- if (toDelete.length > 0) {
830
- const toDeleteSet = new Set(toDelete);
831
- const remaining = getAllTasks(tasksDir);
832
- for (const task of remaining) {
833
- const modified = removeRefsFromTask(task, (id) => toDeleteSet.has(id));
834
- if (modified) {
835
- atomicWrite(getTaskPath(tasksDir, taskId(task)), JSON.stringify(task, null, 2));
836
- }
837
- }
838
- }
839
-
840
- return toDelete.length;
841
- }
842
-
843
- // --- Log Operations ---
844
-
845
- export function addLogEntry(taskIdStr: string, message: string): LogEntry {
846
- const tasksDir = getTasksDir();
847
- const path = getTaskPath(tasksDir, taskIdStr);
848
- const task = readTaskFile(path, tasksDir);
849
- if (!task) {
850
- throw new Error(`Task not found: ${taskIdStr}`);
851
- }
852
-
853
- const now = new Date().toISOString();
854
- const entry: LogEntry = {
855
- ts: now,
856
- msg: message,
857
- };
858
-
859
- task.logs.push(entry);
860
- task.updated_at = now;
861
- atomicWrite(path, JSON.stringify(task, null, 2));
862
-
863
- return entry;
864
- }
865
-
866
- // --- Block Operations ---
867
-
868
- export function addBlock(taskIdStr: string, blockedBy: string): { ok: boolean; error?: string } {
869
- const tasksDir = ensureTasksDir();
870
- const taskPath = getTaskPath(tasksDir, taskIdStr);
871
- const blockerPath = getTaskPath(tasksDir, blockedBy);
872
-
873
- const task = readTaskFile(taskPath, tasksDir);
874
- if (!task) {
875
- return { ok: false, error: `Task not found: ${taskIdStr}` };
876
- }
877
-
878
- if (!existsSync(blockerPath)) {
879
- return { ok: false, error: `Blocker not found: ${blockedBy}` };
880
- }
881
-
882
- if (task.blocked_by.includes(blockedBy)) {
883
- return { ok: false, error: "Already blocked by this task" };
884
- }
885
-
886
- if (wouldCreateBlockCycle(tasksDir, taskIdStr, blockedBy)) {
887
- return { ok: false, error: "Would create circular dependency" };
888
- }
889
-
890
- task.blocked_by.push(blockedBy);
891
- task.updated_at = new Date().toISOString();
892
- atomicWrite(taskPath, JSON.stringify(task, null, 2));
893
-
894
- return { ok: true };
895
- }
896
-
897
- export function removeBlock(taskIdStr: string, blockedBy: string): boolean {
898
- const tasksDir = getTasksDir();
899
- const taskPath = getTaskPath(tasksDir, taskIdStr);
900
- const task = readTaskFile(taskPath, tasksDir);
901
- if (!task) return false;
902
-
903
- const idx = task.blocked_by.indexOf(blockedBy);
904
- if (idx === -1) return false;
905
-
906
- task.blocked_by.splice(idx, 1);
907
- task.updated_at = new Date().toISOString();
908
- atomicWrite(taskPath, JSON.stringify(task, null, 2));
909
-
910
- return true;
911
- }
912
-
913
- // --- Utility ---
914
-
915
- export function initTasks(project?: string): string {
916
- const tasksDir = ensureTasksDir();
917
- const config = getConfig();
918
-
919
- // Derive project from directory name if not specified
920
- if (project) {
921
- config.project = project;
922
- } else if (config.project === DEFAULT_CONFIG.project) {
923
- // Only auto-derive if still using default "tk"
924
- const dirName = basename(getWorkingDir());
925
- // Sanitize: lowercase, replace non-alphanumeric with empty, ensure starts with letter
926
- const sanitized = dirName.toLowerCase().replace(/[^a-z0-9]/g, "");
927
- if (sanitized && /^[a-z]/.test(sanitized)) {
928
- config.project = sanitized;
929
- }
930
- }
931
- saveConfig(config);
932
- return tasksDir;
933
- }
934
-
935
- // --- ID Resolution ---
936
-
937
- /**
938
- * Resolve a partial ID to a full ID.
939
- * Matches by prefix - if input matches start of exactly one task ID, returns it.
940
- * Returns null if no match or ambiguous (multiple matches).
941
- *
942
- * Optimized: only reads filenames, not file contents.
943
- */
944
- export function resolveId(input: string): string | null {
945
- const tasksDir = getTasksDir();
946
- if (!existsSync(tasksDir)) return null;
947
-
948
- // Fast path: full valid ID that exists
949
- if (parseId(input) && existsSync(getTaskPath(tasksDir, input))) {
950
- return input;
951
- }
952
-
953
- const matches = findMatchingIds(input);
954
- return matches.length === 1 ? (matches[0] ?? null) : null;
955
- }
956
-
957
- export function findMatchingIds(input: string): string[] {
958
- const tasksDir = getTasksDir();
959
- if (!existsSync(tasksDir)) return [];
960
-
961
- const inputLower = input.toLowerCase();
962
- const files = readdirSync(tasksDir);
963
- const matches: string[] = [];
964
-
965
- for (const file of files) {
966
- if (!file.endsWith(".json") || file === "config.json") continue;
967
- const id = file.slice(0, -5);
968
- if (!parseId(id)) continue;
969
- const ref = id.split("-")[1] ?? "";
970
- if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
971
- matches.push(id);
972
- }
973
- }
974
-
975
- return matches;
976
- }
977
-
978
- // --- Health Check ---
979
-
980
- export interface CheckResult {
981
- totalTasks: number;
982
- cleaned: { task: string; info: CleanupInfo }[];
983
- unfixable: { file: string; error: string }[];
984
- }
985
-
986
- /**
987
- * Run health check on all tasks.
988
- * Auto-fixes orphaned refs/ID mismatches and reports unfixable issues.
989
- */
990
- export function checkTasks(): CheckResult {
991
- const tasksDir = getTasksDir();
992
- const result: CheckResult = {
993
- totalTasks: 0,
994
- cleaned: [],
995
- unfixable: [],
996
- };
997
-
998
- if (!existsSync(tasksDir)) return result;
999
-
1000
- const files = readdirSync(tasksDir).filter((f) => f.endsWith(".json") && f !== "config.json");
1001
-
1002
- for (const file of files) {
1003
- const expectedId = file.slice(0, -5);
1004
-
1005
- // Warn about unexpected files (non-task JSON)
1006
- if (!parseId(expectedId)) {
1007
- result.unfixable.push({
1008
- file,
1009
- error: "Invalid task ID format in filename",
1010
- });
1011
- continue;
1012
- }
1013
-
1014
- const path = join(tasksDir, file);
1015
-
1016
- // Try to read and parse
1017
- let parsed: unknown;
1018
- try {
1019
- const text = readFileSync(path, "utf-8");
1020
- parsed = JSON.parse(text);
1021
- } catch (e) {
1022
- // Corrupted JSON - unfixable
1023
- result.unfixable.push({
1024
- file,
1025
- error: e instanceof Error ? e.message : "Invalid JSON",
1026
- });
1027
- continue;
1028
- }
1029
-
1030
- // Validate task structure
1031
- if (!isValidTaskStructure(parsed)) {
1032
- result.unfixable.push({
1033
- file,
1034
- error: "Invalid task structure (missing required fields)",
1035
- });
1036
- continue;
1037
- }
1038
-
1039
- const task = parsed;
1040
- result.totalTasks++;
1041
-
1042
- // Auto-clean (same as getTask does)
1043
- const cleanup = cleanTaskOrphans(task, path, expectedId, tasksDir);
1044
- if (hasCleanup(cleanup)) {
1045
- result.cleaned.push({ task: expectedId, info: cleanup });
1046
- }
1047
- }
1048
-
1049
- return result;
1050
- }