@andypai/agent-kanban 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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/package.json +80 -0
  4. package/src/__tests__/activity.test.ts +139 -0
  5. package/src/__tests__/api.test.ts +74 -0
  6. package/src/__tests__/commands/board.test.ts +51 -0
  7. package/src/__tests__/commands/bulk.test.ts +51 -0
  8. package/src/__tests__/commands/column.test.ts +78 -0
  9. package/src/__tests__/commands/task.test.ts +144 -0
  10. package/src/__tests__/db.test.ts +327 -0
  11. package/src/__tests__/id.test.ts +19 -0
  12. package/src/__tests__/index.test.ts +75 -0
  13. package/src/__tests__/metrics.test.ts +64 -0
  14. package/src/__tests__/output.test.ts +39 -0
  15. package/src/activity.ts +73 -0
  16. package/src/api.ts +209 -0
  17. package/src/commands/board.ts +29 -0
  18. package/src/commands/bulk.ts +19 -0
  19. package/src/commands/column.ts +60 -0
  20. package/src/commands/task.ts +117 -0
  21. package/src/config.ts +29 -0
  22. package/src/db.ts +587 -0
  23. package/src/errors.ts +32 -0
  24. package/src/fixtures.ts +128 -0
  25. package/src/id.ts +8 -0
  26. package/src/index.ts +413 -0
  27. package/src/metrics.ts +98 -0
  28. package/src/output.ts +105 -0
  29. package/src/providers/capabilities.ts +25 -0
  30. package/src/providers/errors.ts +16 -0
  31. package/src/providers/index.ts +24 -0
  32. package/src/providers/linear-cache.ts +385 -0
  33. package/src/providers/linear-client.ts +329 -0
  34. package/src/providers/linear.ts +305 -0
  35. package/src/providers/local.ts +135 -0
  36. package/src/providers/types.ts +65 -0
  37. package/src/server.ts +91 -0
  38. package/src/types.ts +123 -0
  39. package/ui/dist/assets/index-DEnUD0fq.css +1 -0
  40. package/ui/dist/assets/index-DMRjw1nI.js +40 -0
  41. package/ui/dist/index.html +13 -0
package/src/db.ts ADDED
@@ -0,0 +1,587 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { existsSync, mkdirSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { generateId } from './id.ts'
6
+ import { ErrorCode, KanbanError } from './errors.ts'
7
+ import type { Column, Task, TaskWithColumn, Priority, BoardView } from './types.ts'
8
+ import { logActivity, enterColumn, exitColumn } from './activity.ts'
9
+
10
+ const DEFAULT_COLUMNS = [
11
+ { name: 'recurring', position: 0 },
12
+ { name: 'backlog', position: 1 },
13
+ { name: 'in-progress', position: 2 },
14
+ { name: 'review', position: 3 },
15
+ { name: 'done', position: 4 },
16
+ ]
17
+
18
+ export function getDbPath(): string {
19
+ const envPath = process.env['KANBAN_DB_PATH']
20
+ if (envPath) return envPath
21
+
22
+ const localPath = '.kanban/board.db'
23
+ if (existsSync(localPath)) return localPath
24
+
25
+ const homePath = process.env['HOME'] || homedir()
26
+ const globalPath = join(homePath, '.kanban', 'board.db')
27
+ if (existsSync(globalPath)) return globalPath
28
+
29
+ return localPath
30
+ }
31
+
32
+ export function openDb(path?: string): Database {
33
+ const dbPath = path ?? getDbPath()
34
+ const dir = dbPath.substring(0, dbPath.lastIndexOf('/'))
35
+ if (dir) {
36
+ mkdirSync(dir, { recursive: true })
37
+ }
38
+ const db = new Database(dbPath)
39
+ db.run('PRAGMA journal_mode = WAL')
40
+ db.run('PRAGMA foreign_keys = ON')
41
+ return db
42
+ }
43
+
44
+ export function initSchema(db: Database): void {
45
+ db.run('PRAGMA foreign_keys = ON')
46
+ db.run(`
47
+ CREATE TABLE IF NOT EXISTS columns (
48
+ id TEXT PRIMARY KEY,
49
+ name TEXT UNIQUE NOT NULL,
50
+ position INTEGER NOT NULL,
51
+ color TEXT,
52
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
53
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
54
+ )
55
+ `)
56
+ db.run(`
57
+ CREATE TABLE IF NOT EXISTS tasks (
58
+ id TEXT PRIMARY KEY,
59
+ title TEXT NOT NULL,
60
+ description TEXT NOT NULL DEFAULT '',
61
+ column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE RESTRICT,
62
+ position INTEGER NOT NULL DEFAULT 0,
63
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low','medium','high','urgent')),
64
+ assignee TEXT NOT NULL DEFAULT '',
65
+ project TEXT NOT NULL DEFAULT '',
66
+ metadata TEXT NOT NULL DEFAULT '{}',
67
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
68
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
69
+ )
70
+ `)
71
+ db.run(`
72
+ CREATE TABLE IF NOT EXISTS activity_log (
73
+ id TEXT PRIMARY KEY,
74
+ task_id TEXT NOT NULL,
75
+ action TEXT NOT NULL,
76
+ field_changed TEXT,
77
+ old_value TEXT,
78
+ new_value TEXT,
79
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
80
+ )
81
+ `)
82
+ db.run(`
83
+ CREATE TABLE IF NOT EXISTS column_time_tracking (
84
+ id TEXT PRIMARY KEY,
85
+ task_id TEXT NOT NULL,
86
+ column_id TEXT NOT NULL,
87
+ entered_at TEXT NOT NULL DEFAULT (datetime('now')),
88
+ exited_at TEXT
89
+ )
90
+ `)
91
+ // Run migrations before creating indexes — existing DBs may lack newer columns
92
+ migrateSchema(db)
93
+ // Now safe to create all indexes
94
+ db.run('CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)')
95
+ db.run('CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)')
96
+ db.run('CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)')
97
+ db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
98
+ db.run('CREATE INDEX IF NOT EXISTS idx_activity_task_id ON activity_log(task_id)')
99
+ db.run('CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp)')
100
+ db.run('CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)')
101
+ }
102
+
103
+ export function migrateSchema(db: Database): void {
104
+ // Guard: if tasks table doesn't exist yet, nothing to migrate
105
+ const tables = db
106
+ .query("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
107
+ .all()
108
+ if (tables.length === 0) return
109
+
110
+ const columns = db.query('PRAGMA table_info(tasks)').all() as { name: string }[]
111
+ const hasProject = columns.some((c) => c.name === 'project')
112
+ if (!hasProject) {
113
+ db.run("ALTER TABLE tasks ADD COLUMN project TEXT NOT NULL DEFAULT ''")
114
+ }
115
+ db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
116
+ }
117
+
118
+ export function seedDefaultColumns(db: Database): void {
119
+ const existing = db.query('SELECT COUNT(*) as count FROM columns').get() as {
120
+ count: number
121
+ }
122
+ if (existing.count > 0) return
123
+ const stmt = db.prepare('INSERT INTO columns (id, name, position) VALUES ($id, $name, $position)')
124
+ for (const col of DEFAULT_COLUMNS) {
125
+ stmt.run({ $id: generateId('c'), $name: col.name, $position: col.position })
126
+ }
127
+ }
128
+
129
+ export function isInitialized(db: Database): boolean {
130
+ const result = db
131
+ .query("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='columns'")
132
+ .get() as { count: number }
133
+ return result.count > 0
134
+ }
135
+
136
+ // --- Column CRUD ---
137
+
138
+ export function resolveColumn(db: Database, idOrName: string): Column {
139
+ const byId = db
140
+ .query('SELECT * FROM columns WHERE id = $id')
141
+ .get({ $id: idOrName }) as Column | null
142
+ if (byId) return byId
143
+ const byName = db
144
+ .query('SELECT * FROM columns WHERE LOWER(name) = LOWER($name)')
145
+ .get({ $name: idOrName }) as Column | null
146
+ if (byName) return byName
147
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No column matching '${idOrName}'`)
148
+ }
149
+
150
+ export function listColumns(db: Database): Column[] {
151
+ return db.query('SELECT * FROM columns ORDER BY position').all() as Column[]
152
+ }
153
+
154
+ export function addColumn(
155
+ db: Database,
156
+ name: string,
157
+ opts: { position?: number; color?: string } = {},
158
+ ): Column {
159
+ const existing = db
160
+ .query('SELECT id FROM columns WHERE LOWER(name) = LOWER($name)')
161
+ .get({ $name: name })
162
+ if (existing) {
163
+ throw new KanbanError(ErrorCode.COLUMN_NAME_EXISTS, `Column '${name}' already exists`)
164
+ }
165
+
166
+ const id = generateId('c')
167
+ const position =
168
+ opts.position ??
169
+ (
170
+ db.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM columns').get() as {
171
+ next: number
172
+ }
173
+ ).next
174
+
175
+ db.query('UPDATE columns SET position = position + 1 WHERE position >= $pos').run({
176
+ $pos: position,
177
+ })
178
+
179
+ db.query(
180
+ 'INSERT INTO columns (id, name, position, color) VALUES ($id, $name, $position, $color)',
181
+ ).run({
182
+ $id: id,
183
+ $name: name,
184
+ $position: position,
185
+ $color: opts.color ?? null,
186
+ })
187
+ return resolveColumn(db, id)
188
+ }
189
+
190
+ export function renameColumn(db: Database, idOrName: string, newName: string): Column {
191
+ const col = resolveColumn(db, idOrName)
192
+ const conflict = db
193
+ .query('SELECT id FROM columns WHERE LOWER(name) = LOWER($name) AND id != $id')
194
+ .get({ $name: newName, $id: col.id })
195
+ if (conflict) {
196
+ throw new KanbanError(ErrorCode.COLUMN_NAME_EXISTS, `Column '${newName}' already exists`)
197
+ }
198
+ db.query("UPDATE columns SET name = $name, updated_at = datetime('now') WHERE id = $id").run({
199
+ $name: newName,
200
+ $id: col.id,
201
+ })
202
+ return resolveColumn(db, col.id)
203
+ }
204
+
205
+ export function reorderColumn(db: Database, idOrName: string, newPosition: number): Column {
206
+ const col = resolveColumn(db, idOrName)
207
+ if (newPosition < 0) {
208
+ throw new KanbanError(ErrorCode.INVALID_POSITION, 'Position must be >= 0')
209
+ }
210
+ const oldPos = col.position
211
+ if (oldPos === newPosition) return col
212
+
213
+ if (newPosition < oldPos) {
214
+ db.query(
215
+ 'UPDATE columns SET position = position + 1 WHERE position >= $new AND position < $old',
216
+ ).run({ $new: newPosition, $old: oldPos })
217
+ } else {
218
+ db.query(
219
+ 'UPDATE columns SET position = position - 1 WHERE position > $old AND position <= $new',
220
+ ).run({ $old: oldPos, $new: newPosition })
221
+ }
222
+ db.query("UPDATE columns SET position = $pos, updated_at = datetime('now') WHERE id = $id").run({
223
+ $pos: newPosition,
224
+ $id: col.id,
225
+ })
226
+ renumberColumns(db)
227
+ return resolveColumn(db, col.id)
228
+ }
229
+
230
+ export function deleteColumn(db: Database, idOrName: string): Column {
231
+ const col = resolveColumn(db, idOrName)
232
+ const taskCount = db.query('SELECT COUNT(*) as count FROM tasks WHERE column_id = $id').get({
233
+ $id: col.id,
234
+ }) as { count: number }
235
+ if (taskCount.count > 0) {
236
+ throw new KanbanError(
237
+ ErrorCode.COLUMN_NOT_EMPTY,
238
+ `Column '${col.name}' has ${taskCount.count} task(s). Move or delete them first.`,
239
+ )
240
+ }
241
+ db.query('DELETE FROM columns WHERE id = $id').run({ $id: col.id })
242
+ renumberColumns(db)
243
+ return col
244
+ }
245
+
246
+ function renumberColumns(db: Database): void {
247
+ const cols = db.query('SELECT id FROM columns ORDER BY position').all() as { id: string }[]
248
+ const stmt = db.prepare('UPDATE columns SET position = $pos WHERE id = $id')
249
+ cols.forEach(({ id }, i) => stmt.run({ $pos: i, $id: id }))
250
+ }
251
+
252
+ // --- Task CRUD ---
253
+
254
+ export function addTask(
255
+ db: Database,
256
+ title: string,
257
+ opts: {
258
+ description?: string
259
+ column?: string
260
+ priority?: Priority
261
+ assignee?: string
262
+ project?: string
263
+ metadata?: string
264
+ } = {},
265
+ ): TaskWithColumn {
266
+ const column = opts.column ? resolveColumn(db, opts.column) : resolveColumn(db, 'backlog')
267
+
268
+ if (opts.priority && !['low', 'medium', 'high', 'urgent'].includes(opts.priority)) {
269
+ throw new KanbanError(
270
+ ErrorCode.INVALID_PRIORITY,
271
+ `Invalid priority '${opts.priority}'. Must be low, medium, high, or urgent.`,
272
+ )
273
+ }
274
+
275
+ if (opts.metadata) {
276
+ try {
277
+ JSON.parse(opts.metadata)
278
+ } catch {
279
+ throw new KanbanError(ErrorCode.INVALID_METADATA, 'Metadata must be valid JSON')
280
+ }
281
+ }
282
+
283
+ const id = generateId('t')
284
+ const maxPos = db
285
+ .query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
286
+ .get({ $col: column.id }) as { next: number }
287
+
288
+ db.query(
289
+ `INSERT INTO tasks (id, title, description, column_id, position, priority, assignee, project, metadata)
290
+ VALUES ($id, $title, $desc, $col, $pos, $pri, $assignee, $project, $meta)`,
291
+ ).run({
292
+ $id: id,
293
+ $title: title,
294
+ $desc: opts.description ?? '',
295
+ $col: column.id,
296
+ $pos: maxPos.next,
297
+ $pri: opts.priority ?? 'medium',
298
+ $assignee: opts.assignee ?? '',
299
+ $project: opts.project ?? '',
300
+ $meta: opts.metadata ?? '{}',
301
+ })
302
+ logActivity(db, id, 'created', { new_value: title })
303
+ enterColumn(db, id, column.id)
304
+ return getTask(db, id)
305
+ }
306
+
307
+ export function getTask(db: Database, id: string): TaskWithColumn {
308
+ const task = db
309
+ .query(
310
+ `SELECT t.*, c.name as column_name FROM tasks t
311
+ JOIN columns c ON t.column_id = c.id WHERE t.id = $id`,
312
+ )
313
+ .get({ $id: id }) as TaskWithColumn | null
314
+ if (!task) {
315
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${id}'`)
316
+ }
317
+ return task
318
+ }
319
+
320
+ export function listTasks(
321
+ db: Database,
322
+ opts: {
323
+ column?: string
324
+ priority?: string
325
+ assignee?: string
326
+ project?: string
327
+ limit?: number
328
+ sort?: string
329
+ } = {},
330
+ ): TaskWithColumn[] {
331
+ const conditions: string[] = []
332
+ const params: Record<string, string | number> = {}
333
+
334
+ if (opts.column) {
335
+ const col = resolveColumn(db, opts.column)
336
+ conditions.push('t.column_id = $col')
337
+ params['$col'] = col.id
338
+ }
339
+ if (opts.priority) {
340
+ conditions.push('t.priority = $pri')
341
+ params['$pri'] = opts.priority
342
+ }
343
+ if (opts.assignee) {
344
+ conditions.push('t.assignee = $assignee')
345
+ params['$assignee'] = opts.assignee
346
+ }
347
+ if (opts.project) {
348
+ conditions.push('t.project = $project')
349
+ params['$project'] = opts.project
350
+ }
351
+
352
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
353
+
354
+ const sortMap: Record<string, string> = {
355
+ priority:
356
+ "CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END",
357
+ created: 't.created_at',
358
+ updated: 't.updated_at',
359
+ position: 't.position',
360
+ title: 't.title',
361
+ }
362
+ const orderBy = sortMap[opts.sort ?? 'position'] ?? 't.position'
363
+ const limitClause = opts.limit ? `LIMIT ${opts.limit}` : ''
364
+
365
+ return db
366
+ .query(
367
+ `SELECT t.*, c.name as column_name FROM tasks t
368
+ JOIN columns c ON t.column_id = c.id
369
+ ${where} ORDER BY ${orderBy} ${limitClause}`,
370
+ )
371
+ .all(params as Record<string, string>) as TaskWithColumn[]
372
+ }
373
+
374
+ export function updateTask(
375
+ db: Database,
376
+ id: string,
377
+ updates: {
378
+ title?: string
379
+ description?: string
380
+ priority?: Priority
381
+ assignee?: string
382
+ project?: string
383
+ metadata?: string
384
+ },
385
+ ): TaskWithColumn {
386
+ const existing = getTask(db, id)
387
+
388
+ if (updates.priority && !['low', 'medium', 'high', 'urgent'].includes(updates.priority)) {
389
+ throw new KanbanError(
390
+ ErrorCode.INVALID_PRIORITY,
391
+ `Invalid priority '${updates.priority}'. Must be low, medium, high, or urgent.`,
392
+ )
393
+ }
394
+ if (updates.metadata) {
395
+ try {
396
+ JSON.parse(updates.metadata)
397
+ } catch {
398
+ throw new KanbanError(ErrorCode.INVALID_METADATA, 'Metadata must be valid JSON')
399
+ }
400
+ }
401
+
402
+ const sets: string[] = ["updated_at = datetime('now')"]
403
+ const params: Record<string, string> = { $id: id }
404
+
405
+ if (updates.title !== undefined) {
406
+ sets.push('title = $title')
407
+ params['$title'] = updates.title
408
+ }
409
+ if (updates.description !== undefined) {
410
+ sets.push('description = $desc')
411
+ params['$desc'] = updates.description
412
+ }
413
+ if (updates.priority !== undefined) {
414
+ sets.push('priority = $pri')
415
+ params['$pri'] = updates.priority
416
+ }
417
+ if (updates.assignee !== undefined) {
418
+ sets.push('assignee = $assignee')
419
+ params['$assignee'] = updates.assignee
420
+ }
421
+ if (updates.project !== undefined) {
422
+ sets.push('project = $project')
423
+ params['$project'] = updates.project
424
+ }
425
+ if (updates.metadata !== undefined) {
426
+ sets.push('metadata = $meta')
427
+ params['$meta'] = updates.metadata
428
+ }
429
+
430
+ db.query(`UPDATE tasks SET ${sets.join(', ')} WHERE id = $id`).run(params)
431
+
432
+ if (updates.assignee !== undefined && updates.assignee !== existing.assignee) {
433
+ logActivity(db, id, 'assigned', {
434
+ field: 'assignee',
435
+ old_value: existing.assignee || null,
436
+ new_value: updates.assignee,
437
+ })
438
+ }
439
+ if (updates.priority !== undefined && updates.priority !== existing.priority) {
440
+ logActivity(db, id, 'prioritized', {
441
+ field: 'priority',
442
+ old_value: existing.priority,
443
+ new_value: updates.priority,
444
+ })
445
+ }
446
+ if (updates.project !== undefined && updates.project !== existing.project) {
447
+ logActivity(db, id, 'updated', {
448
+ field: 'project',
449
+ old_value: existing.project || null,
450
+ new_value: updates.project,
451
+ })
452
+ }
453
+ const fieldsToLog: Array<{ key: keyof typeof updates; field: string }> = [
454
+ { key: 'title', field: 'title' },
455
+ { key: 'description', field: 'description' },
456
+ { key: 'metadata', field: 'metadata' },
457
+ ]
458
+ for (const { key, field } of fieldsToLog) {
459
+ if (updates[key] !== undefined && updates[key] !== existing[key as keyof TaskWithColumn]) {
460
+ logActivity(db, id, 'updated', {
461
+ field,
462
+ old_value: String(existing[key as keyof TaskWithColumn] ?? ''),
463
+ new_value: String(updates[key]),
464
+ })
465
+ }
466
+ }
467
+
468
+ return getTask(db, id)
469
+ }
470
+
471
+ export function deleteTask(db: Database, id: string): TaskWithColumn {
472
+ const task = getTask(db, id)
473
+ exitColumn(db, id, task.column_id)
474
+ logActivity(db, id, 'deleted', { old_value: task.title })
475
+ db.query('DELETE FROM tasks WHERE id = $id').run({ $id: id })
476
+ renumberTasksInColumn(db, task.column_id)
477
+ return task
478
+ }
479
+
480
+ export function moveTask(db: Database, id: string, columnIdOrName: string): TaskWithColumn {
481
+ const task = getTask(db, id)
482
+ const column = resolveColumn(db, columnIdOrName)
483
+
484
+ const maxPos = db
485
+ .query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
486
+ .get({ $col: column.id }) as { next: number }
487
+
488
+ const oldColumnId = task.column_id
489
+ db.query(
490
+ "UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now') WHERE id = $id",
491
+ ).run({ $col: column.id, $pos: maxPos.next, $id: id })
492
+ renumberTasksInColumn(db, oldColumnId)
493
+
494
+ exitColumn(db, id, oldColumnId)
495
+ enterColumn(db, id, column.id)
496
+ logActivity(db, id, 'moved', {
497
+ field: 'column',
498
+ old_value: task.column_name,
499
+ new_value: column.name,
500
+ })
501
+
502
+ return getTask(db, id)
503
+ }
504
+
505
+ function renumberTasksInColumn(db: Database, columnId: string): void {
506
+ const tasks = db
507
+ .query('SELECT id FROM tasks WHERE column_id = $col ORDER BY position')
508
+ .all({ $col: columnId }) as { id: string }[]
509
+ const stmt = db.prepare('UPDATE tasks SET position = $pos WHERE id = $id')
510
+ tasks.forEach(({ id }, i) => stmt.run({ $pos: i, $id: id }))
511
+ }
512
+
513
+ // --- Board ---
514
+
515
+ export function getBoardView(db: Database): BoardView {
516
+ const columns = listColumns(db)
517
+ return {
518
+ columns: columns.map((col) => ({
519
+ ...col,
520
+ tasks: db
521
+ .query('SELECT * FROM tasks WHERE column_id = $col ORDER BY position')
522
+ .all({ $col: col.id }) as Task[],
523
+ })),
524
+ }
525
+ }
526
+
527
+ // --- Bulk ---
528
+
529
+ export function bulkMoveAll(
530
+ db: Database,
531
+ fromIdOrName: string,
532
+ toIdOrName: string,
533
+ ): { moved: number } {
534
+ const fromCol = resolveColumn(db, fromIdOrName)
535
+ const toCol = resolveColumn(db, toIdOrName)
536
+
537
+ const maxPos = db
538
+ .query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
539
+ .get({ $col: toCol.id }) as { next: number }
540
+
541
+ const tasks = db
542
+ .query('SELECT id FROM tasks WHERE column_id = $col ORDER BY position')
543
+ .all({ $col: fromCol.id }) as { id: string }[]
544
+
545
+ const stmt = db.prepare(
546
+ "UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now') WHERE id = $id",
547
+ )
548
+ tasks.forEach(({ id }, i) => {
549
+ stmt.run({ $toCol: toCol.id, $pos: maxPos.next + i, $id: id })
550
+ exitColumn(db, id, fromCol.id)
551
+ enterColumn(db, id, toCol.id)
552
+ logActivity(db, id, 'moved', {
553
+ field: 'column',
554
+ old_value: fromCol.name,
555
+ new_value: toCol.name,
556
+ })
557
+ })
558
+
559
+ return { moved: tasks.length }
560
+ }
561
+
562
+ export function bulkClearDone(db: Database): { deleted: number } {
563
+ const doneCol = db.query("SELECT id, name FROM columns WHERE LOWER(name) = 'done'").get() as {
564
+ id: string
565
+ name: string
566
+ } | null
567
+ if (!doneCol) return { deleted: 0 }
568
+
569
+ const tasks = db
570
+ .query('SELECT id, title FROM tasks WHERE column_id = $col')
571
+ .all({ $col: doneCol.id }) as { id: string; title: string }[]
572
+ for (const task of tasks) {
573
+ exitColumn(db, task.id, doneCol.id)
574
+ logActivity(db, task.id, 'deleted', { old_value: task.title })
575
+ }
576
+ db.query('DELETE FROM tasks WHERE column_id = $col').run({ $col: doneCol.id })
577
+ return { deleted: tasks.length }
578
+ }
579
+
580
+ export function resetBoard(db: Database): void {
581
+ db.run('DROP TABLE IF EXISTS column_time_tracking')
582
+ db.run('DROP TABLE IF EXISTS activity_log')
583
+ db.run('DROP TABLE IF EXISTS tasks')
584
+ db.run('DROP TABLE IF EXISTS columns')
585
+ initSchema(db)
586
+ seedDefaultColumns(db)
587
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,32 @@
1
+ export const ErrorCode = {
2
+ BOARD_NOT_INITIALIZED: 'BOARD_NOT_INITIALIZED',
3
+ BOARD_ALREADY_INITIALIZED: 'BOARD_ALREADY_INITIALIZED',
4
+ TASK_NOT_FOUND: 'TASK_NOT_FOUND',
5
+ COLUMN_NOT_FOUND: 'COLUMN_NOT_FOUND',
6
+ COLUMN_NOT_EMPTY: 'COLUMN_NOT_EMPTY',
7
+ COLUMN_NAME_EXISTS: 'COLUMN_NAME_EXISTS',
8
+ INVALID_PRIORITY: 'INVALID_PRIORITY',
9
+ INVALID_METADATA: 'INVALID_METADATA',
10
+ INVALID_POSITION: 'INVALID_POSITION',
11
+ MISSING_ARGUMENT: 'MISSING_ARGUMENT',
12
+ UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
13
+ UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
14
+ PROVIDER_AUTH_FAILED: 'PROVIDER_AUTH_FAILED',
15
+ PROVIDER_RATE_LIMITED: 'PROVIDER_RATE_LIMITED',
16
+ PROVIDER_UPSTREAM_ERROR: 'PROVIDER_UPSTREAM_ERROR',
17
+ PROVIDER_SYNC_REQUIRED: 'PROVIDER_SYNC_REQUIRED',
18
+ PROVIDER_NOT_CONFIGURED: 'PROVIDER_NOT_CONFIGURED',
19
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
20
+ } as const
21
+
22
+ export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
23
+
24
+ export class KanbanError extends Error {
25
+ constructor(
26
+ public code: ErrorCodeValue,
27
+ message: string,
28
+ ) {
29
+ super(message)
30
+ this.name = 'KanbanError'
31
+ }
32
+ }