@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/README.md +34 -21
- package/package.json +4 -4
- package/src/cli.test.ts +348 -8
- package/src/cli.ts +240 -184
- package/src/db/storage.ts +389 -152
- package/src/lib/completions.ts +68 -61
- package/src/lib/format.test.ts +18 -8
- package/src/lib/format.ts +39 -25
- package/src/lib/time.ts +115 -0
- package/src/types.ts +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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
|
-
//
|
|
374
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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):
|
|
612
|
+
export function listTasks(options?: ListOptions): TaskWithMeta[] {
|
|
420
613
|
const tasksDir = getTasksDir();
|
|
421
614
|
if (!existsSync(tasksDir)) return [];
|
|
422
615
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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) => (
|
|
646
|
+
return tasks.map((t) => enrichTask(t, statusMap));
|
|
473
647
|
}
|
|
474
648
|
|
|
475
|
-
export function listReadyTasks():
|
|
649
|
+
export function listReadyTasks(): TaskWithMeta[] {
|
|
476
650
|
const tasksDir = getTasksDir();
|
|
477
651
|
if (!existsSync(tasksDir)) return [];
|
|
478
652
|
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
+
if (task.status !== "done") continue;
|
|
578
754
|
|
|
579
|
-
if (!
|
|
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
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
951
|
+
* Run health check on all tasks.
|
|
952
|
+
* Auto-fixes orphaned refs/ID mismatches and reports unfixable issues.
|
|
774
953
|
*/
|
|
775
|
-
export function
|
|
776
|
-
|
|
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
|
}
|