heartbeat-opencode-plugin 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/README.md +198 -0
- package/config/index.ts +409 -0
- package/heartbeat.config.json +13 -0
- package/index.ts +71 -0
- package/memory/index.ts +307 -0
- package/package.json +44 -0
- package/scheduler/index.ts +966 -0
- package/task-manager/index.ts +459 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { getTasksFilePath, loadConfig } from "../config";
|
|
5
|
+
import { appendLog } from "../memory";
|
|
6
|
+
|
|
7
|
+
export type TaskStatus = "pending" | "running" | "done" | "blocked" | "raised";
|
|
8
|
+
export type TaskPriority = "low" | "normal" | "high" | "critical";
|
|
9
|
+
|
|
10
|
+
export interface Task {
|
|
11
|
+
program: string;
|
|
12
|
+
taskId: string;
|
|
13
|
+
parentTaskId?: string;
|
|
14
|
+
status: TaskStatus;
|
|
15
|
+
description: string;
|
|
16
|
+
priority: TaskPriority;
|
|
17
|
+
references?: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
resolvedAt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TaskFilters {
|
|
24
|
+
program?: string;
|
|
25
|
+
parentTaskId?: string;
|
|
26
|
+
status?: TaskStatus;
|
|
27
|
+
priority?: TaskPriority;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const VALID_STATUSES = new Set<TaskStatus>([
|
|
31
|
+
"pending",
|
|
32
|
+
"running",
|
|
33
|
+
"done",
|
|
34
|
+
"blocked",
|
|
35
|
+
"raised",
|
|
36
|
+
]);
|
|
37
|
+
const VALID_PRIORITIES = new Set<TaskPriority>([
|
|
38
|
+
"low",
|
|
39
|
+
"normal",
|
|
40
|
+
"high",
|
|
41
|
+
"critical",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function nowIso(): string {
|
|
45
|
+
return new Date().toISOString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeStatus(input?: string): TaskStatus | undefined {
|
|
49
|
+
if (!input) return undefined;
|
|
50
|
+
const value = input.toLowerCase() as TaskStatus;
|
|
51
|
+
return VALID_STATUSES.has(value) ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizePriority(input?: string): TaskPriority | undefined {
|
|
55
|
+
if (!input) return undefined;
|
|
56
|
+
const value = input.toLowerCase() as TaskPriority;
|
|
57
|
+
return VALID_PRIORITIES.has(value) ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class TaskStore {
|
|
61
|
+
private readonly filePath: string;
|
|
62
|
+
|
|
63
|
+
constructor(filePath: string) {
|
|
64
|
+
this.filePath = filePath;
|
|
65
|
+
this.ensureFileExists();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private ensureFileExists(): void {
|
|
69
|
+
const dir = path.dirname(this.filePath);
|
|
70
|
+
if (!fs.existsSync(dir)) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(this.filePath)) {
|
|
75
|
+
fs.writeFileSync(this.filePath, JSON.stringify([], null, 2));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
read(): Task[] {
|
|
80
|
+
try {
|
|
81
|
+
const data = fs.readFileSync(this.filePath, "utf-8");
|
|
82
|
+
return JSON.parse(data) as Task[];
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("Failed to read tasks:", error);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
write(tasks: Task[]): void {
|
|
90
|
+
fs.writeFileSync(this.filePath, JSON.stringify(tasks, null, 2));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getFilePath(): string {
|
|
94
|
+
return this.filePath;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let store: TaskStore | null = null;
|
|
99
|
+
|
|
100
|
+
function ensureStore(projectDir?: string, filePath?: string): TaskStore {
|
|
101
|
+
loadConfig(projectDir);
|
|
102
|
+
const resolvedPath = filePath ?? getTasksFilePath();
|
|
103
|
+
|
|
104
|
+
if (store && store.getFilePath() === resolvedPath) {
|
|
105
|
+
return store;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
store = new TaskStore(resolvedPath);
|
|
109
|
+
return store;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function storeInstance(): TaskStore {
|
|
113
|
+
if (!store) {
|
|
114
|
+
return ensureStore();
|
|
115
|
+
}
|
|
116
|
+
return store;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function taskKey(program: string, taskId: string): string {
|
|
120
|
+
return `${program}::${taskId}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function initStore(projectDir?: string, filePath?: string): void {
|
|
124
|
+
ensureStore(projectDir, filePath);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function listTasks(filters?: TaskFilters): Task[] {
|
|
128
|
+
const tasks = storeInstance().read();
|
|
129
|
+
return tasks.filter((task) => {
|
|
130
|
+
if (filters?.program && task.program !== filters.program) return false;
|
|
131
|
+
if (filters?.parentTaskId && task.parentTaskId !== filters.parentTaskId) return false;
|
|
132
|
+
if (filters?.status && task.status !== filters.status) return false;
|
|
133
|
+
if (filters?.priority && task.priority !== filters.priority) return false;
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getTask(program: string, taskId: string): Task | undefined {
|
|
139
|
+
const tasks = storeInstance().read();
|
|
140
|
+
return tasks.find((task) => taskKey(task.program, task.taskId) === taskKey(program, taskId));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createTask(input: {
|
|
144
|
+
program: string;
|
|
145
|
+
taskId: string;
|
|
146
|
+
parentTaskId?: string;
|
|
147
|
+
description: string;
|
|
148
|
+
status?: TaskStatus;
|
|
149
|
+
priority?: TaskPriority;
|
|
150
|
+
references?: string;
|
|
151
|
+
}): Task {
|
|
152
|
+
const instance = storeInstance();
|
|
153
|
+
const tasks = instance.read();
|
|
154
|
+
|
|
155
|
+
const duplicate = tasks.find((task) => taskKey(task.program, task.taskId) === taskKey(input.program, input.taskId));
|
|
156
|
+
if (duplicate) {
|
|
157
|
+
throw new Error(`Task already exists: [${input.program}][${input.taskId}]`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const timestamp = nowIso();
|
|
161
|
+
const task: Task = {
|
|
162
|
+
program: input.program,
|
|
163
|
+
taskId: input.taskId,
|
|
164
|
+
parentTaskId: input.parentTaskId,
|
|
165
|
+
description: input.description,
|
|
166
|
+
status: input.status ?? "pending",
|
|
167
|
+
priority: input.priority ?? "normal",
|
|
168
|
+
references: input.references,
|
|
169
|
+
createdAt: timestamp,
|
|
170
|
+
updatedAt: timestamp,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
tasks.push(task);
|
|
174
|
+
instance.write(tasks);
|
|
175
|
+
appendLog(`[${task.program}][${task.taskId}] Task created with status=${task.status}`);
|
|
176
|
+
return task;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function updateTask(input: {
|
|
180
|
+
program: string;
|
|
181
|
+
taskId: string;
|
|
182
|
+
parentTaskId?: string;
|
|
183
|
+
description?: string;
|
|
184
|
+
status?: TaskStatus;
|
|
185
|
+
priority?: TaskPriority;
|
|
186
|
+
references?: string;
|
|
187
|
+
}): Task | null {
|
|
188
|
+
const instance = storeInstance();
|
|
189
|
+
const tasks = instance.read();
|
|
190
|
+
const index = tasks.findIndex((task) => taskKey(task.program, task.taskId) === taskKey(input.program, input.taskId));
|
|
191
|
+
|
|
192
|
+
if (index === -1) return null;
|
|
193
|
+
|
|
194
|
+
const existing = tasks[index];
|
|
195
|
+
const updated: Task = {
|
|
196
|
+
...existing,
|
|
197
|
+
...("parentTaskId" in input ? { parentTaskId: input.parentTaskId } : {}),
|
|
198
|
+
...("description" in input ? { description: input.description ?? existing.description } : {}),
|
|
199
|
+
...("status" in input ? { status: input.status ?? existing.status } : {}),
|
|
200
|
+
...("priority" in input ? { priority: input.priority ?? existing.priority } : {}),
|
|
201
|
+
...("references" in input ? { references: input.references } : {}),
|
|
202
|
+
updatedAt: nowIso(),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (updated.status === "done" && !updated.resolvedAt) {
|
|
206
|
+
updated.resolvedAt = updated.updatedAt;
|
|
207
|
+
}
|
|
208
|
+
if (updated.status !== "done") {
|
|
209
|
+
delete updated.resolvedAt;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
tasks[index] = updated;
|
|
213
|
+
instance.write(tasks);
|
|
214
|
+
appendLog(`[${updated.program}][${updated.taskId}] Task updated status=${updated.status}`);
|
|
215
|
+
return updated;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function markTaskDone(program: string, taskId: string): Task | null {
|
|
219
|
+
return updateTask({
|
|
220
|
+
program,
|
|
221
|
+
taskId,
|
|
222
|
+
status: "done",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function deleteTask(program: string, taskId: string): boolean {
|
|
227
|
+
const instance = storeInstance();
|
|
228
|
+
const tasks = instance.read();
|
|
229
|
+
const filtered = tasks.filter((task) => taskKey(task.program, task.taskId) !== taskKey(program, taskId));
|
|
230
|
+
|
|
231
|
+
if (filtered.length === tasks.length) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
instance.write(filtered);
|
|
236
|
+
appendLog(`[${program}][${taskId}] Task deleted`);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getTaskStorePath(): string {
|
|
241
|
+
return storeInstance().getFilePath();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const TaskManagerPlugin: Plugin = async (ctx) => {
|
|
245
|
+
initStore(ctx.directory);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
tool: {
|
|
249
|
+
task_create: tool({
|
|
250
|
+
description:
|
|
251
|
+
"Create a task tracked by [program][taskId]. Use this before running a cron cycle.",
|
|
252
|
+
args: {
|
|
253
|
+
program: tool.schema.string().describe("Program name used for task and memory tags"),
|
|
254
|
+
taskId: tool.schema.string().describe("Task identifier in kebab-case"),
|
|
255
|
+
parentTaskId: tool.schema
|
|
256
|
+
.string()
|
|
257
|
+
.optional()
|
|
258
|
+
.describe("Optional parent task ID to create task hierarchies"),
|
|
259
|
+
description: tool.schema.string().describe("Human description of the task"),
|
|
260
|
+
status: tool.schema
|
|
261
|
+
.string()
|
|
262
|
+
.optional()
|
|
263
|
+
.describe("Optional status: pending, running, blocked, raised, done"),
|
|
264
|
+
priority: tool.schema
|
|
265
|
+
.string()
|
|
266
|
+
.optional()
|
|
267
|
+
.describe("Optional priority: low, normal, high, critical"),
|
|
268
|
+
references: tool.schema
|
|
269
|
+
.string()
|
|
270
|
+
.optional()
|
|
271
|
+
.describe("Optional file paths or notes for this task"),
|
|
272
|
+
},
|
|
273
|
+
async execute(args) {
|
|
274
|
+
const status = normalizeStatus(args.status);
|
|
275
|
+
if (args.status && !status) {
|
|
276
|
+
return `Invalid status: ${args.status}. Use pending|running|blocked|raised|done`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const priority = normalizePriority(args.priority);
|
|
280
|
+
if (args.priority && !priority) {
|
|
281
|
+
return `Invalid priority: ${args.priority}. Use low|normal|high|critical`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const task = createTask({
|
|
286
|
+
program: args.program,
|
|
287
|
+
taskId: args.taskId,
|
|
288
|
+
parentTaskId: args.parentTaskId,
|
|
289
|
+
description: args.description,
|
|
290
|
+
status,
|
|
291
|
+
priority,
|
|
292
|
+
references: args.references,
|
|
293
|
+
});
|
|
294
|
+
return `Created task [${task.program}][${task.taskId}] status=${task.status} priority=${task.priority}`;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return `Failed to create task: ${error instanceof Error ? error.message : String(error)}`;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
|
|
301
|
+
task_list: tool({
|
|
302
|
+
description: "List tasks, optionally filtered by program/status/priority.",
|
|
303
|
+
args: {
|
|
304
|
+
program: tool.schema.string().optional().describe("Optional program filter"),
|
|
305
|
+
parentTaskId: tool.schema.string().optional().describe("Optional parent task filter"),
|
|
306
|
+
status: tool.schema.string().optional().describe("Optional status filter"),
|
|
307
|
+
priority: tool.schema.string().optional().describe("Optional priority filter"),
|
|
308
|
+
},
|
|
309
|
+
async execute(args) {
|
|
310
|
+
const status = normalizeStatus(args.status);
|
|
311
|
+
if (args.status && !status) {
|
|
312
|
+
return `Invalid status: ${args.status}. Use pending|running|blocked|raised|done`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const priority = normalizePriority(args.priority);
|
|
316
|
+
if (args.priority && !priority) {
|
|
317
|
+
return `Invalid priority: ${args.priority}. Use low|normal|high|critical`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const tasks = listTasks({
|
|
321
|
+
program: args.program,
|
|
322
|
+
parentTaskId: args.parentTaskId,
|
|
323
|
+
status,
|
|
324
|
+
priority,
|
|
325
|
+
});
|
|
326
|
+
if (tasks.length === 0) {
|
|
327
|
+
return "No matching tasks found.";
|
|
328
|
+
}
|
|
329
|
+
return JSON.stringify(tasks, null, 2);
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
|
|
333
|
+
task_get: tool({
|
|
334
|
+
description: "Get a single task by [program][taskId].",
|
|
335
|
+
args: {
|
|
336
|
+
program: tool.schema.string().describe("Program name"),
|
|
337
|
+
taskId: tool.schema.string().describe("Task ID"),
|
|
338
|
+
},
|
|
339
|
+
async execute(args) {
|
|
340
|
+
const task = getTask(args.program, args.taskId);
|
|
341
|
+
if (!task) {
|
|
342
|
+
return `Task not found: [${args.program}][${args.taskId}]`;
|
|
343
|
+
}
|
|
344
|
+
return JSON.stringify(task, null, 2);
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
|
|
348
|
+
task_update: tool({
|
|
349
|
+
description: "Update task metadata or status.",
|
|
350
|
+
args: {
|
|
351
|
+
program: tool.schema.string().describe("Program name"),
|
|
352
|
+
taskId: tool.schema.string().describe("Task ID"),
|
|
353
|
+
parentTaskId: tool.schema
|
|
354
|
+
.string()
|
|
355
|
+
.optional()
|
|
356
|
+
.describe("Updated parent task ID"),
|
|
357
|
+
description: tool.schema.string().optional().describe("Updated description"),
|
|
358
|
+
status: tool.schema.string().optional().describe("Updated status"),
|
|
359
|
+
priority: tool.schema.string().optional().describe("Updated priority"),
|
|
360
|
+
references: tool.schema.string().optional().describe("Updated references"),
|
|
361
|
+
},
|
|
362
|
+
async execute(args) {
|
|
363
|
+
const status = normalizeStatus(args.status);
|
|
364
|
+
if (args.status && !status) {
|
|
365
|
+
return `Invalid status: ${args.status}. Use pending|running|blocked|raised|done`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const priority = normalizePriority(args.priority);
|
|
369
|
+
if (args.priority && !priority) {
|
|
370
|
+
return `Invalid priority: ${args.priority}. Use low|normal|high|critical`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const updates: {
|
|
374
|
+
program: string;
|
|
375
|
+
taskId: string;
|
|
376
|
+
parentTaskId?: string;
|
|
377
|
+
description?: string;
|
|
378
|
+
status?: TaskStatus;
|
|
379
|
+
priority?: TaskPriority;
|
|
380
|
+
references?: string;
|
|
381
|
+
} = {
|
|
382
|
+
program: args.program,
|
|
383
|
+
taskId: args.taskId,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (args.parentTaskId !== undefined) {
|
|
387
|
+
updates.parentTaskId = args.parentTaskId;
|
|
388
|
+
}
|
|
389
|
+
if (args.description !== undefined) {
|
|
390
|
+
updates.description = args.description;
|
|
391
|
+
}
|
|
392
|
+
if (args.status !== undefined) {
|
|
393
|
+
updates.status = status;
|
|
394
|
+
}
|
|
395
|
+
if (args.priority !== undefined) {
|
|
396
|
+
updates.priority = priority;
|
|
397
|
+
}
|
|
398
|
+
if (args.references !== undefined) {
|
|
399
|
+
updates.references = args.references;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const task = updateTask(updates);
|
|
403
|
+
|
|
404
|
+
if (!task) {
|
|
405
|
+
return `Task not found: [${args.program}][${args.taskId}]`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return `Updated [${task.program}][${task.taskId}] status=${task.status} priority=${task.priority}`;
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
|
|
412
|
+
task_mark_done: tool({
|
|
413
|
+
description: "Mark a task as done after it is resolved.",
|
|
414
|
+
args: {
|
|
415
|
+
program: tool.schema.string().describe("Program name"),
|
|
416
|
+
taskId: tool.schema.string().describe("Task ID"),
|
|
417
|
+
},
|
|
418
|
+
async execute(args) {
|
|
419
|
+
const task = markTaskDone(args.program, args.taskId);
|
|
420
|
+
if (!task) {
|
|
421
|
+
return `Task not found: [${args.program}][${args.taskId}]`;
|
|
422
|
+
}
|
|
423
|
+
return `Marked done [${task.program}][${task.taskId}]`;
|
|
424
|
+
},
|
|
425
|
+
}),
|
|
426
|
+
|
|
427
|
+
task_delete: tool({
|
|
428
|
+
description: "Delete a task from the task manager.",
|
|
429
|
+
args: {
|
|
430
|
+
program: tool.schema.string().describe("Program name"),
|
|
431
|
+
taskId: tool.schema.string().describe("Task ID"),
|
|
432
|
+
},
|
|
433
|
+
async execute(args) {
|
|
434
|
+
const deleted = deleteTask(args.program, args.taskId);
|
|
435
|
+
if (!deleted) {
|
|
436
|
+
return `Task not found: [${args.program}][${args.taskId}]`;
|
|
437
|
+
}
|
|
438
|
+
return `Deleted [${args.program}][${args.taskId}]`;
|
|
439
|
+
},
|
|
440
|
+
}),
|
|
441
|
+
|
|
442
|
+
task_store_info: tool({
|
|
443
|
+
description: "Get task store file path and current task count.",
|
|
444
|
+
args: {},
|
|
445
|
+
async execute() {
|
|
446
|
+
const tasks = listTasks();
|
|
447
|
+
return JSON.stringify(
|
|
448
|
+
{
|
|
449
|
+
path: getTaskStorePath(),
|
|
450
|
+
count: tasks.length,
|
|
451
|
+
},
|
|
452
|
+
null,
|
|
453
|
+
2,
|
|
454
|
+
);
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
};
|