@nijaru/tk 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -17
- package/package.json +4 -4
- package/src/cli.test.ts +265 -5
- package/src/cli.ts +227 -106
- package/src/db/storage.ts +305 -90
- package/src/lib/completions.ts +15 -8
- package/src/lib/format.ts +26 -0
- package/src/types.ts +2 -2
package/src/db/storage.ts
CHANGED
|
@@ -45,10 +45,24 @@ function validatePathSafety(path: string, tasksDir: string): boolean {
|
|
|
45
45
|
}
|
|
46
46
|
return true;
|
|
47
47
|
} catch {
|
|
48
|
-
|
|
48
|
+
// Fail closed: deny access on any error
|
|
49
|
+
return false;
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function isTaskOverdue(dueDate: string | null, status: Status): boolean {
|
|
54
|
+
if (!dueDate || status === "done") return false;
|
|
55
|
+
const today = new Date();
|
|
56
|
+
today.setHours(0, 0, 0, 0);
|
|
57
|
+
const parts = dueDate.split("-").map(Number);
|
|
58
|
+
const year = parts[0];
|
|
59
|
+
const month = parts[1];
|
|
60
|
+
const day = parts[2];
|
|
61
|
+
if (!year || !month || !day) return false;
|
|
62
|
+
const due = new Date(year, month - 1, day);
|
|
63
|
+
return due < today;
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
// --- Config Operations ---
|
|
53
67
|
|
|
54
68
|
export function getConfig(): Config {
|
|
@@ -197,7 +211,10 @@ function readTaskFile(path: string, tasksDir?: string): Task | null {
|
|
|
197
211
|
}
|
|
198
212
|
try {
|
|
199
213
|
const text = readFileSync(path, "utf-8");
|
|
200
|
-
|
|
214
|
+
const parsed = JSON.parse(text);
|
|
215
|
+
// Validate structure to avoid crashes on malformed data
|
|
216
|
+
if (!isValidTaskStructure(parsed)) return null;
|
|
217
|
+
return parsed;
|
|
201
218
|
} catch {
|
|
202
219
|
return null;
|
|
203
220
|
}
|
|
@@ -207,6 +224,134 @@ function writeTaskFile(path: string, task: Task): void {
|
|
|
207
224
|
writeFileSync(path, JSON.stringify(task, null, 2));
|
|
208
225
|
}
|
|
209
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Validate that a parsed object has the minimum required Task structure.
|
|
229
|
+
* Returns true if valid, false otherwise.
|
|
230
|
+
*/
|
|
231
|
+
function isValidTaskStructure(obj: unknown): obj is Task {
|
|
232
|
+
if (!obj || typeof obj !== "object") return false;
|
|
233
|
+
const t = obj as Record<string, unknown>;
|
|
234
|
+
return (
|
|
235
|
+
typeof t.project === "string" &&
|
|
236
|
+
typeof t.ref === "string" &&
|
|
237
|
+
typeof t.title === "string" &&
|
|
238
|
+
typeof t.status === "string" &&
|
|
239
|
+
Array.isArray(t.blocked_by)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Remove references to specific IDs from a task's blocked_by and parent fields.
|
|
245
|
+
* Returns true if the task was modified.
|
|
246
|
+
*/
|
|
247
|
+
function removeRefsFromTask(task: Task, shouldRemove: (id: string) => boolean): boolean {
|
|
248
|
+
let modified = false;
|
|
249
|
+
|
|
250
|
+
const originalLen = task.blocked_by.length;
|
|
251
|
+
task.blocked_by = task.blocked_by.filter((id) => !shouldRemove(id));
|
|
252
|
+
if (task.blocked_by.length !== originalLen) {
|
|
253
|
+
modified = true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (task.parent && shouldRemove(task.parent)) {
|
|
257
|
+
task.parent = null;
|
|
258
|
+
modified = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return modified;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Auto-cleanup ---
|
|
265
|
+
|
|
266
|
+
export interface CleanupInfo {
|
|
267
|
+
orphanedBlockers: string[];
|
|
268
|
+
orphanedParent: string | null;
|
|
269
|
+
idMismatch: { was: string; fixed: string } | null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Clean orphaned references from a task. Returns info about what was cleaned.
|
|
274
|
+
* Writes to disk if any changes were made.
|
|
275
|
+
*/
|
|
276
|
+
function cleanTaskOrphans(
|
|
277
|
+
task: Task,
|
|
278
|
+
path: string,
|
|
279
|
+
expectedId: string,
|
|
280
|
+
tasksDir: string,
|
|
281
|
+
): CleanupInfo {
|
|
282
|
+
const cleanup: CleanupInfo = {
|
|
283
|
+
orphanedBlockers: [],
|
|
284
|
+
orphanedParent: null,
|
|
285
|
+
idMismatch: null,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
let modified = false;
|
|
289
|
+
|
|
290
|
+
// Check ID mismatch (filename vs content)
|
|
291
|
+
const actualId = taskId(task);
|
|
292
|
+
if (actualId !== expectedId) {
|
|
293
|
+
const parsed = parseId(expectedId);
|
|
294
|
+
if (parsed) {
|
|
295
|
+
cleanup.idMismatch = { was: actualId, fixed: expectedId };
|
|
296
|
+
task.project = parsed.project;
|
|
297
|
+
task.ref = parsed.ref;
|
|
298
|
+
modified = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check orphaned blocked_by references
|
|
303
|
+
const validBlockers: string[] = [];
|
|
304
|
+
for (const blockerId of task.blocked_by) {
|
|
305
|
+
if (existsSync(getTaskPath(tasksDir, blockerId))) {
|
|
306
|
+
validBlockers.push(blockerId);
|
|
307
|
+
} else {
|
|
308
|
+
cleanup.orphanedBlockers.push(blockerId);
|
|
309
|
+
modified = true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (cleanup.orphanedBlockers.length > 0) {
|
|
313
|
+
task.blocked_by = validBlockers;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check orphaned parent reference
|
|
317
|
+
if (task.parent && !existsSync(getTaskPath(tasksDir, task.parent))) {
|
|
318
|
+
cleanup.orphanedParent = task.parent;
|
|
319
|
+
task.parent = null;
|
|
320
|
+
modified = true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Write back if modified
|
|
324
|
+
if (modified) {
|
|
325
|
+
task.updated_at = new Date().toISOString();
|
|
326
|
+
writeTaskFile(path, task);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return cleanup;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function hasCleanup(info: CleanupInfo): boolean {
|
|
333
|
+
return (
|
|
334
|
+
info.orphanedBlockers.length > 0 || info.orphanedParent !== null || info.idMismatch !== null
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function formatCleanupMessage(taskId: string, info: CleanupInfo): string {
|
|
339
|
+
const parts: string[] = [];
|
|
340
|
+
|
|
341
|
+
if (info.orphanedBlockers.length > 0) {
|
|
342
|
+
const s = info.orphanedBlockers.length === 1 ? "reference" : "references";
|
|
343
|
+
parts.push(`${info.orphanedBlockers.length} orphaned ${s}`);
|
|
344
|
+
}
|
|
345
|
+
if (info.orphanedParent) {
|
|
346
|
+
parts.push("orphaned parent");
|
|
347
|
+
}
|
|
348
|
+
if (info.idMismatch) {
|
|
349
|
+
parts.push(`ID mismatch (was ${info.idMismatch.was})`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return `(cleaned ${parts.join(", ")} from ${taskId})`;
|
|
353
|
+
}
|
|
354
|
+
|
|
210
355
|
function writeTaskFileExclusive(path: string, task: Task): boolean {
|
|
211
356
|
try {
|
|
212
357
|
writeFileSync(path, JSON.stringify(task, null, 2), { flag: "wx" });
|
|
@@ -351,7 +496,12 @@ export function createTask(options: CreateTaskOptions): Task & { id: string } {
|
|
|
351
496
|
throw new Error(`Failed to create task after ${maxRetries} attempts`);
|
|
352
497
|
}
|
|
353
498
|
|
|
354
|
-
export
|
|
499
|
+
export interface TaskResult {
|
|
500
|
+
task: Task & { id: string };
|
|
501
|
+
cleanup: CleanupInfo | null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function getTask(id: string): TaskResult | null {
|
|
355
505
|
const tasksDir = getTasksDir();
|
|
356
506
|
if (!existsSync(tasksDir)) return null;
|
|
357
507
|
|
|
@@ -359,10 +509,21 @@ export function getTask(id: string): (Task & { id: string }) | null {
|
|
|
359
509
|
const task = readTaskFile(path, tasksDir);
|
|
360
510
|
if (!task) return null;
|
|
361
511
|
|
|
362
|
-
|
|
512
|
+
// Auto-clean orphaned references
|
|
513
|
+
const cleanup = cleanTaskOrphans(task, path, id, tasksDir);
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
task: { ...task, id: taskId(task) },
|
|
517
|
+
cleanup: hasCleanup(cleanup) ? cleanup : null,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export interface TaskWithMetaResult {
|
|
522
|
+
task: TaskWithMeta;
|
|
523
|
+
cleanup: CleanupInfo | null;
|
|
363
524
|
}
|
|
364
525
|
|
|
365
|
-
export function getTaskWithMeta(id: string):
|
|
526
|
+
export function getTaskWithMeta(id: string): TaskWithMetaResult | null {
|
|
366
527
|
const tasksDir = getTasksDir();
|
|
367
528
|
if (!existsSync(tasksDir)) return null;
|
|
368
529
|
|
|
@@ -370,7 +531,10 @@ export function getTaskWithMeta(id: string): TaskWithMeta | null {
|
|
|
370
531
|
const task = readTaskFile(path, tasksDir);
|
|
371
532
|
if (!task) return null;
|
|
372
533
|
|
|
373
|
-
//
|
|
534
|
+
// Auto-clean orphaned references
|
|
535
|
+
const cleanup = cleanTaskOrphans(task, path, id, tasksDir);
|
|
536
|
+
|
|
537
|
+
// Check if any blockers are incomplete (after cleanup, all blockers exist)
|
|
374
538
|
let blockedByIncomplete = false;
|
|
375
539
|
for (const blockerId of task.blocked_by) {
|
|
376
540
|
const blockerTask = readTaskFile(getTaskPath(tasksDir, blockerId), tasksDir);
|
|
@@ -381,26 +545,14 @@ export function getTaskWithMeta(id: string): TaskWithMeta | null {
|
|
|
381
545
|
}
|
|
382
546
|
}
|
|
383
547
|
|
|
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
|
-
}
|
|
398
|
-
|
|
399
548
|
return {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
549
|
+
task: {
|
|
550
|
+
...task,
|
|
551
|
+
id: taskId(task),
|
|
552
|
+
blocked_by_incomplete: blockedByIncomplete,
|
|
553
|
+
is_overdue: isTaskOverdue(task.due_date, task.status),
|
|
554
|
+
},
|
|
555
|
+
cleanup: hasCleanup(cleanup) ? cleanup : null,
|
|
404
556
|
};
|
|
405
557
|
}
|
|
406
558
|
|
|
@@ -445,18 +597,7 @@ export function listTasks(options?: ListOptions): (Task & { id: string })[] {
|
|
|
445
597
|
tasks = tasks.filter((t) => t.parent === null);
|
|
446
598
|
}
|
|
447
599
|
if (options?.overdue) {
|
|
448
|
-
|
|
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
|
-
});
|
|
600
|
+
tasks = tasks.filter((t) => isTaskOverdue(t.due_date, t.status));
|
|
460
601
|
}
|
|
461
602
|
|
|
462
603
|
// Sort by priority (lower = more urgent), then created_at (newer first)
|
|
@@ -476,20 +617,33 @@ export function listReadyTasks(): (Task & { id: string })[] {
|
|
|
476
617
|
const tasksDir = getTasksDir();
|
|
477
618
|
if (!existsSync(tasksDir)) return [];
|
|
478
619
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
620
|
+
// Get all tasks once and build status map for O(1) blocker lookups
|
|
621
|
+
const allTasks = getAllTasks(tasksDir);
|
|
622
|
+
const statusMap = new Map<string, Status>();
|
|
623
|
+
for (const task of allTasks) {
|
|
624
|
+
statusMap.set(taskId(task), task.status);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Filter to open tasks that have no incomplete blockers
|
|
628
|
+
const readyTasks = allTasks
|
|
629
|
+
.filter((task) => task.status === "open")
|
|
630
|
+
.filter((task) => {
|
|
631
|
+
for (const blockerId of task.blocked_by) {
|
|
632
|
+
const blockerStatus = statusMap.get(blockerId);
|
|
633
|
+
// Missing blocker (orphaned ref) doesn't block
|
|
634
|
+
if (!blockerStatus) continue;
|
|
635
|
+
if (blockerStatus !== "done") return false;
|
|
487
636
|
}
|
|
488
|
-
|
|
489
|
-
|
|
637
|
+
return true;
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Sort by priority (lower = more urgent), then created_at (newer first)
|
|
641
|
+
readyTasks.sort((a, b) => {
|
|
642
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
643
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
490
644
|
});
|
|
491
645
|
|
|
492
|
-
return readyTasks;
|
|
646
|
+
return readyTasks.map((t) => ({ ...t, id: taskId(t) }));
|
|
493
647
|
}
|
|
494
648
|
|
|
495
649
|
export function updateTaskStatus(id: string, status: Status): (Task & { id: string }) | null {
|
|
@@ -546,11 +700,11 @@ export function deleteTask(id: string): boolean {
|
|
|
546
700
|
|
|
547
701
|
unlinkSync(path);
|
|
548
702
|
|
|
549
|
-
// Remove this task from all blocked_by arrays
|
|
703
|
+
// Remove this task from all blocked_by arrays and parent references
|
|
550
704
|
const allTasks = getAllTasks(tasksDir);
|
|
551
705
|
for (const task of allTasks) {
|
|
552
|
-
|
|
553
|
-
|
|
706
|
+
const modified = removeRefsFromTask(task, (ref) => ref === id);
|
|
707
|
+
if (modified) {
|
|
554
708
|
writeTaskFile(getTaskPath(tasksDir, taskId(task)), task);
|
|
555
709
|
}
|
|
556
710
|
}
|
|
@@ -560,8 +714,7 @@ export function deleteTask(id: string): boolean {
|
|
|
560
714
|
|
|
561
715
|
export interface CleanOptions {
|
|
562
716
|
olderThanMs?: number;
|
|
563
|
-
|
|
564
|
-
all?: boolean;
|
|
717
|
+
force?: boolean; // ignore age threshold
|
|
565
718
|
}
|
|
566
719
|
|
|
567
720
|
export function cleanTasks(options: CleanOptions): number {
|
|
@@ -569,16 +722,15 @@ export function cleanTasks(options: CleanOptions): number {
|
|
|
569
722
|
if (!existsSync(tasksDir)) return 0;
|
|
570
723
|
|
|
571
724
|
const tasks = getAllTasks(tasksDir);
|
|
572
|
-
|
|
725
|
+
// Clamp cutoff to prevent negative values from very large olderThanMs
|
|
726
|
+
const cutoff = options.olderThanMs ? Math.max(0, Date.now() - options.olderThanMs) : 0;
|
|
573
727
|
|
|
574
728
|
const toDelete: string[] = [];
|
|
575
729
|
|
|
576
730
|
for (const task of tasks) {
|
|
577
|
-
|
|
731
|
+
if (task.status !== "done") continue;
|
|
578
732
|
|
|
579
|
-
if (!
|
|
580
|
-
|
|
581
|
-
if (!options.all && cutoff > 0) {
|
|
733
|
+
if (!options.force && cutoff > 0) {
|
|
582
734
|
if (!task.completed_at) continue;
|
|
583
735
|
if (new Date(task.completed_at).getTime() >= cutoff) continue;
|
|
584
736
|
}
|
|
@@ -590,13 +742,13 @@ export function cleanTasks(options: CleanOptions): number {
|
|
|
590
742
|
unlinkSync(getTaskPath(tasksDir, id));
|
|
591
743
|
}
|
|
592
744
|
|
|
593
|
-
// Clean up dangling blocked_by references
|
|
745
|
+
// Clean up dangling blocked_by and parent references
|
|
594
746
|
if (toDelete.length > 0) {
|
|
747
|
+
const toDeleteSet = new Set(toDelete);
|
|
595
748
|
const remaining = getAllTasks(tasksDir);
|
|
596
749
|
for (const task of remaining) {
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
if (task.blocked_by.length !== original) {
|
|
750
|
+
const modified = removeRefsFromTask(task, (id) => toDeleteSet.has(id));
|
|
751
|
+
if (modified) {
|
|
600
752
|
writeTaskFile(getTaskPath(tasksDir, taskId(task)), task);
|
|
601
753
|
}
|
|
602
754
|
}
|
|
@@ -628,13 +780,6 @@ export function addLogEntry(taskIdStr: string, message: string): LogEntry {
|
|
|
628
780
|
return entry;
|
|
629
781
|
}
|
|
630
782
|
|
|
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
783
|
// --- Block Operations ---
|
|
639
784
|
|
|
640
785
|
export function addBlock(taskIdStr: string, blockedBy: string): { ok: boolean; error?: string } {
|
|
@@ -682,25 +827,8 @@ export function removeBlock(taskIdStr: string, blockedBy: string): boolean {
|
|
|
682
827
|
return true;
|
|
683
828
|
}
|
|
684
829
|
|
|
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
830
|
// --- Utility ---
|
|
698
831
|
|
|
699
|
-
export function tasksExist(): boolean {
|
|
700
|
-
const tasksDir = getTasksDir();
|
|
701
|
-
return existsSync(tasksDir);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
832
|
export function initTasks(project?: string): string {
|
|
705
833
|
const tasksDir = ensureTasksDir();
|
|
706
834
|
const config = getConfig();
|
|
@@ -765,13 +893,100 @@ export function resolveId(input: string): string | null {
|
|
|
765
893
|
return matches[0];
|
|
766
894
|
}
|
|
767
895
|
|
|
768
|
-
// No match or ambiguous
|
|
769
896
|
return null;
|
|
770
897
|
}
|
|
771
898
|
|
|
899
|
+
export function findMatchingIds(input: string): string[] {
|
|
900
|
+
const tasksDir = getTasksDir();
|
|
901
|
+
if (!existsSync(tasksDir)) return [];
|
|
902
|
+
|
|
903
|
+
const inputLower = input.toLowerCase();
|
|
904
|
+
const files = readdirSync(tasksDir);
|
|
905
|
+
const matches: string[] = [];
|
|
906
|
+
|
|
907
|
+
for (const file of files) {
|
|
908
|
+
if (!file.endsWith(".json") || file === "config.json") continue;
|
|
909
|
+
const id = file.slice(0, -5);
|
|
910
|
+
if (!parseId(id)) continue;
|
|
911
|
+
const ref = id.split("-")[1] ?? "";
|
|
912
|
+
if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
|
|
913
|
+
matches.push(id);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return matches;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// --- Health Check ---
|
|
921
|
+
|
|
922
|
+
export interface CheckResult {
|
|
923
|
+
totalTasks: number;
|
|
924
|
+
cleaned: { task: string; info: CleanupInfo }[];
|
|
925
|
+
unfixable: { file: string; error: string }[];
|
|
926
|
+
}
|
|
927
|
+
|
|
772
928
|
/**
|
|
773
|
-
*
|
|
929
|
+
* Run health check on all tasks.
|
|
930
|
+
* Auto-fixes orphaned refs/ID mismatches and reports unfixable issues.
|
|
774
931
|
*/
|
|
775
|
-
export function
|
|
776
|
-
|
|
932
|
+
export function checkTasks(): CheckResult {
|
|
933
|
+
const tasksDir = getTasksDir();
|
|
934
|
+
const result: CheckResult = {
|
|
935
|
+
totalTasks: 0,
|
|
936
|
+
cleaned: [],
|
|
937
|
+
unfixable: [],
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
if (!existsSync(tasksDir)) return result;
|
|
941
|
+
|
|
942
|
+
const files = readdirSync(tasksDir).filter((f) => f.endsWith(".json") && f !== "config.json");
|
|
943
|
+
|
|
944
|
+
for (const file of files) {
|
|
945
|
+
const expectedId = file.slice(0, -5);
|
|
946
|
+
|
|
947
|
+
// Warn about unexpected files (non-task JSON)
|
|
948
|
+
if (!parseId(expectedId)) {
|
|
949
|
+
result.unfixable.push({
|
|
950
|
+
file,
|
|
951
|
+
error: "Invalid task ID format in filename",
|
|
952
|
+
});
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const path = join(tasksDir, file);
|
|
957
|
+
|
|
958
|
+
// Try to read and parse
|
|
959
|
+
let parsed: unknown;
|
|
960
|
+
try {
|
|
961
|
+
const text = readFileSync(path, "utf-8");
|
|
962
|
+
parsed = JSON.parse(text);
|
|
963
|
+
} catch (e) {
|
|
964
|
+
// Corrupted JSON - unfixable
|
|
965
|
+
result.unfixable.push({
|
|
966
|
+
file,
|
|
967
|
+
error: e instanceof Error ? e.message : "Invalid JSON",
|
|
968
|
+
});
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Validate task structure
|
|
973
|
+
if (!isValidTaskStructure(parsed)) {
|
|
974
|
+
result.unfixable.push({
|
|
975
|
+
file,
|
|
976
|
+
error: "Invalid task structure (missing required fields)",
|
|
977
|
+
});
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const task = parsed;
|
|
982
|
+
result.totalTasks++;
|
|
983
|
+
|
|
984
|
+
// Auto-clean (same as getTask does)
|
|
985
|
+
const cleanup = cleanTaskOrphans(task, path, expectedId, tasksDir);
|
|
986
|
+
if (hasCleanup(cleanup)) {
|
|
987
|
+
result.cleaned.push({ task: expectedId, info: cleanup });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return result;
|
|
777
992
|
}
|
package/src/lib/completions.ts
CHANGED
|
@@ -12,7 +12,7 @@ _tk() {
|
|
|
12
12
|
local cur prev words cword
|
|
13
13
|
_init_completion || return
|
|
14
14
|
|
|
15
|
-
local commands="init add ls list ready show start done reopen edit log block unblock rm remove clean config completions help"
|
|
15
|
+
local commands="init add ls list ready show start done reopen edit log block unblock rm remove clean check config completions help"
|
|
16
16
|
local global_opts="--json --help -h --version -V"
|
|
17
17
|
|
|
18
18
|
# Find the command (first non-option word after 'tk')
|
|
@@ -85,7 +85,7 @@ _tk() {
|
|
|
85
85
|
;;
|
|
86
86
|
clean)
|
|
87
87
|
if [[ "$cur" == -* ]]; then
|
|
88
|
-
COMPREPLY=($(compgen -W "--older-than
|
|
88
|
+
COMPREPLY=($(compgen -W "--older-than -f --force --json" -- "$cur"))
|
|
89
89
|
fi
|
|
90
90
|
;;
|
|
91
91
|
config)
|
|
@@ -142,6 +142,7 @@ _tk() {
|
|
|
142
142
|
'rm:Delete task'
|
|
143
143
|
'remove:Delete task'
|
|
144
144
|
'clean:Remove old done tasks'
|
|
145
|
+
'check:Check task integrity'
|
|
145
146
|
'config:Show/set configuration'
|
|
146
147
|
'completions:Output shell completions'
|
|
147
148
|
'help:Show help'
|
|
@@ -233,9 +234,12 @@ _tk() {
|
|
|
233
234
|
;;
|
|
234
235
|
clean)
|
|
235
236
|
_arguments \\
|
|
236
|
-
'--older-than[
|
|
237
|
-
'--
|
|
238
|
-
'
|
|
237
|
+
'--older-than[Days]:days:' \\
|
|
238
|
+
'(-f --force)'{-f,--force}'[Force clean even if disabled]' \\
|
|
239
|
+
'--json[Output as JSON]'
|
|
240
|
+
;;
|
|
241
|
+
check)
|
|
242
|
+
_arguments \\
|
|
239
243
|
'--json[Output as JSON]'
|
|
240
244
|
;;
|
|
241
245
|
config)
|
|
@@ -331,6 +335,7 @@ complete -c tk -n __tk_needs_command -f -a unblock -d 'Remove blocker'
|
|
|
331
335
|
complete -c tk -n __tk_needs_command -f -a rm -d 'Delete task'
|
|
332
336
|
complete -c tk -n __tk_needs_command -f -a remove -d 'Delete task'
|
|
333
337
|
complete -c tk -n __tk_needs_command -f -a clean -d 'Remove old done tasks'
|
|
338
|
+
complete -c tk -n __tk_needs_command -f -a check -d 'Check task integrity'
|
|
334
339
|
complete -c tk -n __tk_needs_command -f -a config -d 'Show/set configuration'
|
|
335
340
|
complete -c tk -n __tk_needs_command -f -a completions -d 'Output shell completions'
|
|
336
341
|
complete -c tk -n __tk_needs_command -f -a help -d 'Show help'
|
|
@@ -401,11 +406,13 @@ complete -c tk -n '__tk_using_command unblock' -l json -d 'Output as JSON'
|
|
|
401
406
|
complete -c tk -n '__tk_using_command log' -l json -d 'Output as JSON'
|
|
402
407
|
|
|
403
408
|
# clean command
|
|
404
|
-
complete -c tk -n '__tk_using_command clean' -l older-than -d '
|
|
405
|
-
complete -c tk -n '__tk_using_command clean' -l
|
|
406
|
-
complete -c tk -n '__tk_using_command clean' -s a -l all -d 'All statuses'
|
|
409
|
+
complete -c tk -n '__tk_using_command clean' -l older-than -d 'Days threshold'
|
|
410
|
+
complete -c tk -n '__tk_using_command clean' -s f -l force -d 'Force clean even if disabled'
|
|
407
411
|
complete -c tk -n '__tk_using_command clean' -l json -d 'Output as JSON'
|
|
408
412
|
|
|
413
|
+
# check command
|
|
414
|
+
complete -c tk -n '__tk_using_command check' -l json -d 'Output as JSON'
|
|
415
|
+
|
|
409
416
|
# config command
|
|
410
417
|
complete -c tk -n '__tk_using_command config' -f -a 'project alias' -d 'Config option'
|
|
411
418
|
complete -c tk -n '__tk_using_command config' -l rm -d 'Remove alias'
|
package/src/lib/format.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { Task, TaskWithMeta, LogEntry } from "../types";
|
|
|
2
2
|
import { PRIORITY_COLORS, STATUS_COLORS, OVERDUE_COLOR, RESET } from "../types";
|
|
3
3
|
import { formatPriority } from "./priority";
|
|
4
4
|
|
|
5
|
+
// Message colors
|
|
6
|
+
const GREEN = "\x1b[32m";
|
|
7
|
+
const RED = "\x1b[31m";
|
|
8
|
+
const YELLOW = "\x1b[33m";
|
|
9
|
+
const DIM = "\x1b[2m";
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* Determines if color output should be used.
|
|
7
13
|
* Respects NO_COLOR env var (https://no-color.org/) and TTY detection.
|
|
@@ -16,6 +22,26 @@ export function shouldUseColor(): boolean {
|
|
|
16
22
|
return true;
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
/** Format text green (success) */
|
|
26
|
+
export function green(msg: string): string {
|
|
27
|
+
return shouldUseColor() ? `${GREEN}${msg}${RESET}` : msg;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Format text red (error) */
|
|
31
|
+
export function red(msg: string): string {
|
|
32
|
+
return shouldUseColor() ? `${RED}${msg}${RESET}` : msg;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Format text yellow (warning) */
|
|
36
|
+
export function yellow(msg: string): string {
|
|
37
|
+
return shouldUseColor() ? `${YELLOW}${msg}${RESET}` : msg;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Format text dim/muted */
|
|
41
|
+
export function dim(msg: string): string {
|
|
42
|
+
return shouldUseColor() ? `${DIM}${msg}${RESET}` : msg;
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
function formatId(id: string): string {
|
|
20
46
|
// Truncate project prefix to 6 chars, keep full 4-char ref
|
|
21
47
|
// "myproject-a1b2" -> "myproj-a1b2"
|
package/src/types.ts
CHANGED
|
@@ -81,7 +81,7 @@ export interface Config {
|
|
|
81
81
|
assignees: string[];
|
|
82
82
|
};
|
|
83
83
|
aliases: Record<string, string>; // alias -> path for auto-project detection
|
|
84
|
-
|
|
84
|
+
clean_after: number | false; // days to keep done tasks, or false to disable
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export const DEFAULT_CONFIG: Config = {
|
|
@@ -93,7 +93,7 @@ export const DEFAULT_CONFIG: Config = {
|
|
|
93
93
|
assignees: [],
|
|
94
94
|
},
|
|
95
95
|
aliases: {},
|
|
96
|
-
|
|
96
|
+
clean_after: 14,
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
export const PRIORITY_LABELS: Record<Priority, string> = {
|