@nijaru/tk 0.0.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/LICENSE +21 -0
- package/README.md +229 -0
- package/package.json +47 -0
- package/src/cli.test.ts +636 -0
- package/src/cli.ts +871 -0
- package/src/db/storage.ts +777 -0
- package/src/lib/completions.ts +418 -0
- package/src/lib/format.test.ts +347 -0
- package/src/lib/format.ts +162 -0
- package/src/lib/priority.test.ts +105 -0
- package/src/lib/priority.ts +40 -0
- package/src/lib/root.ts +79 -0
- package/src/types.ts +130 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { join, resolve, basename } from "path";
|
|
12
|
+
import { getTasksDir, getWorkingDir } from "../lib/root";
|
|
13
|
+
import type { Task, Config, Status, Priority, TaskWithMeta, LogEntry } from "../types";
|
|
14
|
+
import { DEFAULT_CONFIG, taskId, parseId, generateRef } from "../types";
|
|
15
|
+
|
|
16
|
+
// --- Path Utilities ---
|
|
17
|
+
|
|
18
|
+
function ensureTasksDir(): string {
|
|
19
|
+
const tasksDir = getTasksDir();
|
|
20
|
+
if (!existsSync(tasksDir)) {
|
|
21
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
return tasksDir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getTaskPath(tasksDir: string, id: string): string {
|
|
27
|
+
return join(tasksDir, `${id}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getConfigPath(tasksDir: string): string {
|
|
31
|
+
return join(tasksDir, "config.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validatePathSafety(path: string, tasksDir: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(path)) {
|
|
37
|
+
const stats = lstatSync(path);
|
|
38
|
+
if (stats.isSymbolicLink()) {
|
|
39
|
+
const realPath = realpathSync(path);
|
|
40
|
+
const resolvedTasksDir = resolve(tasksDir);
|
|
41
|
+
if (!realPath.startsWith(resolvedTasksDir)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Config Operations ---
|
|
53
|
+
|
|
54
|
+
export function getConfig(): Config {
|
|
55
|
+
const tasksDir = getTasksDir();
|
|
56
|
+
const configPath = getConfigPath(tasksDir);
|
|
57
|
+
if (!existsSync(configPath)) {
|
|
58
|
+
return { ...DEFAULT_CONFIG };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const text = readFileSync(configPath, "utf-8");
|
|
62
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(text) };
|
|
63
|
+
} catch {
|
|
64
|
+
return { ...DEFAULT_CONFIG };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveConfig(config: Config): void {
|
|
69
|
+
const tasksDir = ensureTasksDir();
|
|
70
|
+
const configPath = getConfigPath(tasksDir);
|
|
71
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function updateConfig(updates: Partial<Config>): Config {
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
Object.assign(config, updates);
|
|
77
|
+
saveConfig(config);
|
|
78
|
+
return config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setDefaultProject(project: string): Config {
|
|
82
|
+
return updateConfig({ project });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RenameResult {
|
|
86
|
+
renamed: string[];
|
|
87
|
+
referencesUpdated: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renameProject(oldProject: string, newProject: string): RenameResult {
|
|
91
|
+
const tasksDir = getTasksDir();
|
|
92
|
+
if (!existsSync(tasksDir)) {
|
|
93
|
+
throw new Error("No .tasks/ directory found");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const allTasks = getAllTasks(tasksDir);
|
|
97
|
+
|
|
98
|
+
// Find tasks to rename
|
|
99
|
+
const toRename = allTasks.filter((t) => t.project === oldProject);
|
|
100
|
+
if (toRename.length === 0) {
|
|
101
|
+
throw new Error(`No tasks found with project "${oldProject}"`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check for collisions
|
|
105
|
+
const existingIds = new Set(allTasks.map((t) => taskId(t)));
|
|
106
|
+
for (const task of toRename) {
|
|
107
|
+
const newId = `${newProject}-${task.ref}`;
|
|
108
|
+
if (existingIds.has(newId)) {
|
|
109
|
+
throw new Error(`Cannot rename: "${newId}" already exists`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Build old→new ID mapping
|
|
114
|
+
const idMap = new Map<string, string>();
|
|
115
|
+
for (const task of toRename) {
|
|
116
|
+
const oldId = taskId(task);
|
|
117
|
+
const newId = `${newProject}-${task.ref}`;
|
|
118
|
+
idMap.set(oldId, newId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Update references in ALL tasks and rename files
|
|
122
|
+
let referencesUpdated = 0;
|
|
123
|
+
for (const task of allTasks) {
|
|
124
|
+
let modified = false;
|
|
125
|
+
|
|
126
|
+
// Update blocked_by references
|
|
127
|
+
if (task.blocked_by.length > 0) {
|
|
128
|
+
const newBlockedBy = task.blocked_by.map((id) => {
|
|
129
|
+
const newId = idMap.get(id);
|
|
130
|
+
if (newId) {
|
|
131
|
+
referencesUpdated++;
|
|
132
|
+
return newId;
|
|
133
|
+
}
|
|
134
|
+
return id;
|
|
135
|
+
});
|
|
136
|
+
if (newBlockedBy.some((id, i) => id !== task.blocked_by[i])) {
|
|
137
|
+
task.blocked_by = newBlockedBy;
|
|
138
|
+
modified = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Update parent reference
|
|
143
|
+
if (task.parent && idMap.has(task.parent)) {
|
|
144
|
+
task.parent = idMap.get(task.parent)!;
|
|
145
|
+
referencesUpdated++;
|
|
146
|
+
modified = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// If this task is being renamed
|
|
150
|
+
if (task.project === oldProject) {
|
|
151
|
+
const oldPath = getTaskPath(tasksDir, taskId(task));
|
|
152
|
+
task.project = newProject;
|
|
153
|
+
const newPath = getTaskPath(tasksDir, taskId(task));
|
|
154
|
+
|
|
155
|
+
// Write to new path, delete old
|
|
156
|
+
writeFileSync(newPath, JSON.stringify(task, null, 2));
|
|
157
|
+
unlinkSync(oldPath);
|
|
158
|
+
} else if (modified) {
|
|
159
|
+
// Just update references in non-renamed task
|
|
160
|
+
const path = getTaskPath(tasksDir, taskId(task));
|
|
161
|
+
writeFileSync(path, JSON.stringify(task, null, 2));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Update default project in config
|
|
166
|
+
const config = getConfig();
|
|
167
|
+
if (config.project === oldProject) {
|
|
168
|
+
updateConfig({ project: newProject });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
renamed: toRename.map((t) => `${newProject}-${t.ref}`),
|
|
173
|
+
referencesUpdated,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function setAlias(alias: string, path: string): Config {
|
|
178
|
+
const config = getConfig();
|
|
179
|
+
config.aliases[alias] = path;
|
|
180
|
+
saveConfig(config);
|
|
181
|
+
return config;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function removeAlias(alias: string): Config {
|
|
185
|
+
const config = getConfig();
|
|
186
|
+
delete config.aliases[alias];
|
|
187
|
+
saveConfig(config);
|
|
188
|
+
return config;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- Task File Operations ---
|
|
192
|
+
|
|
193
|
+
function readTaskFile(path: string, tasksDir?: string): Task | null {
|
|
194
|
+
if (!existsSync(path)) return null;
|
|
195
|
+
if (tasksDir && !validatePathSafety(path, tasksDir)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const text = readFileSync(path, "utf-8");
|
|
200
|
+
return JSON.parse(text);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function writeTaskFile(path: string, task: Task): void {
|
|
207
|
+
writeFileSync(path, JSON.stringify(task, null, 2));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function writeTaskFileExclusive(path: string, task: Task): boolean {
|
|
211
|
+
try {
|
|
212
|
+
writeFileSync(path, JSON.stringify(task, null, 2), { flag: "wx" });
|
|
213
|
+
return true;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
if (e instanceof Error && "code" in e && e.code === "EEXIST") {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getAllTasks(tasksDir: string): Task[] {
|
|
223
|
+
if (!existsSync(tasksDir)) return [];
|
|
224
|
+
const files = readdirSync(tasksDir).filter((f) => f.endsWith(".json") && f !== "config.json");
|
|
225
|
+
const tasks: Task[] = [];
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const task = readTaskFile(join(tasksDir, file), tasksDir);
|
|
228
|
+
if (task) tasks.push(task);
|
|
229
|
+
}
|
|
230
|
+
return tasks;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Cycle Detection ---
|
|
234
|
+
|
|
235
|
+
function wouldCreateBlockCycle(tasksDir: string, taskIdStr: string, blockerId: string): boolean {
|
|
236
|
+
const visited = new Set<string>();
|
|
237
|
+
const stack = [blockerId];
|
|
238
|
+
|
|
239
|
+
while (stack.length > 0) {
|
|
240
|
+
const current = stack.pop()!;
|
|
241
|
+
if (current === taskIdStr) return true;
|
|
242
|
+
if (visited.has(current)) continue;
|
|
243
|
+
visited.add(current);
|
|
244
|
+
|
|
245
|
+
const task = readTaskFile(getTaskPath(tasksDir, current), tasksDir);
|
|
246
|
+
if (task) {
|
|
247
|
+
for (const dep of task.blocked_by) {
|
|
248
|
+
stack.push(dep);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function wouldCreateParentCycle(tasksDir: string, taskIdStr: string, parentId: string): boolean {
|
|
256
|
+
const visited = new Set<string>();
|
|
257
|
+
let current: string | null = parentId;
|
|
258
|
+
|
|
259
|
+
while (current) {
|
|
260
|
+
if (current === taskIdStr) return true;
|
|
261
|
+
if (visited.has(current)) return true;
|
|
262
|
+
visited.add(current);
|
|
263
|
+
|
|
264
|
+
const task = readTaskFile(getTaskPath(tasksDir, current), tasksDir);
|
|
265
|
+
current = task?.parent ?? null;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- Parent Validation ---
|
|
271
|
+
|
|
272
|
+
export function validateParent(
|
|
273
|
+
parentId: string,
|
|
274
|
+
currentTaskId?: string,
|
|
275
|
+
): { ok: boolean; error?: string } {
|
|
276
|
+
if (!parseId(parentId)) {
|
|
277
|
+
return { ok: false, error: `Invalid parent ID format: ${parentId}` };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (currentTaskId && parentId === currentTaskId) {
|
|
281
|
+
return { ok: false, error: "Task cannot be its own parent" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const tasksDir = getTasksDir();
|
|
285
|
+
|
|
286
|
+
const parentPath = getTaskPath(tasksDir, parentId);
|
|
287
|
+
if (!existsSync(parentPath)) {
|
|
288
|
+
return { ok: false, error: `Parent task not found: ${parentId}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (currentTaskId && wouldCreateParentCycle(tasksDir, currentTaskId, parentId)) {
|
|
292
|
+
return { ok: false, error: "Would create circular parent relationship" };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { ok: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Task CRUD ---
|
|
299
|
+
|
|
300
|
+
export interface CreateTaskOptions {
|
|
301
|
+
title: string;
|
|
302
|
+
description?: string;
|
|
303
|
+
priority?: Priority;
|
|
304
|
+
project?: string;
|
|
305
|
+
labels?: string[];
|
|
306
|
+
assignees?: string[];
|
|
307
|
+
parent?: string;
|
|
308
|
+
estimate?: number;
|
|
309
|
+
due_date?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function createTask(options: CreateTaskOptions): Task & { id: string } {
|
|
313
|
+
const tasksDir = ensureTasksDir();
|
|
314
|
+
const config = getConfig();
|
|
315
|
+
const project = options.project ?? config.project;
|
|
316
|
+
const now = new Date().toISOString();
|
|
317
|
+
|
|
318
|
+
// Generate random ref with collision detection
|
|
319
|
+
const maxRetries = 10;
|
|
320
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
321
|
+
const ref = generateRef();
|
|
322
|
+
const id = `${project}-${ref}`;
|
|
323
|
+
|
|
324
|
+
const task: Task = {
|
|
325
|
+
project,
|
|
326
|
+
ref,
|
|
327
|
+
title: options.title,
|
|
328
|
+
description: options.description ?? null,
|
|
329
|
+
status: "open",
|
|
330
|
+
priority: options.priority ?? config.defaults.priority,
|
|
331
|
+
labels: options.labels ?? [...config.defaults.labels],
|
|
332
|
+
assignees: options.assignees ?? [...config.defaults.assignees],
|
|
333
|
+
parent: options.parent ?? null,
|
|
334
|
+
blocked_by: [],
|
|
335
|
+
estimate: options.estimate ?? null,
|
|
336
|
+
due_date: options.due_date ?? null,
|
|
337
|
+
logs: [],
|
|
338
|
+
created_at: now,
|
|
339
|
+
updated_at: now,
|
|
340
|
+
completed_at: null,
|
|
341
|
+
external: {},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const path = getTaskPath(tasksDir, id);
|
|
345
|
+
if (writeTaskFileExclusive(path, task)) {
|
|
346
|
+
return { ...task, id };
|
|
347
|
+
}
|
|
348
|
+
// Collision (extremely rare with 4 chars) - retry with new ref
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw new Error(`Failed to create task after ${maxRetries} attempts`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function getTask(id: string): (Task & { id: string }) | null {
|
|
355
|
+
const tasksDir = getTasksDir();
|
|
356
|
+
if (!existsSync(tasksDir)) return null;
|
|
357
|
+
|
|
358
|
+
const path = getTaskPath(tasksDir, id);
|
|
359
|
+
const task = readTaskFile(path, tasksDir);
|
|
360
|
+
if (!task) return null;
|
|
361
|
+
|
|
362
|
+
return { ...task, id: taskId(task) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getTaskWithMeta(id: string): TaskWithMeta | null {
|
|
366
|
+
const tasksDir = getTasksDir();
|
|
367
|
+
if (!existsSync(tasksDir)) return null;
|
|
368
|
+
|
|
369
|
+
const path = getTaskPath(tasksDir, id);
|
|
370
|
+
const task = readTaskFile(path, tasksDir);
|
|
371
|
+
if (!task) return null;
|
|
372
|
+
|
|
373
|
+
// Check if any blockers are incomplete
|
|
374
|
+
let blockedByIncomplete = false;
|
|
375
|
+
for (const blockerId of task.blocked_by) {
|
|
376
|
+
const blockerTask = readTaskFile(getTaskPath(tasksDir, blockerId), tasksDir);
|
|
377
|
+
if (!blockerTask) continue;
|
|
378
|
+
if (blockerTask.status !== "done") {
|
|
379
|
+
blockedByIncomplete = true;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check if overdue (due date is before today, not including today)
|
|
385
|
+
let isOverdue = false;
|
|
386
|
+
if (task.due_date && task.status !== "done") {
|
|
387
|
+
const today = new Date();
|
|
388
|
+
today.setHours(0, 0, 0, 0);
|
|
389
|
+
const parts = task.due_date.split("-").map(Number);
|
|
390
|
+
const year = parts[0];
|
|
391
|
+
const month = parts[1];
|
|
392
|
+
const day = parts[2];
|
|
393
|
+
if (year && month && day) {
|
|
394
|
+
const dueDate = new Date(year, month - 1, day);
|
|
395
|
+
isOverdue = dueDate < today;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
...task,
|
|
401
|
+
id: taskId(task),
|
|
402
|
+
blocked_by_incomplete: blockedByIncomplete,
|
|
403
|
+
is_overdue: isOverdue,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export interface ListOptions {
|
|
408
|
+
status?: Status;
|
|
409
|
+
priority?: Priority;
|
|
410
|
+
project?: string;
|
|
411
|
+
label?: string;
|
|
412
|
+
assignee?: string;
|
|
413
|
+
parent?: string;
|
|
414
|
+
roots?: boolean;
|
|
415
|
+
overdue?: boolean;
|
|
416
|
+
limit?: number;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function listTasks(options?: ListOptions): (Task & { id: string })[] {
|
|
420
|
+
const tasksDir = getTasksDir();
|
|
421
|
+
if (!existsSync(tasksDir)) return [];
|
|
422
|
+
|
|
423
|
+
let tasks = getAllTasks(tasksDir);
|
|
424
|
+
|
|
425
|
+
// Apply filters
|
|
426
|
+
if (options?.status) {
|
|
427
|
+
tasks = tasks.filter((t) => t.status === options.status);
|
|
428
|
+
}
|
|
429
|
+
if (options?.priority !== undefined) {
|
|
430
|
+
tasks = tasks.filter((t) => t.priority === options.priority);
|
|
431
|
+
}
|
|
432
|
+
if (options?.project) {
|
|
433
|
+
tasks = tasks.filter((t) => t.project === options.project);
|
|
434
|
+
}
|
|
435
|
+
if (options?.label) {
|
|
436
|
+
tasks = tasks.filter((t) => t.labels.includes(options.label!));
|
|
437
|
+
}
|
|
438
|
+
if (options?.assignee) {
|
|
439
|
+
tasks = tasks.filter((t) => t.assignees.includes(options.assignee!));
|
|
440
|
+
}
|
|
441
|
+
if (options?.parent) {
|
|
442
|
+
tasks = tasks.filter((t) => t.parent === options.parent);
|
|
443
|
+
}
|
|
444
|
+
if (options?.roots) {
|
|
445
|
+
tasks = tasks.filter((t) => t.parent === null);
|
|
446
|
+
}
|
|
447
|
+
if (options?.overdue) {
|
|
448
|
+
const today = new Date();
|
|
449
|
+
today.setHours(0, 0, 0, 0);
|
|
450
|
+
tasks = tasks.filter((t) => {
|
|
451
|
+
if (!t.due_date || t.status === "done") return false;
|
|
452
|
+
const parts = t.due_date.split("-").map(Number);
|
|
453
|
+
const year = parts[0];
|
|
454
|
+
const month = parts[1];
|
|
455
|
+
const day = parts[2];
|
|
456
|
+
if (!year || !month || !day) return false;
|
|
457
|
+
const dueDate = new Date(year, month - 1, day);
|
|
458
|
+
return dueDate < today;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Sort by priority (lower = more urgent), then created_at (newer first)
|
|
463
|
+
tasks.sort((a, b) => {
|
|
464
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
465
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
if (options?.limit) {
|
|
469
|
+
tasks = tasks.slice(0, options.limit);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return tasks.map((t) => ({ ...t, id: taskId(t) }));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function listReadyTasks(): (Task & { id: string })[] {
|
|
476
|
+
const tasksDir = getTasksDir();
|
|
477
|
+
if (!existsSync(tasksDir)) return [];
|
|
478
|
+
|
|
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;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return readyTasks;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function updateTaskStatus(id: string, status: Status): (Task & { id: string }) | null {
|
|
496
|
+
const tasksDir = getTasksDir();
|
|
497
|
+
const path = getTaskPath(tasksDir, id);
|
|
498
|
+
const task = readTaskFile(path, tasksDir);
|
|
499
|
+
if (!task) return null;
|
|
500
|
+
|
|
501
|
+
const now = new Date().toISOString();
|
|
502
|
+
task.status = status;
|
|
503
|
+
task.updated_at = now;
|
|
504
|
+
task.completed_at = status === "done" ? now : null;
|
|
505
|
+
|
|
506
|
+
writeTaskFile(path, task);
|
|
507
|
+
return { ...task, id: taskId(task) };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface UpdateTaskOptions {
|
|
511
|
+
title?: string;
|
|
512
|
+
description?: string;
|
|
513
|
+
priority?: Priority;
|
|
514
|
+
labels?: string[];
|
|
515
|
+
assignees?: string[];
|
|
516
|
+
parent?: string | null;
|
|
517
|
+
estimate?: number | null;
|
|
518
|
+
due_date?: string | null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function updateTask(id: string, updates: UpdateTaskOptions): (Task & { id: string }) | null {
|
|
522
|
+
const tasksDir = getTasksDir();
|
|
523
|
+
const path = getTaskPath(tasksDir, id);
|
|
524
|
+
const task = readTaskFile(path, tasksDir);
|
|
525
|
+
if (!task) return null;
|
|
526
|
+
|
|
527
|
+
const now = new Date().toISOString();
|
|
528
|
+
if (updates.title !== undefined) task.title = updates.title;
|
|
529
|
+
if (updates.description !== undefined) task.description = updates.description;
|
|
530
|
+
if (updates.priority !== undefined) task.priority = updates.priority;
|
|
531
|
+
if (updates.labels !== undefined) task.labels = updates.labels;
|
|
532
|
+
if (updates.assignees !== undefined) task.assignees = updates.assignees;
|
|
533
|
+
if (updates.parent !== undefined) task.parent = updates.parent;
|
|
534
|
+
if (updates.estimate !== undefined) task.estimate = updates.estimate;
|
|
535
|
+
if (updates.due_date !== undefined) task.due_date = updates.due_date;
|
|
536
|
+
task.updated_at = now;
|
|
537
|
+
|
|
538
|
+
writeTaskFile(path, task);
|
|
539
|
+
return { ...task, id: taskId(task) };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function deleteTask(id: string): boolean {
|
|
543
|
+
const tasksDir = getTasksDir();
|
|
544
|
+
const path = getTaskPath(tasksDir, id);
|
|
545
|
+
if (!existsSync(path)) return false;
|
|
546
|
+
|
|
547
|
+
unlinkSync(path);
|
|
548
|
+
|
|
549
|
+
// Remove this task from all blocked_by arrays
|
|
550
|
+
const allTasks = getAllTasks(tasksDir);
|
|
551
|
+
for (const task of allTasks) {
|
|
552
|
+
if (task.blocked_by.includes(id)) {
|
|
553
|
+
task.blocked_by = task.blocked_by.filter((b) => b !== id);
|
|
554
|
+
writeTaskFile(getTaskPath(tasksDir, taskId(task)), task);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export interface CleanOptions {
|
|
562
|
+
olderThanMs?: number;
|
|
563
|
+
status?: Status;
|
|
564
|
+
all?: boolean;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function cleanTasks(options: CleanOptions): number {
|
|
568
|
+
const tasksDir = getTasksDir();
|
|
569
|
+
if (!existsSync(tasksDir)) return 0;
|
|
570
|
+
|
|
571
|
+
const tasks = getAllTasks(tasksDir);
|
|
572
|
+
const cutoff = options.olderThanMs ? Date.now() - options.olderThanMs : 0;
|
|
573
|
+
|
|
574
|
+
const toDelete: string[] = [];
|
|
575
|
+
|
|
576
|
+
for (const task of tasks) {
|
|
577
|
+
const validStatus = options.status ? task.status === options.status : task.status === "done";
|
|
578
|
+
|
|
579
|
+
if (!validStatus) continue;
|
|
580
|
+
|
|
581
|
+
if (!options.all && cutoff > 0) {
|
|
582
|
+
if (!task.completed_at) continue;
|
|
583
|
+
if (new Date(task.completed_at).getTime() >= cutoff) continue;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
toDelete.push(taskId(task));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (const id of toDelete) {
|
|
590
|
+
unlinkSync(getTaskPath(tasksDir, id));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Clean up dangling blocked_by references
|
|
594
|
+
if (toDelete.length > 0) {
|
|
595
|
+
const remaining = getAllTasks(tasksDir);
|
|
596
|
+
for (const task of remaining) {
|
|
597
|
+
const original = task.blocked_by.length;
|
|
598
|
+
task.blocked_by = task.blocked_by.filter((b) => !toDelete.includes(b));
|
|
599
|
+
if (task.blocked_by.length !== original) {
|
|
600
|
+
writeTaskFile(getTaskPath(tasksDir, taskId(task)), task);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return toDelete.length;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// --- Log Operations ---
|
|
609
|
+
|
|
610
|
+
export function addLogEntry(taskIdStr: string, message: string): LogEntry {
|
|
611
|
+
const tasksDir = getTasksDir();
|
|
612
|
+
const path = getTaskPath(tasksDir, taskIdStr);
|
|
613
|
+
const task = readTaskFile(path, tasksDir);
|
|
614
|
+
if (!task) {
|
|
615
|
+
throw new Error(`Task not found: ${taskIdStr}`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const now = new Date().toISOString();
|
|
619
|
+
const entry: LogEntry = {
|
|
620
|
+
ts: now,
|
|
621
|
+
msg: message,
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
task.logs.push(entry);
|
|
625
|
+
task.updated_at = now;
|
|
626
|
+
writeTaskFile(path, task);
|
|
627
|
+
|
|
628
|
+
return entry;
|
|
629
|
+
}
|
|
630
|
+
|
|
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
|
+
// --- Block Operations ---
|
|
639
|
+
|
|
640
|
+
export function addBlock(taskIdStr: string, blockedBy: string): { ok: boolean; error?: string } {
|
|
641
|
+
const tasksDir = ensureTasksDir();
|
|
642
|
+
const taskPath = getTaskPath(tasksDir, taskIdStr);
|
|
643
|
+
const blockerPath = getTaskPath(tasksDir, blockedBy);
|
|
644
|
+
|
|
645
|
+
const task = readTaskFile(taskPath, tasksDir);
|
|
646
|
+
if (!task) {
|
|
647
|
+
return { ok: false, error: `Task not found: ${taskIdStr}` };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!existsSync(blockerPath)) {
|
|
651
|
+
return { ok: false, error: `Blocker not found: ${blockedBy}` };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (task.blocked_by.includes(blockedBy)) {
|
|
655
|
+
return { ok: false, error: "Already blocked by this task" };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (wouldCreateBlockCycle(tasksDir, taskIdStr, blockedBy)) {
|
|
659
|
+
return { ok: false, error: "Would create circular dependency" };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
task.blocked_by.push(blockedBy);
|
|
663
|
+
task.updated_at = new Date().toISOString();
|
|
664
|
+
writeTaskFile(taskPath, task);
|
|
665
|
+
|
|
666
|
+
return { ok: true };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function removeBlock(taskIdStr: string, blockedBy: string): boolean {
|
|
670
|
+
const tasksDir = getTasksDir();
|
|
671
|
+
const taskPath = getTaskPath(tasksDir, taskIdStr);
|
|
672
|
+
const task = readTaskFile(taskPath, tasksDir);
|
|
673
|
+
if (!task) return false;
|
|
674
|
+
|
|
675
|
+
const idx = task.blocked_by.indexOf(blockedBy);
|
|
676
|
+
if (idx === -1) return false;
|
|
677
|
+
|
|
678
|
+
task.blocked_by.splice(idx, 1);
|
|
679
|
+
task.updated_at = new Date().toISOString();
|
|
680
|
+
writeTaskFile(taskPath, task);
|
|
681
|
+
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
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
|
+
// --- Utility ---
|
|
698
|
+
|
|
699
|
+
export function tasksExist(): boolean {
|
|
700
|
+
const tasksDir = getTasksDir();
|
|
701
|
+
return existsSync(tasksDir);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function initTasks(project?: string): string {
|
|
705
|
+
const tasksDir = ensureTasksDir();
|
|
706
|
+
const config = getConfig();
|
|
707
|
+
|
|
708
|
+
// Derive project from directory name if not specified
|
|
709
|
+
if (project) {
|
|
710
|
+
config.project = project;
|
|
711
|
+
} else if (config.project === DEFAULT_CONFIG.project) {
|
|
712
|
+
// Only auto-derive if still using default "tk"
|
|
713
|
+
const dirName = basename(getWorkingDir());
|
|
714
|
+
// Sanitize: lowercase, replace non-alphanumeric with empty, ensure starts with letter
|
|
715
|
+
const sanitized = dirName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
716
|
+
if (sanitized && /^[a-z]/.test(sanitized)) {
|
|
717
|
+
config.project = sanitized;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
saveConfig(config);
|
|
721
|
+
return tasksDir;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// --- ID Resolution ---
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Resolve a partial ID to a full ID.
|
|
728
|
+
* Matches by prefix - if input matches start of exactly one task ID, returns it.
|
|
729
|
+
* Returns null if no match or ambiguous (multiple matches).
|
|
730
|
+
*
|
|
731
|
+
* Optimized: only reads filenames, not file contents.
|
|
732
|
+
*/
|
|
733
|
+
export function resolveId(input: string): string | null {
|
|
734
|
+
const tasksDir = getTasksDir();
|
|
735
|
+
if (!existsSync(tasksDir)) return null;
|
|
736
|
+
|
|
737
|
+
// If it's already a full valid ID, check if task exists
|
|
738
|
+
if (parseId(input)) {
|
|
739
|
+
if (existsSync(getTaskPath(tasksDir, input))) {
|
|
740
|
+
return input;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const inputLower = input.toLowerCase();
|
|
745
|
+
|
|
746
|
+
// Read filenames only (no file content parsing)
|
|
747
|
+
const files = readdirSync(tasksDir);
|
|
748
|
+
const matches: string[] = [];
|
|
749
|
+
|
|
750
|
+
for (const file of files) {
|
|
751
|
+
if (!file.endsWith(".json") || file === "config.json") continue;
|
|
752
|
+
|
|
753
|
+
// Extract ID from filename: "project-ref.json" -> "project-ref"
|
|
754
|
+
const id = file.slice(0, -5);
|
|
755
|
+
if (!parseId(id)) continue;
|
|
756
|
+
|
|
757
|
+
// Match by full ID prefix or just ref prefix
|
|
758
|
+
const ref = id.split("-")[1] ?? "";
|
|
759
|
+
if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
|
|
760
|
+
matches.push(id);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (matches.length === 1 && matches[0]) {
|
|
765
|
+
return matches[0];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// No match or ambiguous
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Validate that an ID is in the correct format.
|
|
774
|
+
*/
|
|
775
|
+
export function isValidId(id: string): boolean {
|
|
776
|
+
return parseId(id) !== null;
|
|
777
|
+
}
|