@versdotsh/reef 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.github/workflows/test.yml +47 -0
  2. package/README.md +257 -0
  3. package/bun.lock +587 -0
  4. package/examples/services/board/board.test.ts +215 -0
  5. package/examples/services/board/index.ts +155 -0
  6. package/examples/services/board/routes.ts +335 -0
  7. package/examples/services/board/store.ts +329 -0
  8. package/examples/services/board/tools.ts +214 -0
  9. package/examples/services/commits/commits.test.ts +74 -0
  10. package/examples/services/commits/index.ts +14 -0
  11. package/examples/services/commits/routes.ts +43 -0
  12. package/examples/services/commits/store.ts +114 -0
  13. package/examples/services/feed/behaviors.ts +23 -0
  14. package/examples/services/feed/feed.test.ts +101 -0
  15. package/examples/services/feed/index.ts +117 -0
  16. package/examples/services/feed/routes.ts +224 -0
  17. package/examples/services/feed/store.ts +194 -0
  18. package/examples/services/feed/tools.ts +83 -0
  19. package/examples/services/journal/index.ts +15 -0
  20. package/examples/services/journal/journal.test.ts +57 -0
  21. package/examples/services/journal/routes.ts +45 -0
  22. package/examples/services/journal/store.ts +119 -0
  23. package/examples/services/journal/tools.ts +32 -0
  24. package/examples/services/log/index.ts +15 -0
  25. package/examples/services/log/log.test.ts +70 -0
  26. package/examples/services/log/routes.ts +44 -0
  27. package/examples/services/log/store.ts +105 -0
  28. package/examples/services/log/tools.ts +57 -0
  29. package/examples/services/registry/behaviors.ts +128 -0
  30. package/examples/services/registry/index.ts +37 -0
  31. package/examples/services/registry/registry.test.ts +135 -0
  32. package/examples/services/registry/routes.ts +76 -0
  33. package/examples/services/registry/store.ts +224 -0
  34. package/examples/services/registry/tools.ts +116 -0
  35. package/examples/services/reports/index.ts +14 -0
  36. package/examples/services/reports/reports.test.ts +75 -0
  37. package/examples/services/reports/routes.ts +42 -0
  38. package/examples/services/reports/store.ts +110 -0
  39. package/examples/services/ui/auth.ts +61 -0
  40. package/examples/services/ui/index.ts +16 -0
  41. package/examples/services/ui/routes.ts +160 -0
  42. package/examples/services/ui/static/app.js +369 -0
  43. package/examples/services/ui/static/index.html +42 -0
  44. package/examples/services/ui/static/style.css +157 -0
  45. package/examples/services/usage/behaviors.ts +166 -0
  46. package/examples/services/usage/index.ts +19 -0
  47. package/examples/services/usage/routes.ts +53 -0
  48. package/examples/services/usage/store.ts +341 -0
  49. package/examples/services/usage/tools.ts +75 -0
  50. package/examples/services/usage/usage.test.ts +91 -0
  51. package/package.json +29 -0
  52. package/services/agent/index.ts +465 -0
  53. package/services/board/index.ts +155 -0
  54. package/services/board/routes.ts +335 -0
  55. package/services/board/store.ts +329 -0
  56. package/services/board/tools.ts +214 -0
  57. package/services/docs/index.ts +391 -0
  58. package/services/feed/behaviors.ts +23 -0
  59. package/services/feed/index.ts +117 -0
  60. package/services/feed/routes.ts +224 -0
  61. package/services/feed/store.ts +194 -0
  62. package/services/feed/tools.ts +83 -0
  63. package/services/installer/index.ts +574 -0
  64. package/services/services/index.ts +165 -0
  65. package/services/ui/auth.ts +61 -0
  66. package/services/ui/index.ts +16 -0
  67. package/services/ui/routes.ts +160 -0
  68. package/services/ui/static/app.js +369 -0
  69. package/services/ui/static/index.html +42 -0
  70. package/services/ui/static/style.css +157 -0
  71. package/skills/create-service/SKILL.md +698 -0
  72. package/src/core/auth.ts +28 -0
  73. package/src/core/client.ts +99 -0
  74. package/src/core/discover.ts +152 -0
  75. package/src/core/events.ts +44 -0
  76. package/src/core/extension.ts +66 -0
  77. package/src/core/server.ts +262 -0
  78. package/src/core/testing.ts +155 -0
  79. package/src/core/types.ts +194 -0
  80. package/src/extension.ts +16 -0
  81. package/src/main.ts +11 -0
  82. package/tests/server.test.ts +1338 -0
  83. package/tsconfig.json +29 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Board HTTP routes.
3
+ *
4
+ * Receives a store instance and a lazy event bus getter.
5
+ * Emits board:task_created, board:task_updated, board:task_deleted.
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import type { BoardStore, TaskFilters, TaskStatus, AddArtifactInput } from "./store.js";
10
+ import { NotFoundError, ValidationError } from "./store.js";
11
+ import type { ServiceEventBus } from "../src/core/events.js";
12
+
13
+ export function createRoutes(
14
+ store: BoardStore,
15
+ getEvents: () => ServiceEventBus | null = () => null,
16
+ ): Hono {
17
+ const routes = new Hono();
18
+
19
+ // Create a task
20
+ routes.post("/tasks", async (c) => {
21
+ try {
22
+ const body = await c.req.json();
23
+ const task = store.createTask(body);
24
+ getEvents()?.fire("board:task_created", { task });
25
+ return c.json(task, 201);
26
+ } catch (e) {
27
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
28
+ throw e;
29
+ }
30
+ });
31
+
32
+ // List tasks
33
+ routes.get("/tasks", (c) => {
34
+ const filters: TaskFilters = {};
35
+ const status = c.req.query("status");
36
+ const assignee = c.req.query("assignee");
37
+ const tag = c.req.query("tag");
38
+
39
+ if (status) filters.status = status as TaskStatus;
40
+ if (assignee) filters.assignee = assignee;
41
+ if (tag) filters.tag = tag;
42
+
43
+ const tasks = store.listTasks(filters);
44
+ return c.json({ tasks, count: tasks.length });
45
+ });
46
+
47
+ // Get a single task
48
+ routes.get("/tasks/:id", (c) => {
49
+ const task = store.getTask(c.req.param("id"));
50
+ if (!task) return c.json({ error: "task not found" }, 404);
51
+ return c.json(task);
52
+ });
53
+
54
+ // Update a task
55
+ routes.patch("/tasks/:id", async (c) => {
56
+ try {
57
+ const body = await c.req.json();
58
+ const task = store.updateTask(c.req.param("id"), body);
59
+ getEvents()?.fire("board:task_updated", { task, changes: body });
60
+ return c.json(task);
61
+ } catch (e) {
62
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
63
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
64
+ throw e;
65
+ }
66
+ });
67
+
68
+ // Delete a task
69
+ routes.delete("/tasks/:id", (c) => {
70
+ const deleted = store.deleteTask(c.req.param("id"));
71
+ if (!deleted) return c.json({ error: "task not found" }, 404);
72
+ getEvents()?.fire("board:task_deleted", { taskId: c.req.param("id") });
73
+ return c.json({ deleted: true });
74
+ });
75
+
76
+ // Bump a task's score
77
+ routes.post("/tasks/:id/bump", (c) => {
78
+ try {
79
+ const task = store.bumpTask(c.req.param("id"));
80
+ return c.json(task);
81
+ } catch (e) {
82
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
83
+ throw e;
84
+ }
85
+ });
86
+
87
+ // Add a note to a task
88
+ routes.post("/tasks/:id/notes", async (c) => {
89
+ try {
90
+ const body = await c.req.json();
91
+ const note = store.addNote(c.req.param("id"), body);
92
+ return c.json(note, 201);
93
+ } catch (e) {
94
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
95
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
96
+ throw e;
97
+ }
98
+ });
99
+
100
+ // Get notes for a task
101
+ routes.get("/tasks/:id/notes", (c) => {
102
+ try {
103
+ const notes = store.getNotes(c.req.param("id"));
104
+ return c.json({ notes, count: notes.length });
105
+ } catch (e) {
106
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
107
+ throw e;
108
+ }
109
+ });
110
+
111
+ // Add artifacts to a task
112
+ routes.post("/tasks/:id/artifacts", async (c) => {
113
+ try {
114
+ const body = await c.req.json();
115
+ store.addArtifacts(c.req.param("id"), body.artifacts);
116
+ const task = store.getTask(c.req.param("id"));
117
+ return c.json(task, 201);
118
+ } catch (e) {
119
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
120
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
121
+ throw e;
122
+ }
123
+ });
124
+
125
+ // Submit a task for review
126
+ routes.post("/tasks/:id/review", async (c) => {
127
+ try {
128
+ const body = await c.req.json();
129
+ const id = c.req.param("id");
130
+
131
+ if (!body.summary?.trim()) {
132
+ return c.json({ error: "summary is required" }, 400);
133
+ }
134
+
135
+ store.updateTask(id, { status: "in_review" });
136
+
137
+ const author = body.reviewedBy?.trim() || "unknown";
138
+ store.addNote(id, { author, content: body.summary.trim(), type: "update" });
139
+
140
+ if (body.artifacts?.length) {
141
+ const artifacts = body.artifacts.map((a: AddArtifactInput) => ({
142
+ ...a,
143
+ addedBy: a.addedBy || author,
144
+ }));
145
+ store.addArtifacts(id, artifacts);
146
+ }
147
+
148
+ const task = store.getTask(id);
149
+ getEvents()?.fire("board:task_updated", { task, changes: { status: "in_review" } });
150
+ return c.json(task);
151
+ } catch (e) {
152
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
153
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
154
+ throw e;
155
+ }
156
+ });
157
+
158
+ // Approve a reviewed task
159
+ routes.post("/tasks/:id/approve", async (c) => {
160
+ try {
161
+ const body = await c.req.json();
162
+ const id = c.req.param("id");
163
+ const approvedBy = body.approvedBy?.trim() || "unknown";
164
+ const comment = body.comment?.trim() || "";
165
+
166
+ store.updateTask(id, { status: "done" });
167
+ store.addNote(id, {
168
+ author: approvedBy,
169
+ content: comment ? `Approved by ${approvedBy}: ${comment}` : `Approved by ${approvedBy}`,
170
+ type: "update",
171
+ });
172
+
173
+ const task = store.getTask(id);
174
+ getEvents()?.fire("board:task_updated", { task, changes: { status: "done" } });
175
+ return c.json(task);
176
+ } catch (e) {
177
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
178
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
179
+ throw e;
180
+ }
181
+ });
182
+
183
+ // Reject a reviewed task
184
+ routes.post("/tasks/:id/reject", async (c) => {
185
+ try {
186
+ const body = await c.req.json();
187
+ const id = c.req.param("id");
188
+
189
+ if (!body.reason?.trim()) return c.json({ error: "reason is required" }, 400);
190
+
191
+ const rejectedBy = body.rejectedBy?.trim() || "unknown";
192
+ store.updateTask(id, { status: "open" });
193
+ store.addNote(id, {
194
+ author: rejectedBy,
195
+ content: `Rejected by ${rejectedBy}: ${body.reason.trim()}`,
196
+ type: "update",
197
+ });
198
+
199
+ const task = store.getTask(id);
200
+ getEvents()?.fire("board:task_updated", { task, changes: { status: "open" } });
201
+ return c.json(task);
202
+ } catch (e) {
203
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
204
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
205
+ throw e;
206
+ }
207
+ });
208
+
209
+ // List tasks in review
210
+ routes.get("/review", (c) => {
211
+ const tasks = store.listTasks({ status: "in_review" });
212
+ tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
213
+ return c.json({ tasks, count: tasks.length });
214
+ });
215
+
216
+ // ─── UI Panel ───
217
+
218
+ routes.get("/_panel", (c) => {
219
+ return c.html(`
220
+ <style>
221
+ .panel-board { padding: 8px; }
222
+ .panel-board .status-group { margin-bottom: 12px; }
223
+ .panel-board .status-label {
224
+ font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
225
+ padding: 4px 8px; color: var(--text-dim, #666); display: flex; align-items: center; gap: 8px;
226
+ }
227
+ .panel-board .status-label .count {
228
+ background: var(--bg-card, #1a1a1a); padding: 1px 6px; border-radius: 3px; font-size: 10px;
229
+ }
230
+ .panel-board .task-card {
231
+ background: var(--bg-card, #1a1a1a); border: 1px solid var(--border, #2a2a2a);
232
+ border-radius: 4px; padding: 10px 12px; margin: 4px 0; cursor: pointer;
233
+ transition: border-color 0.15s;
234
+ }
235
+ .panel-board .task-card:hover { border-color: #444; }
236
+ .panel-board .task-card .title { color: var(--text-bright, #eee); font-weight: 500; margin-bottom: 4px; }
237
+ .panel-board .task-card .meta {
238
+ font-size: 11px; color: var(--text-dim, #666); display: flex; gap: 12px; flex-wrap: wrap;
239
+ }
240
+ .panel-board .task-card .tag {
241
+ background: #222; padding: 1px 6px; border-radius: 3px; font-size: 10px; color: var(--blue, #5af);
242
+ }
243
+ .panel-board .task-card .assignee { color: var(--purple, #a7f); }
244
+ .panel-board .task-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
245
+ .panel-board .bump-btn {
246
+ background: none; border: 1px solid var(--border, #2a2a2a); border-radius: 3px;
247
+ color: var(--text-dim, #666); cursor: pointer; font-size: 11px; padding: 2px 6px;
248
+ font-family: inherit; transition: all 0.15s;
249
+ }
250
+ .panel-board .bump-btn:hover { border-color: var(--accent, #4f9); color: var(--accent, #4f9); }
251
+ .panel-board .score { font-weight: 700; color: var(--yellow, #fd0); }
252
+ .panel-board .score.dim { color: var(--text-dim, #666); font-weight: 400; }
253
+ .panel-board .notes { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border, #2a2a2a); font-size: 11px; display: none; }
254
+ .panel-board .task-card.expanded .notes { display: block; }
255
+ .panel-board .note { padding: 2px 0; color: var(--text-dim, #666); }
256
+ .panel-board .note-author { color: var(--purple, #a7f); }
257
+ .panel-board .note-type { color: var(--orange, #f93); font-size: 10px; }
258
+ .panel-board .status-open { border-left: 3px solid var(--blue, #5af); }
259
+ .panel-board .status-in_progress { border-left: 3px solid var(--yellow, #fd0); }
260
+ .panel-board .status-in_review { border-left: 3px solid var(--orange, #f93); }
261
+ .panel-board .status-blocked { border-left: 3px solid var(--red, #f55); }
262
+ .panel-board .status-done { border-left: 3px solid var(--accent, #4f9); opacity: 0.6; }
263
+ .panel-board .empty { color: var(--text-dim, #666); font-style: italic; padding: 20px; text-align: center; }
264
+ </style>
265
+
266
+ <div class="panel-board" id="board-root">
267
+ <div class="empty">Loading board…</div>
268
+ </div>
269
+
270
+ <script>
271
+ (function() {
272
+ const root = document.getElementById('board-root');
273
+ const API = typeof PANEL_API !== 'undefined' ? PANEL_API : '/ui/api';
274
+ const ORDER = ['open', 'in_progress', 'in_review', 'blocked', 'done'];
275
+
276
+ function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
277
+ function ago(iso) {
278
+ const ms = Date.now() - new Date(iso).getTime();
279
+ if (ms < 60000) return Math.floor(ms/1000) + 's ago';
280
+ if (ms < 3600000) return Math.floor(ms/60000) + 'm ago';
281
+ if (ms < 86400000) return Math.floor(ms/3600000) + 'h ago';
282
+ return Math.floor(ms/86400000) + 'd ago';
283
+ }
284
+
285
+ async function load() {
286
+ try {
287
+ const res = await fetch(API + '/board/tasks');
288
+ if (!res.ok) throw new Error(res.status);
289
+ const data = await res.json();
290
+ render(data.tasks || []);
291
+ } catch (e) {
292
+ root.innerHTML = '<div class="empty">Board unavailable: ' + esc(e.message) + '</div>';
293
+ }
294
+ }
295
+
296
+ function render(tasks) {
297
+ const grouped = {};
298
+ ORDER.forEach(s => grouped[s] = []);
299
+ tasks.forEach(t => (grouped[t.status] || grouped.open).push(t));
300
+
301
+ let html = '';
302
+ for (const status of ORDER) {
303
+ const items = grouped[status];
304
+ if (!items.length) continue;
305
+ html += '<div class="status-group"><div class="status-label">'
306
+ + status.replace(/_/g, ' ') + ' <span class="count">' + items.length + '</span></div>';
307
+ for (const t of items) {
308
+ const tags = (t.tags||[]).map(tag => '<span class="tag">' + esc(tag) + '</span>').join('');
309
+ const assignee = t.assignee ? '<span class="assignee">@' + esc(t.assignee) + '</span>' : '';
310
+ const notes = (t.notes||[]).map(n =>
311
+ '<div class="note"><span class="note-author">@' + esc(n.author) + '</span> <span class="note-type">' + esc(n.type) + '</span> ' + esc(n.content) + '</div>'
312
+ ).join('');
313
+ const score = t.score || 0;
314
+ html += '<div class="task-card status-' + status + '" onclick="this.classList.toggle(\\'expanded\\')">'
315
+ + '<div class="task-top"><div class="title">' + esc(t.title) + '</div>'
316
+ + '<button class="bump-btn" onclick="event.stopPropagation();fetch(\\'' + API + '/board/tasks/' + t.id + '/bump\\',{method:\\'POST\\'}).then(()=>window._boardLoad())"><span class="score' + (score ? '' : ' dim') + '">' + score + '</span></button></div>'
317
+ + '<div class="meta">' + assignee + tags + '<span>' + ago(t.createdAt) + '</span></div>'
318
+ + (notes ? '<div class="notes">' + notes + '</div>' : '')
319
+ + '</div>';
320
+ }
321
+ html += '</div>';
322
+ }
323
+ root.innerHTML = html || '<div class="empty">No tasks</div>';
324
+ }
325
+
326
+ window._boardLoad = load;
327
+ load();
328
+ setInterval(load, 10000);
329
+ })();
330
+ </script>
331
+ `);
332
+ });
333
+
334
+ return routes;
335
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Board store — task tracking with notes and artifacts.
3
+ *
4
+ * Uses Bun's file APIs for persistence. JSON file with debounced writes.
5
+ */
6
+
7
+ import { ulid } from "ulid";
8
+ import { existsSync, mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ // =============================================================================
12
+ // Types
13
+ // =============================================================================
14
+
15
+ export type TaskStatus = "open" | "in_progress" | "in_review" | "blocked" | "done";
16
+ export type NoteType = "finding" | "blocker" | "question" | "update";
17
+ export type ArtifactType = "branch" | "report" | "deploy" | "diff" | "file" | "url";
18
+
19
+ export interface Note {
20
+ id: string;
21
+ author: string;
22
+ content: string;
23
+ type: NoteType;
24
+ createdAt: string;
25
+ }
26
+
27
+ export interface Artifact {
28
+ type: ArtifactType;
29
+ url: string;
30
+ label: string;
31
+ addedAt: string;
32
+ addedBy?: string;
33
+ }
34
+
35
+ export interface Task {
36
+ id: string;
37
+ title: string;
38
+ description?: string;
39
+ status: TaskStatus;
40
+ assignee?: string;
41
+ tags: string[];
42
+ dependencies: string[];
43
+ createdBy: string;
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ notes: Note[];
47
+ artifacts: Artifact[];
48
+ score: number;
49
+ }
50
+
51
+ export interface CreateTaskInput {
52
+ title: string;
53
+ description?: string;
54
+ status?: TaskStatus;
55
+ assignee?: string;
56
+ tags?: string[];
57
+ dependencies?: string[];
58
+ createdBy: string;
59
+ }
60
+
61
+ export interface UpdateTaskInput {
62
+ title?: string;
63
+ description?: string;
64
+ status?: TaskStatus;
65
+ assignee?: string | null;
66
+ tags?: string[];
67
+ dependencies?: string[];
68
+ }
69
+
70
+ export interface AddNoteInput {
71
+ author: string;
72
+ content: string;
73
+ type: NoteType;
74
+ }
75
+
76
+ export interface AddArtifactInput {
77
+ type: ArtifactType;
78
+ url: string;
79
+ label: string;
80
+ addedBy?: string;
81
+ }
82
+
83
+ export interface TaskFilters {
84
+ status?: TaskStatus;
85
+ assignee?: string;
86
+ tag?: string;
87
+ }
88
+
89
+ // =============================================================================
90
+ // Validation
91
+ // =============================================================================
92
+
93
+ const VALID_STATUSES = new Set<string>(["open", "in_progress", "in_review", "blocked", "done"]);
94
+ const VALID_NOTE_TYPES = new Set<string>(["finding", "blocker", "question", "update"]);
95
+ const VALID_ARTIFACT_TYPES = new Set<string>(["branch", "report", "deploy", "diff", "file", "url"]);
96
+
97
+ // =============================================================================
98
+ // Errors
99
+ // =============================================================================
100
+
101
+ export class NotFoundError extends Error {
102
+ constructor(message: string) {
103
+ super(message);
104
+ this.name = "NotFoundError";
105
+ }
106
+ }
107
+
108
+ export class ValidationError extends Error {
109
+ constructor(message: string) {
110
+ super(message);
111
+ this.name = "ValidationError";
112
+ }
113
+ }
114
+
115
+ // =============================================================================
116
+ // Store
117
+ // =============================================================================
118
+
119
+ export class BoardStore {
120
+ private tasks = new Map<string, Task>();
121
+ private filePath: string;
122
+ private writeTimer: ReturnType<typeof setTimeout> | null = null;
123
+
124
+ constructor(filePath = "data/board.json") {
125
+ this.filePath = filePath;
126
+ this.load();
127
+ }
128
+
129
+ private load(): void {
130
+ try {
131
+ const file = Bun.file(this.filePath);
132
+ if (existsSync(this.filePath)) {
133
+ // Synchronous read at startup — Bun.file().text() is async,
134
+ // so we use the fs import path for the initial load
135
+ const raw = require("node:fs").readFileSync(this.filePath, "utf-8");
136
+ const data = JSON.parse(raw);
137
+ if (Array.isArray(data.tasks)) {
138
+ for (const t of data.tasks) {
139
+ if (!t.artifacts) t.artifacts = [];
140
+ if (t.score === undefined) t.score = 0;
141
+ this.tasks.set(t.id, t);
142
+ }
143
+ }
144
+ }
145
+ } catch {
146
+ this.tasks = new Map();
147
+ }
148
+ }
149
+
150
+ private scheduleSave(): void {
151
+ if (this.writeTimer) return;
152
+ this.writeTimer = setTimeout(() => {
153
+ this.writeTimer = null;
154
+ this.flushAsync();
155
+ }, 100);
156
+ }
157
+
158
+ private async flushAsync(): Promise<void> {
159
+ const dir = dirname(this.filePath);
160
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
161
+ const data = JSON.stringify({ tasks: Array.from(this.tasks.values()) }, null, 2);
162
+ await Bun.write(this.filePath, data);
163
+ }
164
+
165
+ flush(): void {
166
+ if (this.writeTimer) {
167
+ clearTimeout(this.writeTimer);
168
+ this.writeTimer = null;
169
+ }
170
+ const dir = dirname(this.filePath);
171
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
172
+ const data = JSON.stringify({ tasks: Array.from(this.tasks.values()) }, null, 2);
173
+ require("node:fs").writeFileSync(this.filePath, data, "utf-8");
174
+ }
175
+
176
+ // --- CRUD ---
177
+
178
+ createTask(input: CreateTaskInput): Task {
179
+ if (!input.title?.trim()) throw new ValidationError("title is required");
180
+ if (!input.createdBy?.trim()) throw new ValidationError("createdBy is required");
181
+ if (input.status && !VALID_STATUSES.has(input.status)) {
182
+ throw new ValidationError(`invalid status: ${input.status}`);
183
+ }
184
+
185
+ const now = new Date().toISOString();
186
+ const task: Task = {
187
+ id: ulid(),
188
+ title: input.title.trim(),
189
+ description: input.description?.trim(),
190
+ status: input.status || "open",
191
+ assignee: input.assignee?.trim(),
192
+ tags: input.tags || [],
193
+ dependencies: input.dependencies || [],
194
+ createdBy: input.createdBy.trim(),
195
+ createdAt: now,
196
+ updatedAt: now,
197
+ notes: [],
198
+ artifacts: [],
199
+ score: 0,
200
+ };
201
+
202
+ this.tasks.set(task.id, task);
203
+ this.scheduleSave();
204
+ return task;
205
+ }
206
+
207
+ getTask(id: string): Task | undefined {
208
+ return this.tasks.get(id);
209
+ }
210
+
211
+ listTasks(filters?: TaskFilters): Task[] {
212
+ let results = Array.from(this.tasks.values());
213
+
214
+ if (filters?.status) results = results.filter((t) => t.status === filters.status);
215
+ if (filters?.assignee) results = results.filter((t) => t.assignee === filters.assignee);
216
+ if (filters?.tag) results = results.filter((t) => t.tags.includes(filters.tag!));
217
+
218
+ results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
219
+ return results;
220
+ }
221
+
222
+ updateTask(id: string, input: UpdateTaskInput): Task {
223
+ const task = this.tasks.get(id);
224
+ if (!task) throw new NotFoundError("task not found");
225
+
226
+ if (input.status !== undefined && !VALID_STATUSES.has(input.status)) {
227
+ throw new ValidationError(`invalid status: ${input.status}`);
228
+ }
229
+ if (input.title !== undefined) {
230
+ if (!input.title.trim()) throw new ValidationError("title cannot be empty");
231
+ task.title = input.title.trim();
232
+ }
233
+ if (input.description !== undefined) task.description = input.description?.trim();
234
+ if (input.status !== undefined) task.status = input.status;
235
+ if (input.assignee !== undefined) {
236
+ task.assignee = input.assignee === null ? undefined : input.assignee?.trim();
237
+ }
238
+ if (input.tags !== undefined) task.tags = input.tags;
239
+ if (input.dependencies !== undefined) task.dependencies = input.dependencies;
240
+
241
+ task.updatedAt = new Date().toISOString();
242
+ this.tasks.set(id, task);
243
+ this.scheduleSave();
244
+ return task;
245
+ }
246
+
247
+ deleteTask(id: string): boolean {
248
+ const existed = this.tasks.delete(id);
249
+ if (existed) this.scheduleSave();
250
+ return existed;
251
+ }
252
+
253
+ bumpTask(id: string): Task {
254
+ const task = this.tasks.get(id);
255
+ if (!task) throw new NotFoundError("task not found");
256
+
257
+ task.score = (task.score || 0) + 1;
258
+ task.updatedAt = new Date().toISOString();
259
+ this.tasks.set(id, task);
260
+ this.scheduleSave();
261
+ return task;
262
+ }
263
+
264
+ // --- Notes ---
265
+
266
+ addNote(taskId: string, input: AddNoteInput): Note {
267
+ const task = this.tasks.get(taskId);
268
+ if (!task) throw new NotFoundError("task not found");
269
+
270
+ if (!input.author?.trim()) throw new ValidationError("author is required");
271
+ if (!input.content?.trim()) throw new ValidationError("content is required");
272
+ if (!VALID_NOTE_TYPES.has(input.type)) throw new ValidationError(`invalid note type: ${input.type}`);
273
+
274
+ const note: Note = {
275
+ id: ulid(),
276
+ author: input.author.trim(),
277
+ content: input.content.trim(),
278
+ type: input.type,
279
+ createdAt: new Date().toISOString(),
280
+ };
281
+
282
+ task.notes.push(note);
283
+ task.updatedAt = new Date().toISOString();
284
+ this.tasks.set(taskId, task);
285
+ this.scheduleSave();
286
+ return note;
287
+ }
288
+
289
+ getNotes(taskId: string): Note[] {
290
+ const task = this.tasks.get(taskId);
291
+ if (!task) throw new NotFoundError("task not found");
292
+ return task.notes;
293
+ }
294
+
295
+ // --- Artifacts ---
296
+
297
+ addArtifacts(taskId: string, artifacts: AddArtifactInput[]): Artifact[] {
298
+ const task = this.tasks.get(taskId);
299
+ if (!task) throw new NotFoundError("task not found");
300
+
301
+ if (!Array.isArray(artifacts) || artifacts.length === 0) {
302
+ throw new ValidationError("artifacts array is required and must not be empty");
303
+ }
304
+
305
+ const now = new Date().toISOString();
306
+ const added: Artifact[] = [];
307
+
308
+ for (const a of artifacts) {
309
+ if (!VALID_ARTIFACT_TYPES.has(a.type)) throw new ValidationError(`invalid artifact type: ${a.type}`);
310
+ if (!a.url?.trim()) throw new ValidationError("artifact url is required");
311
+ if (!a.label?.trim()) throw new ValidationError("artifact label is required");
312
+
313
+ const artifact: Artifact = {
314
+ type: a.type,
315
+ url: a.url.trim(),
316
+ label: a.label.trim(),
317
+ addedAt: now,
318
+ addedBy: a.addedBy?.trim(),
319
+ };
320
+ task.artifacts.push(artifact);
321
+ added.push(artifact);
322
+ }
323
+
324
+ task.updatedAt = now;
325
+ this.tasks.set(taskId, task);
326
+ this.scheduleSave();
327
+ return added;
328
+ }
329
+ }