@agnishc/edb-todo 0.8.2 → 0.10.4
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/CHANGELOG.md +29 -0
- package/README.md +145 -33
- package/package.json +1 -1
- package/src/auto-clear.ts +87 -0
- package/src/component.ts +194 -57
- package/src/config.ts +25 -0
- package/src/file-store.ts +408 -0
- package/src/index.ts +554 -108
- package/src/process-tracker.ts +146 -0
- package/src/prompt.ts +15 -11
- package/src/schemas.ts +52 -27
- package/src/state.ts +224 -97
- package/src/types.ts +14 -1
package/src/config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// <cwd>/.pi/tasks-config.json — persists extension settings across sessions
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface TodoConfig {
|
|
7
|
+
/** Where tasks are stored. Default: "session" */
|
|
8
|
+
taskScope?: "memory" | "session" | "project";
|
|
9
|
+
/** Auto-clear completed tasks. Default: "on_list_complete" */
|
|
10
|
+
autoClearCompleted?: "never" | "on_list_complete" | "on_task_complete";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadTodoConfig(cwd: string): TodoConfig {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(join(cwd, ".pi", "tasks-config.json"), "utf-8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveTodoConfig(cwd: string, config: TodoConfig): void {
|
|
22
|
+
const path = join(cwd, ".pi", "tasks-config.json");
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, JSON.stringify(config, null, 2));
|
|
25
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-store.ts — File-backed task store with CRUD, dependency management, and file locking.
|
|
3
|
+
*
|
|
4
|
+
* memory (no path): in-memory only.
|
|
5
|
+
* session / project: file-backed with atomic writes and file locking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import type { Task, TaskPriority, TaskStatus, TaskStoreData } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const LOCK_RETRY_MS = 50;
|
|
13
|
+
const LOCK_MAX_RETRIES = 100; // 5s max
|
|
14
|
+
|
|
15
|
+
function acquireLock(lockPath: string): void {
|
|
16
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
19
|
+
return;
|
|
20
|
+
} catch (e: any) {
|
|
21
|
+
if (e.code === "EEXIST") {
|
|
22
|
+
try {
|
|
23
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
24
|
+
if (pid && !isProcessRunning(pid)) {
|
|
25
|
+
unlinkSync(lockPath);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore */
|
|
30
|
+
}
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
while (Date.now() - start < LOCK_RETRY_MS) {
|
|
33
|
+
/* busy wait */
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function releaseLock(lockPath: string): void {
|
|
44
|
+
try {
|
|
45
|
+
unlinkSync(lockPath);
|
|
46
|
+
} catch {
|
|
47
|
+
/* ignore */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isProcessRunning(pid: number): boolean {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── FileTaskStore ──────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export class FileTaskStore {
|
|
63
|
+
private filePath: string | undefined;
|
|
64
|
+
private lockPath: string | undefined;
|
|
65
|
+
|
|
66
|
+
private nextId = 1;
|
|
67
|
+
private tasks = new Map<string, Task>();
|
|
68
|
+
|
|
69
|
+
constructor(filePath?: string) {
|
|
70
|
+
if (!filePath) return;
|
|
71
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
72
|
+
this.filePath = filePath;
|
|
73
|
+
this.lockPath = `${filePath}.lock`;
|
|
74
|
+
this.load();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private load(): void {
|
|
78
|
+
if (!this.filePath) return;
|
|
79
|
+
if (!existsSync(this.filePath)) return;
|
|
80
|
+
try {
|
|
81
|
+
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
82
|
+
this.nextId = data.nextId ?? 1;
|
|
83
|
+
this.tasks.clear();
|
|
84
|
+
for (const t of data.tasks) {
|
|
85
|
+
// Migrate old tasks that lack new fields
|
|
86
|
+
if (!t.metadata) t.metadata = {};
|
|
87
|
+
if (!t.blocks) t.blocks = [];
|
|
88
|
+
if (!t.blockedBy) t.blockedBy = [];
|
|
89
|
+
if (!t.updatedAt) t.updatedAt = t.createdAt;
|
|
90
|
+
this.tasks.set(t.id, t);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
/* corrupt file — start fresh */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private save(): void {
|
|
98
|
+
if (!this.filePath) return;
|
|
99
|
+
const data: TaskStoreData = {
|
|
100
|
+
nextId: this.nextId,
|
|
101
|
+
tasks: Array.from(this.tasks.values()),
|
|
102
|
+
};
|
|
103
|
+
const tmpPath = `${this.filePath}.tmp`;
|
|
104
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
105
|
+
renameSync(tmpPath, this.filePath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private withLock<T>(fn: () => T): T {
|
|
109
|
+
if (!this.lockPath) return fn();
|
|
110
|
+
acquireLock(this.lockPath);
|
|
111
|
+
try {
|
|
112
|
+
this.load();
|
|
113
|
+
const result = fn();
|
|
114
|
+
this.save();
|
|
115
|
+
return result;
|
|
116
|
+
} finally {
|
|
117
|
+
releaseLock(this.lockPath);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Sync ID counter after external writes ────────────────────────────────
|
|
122
|
+
|
|
123
|
+
syncIdCounter(): void {
|
|
124
|
+
for (const t of this.tasks.values()) {
|
|
125
|
+
const m = t.id.match(/^t(\d+)$/);
|
|
126
|
+
if (m) this.nextId = Math.max(this.nextId, parseInt(m[1]!, 10) + 1);
|
|
127
|
+
const n = parseInt(t.id, 10);
|
|
128
|
+
if (!Number.isNaN(n)) this.nextId = Math.max(this.nextId, n + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
generateId(): string {
|
|
133
|
+
return `t${this.nextId++}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
create(
|
|
139
|
+
content: string,
|
|
140
|
+
opts?: {
|
|
141
|
+
description?: string;
|
|
142
|
+
priority?: TaskPriority;
|
|
143
|
+
activeForm?: string;
|
|
144
|
+
metadata?: Record<string, any>;
|
|
145
|
+
},
|
|
146
|
+
): Task {
|
|
147
|
+
return this.withLock(() => {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const task: Task = {
|
|
150
|
+
id: `t${this.nextId++}`,
|
|
151
|
+
content,
|
|
152
|
+
description: opts?.description,
|
|
153
|
+
status: "pending",
|
|
154
|
+
priority: opts?.priority ?? "medium",
|
|
155
|
+
activeForm: opts?.activeForm,
|
|
156
|
+
owner: undefined,
|
|
157
|
+
metadata: opts?.metadata ?? {},
|
|
158
|
+
blocks: [],
|
|
159
|
+
blockedBy: [],
|
|
160
|
+
createdAt: now,
|
|
161
|
+
updatedAt: now,
|
|
162
|
+
};
|
|
163
|
+
this.tasks.set(task.id, task);
|
|
164
|
+
return task;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get(id: string): Task | undefined {
|
|
169
|
+
if (this.filePath) this.load();
|
|
170
|
+
return this.tasks.get(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
list(): Task[] {
|
|
174
|
+
if (this.filePath) this.load();
|
|
175
|
+
return Array.from(this.tasks.values());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
activeTasks(): Task[] {
|
|
179
|
+
return this.list().filter((t) => t.status !== "completed");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Replace entire task list. */
|
|
183
|
+
setTasks(tasks: Task[]): void {
|
|
184
|
+
this.withLock(() => {
|
|
185
|
+
this.tasks.clear();
|
|
186
|
+
for (const t of tasks) {
|
|
187
|
+
this.tasks.set(t.id, t);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
update(
|
|
193
|
+
id: string,
|
|
194
|
+
fields: {
|
|
195
|
+
status?: TaskStatus | "deleted";
|
|
196
|
+
content?: string;
|
|
197
|
+
description?: string;
|
|
198
|
+
priority?: TaskPriority;
|
|
199
|
+
activeForm?: string;
|
|
200
|
+
owner?: string;
|
|
201
|
+
metadata?: Record<string, any>;
|
|
202
|
+
addBlocks?: string[];
|
|
203
|
+
addBlockedBy?: string[];
|
|
204
|
+
},
|
|
205
|
+
): { task: Task | undefined; changedFields: string[]; warnings: string[] } {
|
|
206
|
+
return this.withLock(() => {
|
|
207
|
+
const task = this.tasks.get(id);
|
|
208
|
+
if (!task) return { task: undefined, changedFields: [], warnings: [] };
|
|
209
|
+
|
|
210
|
+
const changedFields: string[] = [];
|
|
211
|
+
const warnings: string[] = [];
|
|
212
|
+
|
|
213
|
+
if (fields.status === "deleted") {
|
|
214
|
+
this.tasks.delete(id);
|
|
215
|
+
// Clean up edges
|
|
216
|
+
for (const t of this.tasks.values()) {
|
|
217
|
+
t.blocks = t.blocks.filter((bid) => bid !== id);
|
|
218
|
+
t.blockedBy = t.blockedBy.filter((bid) => bid !== id);
|
|
219
|
+
}
|
|
220
|
+
return { task: undefined, changedFields: ["deleted"], warnings: [] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
|
|
225
|
+
if (fields.status !== undefined) {
|
|
226
|
+
// Timestamp transitions
|
|
227
|
+
if (task.status !== "in_progress" && fields.status === "in_progress") task.startedAt = now;
|
|
228
|
+
if (task.status !== "completed" && fields.status === "completed") {
|
|
229
|
+
task.startedAt = task.startedAt ?? now;
|
|
230
|
+
task.completedAt = now;
|
|
231
|
+
}
|
|
232
|
+
if (task.status === "completed" && fields.status !== "completed") {
|
|
233
|
+
task.completedAt = undefined;
|
|
234
|
+
}
|
|
235
|
+
task.status = fields.status;
|
|
236
|
+
changedFields.push("status");
|
|
237
|
+
}
|
|
238
|
+
if (fields.content !== undefined) {
|
|
239
|
+
task.content = fields.content;
|
|
240
|
+
changedFields.push("content");
|
|
241
|
+
}
|
|
242
|
+
if (fields.description !== undefined) {
|
|
243
|
+
task.description = fields.description;
|
|
244
|
+
changedFields.push("description");
|
|
245
|
+
}
|
|
246
|
+
if (fields.priority !== undefined) {
|
|
247
|
+
task.priority = fields.priority;
|
|
248
|
+
changedFields.push("priority");
|
|
249
|
+
}
|
|
250
|
+
if (fields.activeForm !== undefined) {
|
|
251
|
+
task.activeForm = fields.activeForm;
|
|
252
|
+
changedFields.push("activeForm");
|
|
253
|
+
}
|
|
254
|
+
if (fields.owner !== undefined) {
|
|
255
|
+
task.owner = fields.owner;
|
|
256
|
+
changedFields.push("owner");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (fields.metadata !== undefined) {
|
|
260
|
+
for (const [key, value] of Object.entries(fields.metadata)) {
|
|
261
|
+
if (value === null) delete task.metadata[key];
|
|
262
|
+
else task.metadata[key] = value;
|
|
263
|
+
}
|
|
264
|
+
changedFields.push("metadata");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (fields.addBlocks && fields.addBlocks.length > 0) {
|
|
268
|
+
for (const targetId of fields.addBlocks) {
|
|
269
|
+
if (!task.blocks.includes(targetId)) task.blocks.push(targetId);
|
|
270
|
+
const target = this.tasks.get(targetId);
|
|
271
|
+
if (target && !target.blockedBy.includes(id)) {
|
|
272
|
+
target.blockedBy.push(id);
|
|
273
|
+
target.updatedAt = now;
|
|
274
|
+
}
|
|
275
|
+
if (targetId === id) warnings.push(`#${id} blocks itself`);
|
|
276
|
+
else if (!target) warnings.push(`#${targetId} does not exist`);
|
|
277
|
+
else if (target.blocks.includes(id)) warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
|
278
|
+
}
|
|
279
|
+
changedFields.push("blocks");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (fields.addBlockedBy && fields.addBlockedBy.length > 0) {
|
|
283
|
+
for (const targetId of fields.addBlockedBy) {
|
|
284
|
+
if (!task.blockedBy.includes(targetId)) task.blockedBy.push(targetId);
|
|
285
|
+
const target = this.tasks.get(targetId);
|
|
286
|
+
if (target && !target.blocks.includes(id)) {
|
|
287
|
+
target.blocks.push(id);
|
|
288
|
+
target.updatedAt = now;
|
|
289
|
+
}
|
|
290
|
+
if (targetId === id) warnings.push(`#${id} blocks itself`);
|
|
291
|
+
else if (!target) warnings.push(`#${targetId} does not exist`);
|
|
292
|
+
else if (task.blocks.includes(targetId))
|
|
293
|
+
warnings.push(`cycle: #${id} and #${targetId} block each other`);
|
|
294
|
+
}
|
|
295
|
+
changedFields.push("blockedBy");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
task.updatedAt = now;
|
|
299
|
+
return { task, changedFields, warnings };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
delete(id: string): boolean {
|
|
304
|
+
return this.withLock(() => {
|
|
305
|
+
if (!this.tasks.has(id)) return false;
|
|
306
|
+
this.tasks.delete(id);
|
|
307
|
+
for (const t of this.tasks.values()) {
|
|
308
|
+
t.blocks = t.blocks.filter((bid) => bid !== id);
|
|
309
|
+
t.blockedBy = t.blockedBy.filter((bid) => bid !== id);
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
removeByIds(ids: string[]): string[] {
|
|
316
|
+
return this.withLock(() => {
|
|
317
|
+
const removed: string[] = [];
|
|
318
|
+
for (const id of ids) {
|
|
319
|
+
if (this.tasks.has(id)) {
|
|
320
|
+
this.tasks.delete(id);
|
|
321
|
+
removed.push(id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Clean up edges
|
|
325
|
+
if (removed.length > 0) {
|
|
326
|
+
const removedSet = new Set(removed);
|
|
327
|
+
for (const t of this.tasks.values()) {
|
|
328
|
+
t.blocks = t.blocks.filter((bid) => !removedSet.has(bid));
|
|
329
|
+
t.blockedBy = t.blockedBy.filter((bid) => !removedSet.has(bid));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return removed;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
clearAll(): number {
|
|
337
|
+
return this.withLock(() => {
|
|
338
|
+
const count = this.tasks.size;
|
|
339
|
+
this.tasks.clear();
|
|
340
|
+
return count;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
clearCompleted(): number {
|
|
345
|
+
return this.withLock(() => {
|
|
346
|
+
let count = 0;
|
|
347
|
+
const toDelete: string[] = [];
|
|
348
|
+
for (const [id, task] of this.tasks) {
|
|
349
|
+
if (task.status === "completed") {
|
|
350
|
+
toDelete.push(id);
|
|
351
|
+
count++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const id of toDelete) this.tasks.delete(id);
|
|
355
|
+
if (count > 0) {
|
|
356
|
+
const validIds = new Set(this.tasks.keys());
|
|
357
|
+
for (const t of this.tasks.values()) {
|
|
358
|
+
t.blocks = t.blocks.filter((bid) => validIds.has(bid));
|
|
359
|
+
t.blockedBy = t.blockedBy.filter((bid) => validIds.has(bid));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return count;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
deleteFileIfEmpty(): boolean {
|
|
367
|
+
if (!this.filePath || this.tasks.size > 0) return false;
|
|
368
|
+
try {
|
|
369
|
+
unlinkSync(this.filePath);
|
|
370
|
+
} catch {
|
|
371
|
+
/* ignore */
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Apply status transitions and timestamps for bulk writes. */
|
|
377
|
+
applyStatusTransitions(updated: Task[]): void {
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
const existing = new Map(this.tasks);
|
|
380
|
+
for (const task of updated) {
|
|
381
|
+
const prev = existing.get(task.id);
|
|
382
|
+
if (!prev) {
|
|
383
|
+
task.createdAt = task.createdAt ?? now;
|
|
384
|
+
task.updatedAt = now;
|
|
385
|
+
if (task.status === "in_progress") task.startedAt = now;
|
|
386
|
+
if (task.status === "completed") {
|
|
387
|
+
task.startedAt = task.startedAt ?? now;
|
|
388
|
+
task.completedAt = now;
|
|
389
|
+
}
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
task.createdAt = prev.createdAt;
|
|
393
|
+
task.startedAt = prev.startedAt;
|
|
394
|
+
task.completedAt = prev.completedAt;
|
|
395
|
+
task.blocks = task.blocks?.length ? task.blocks : prev.blocks;
|
|
396
|
+
task.blockedBy = task.blockedBy?.length ? task.blockedBy : prev.blockedBy;
|
|
397
|
+
task.metadata = task.metadata && Object.keys(task.metadata).length ? task.metadata : prev.metadata;
|
|
398
|
+
task.updatedAt = now;
|
|
399
|
+
|
|
400
|
+
if (prev.status !== "in_progress" && task.status === "in_progress") task.startedAt = now;
|
|
401
|
+
if (prev.status !== "completed" && task.status === "completed") {
|
|
402
|
+
task.startedAt = task.startedAt ?? now;
|
|
403
|
+
task.completedAt = now;
|
|
404
|
+
}
|
|
405
|
+
if (prev.status === "completed" && task.status !== "completed") task.completedAt = undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|