pi-task-manager 0.1.0
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/PLAN.md +106 -0
- package/README.md +85 -0
- package/index.ts +89 -0
- package/lib/parser.ts +345 -0
- package/lib/task-manager.ts +714 -0
- package/lib/task.ts +57 -0
- package/lib/tools.ts +179 -0
- package/package.json +28 -0
- package/skills/task-manager/SKILL.md +50 -0
- package/tests/parser.test.ts +120 -0
- package/tests/robustness.test.ts +154 -0
- package/tests/task-manager.test.ts +158 -0
- package/tests/validation.test.ts +194 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskManager — hierarchical TODO.md manager.
|
|
3
|
+
*
|
|
4
|
+
* The in-memory data structure is a tree of Task nodes (roots + parent/
|
|
5
|
+
* children links). Serialization is a recursive DFS; de-serialization is
|
|
6
|
+
* a series of appends (see parseTodoFile). depth, position, parent_id,
|
|
7
|
+
* and children_ids are always derived, never stored.
|
|
8
|
+
*
|
|
9
|
+
* Every mutation auto-saves (temp file + rename, with .bak backup);
|
|
10
|
+
* save() is a deterministic checkpoint.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { randomInt } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
MAX_DEPTH,
|
|
18
|
+
TODO_FILENAME,
|
|
19
|
+
BASE62_CHARS,
|
|
20
|
+
PRIORITY_EMOJI,
|
|
21
|
+
STATUS_CHARS,
|
|
22
|
+
findAnnotationEmoji,
|
|
23
|
+
findTodoIssues,
|
|
24
|
+
parseTodoFile,
|
|
25
|
+
tasksToMarkdown,
|
|
26
|
+
type TodoIssue,
|
|
27
|
+
} from "./parser.ts";
|
|
28
|
+
import { depthOf, newTask, type Task } from "./task.ts";
|
|
29
|
+
|
|
30
|
+
export type Result = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
/** null/undefined tool args mean "not provided" (matches Python args.get). */
|
|
33
|
+
const provided = (v: unknown): boolean => v !== undefined && v !== null;
|
|
34
|
+
|
|
35
|
+
/** Dates must be YYYY-MM-DD to round-trip through the parser. */
|
|
36
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
37
|
+
|
|
38
|
+
/** Human-readable description of a structural problem found in TODO.md. */
|
|
39
|
+
function describeIssue(issue: TodoIssue): string {
|
|
40
|
+
switch (issue.kind) {
|
|
41
|
+
case "orphan":
|
|
42
|
+
return `orphan line ${issue.line} (${issue.id}) — no ancestor at its indent level`;
|
|
43
|
+
case "tab":
|
|
44
|
+
return `tab indentation on line ${issue.line} (${issue.id})`;
|
|
45
|
+
case "duplicate":
|
|
46
|
+
return `duplicate ID ${issue.id} (lines ${issue.firstLine}, ${issue.line})`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class TaskManager {
|
|
51
|
+
private roots: Task[] = [];
|
|
52
|
+
private taskMap = new Map<string, Task>();
|
|
53
|
+
private path: string | null = null;
|
|
54
|
+
private dirty = false;
|
|
55
|
+
|
|
56
|
+
get isOpen(): boolean {
|
|
57
|
+
return this.path !== null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get filePath(): string | null {
|
|
61
|
+
return this.path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── internal helpers ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
private rebuildMap(): void {
|
|
67
|
+
this.taskMap = new Map();
|
|
68
|
+
const walk = (tasks: Task[]): void => {
|
|
69
|
+
for (const t of tasks) {
|
|
70
|
+
this.taskMap.set(t.id, t);
|
|
71
|
+
walk(t.children);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(this.roots);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private today(): string {
|
|
78
|
+
return new Date().toISOString().slice(0, 10);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private findTask(taskId: string): Task {
|
|
82
|
+
const task = this.taskMap.get(taskId);
|
|
83
|
+
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
84
|
+
return task;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private positionOf(task: Task): number {
|
|
88
|
+
return task.parent
|
|
89
|
+
? task.parent.children.indexOf(task)
|
|
90
|
+
: this.roots.indexOf(task);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private getDescendants(task: Task): Task[] {
|
|
94
|
+
const result: Task[] = [];
|
|
95
|
+
for (const child of task.children)
|
|
96
|
+
result.push(child, ...this.getDescendants(child));
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private taskCount(): number {
|
|
101
|
+
let count = 0;
|
|
102
|
+
const visit = (tasks: Task[]): void => {
|
|
103
|
+
for (const t of tasks) {
|
|
104
|
+
count++;
|
|
105
|
+
visit(t.children);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
visit(this.roots);
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** True if taskId is the task itself or inside its subtree. */
|
|
113
|
+
private isSelfOrDescendant(taskId: string, otherId: string): boolean {
|
|
114
|
+
let current: Task | null | undefined = this.taskMap.get(otherId);
|
|
115
|
+
while (current) {
|
|
116
|
+
if (current.id === taskId) return true;
|
|
117
|
+
current = current.parent;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Check if adding newDeps to taskId would create a circular dependency. */
|
|
123
|
+
private wouldCreateCycle(taskId: string, newDeps: string[]): boolean {
|
|
124
|
+
const visited = new Set<string>();
|
|
125
|
+
const stack = [...newDeps];
|
|
126
|
+
while (stack.length > 0) {
|
|
127
|
+
const current = stack.pop()!;
|
|
128
|
+
if (current === taskId) return true;
|
|
129
|
+
if (visited.has(current)) continue;
|
|
130
|
+
visited.add(current);
|
|
131
|
+
const depTask = this.taskMap.get(current);
|
|
132
|
+
if (depTask) stack.push(...depTask.dependsOn);
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private generateId(): string {
|
|
138
|
+
while (true) {
|
|
139
|
+
let id = "";
|
|
140
|
+
for (let i = 0; i < 6; i++) id += BASE62_CHARS[randomInt(62)];
|
|
141
|
+
if (!this.taskMap.has(id)) return id;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Atomic save: temp file + backup + rename. Returns error message on failure. */
|
|
146
|
+
private saveToDisk(): string | null {
|
|
147
|
+
if (!this.path) return null;
|
|
148
|
+
try {
|
|
149
|
+
const content = tasksToMarkdown(this.roots);
|
|
150
|
+
const dir = path.dirname(this.path);
|
|
151
|
+
const tmp = path.join(
|
|
152
|
+
dir,
|
|
153
|
+
`.${path.basename(this.path)}.${process.pid}.tmp`,
|
|
154
|
+
);
|
|
155
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
156
|
+
if (fs.existsSync(this.path)) {
|
|
157
|
+
fs.copyFileSync(this.path, this.path + ".bak");
|
|
158
|
+
}
|
|
159
|
+
fs.renameSync(tmp, this.path);
|
|
160
|
+
this.dirty = false;
|
|
161
|
+
return null;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return (e as Error).message;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Mutate state, then save. Returns save error message, or null. */
|
|
168
|
+
private commit(): string | null {
|
|
169
|
+
this.rebuildMap();
|
|
170
|
+
this.dirty = true;
|
|
171
|
+
return this.saveToDisk();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private taskToDict(task: Task): Record<string, unknown> {
|
|
175
|
+
return {
|
|
176
|
+
id: task.id,
|
|
177
|
+
description: task.description,
|
|
178
|
+
status: task.status,
|
|
179
|
+
depth: depthOf(task),
|
|
180
|
+
position: this.positionOf(task),
|
|
181
|
+
parent_id: task.parent?.id ?? null,
|
|
182
|
+
children_ids: task.children.map((c) => c.id),
|
|
183
|
+
date_created: task.dateCreated,
|
|
184
|
+
date_modified: task.dateModified,
|
|
185
|
+
priority: task.priority,
|
|
186
|
+
date_scheduled: task.dateScheduled,
|
|
187
|
+
date_start: task.dateStart,
|
|
188
|
+
date_due: task.dateDue,
|
|
189
|
+
date_done: task.dateDone,
|
|
190
|
+
date_cancelled: task.dateCancelled,
|
|
191
|
+
recurrence: task.recurrence,
|
|
192
|
+
on_completion: task.onCompletion,
|
|
193
|
+
depends_on: [...task.dependsOn],
|
|
194
|
+
has_spec: task.hasSpec,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private taskSummary(task: Task): Record<string, unknown> {
|
|
199
|
+
return {
|
|
200
|
+
id: task.id,
|
|
201
|
+
description: task.description,
|
|
202
|
+
status: task.status,
|
|
203
|
+
depth: depthOf(task),
|
|
204
|
+
position: this.positionOf(task),
|
|
205
|
+
priority: task.priority,
|
|
206
|
+
date_due: task.dateDue,
|
|
207
|
+
date_scheduled: task.dateScheduled,
|
|
208
|
+
date_done: task.dateDone,
|
|
209
|
+
parent_id: task.parent?.id ?? null,
|
|
210
|
+
children_count: task.children.length,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve the parent task and the sibling reference (before/after) for
|
|
216
|
+
* an insertion. The index itself is computed at the call site against
|
|
217
|
+
* the current children array (so moves can detach first).
|
|
218
|
+
*/
|
|
219
|
+
private resolveInsertion(
|
|
220
|
+
parentId?: string | null,
|
|
221
|
+
beforeId?: string | null,
|
|
222
|
+
afterId?: string | null,
|
|
223
|
+
): {
|
|
224
|
+
status: "error";
|
|
225
|
+
error: string;
|
|
226
|
+
} | { status: "ok"; parent: Task | null; ref: Task | null; before: boolean } {
|
|
227
|
+
let parent: Task | null = null;
|
|
228
|
+
if (parentId) {
|
|
229
|
+
if (!this.taskMap.has(parentId))
|
|
230
|
+
return {
|
|
231
|
+
status: "error",
|
|
232
|
+
error: `Parent task not found: ${parentId}`,
|
|
233
|
+
};
|
|
234
|
+
parent = this.taskMap.get(parentId)!;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const refId = beforeId ?? afterId ?? null;
|
|
238
|
+
let ref: Task | null = null;
|
|
239
|
+
if (refId) {
|
|
240
|
+
const label = beforeId ? "before_id" : "after_id";
|
|
241
|
+
if (!this.taskMap.has(refId))
|
|
242
|
+
return { status: "error", error: `${label} task not found: ${refId}` };
|
|
243
|
+
ref = this.taskMap.get(refId)!;
|
|
244
|
+
if (ref.parent !== parent)
|
|
245
|
+
return {
|
|
246
|
+
status: "error",
|
|
247
|
+
error: `${label} must be a sibling of the new task (child of ${
|
|
248
|
+
parentId ?? "top level"
|
|
249
|
+
}).`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { status: "ok", parent, ref, before: !!beforeId };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private insertIndex(
|
|
257
|
+
parent: Task | null,
|
|
258
|
+
ref: Task | null,
|
|
259
|
+
before: boolean,
|
|
260
|
+
): number {
|
|
261
|
+
const siblings = parent ? parent.children : this.roots;
|
|
262
|
+
return ref ? siblings.indexOf(ref) + (before ? 0 : 1) : siblings.length;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── public API ────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** Open a TODO.md file in a workspace directory. */
|
|
268
|
+
openFile(workspacePath: string): Result {
|
|
269
|
+
const workspace = path.resolve(workspacePath);
|
|
270
|
+
const todoPath = path.join(workspace, TODO_FILENAME);
|
|
271
|
+
|
|
272
|
+
let content: string;
|
|
273
|
+
try {
|
|
274
|
+
if (fs.existsSync(todoPath)) {
|
|
275
|
+
content = fs.readFileSync(todoPath, "utf-8");
|
|
276
|
+
} else {
|
|
277
|
+
fs.writeFileSync(todoPath, "# TODO\n\n", "utf-8");
|
|
278
|
+
content = "# TODO\n\n";
|
|
279
|
+
}
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return {
|
|
282
|
+
status: "error",
|
|
283
|
+
error: `Cannot open ${todoPath}: ${(e as Error).message}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const issues = findTodoIssues(content);
|
|
288
|
+
if (issues.length > 0)
|
|
289
|
+
return {
|
|
290
|
+
status: "error",
|
|
291
|
+
error: `TODO.md has structural problems: ${issues
|
|
292
|
+
.map(describeIssue)
|
|
293
|
+
.join(", ")}. Fix the file and reopen.`,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
this.path = todoPath;
|
|
297
|
+
this.roots = parseTodoFile(content);
|
|
298
|
+
this.rebuildMap();
|
|
299
|
+
this.dirty = false;
|
|
300
|
+
|
|
301
|
+
return { status: "ok", path: todoPath, task_count: this.taskCount() };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
closeFile(): Result {
|
|
305
|
+
if (!this.isOpen) return { status: "error", error: "No file open" };
|
|
306
|
+
if (this.dirty) {
|
|
307
|
+
const err = this.saveToDisk();
|
|
308
|
+
if (err)
|
|
309
|
+
return {
|
|
310
|
+
status: "error",
|
|
311
|
+
error: `Close failed, file left open (save error): ${err}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
this.path = null;
|
|
315
|
+
this.roots = [];
|
|
316
|
+
this.taskMap = new Map();
|
|
317
|
+
this.dirty = false;
|
|
318
|
+
return { status: "ok", message: "File closed." };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
addTask(
|
|
322
|
+
description: string,
|
|
323
|
+
parentId?: string | null,
|
|
324
|
+
beforeId?: string | null,
|
|
325
|
+
afterId?: string | null,
|
|
326
|
+
priority?: string | null,
|
|
327
|
+
scheduled?: string | null,
|
|
328
|
+
start?: string | null,
|
|
329
|
+
due?: string | null,
|
|
330
|
+
recurrence?: string | null,
|
|
331
|
+
onCompletion?: string | null,
|
|
332
|
+
dependsOn?: string[] | null,
|
|
333
|
+
spec = false,
|
|
334
|
+
): Result {
|
|
335
|
+
if (!this.isOpen)
|
|
336
|
+
return { status: "error", error: "No file open. Call open_file first." };
|
|
337
|
+
if (!description || !description.trim())
|
|
338
|
+
return { status: "error", error: "Description cannot be empty." };
|
|
339
|
+
|
|
340
|
+
const desc = description.trim();
|
|
341
|
+
|
|
342
|
+
if (desc.includes("\n") || desc.includes("\r"))
|
|
343
|
+
return {
|
|
344
|
+
status: "error",
|
|
345
|
+
error: "Description cannot contain newlines.",
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const badEmoji = findAnnotationEmoji(desc);
|
|
349
|
+
if (badEmoji)
|
|
350
|
+
return {
|
|
351
|
+
status: "error",
|
|
352
|
+
error: `Description contains annotation emoji ${badEmoji}, which is reserved for task metadata.`,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
for (const [label, value] of [
|
|
356
|
+
["scheduled", scheduled],
|
|
357
|
+
["start", start],
|
|
358
|
+
["due", due],
|
|
359
|
+
] as const) {
|
|
360
|
+
if (value !== undefined && value !== null && !DATE_RE.test(value))
|
|
361
|
+
return {
|
|
362
|
+
status: "error",
|
|
363
|
+
error: `Invalid ${label} date: ${value}. Use YYYY-MM-DD.`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let prio: string | null = null;
|
|
368
|
+
if (priority && priority !== "normal" && priority !== "null") {
|
|
369
|
+
if (!(priority in PRIORITY_EMOJI))
|
|
370
|
+
return { status: "error", error: `Invalid priority: ${priority}` };
|
|
371
|
+
prio = priority;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (dependsOn) {
|
|
375
|
+
for (const depId of dependsOn) {
|
|
376
|
+
if (!this.taskMap.has(depId))
|
|
377
|
+
return {
|
|
378
|
+
status: "error",
|
|
379
|
+
error: `Dependency task not found: ${depId}`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const resolved = this.resolveInsertion(parentId, beforeId, afterId);
|
|
385
|
+
if (resolved.status === "error") return resolved;
|
|
386
|
+
const { parent, ref, before } = resolved;
|
|
387
|
+
|
|
388
|
+
const newDepth = parent ? depthOf(parent) + 1 : 0;
|
|
389
|
+
if (newDepth > MAX_DEPTH)
|
|
390
|
+
return {
|
|
391
|
+
status: "error",
|
|
392
|
+
error: `Adding as child would exceed max depth (${MAX_DEPTH}).`,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const today = this.today();
|
|
396
|
+
const task = newTask(this.generateId(), desc);
|
|
397
|
+
task.parent = parent;
|
|
398
|
+
task.dateCreated = today;
|
|
399
|
+
task.dateModified = today;
|
|
400
|
+
task.priority = prio;
|
|
401
|
+
task.dateScheduled = scheduled ?? null;
|
|
402
|
+
task.dateStart = start ?? null;
|
|
403
|
+
task.dateDue = due ?? null;
|
|
404
|
+
task.recurrence = recurrence ?? null;
|
|
405
|
+
task.onCompletion = onCompletion ?? null;
|
|
406
|
+
task.dependsOn = dependsOn ? [...dependsOn] : [];
|
|
407
|
+
task.hasSpec = !!spec;
|
|
408
|
+
|
|
409
|
+
(parent ? parent.children : this.roots).splice(
|
|
410
|
+
this.insertIndex(parent, ref, before),
|
|
411
|
+
0,
|
|
412
|
+
task,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
if (spec && this.path) {
|
|
416
|
+
const specPath = path.join(path.dirname(this.path), `task-${task.id}.md`);
|
|
417
|
+
try {
|
|
418
|
+
fs.writeFileSync(
|
|
419
|
+
specPath,
|
|
420
|
+
`# Task Specification: ${desc}\n\n**ID:** \`${task.id}\`\n\n## Description\n\n${desc}\n\n## Acceptance Criteria\n\n- [ ] \n\n## Notes\n\n`,
|
|
421
|
+
"utf-8",
|
|
422
|
+
);
|
|
423
|
+
} catch {
|
|
424
|
+
task.hasSpec = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const saveError = this.commit();
|
|
429
|
+
const result: Result = {
|
|
430
|
+
status: "ok",
|
|
431
|
+
task_id: task.id,
|
|
432
|
+
description: task.description,
|
|
433
|
+
};
|
|
434
|
+
if (saveError) result.warning = `Task added but save failed: ${saveError}`;
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
editTask(
|
|
439
|
+
taskId: string,
|
|
440
|
+
description?: string | null,
|
|
441
|
+
status?: string | null,
|
|
442
|
+
priority?: string | null,
|
|
443
|
+
scheduled?: string | null,
|
|
444
|
+
start?: string | null,
|
|
445
|
+
due?: string | null,
|
|
446
|
+
recurrence?: string | null,
|
|
447
|
+
onCompletion?: string | null,
|
|
448
|
+
dependsOn?: string[] | null,
|
|
449
|
+
): Result {
|
|
450
|
+
if (!this.isOpen) return { status: "error", error: "No file open." };
|
|
451
|
+
|
|
452
|
+
let task: Task;
|
|
453
|
+
try {
|
|
454
|
+
task = this.findTask(taskId);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
return { status: "error", error: (e as Error).message };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const changes: Record<string, unknown> = {};
|
|
460
|
+
if (provided(description)) changes.description = description;
|
|
461
|
+
if (provided(status)) changes.status = status;
|
|
462
|
+
if (provided(priority)) changes.priority = priority;
|
|
463
|
+
if (provided(scheduled)) changes.scheduled = scheduled;
|
|
464
|
+
if (provided(start)) changes.start = start;
|
|
465
|
+
if (provided(due)) changes.due = due;
|
|
466
|
+
if (provided(recurrence)) changes.recurrence = recurrence;
|
|
467
|
+
if (provided(onCompletion)) changes.on_completion = onCompletion;
|
|
468
|
+
if (provided(dependsOn)) changes.depends_on = dependsOn;
|
|
469
|
+
|
|
470
|
+
if (Object.keys(changes).length === 0)
|
|
471
|
+
return { status: "error", error: "No fields to edit." };
|
|
472
|
+
|
|
473
|
+
if ("description" in changes) {
|
|
474
|
+
const d = changes.description as string;
|
|
475
|
+
if (!d.trim())
|
|
476
|
+
return { status: "error", error: "Description cannot be empty." };
|
|
477
|
+
if (d.includes("\n") || d.includes("\r"))
|
|
478
|
+
return { status: "error", error: "Description cannot contain newlines." };
|
|
479
|
+
const badEmoji = findAnnotationEmoji(d.trim());
|
|
480
|
+
if (badEmoji)
|
|
481
|
+
return {
|
|
482
|
+
status: "error",
|
|
483
|
+
error: `Description contains annotation emoji ${badEmoji}, which is reserved for task metadata.`,
|
|
484
|
+
};
|
|
485
|
+
task.description = d.trim();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if ("status" in changes) {
|
|
489
|
+
const newStatus = changes.status as string;
|
|
490
|
+
if (!STATUS_CHARS.includes(newStatus))
|
|
491
|
+
return {
|
|
492
|
+
status: "error",
|
|
493
|
+
error: `Invalid status: ${newStatus}. Must be one of: ${STATUS_CHARS.join(", ")}`,
|
|
494
|
+
};
|
|
495
|
+
task.status = newStatus;
|
|
496
|
+
if (newStatus === "x" && !task.dateDone) task.dateDone = this.today();
|
|
497
|
+
else if (newStatus !== "x") task.dateDone = null;
|
|
498
|
+
if (newStatus === "-" && !task.dateCancelled)
|
|
499
|
+
task.dateCancelled = this.today();
|
|
500
|
+
else if (newStatus !== "-") task.dateCancelled = null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if ("priority" in changes) {
|
|
504
|
+
const p = changes.priority as string;
|
|
505
|
+
if (p === "null" || p === "normal") task.priority = null;
|
|
506
|
+
else if (p in PRIORITY_EMOJI) task.priority = p;
|
|
507
|
+
else return { status: "error", error: `Invalid priority: ${p}` };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const key of ["scheduled", "start", "due"] as const) {
|
|
511
|
+
if (key in changes) {
|
|
512
|
+
const v = changes[key] as string;
|
|
513
|
+
if (!DATE_RE.test(v))
|
|
514
|
+
return {
|
|
515
|
+
status: "error",
|
|
516
|
+
error: `Invalid ${key} date: ${v}. Use YYYY-MM-DD.`,
|
|
517
|
+
};
|
|
518
|
+
if (key === "scheduled") task.dateScheduled = v;
|
|
519
|
+
else if (key === "start") task.dateStart = v;
|
|
520
|
+
else task.dateDue = v;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if ("recurrence" in changes) task.recurrence = changes.recurrence as string;
|
|
524
|
+
|
|
525
|
+
if ("on_completion" in changes) {
|
|
526
|
+
const oc = changes.on_completion as string;
|
|
527
|
+
if (oc === "null") task.onCompletion = null;
|
|
528
|
+
else if (oc === "keep" || oc === "delete") task.onCompletion = oc;
|
|
529
|
+
else return { status: "error", error: `Invalid on_completion: ${oc}` };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if ("depends_on" in changes) {
|
|
533
|
+
const deps = changes.depends_on as string[];
|
|
534
|
+
if (deps === null) {
|
|
535
|
+
task.dependsOn = [];
|
|
536
|
+
} else {
|
|
537
|
+
for (const depId of deps) {
|
|
538
|
+
if (!this.taskMap.has(depId))
|
|
539
|
+
return {
|
|
540
|
+
status: "error",
|
|
541
|
+
error: `Dependency task not found: ${depId}`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (this.wouldCreateCycle(taskId, deps))
|
|
545
|
+
return {
|
|
546
|
+
status: "error",
|
|
547
|
+
error: "Circular dependency detected.",
|
|
548
|
+
};
|
|
549
|
+
task.dependsOn = [...deps];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
task.dateModified = this.today();
|
|
554
|
+
const saveError = this.commit();
|
|
555
|
+
const result: Result = { status: "ok", task: this.taskToDict(task) };
|
|
556
|
+
if (saveError)
|
|
557
|
+
result.warning = `Task updated but save failed: ${saveError}`;
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Move a task (with its subtree). No destination = delete. */
|
|
562
|
+
moveTask(
|
|
563
|
+
taskId: string,
|
|
564
|
+
underId?: string | null,
|
|
565
|
+
beforeId?: string | null,
|
|
566
|
+
afterId?: string | null,
|
|
567
|
+
): Result {
|
|
568
|
+
if (!this.isOpen) return { status: "error", error: "No file open." };
|
|
569
|
+
|
|
570
|
+
if (!underId && !beforeId && !afterId) return this.deleteTask(taskId);
|
|
571
|
+
|
|
572
|
+
let task: Task;
|
|
573
|
+
try {
|
|
574
|
+
task = this.findTask(taskId);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
return { status: "error", error: (e as Error).message };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (const [label, destId] of [
|
|
580
|
+
["under_id", underId],
|
|
581
|
+
["before_id", beforeId],
|
|
582
|
+
["after_id", afterId],
|
|
583
|
+
] as const) {
|
|
584
|
+
if (!destId) continue;
|
|
585
|
+
if (!this.taskMap.has(destId))
|
|
586
|
+
return { status: "error", error: `${label} task not found: ${destId}` };
|
|
587
|
+
if (destId === taskId)
|
|
588
|
+
return {
|
|
589
|
+
status: "error",
|
|
590
|
+
error: `Cannot move task ${label === "under_id" ? "under" : label.startsWith("before") ? "before" : "after"} itself.`,
|
|
591
|
+
};
|
|
592
|
+
if (this.isSelfOrDescendant(taskId, destId))
|
|
593
|
+
return {
|
|
594
|
+
status: "error",
|
|
595
|
+
error: `Cannot move task ${label === "under_id" ? "under" : label.startsWith("before") ? "before" : "after"} its own descendant.`,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const resolved = this.resolveInsertion(underId, beforeId, afterId);
|
|
600
|
+
if (resolved.status === "error") return resolved;
|
|
601
|
+
const { parent: newParent, ref, before } = resolved;
|
|
602
|
+
|
|
603
|
+
const oldDepth = depthOf(task);
|
|
604
|
+
const newDepth = newParent ? depthOf(newParent) + 1 : 0;
|
|
605
|
+
const depthDiff = newDepth - oldDepth;
|
|
606
|
+
for (const desc of this.getDescendants(task)) {
|
|
607
|
+
if (depthOf(desc) + depthDiff > MAX_DEPTH)
|
|
608
|
+
return {
|
|
609
|
+
status: "error",
|
|
610
|
+
error: `Moving task would exceed max depth (${MAX_DEPTH}).`,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Detach (subtree moves with the task), then re-attach.
|
|
615
|
+
// The insertion index is computed after removal so positions are
|
|
616
|
+
// correct when the task sits before its reference sibling.
|
|
617
|
+
const oldSiblings = task.parent
|
|
618
|
+
? task.parent.children
|
|
619
|
+
: this.roots;
|
|
620
|
+
oldSiblings.splice(oldSiblings.indexOf(task), 1);
|
|
621
|
+
task.parent = newParent;
|
|
622
|
+
(newParent ? newParent.children : this.roots).splice(
|
|
623
|
+
this.insertIndex(newParent, ref, before),
|
|
624
|
+
0,
|
|
625
|
+
task,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const saveError = this.commit();
|
|
629
|
+
const result: Result = {
|
|
630
|
+
status: "ok",
|
|
631
|
+
message: `Task ${taskId} moved.`,
|
|
632
|
+
task_id: taskId,
|
|
633
|
+
parent_id: task.parent?.id ?? null,
|
|
634
|
+
depth: depthOf(task),
|
|
635
|
+
};
|
|
636
|
+
if (saveError) result.warning = `Task moved but save failed: ${saveError}`;
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
getTask(taskId: string): Result {
|
|
641
|
+
if (!this.isOpen) return { status: "error", error: "No file open." };
|
|
642
|
+
try {
|
|
643
|
+
return { status: "ok", task: this.taskToDict(this.findTask(taskId)) };
|
|
644
|
+
} catch (e) {
|
|
645
|
+
return { status: "error", error: (e as Error).message };
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
listTasks(
|
|
650
|
+
parentId?: string | null,
|
|
651
|
+
status?: string | null,
|
|
652
|
+
priority?: string | null,
|
|
653
|
+
includeSubtasks = false,
|
|
654
|
+
): Result {
|
|
655
|
+
if (!this.isOpen) return { status: "error", error: "No file open." };
|
|
656
|
+
|
|
657
|
+
let start: Task[] = this.roots;
|
|
658
|
+
if (parentId) {
|
|
659
|
+
const parent = this.taskMap.get(parentId);
|
|
660
|
+
if (!parent) return { status: "ok", tasks: [], count: 0 };
|
|
661
|
+
start = includeSubtasks ? [parent] : parent.children;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const results: Record<string, unknown>[] = [];
|
|
665
|
+
const visit = (tasks: Task[]): void => {
|
|
666
|
+
for (const task of tasks) {
|
|
667
|
+
if (
|
|
668
|
+
(!status || task.status === status) &&
|
|
669
|
+
(!priority || task.priority === priority)
|
|
670
|
+
)
|
|
671
|
+
results.push(this.taskSummary(task));
|
|
672
|
+
visit(task.children);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
visit(start);
|
|
676
|
+
|
|
677
|
+
return { status: "ok", tasks: results, count: results.length };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
save(): Result {
|
|
681
|
+
if (!this.isOpen || !this.path)
|
|
682
|
+
return { status: "error", error: "No file open." };
|
|
683
|
+
const err = this.saveToDisk();
|
|
684
|
+
if (err) return { status: "error", error: `Save failed: ${err}` };
|
|
685
|
+
return {
|
|
686
|
+
status: "ok",
|
|
687
|
+
message: `Saved to ${this.path}`,
|
|
688
|
+
task_count: this.taskCount(),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ─── move/delete helpers ───────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
private deleteTask(taskId: string): Result {
|
|
695
|
+
let task: Task;
|
|
696
|
+
try {
|
|
697
|
+
task = this.findTask(taskId);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
return { status: "error", error: (e as Error).message };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const subCount = this.getDescendants(task).length;
|
|
703
|
+
const siblings = task.parent ? task.parent.children : this.roots;
|
|
704
|
+
siblings.splice(siblings.indexOf(task), 1);
|
|
705
|
+
|
|
706
|
+
const saveError = this.commit();
|
|
707
|
+
const result: Result = {
|
|
708
|
+
status: "ok",
|
|
709
|
+
message: `Deleted task ${taskId} and ${subCount} sub-task(s).`,
|
|
710
|
+
};
|
|
711
|
+
if (saveError) result.warning = `Task deleted but save failed: ${saveError}`;
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
}
|