@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/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
- return true;
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
- return JSON.parse(text);
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 function getTask(id: string): (Task & { id: string }) | null {
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
- return { ...task, id: taskId(task) };
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): TaskWithMeta | null {
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
- // Check if any blockers are incomplete
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
- ...task,
401
- id: taskId(task),
402
- blocked_by_incomplete: blockedByIncomplete,
403
- is_overdue: isOverdue,
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
- 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
- });
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
- 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;
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
- return true;
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
- if (task.blocked_by.includes(id)) {
553
- task.blocked_by = task.blocked_by.filter((b) => b !== id);
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
- status?: Status;
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
- const cutoff = options.olderThanMs ? Date.now() - options.olderThanMs : 0;
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
- const validStatus = options.status ? task.status === options.status : task.status === "done";
731
+ if (task.status !== "done") continue;
578
732
 
579
- if (!validStatus) continue;
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 original = task.blocked_by.length;
598
- task.blocked_by = task.blocked_by.filter((b) => !toDelete.includes(b));
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
- * Validate that an ID is in the correct format.
929
+ * Run health check on all tasks.
930
+ * Auto-fixes orphaned refs/ID mismatches and reports unfixable issues.
774
931
  */
775
- export function isValidId(id: string): boolean {
776
- return parseId(id) !== null;
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
  }
@@ -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 --done -a --all --json" -- "$cur"))
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[Duration]:duration:' \\
237
- '--done[Only done tasks]' \\
238
- '(-a --all)'{-a,--all}'[All statuses]' \\
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 'Duration (e.g., 7d, 24h)'
405
- complete -c tk -n '__tk_using_command clean' -l done -d 'Only done tasks'
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
- auto_project: boolean;
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
- auto_project: false,
96
+ clean_after: 14,
97
97
  };
98
98
 
99
99
  export const PRIORITY_LABELS: Record<Priority, string> = {