@kaban-board/core 0.3.1 → 0.3.3

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 (44) hide show
  1. package/dist/db/bun-adapter.d.ts.map +1 -1
  2. package/dist/db/index.d.ts.map +1 -1
  3. package/dist/db/migrate.d.ts.map +1 -1
  4. package/dist/db/migrator.d.ts.map +1 -1
  5. package/dist/db/recovery.d.ts +59 -0
  6. package/dist/db/recovery.d.ts.map +1 -0
  7. package/dist/db/schema.d.ts +344 -0
  8. package/dist/db/schema.d.ts.map +1 -1
  9. package/dist/index-bun.d.ts +1 -0
  10. package/dist/index-bun.d.ts.map +1 -1
  11. package/dist/index-bun.js +811 -65
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1267 -66
  15. package/dist/schemas.d.ts +61 -0
  16. package/dist/schemas.d.ts.map +1 -1
  17. package/dist/services/audit.d.ts +45 -0
  18. package/dist/services/audit.d.ts.map +1 -0
  19. package/dist/services/board.d.ts.map +1 -1
  20. package/dist/services/dependency.d.ts +20 -0
  21. package/dist/services/dependency.d.ts.map +1 -0
  22. package/dist/services/index.d.ts +4 -0
  23. package/dist/services/index.d.ts.map +1 -1
  24. package/dist/services/link.d.ts +21 -0
  25. package/dist/services/link.d.ts.map +1 -0
  26. package/dist/services/markdown.d.ts +37 -0
  27. package/dist/services/markdown.d.ts.map +1 -0
  28. package/dist/services/scoring/index.d.ts +17 -0
  29. package/dist/services/scoring/index.d.ts.map +1 -0
  30. package/dist/services/scoring/scorers/blocking.d.ts +7 -0
  31. package/dist/services/scoring/scorers/blocking.d.ts.map +1 -0
  32. package/dist/services/scoring/scorers/due-date.d.ts +7 -0
  33. package/dist/services/scoring/scorers/due-date.d.ts.map +1 -0
  34. package/dist/services/scoring/scorers/fifo.d.ts +7 -0
  35. package/dist/services/scoring/scorers/fifo.d.ts.map +1 -0
  36. package/dist/services/scoring/scorers/priority.d.ts +7 -0
  37. package/dist/services/scoring/scorers/priority.d.ts.map +1 -0
  38. package/dist/services/scoring/types.d.ts +27 -0
  39. package/dist/services/scoring/types.d.ts.map +1 -0
  40. package/dist/services/task.d.ts +6 -1
  41. package/dist/services/task.d.ts.map +1 -1
  42. package/dist/utils/date-parser.d.ts +14 -0
  43. package/dist/utils/date-parser.d.ts.map +1 -0
  44. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -75,15 +75,19 @@ var exports_schema = {};
75
75
  __export(exports_schema, {
76
76
  undoLog: () => undoLog,
77
77
  tasks: () => tasks,
78
+ taskLinks: () => taskLinks,
79
+ linkTypes: () => linkTypes,
78
80
  columns: () => columns,
79
- boards: () => boards
81
+ boards: () => boards,
82
+ audits: () => audits
80
83
  });
81
84
  import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
82
- var boards, columns, tasks, undoLog;
85
+ var boards, columns, tasks, undoLog, audits, linkTypes, taskLinks;
83
86
  var init_schema = __esm(() => {
84
87
  boards = sqliteTable("boards", {
85
88
  id: text("id").primaryKey(),
86
89
  name: text("name").notNull(),
90
+ maxBoardTaskId: integer("max_board_task_id").notNull().default(0),
87
91
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
88
92
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
89
93
  });
@@ -97,6 +101,7 @@ var init_schema = __esm(() => {
97
101
  });
98
102
  tasks = sqliteTable("tasks", {
99
103
  id: text("id").primaryKey(),
104
+ boardTaskId: integer("board_task_id"),
100
105
  title: text("title").notNull(),
101
106
  description: text("description"),
102
107
  columnId: text("column_id").notNull().references(() => columns.id),
@@ -113,8 +118,10 @@ var init_schema = __esm(() => {
113
118
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
114
119
  startedAt: integer("started_at", { mode: "timestamp" }),
115
120
  completedAt: integer("completed_at", { mode: "timestamp" }),
121
+ dueDate: integer("due_date", { mode: "timestamp" }),
116
122
  archived: integer("archived", { mode: "boolean" }).notNull().default(false),
117
- archivedAt: integer("archived_at", { mode: "timestamp" })
123
+ archivedAt: integer("archived_at", { mode: "timestamp" }),
124
+ updatedBy: text("updated_by")
118
125
  });
119
126
  undoLog = sqliteTable("undo_log", {
120
127
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -122,6 +129,25 @@ var init_schema = __esm(() => {
122
129
  data: text("data", { mode: "json" }).notNull(),
123
130
  createdAt: integer("created_at", { mode: "timestamp" }).notNull()
124
131
  });
132
+ audits = sqliteTable("audits", {
133
+ id: integer("id").primaryKey({ autoIncrement: true }),
134
+ timestamp: integer("timestamp", { mode: "timestamp" }).notNull(),
135
+ eventType: text("event_type", { enum: ["CREATE", "UPDATE", "DELETE"] }).notNull(),
136
+ objectType: text("object_type", { enum: ["task", "column", "board"] }).notNull(),
137
+ objectId: text("object_id").notNull(),
138
+ fieldName: text("field_name"),
139
+ oldValue: text("old_value"),
140
+ newValue: text("new_value"),
141
+ actor: text("actor")
142
+ });
143
+ linkTypes = ["blocks", "blocked_by", "related"];
144
+ taskLinks = sqliteTable("task_links", {
145
+ id: integer("id").primaryKey({ autoIncrement: true }),
146
+ fromTaskId: text("from_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
147
+ toTaskId: text("to_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
148
+ linkType: text("link_type", { enum: linkTypes }).notNull(),
149
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
150
+ });
125
151
  });
126
152
 
127
153
  // drizzle/meta/_journal.json
@@ -151,6 +177,41 @@ var init__journal = __esm(() => {
151
177
  when: 1769351200000,
152
178
  tag: "0002_add_fts5",
153
179
  breakpoints: true
180
+ },
181
+ {
182
+ idx: 3,
183
+ version: "6",
184
+ when: 1769695200000,
185
+ tag: "0003_fix_fts_compatibility",
186
+ breakpoints: true
187
+ },
188
+ {
189
+ idx: 4,
190
+ version: "6",
191
+ when: 1769700000000,
192
+ tag: "0004_board_scoped_ids",
193
+ breakpoints: true
194
+ },
195
+ {
196
+ idx: 5,
197
+ version: "6",
198
+ when: 1769800000000,
199
+ tag: "0005_audit_log",
200
+ breakpoints: true
201
+ },
202
+ {
203
+ idx: 6,
204
+ version: "6",
205
+ when: 1769900000000,
206
+ tag: "0006_due_date",
207
+ breakpoints: true
208
+ },
209
+ {
210
+ idx: 7,
211
+ version: "6",
212
+ when: 1770000000000,
213
+ tag: "0007_task_links",
214
+ breakpoints: true
154
215
  }
155
216
  ]
156
217
  };
@@ -211,6 +272,216 @@ WHERE NOT EXISTS (SELECT 1 FROM tasks_fts LIMIT 1);
211
272
  `;
212
273
  var init_0002_add_fts5 = () => {};
213
274
 
275
+ // drizzle/0003_fix_fts_compatibility.sql
276
+ var _0003_fix_fts_compatibility_default = `-- Fix FTS5 for cross-driver compatibility (bun:sqlite + libsql)
277
+ -- Replaces external-content FTS with simple standalone FTS
278
+ -- Uses DELETE/UPDATE triggers instead of FTS5 'delete' command
279
+ DROP TABLE IF EXISTS tasks_fts;
280
+
281
+ --> statement-breakpoint
282
+ DROP TRIGGER IF EXISTS tasks_fts_insert;
283
+
284
+ --> statement-breakpoint
285
+ DROP TRIGGER IF EXISTS tasks_fts_delete;
286
+
287
+ --> statement-breakpoint
288
+ DROP TRIGGER IF EXISTS tasks_fts_update;
289
+
290
+ --> statement-breakpoint
291
+ CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
292
+ id, title, description,
293
+ tokenize='unicode61 remove_diacritics 2'
294
+ );
295
+
296
+ --> statement-breakpoint
297
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_insert AFTER INSERT ON tasks BEGIN
298
+ INSERT INTO tasks_fts (id, title, description)
299
+ VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''));
300
+ END;
301
+
302
+ --> statement-breakpoint
303
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_delete AFTER DELETE ON tasks BEGIN
304
+ DELETE FROM tasks_fts WHERE id = OLD.id;
305
+ END;
306
+
307
+ --> statement-breakpoint
308
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_update AFTER UPDATE ON tasks BEGIN
309
+ UPDATE tasks_fts
310
+ SET title = NEW.title, description = COALESCE(NEW.description, '')
311
+ WHERE id = OLD.id;
312
+ END;
313
+
314
+ --> statement-breakpoint
315
+ INSERT INTO tasks_fts (id, title, description)
316
+ SELECT id, title, COALESCE(description, '') FROM tasks
317
+ WHERE NOT EXISTS (SELECT 1 FROM tasks_fts LIMIT 1);
318
+ `;
319
+ var init_0003_fix_fts_compatibility = () => {};
320
+
321
+ // drizzle/0004_board_scoped_ids.sql
322
+ var _0004_board_scoped_ids_default = `ALTER TABLE tasks ADD COLUMN board_task_id INTEGER;
323
+
324
+ --> statement-breakpoint
325
+
326
+ ALTER TABLE boards ADD COLUMN max_board_task_id INTEGER NOT NULL DEFAULT 0;
327
+
328
+ --> statement-breakpoint
329
+
330
+ WITH ranked AS (
331
+ SELECT
332
+ t.id,
333
+ ROW_NUMBER() OVER (
334
+ PARTITION BY c.board_id
335
+ ORDER BY t.created_at, t.id
336
+ ) as rn
337
+ FROM tasks t
338
+ JOIN columns c ON t.column_id = c.id
339
+ )
340
+ UPDATE tasks
341
+ SET board_task_id = (SELECT rn FROM ranked WHERE ranked.id = tasks.id);
342
+
343
+ --> statement-breakpoint
344
+
345
+ UPDATE boards SET max_board_task_id = (
346
+ SELECT COALESCE(MAX(t.board_task_id), 0)
347
+ FROM tasks t
348
+ JOIN columns c ON t.column_id = c.id
349
+ WHERE c.board_id = boards.id
350
+ );
351
+ `;
352
+ var init_0004_board_scoped_ids = () => {};
353
+
354
+ // drizzle/0005_audit_log.sql
355
+ var _0005_audit_log_default = `-- Add updated_by column to tasks for actor tracking
356
+ ALTER TABLE tasks ADD COLUMN updated_by TEXT;
357
+
358
+ --> statement-breakpoint
359
+
360
+ -- Create audits table
361
+ CREATE TABLE audits (
362
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
363
+ timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
364
+ event_type TEXT NOT NULL CHECK (event_type IN ('CREATE', 'UPDATE', 'DELETE')),
365
+ object_type TEXT NOT NULL CHECK (object_type IN ('task', 'column', 'board')),
366
+ object_id TEXT NOT NULL,
367
+ field_name TEXT,
368
+ old_value TEXT,
369
+ new_value TEXT,
370
+ actor TEXT
371
+ );
372
+
373
+ --> statement-breakpoint
374
+
375
+ CREATE INDEX idx_audits_object ON audits(object_type, object_id);
376
+
377
+ --> statement-breakpoint
378
+
379
+ CREATE INDEX idx_audits_timestamp ON audits(timestamp);
380
+
381
+ --> statement-breakpoint
382
+
383
+ -- Trigger: task INSERT
384
+ CREATE TRIGGER audit_task_insert
385
+ AFTER INSERT ON tasks
386
+ BEGIN
387
+ INSERT INTO audits (event_type, object_type, object_id, new_value, actor)
388
+ VALUES ('CREATE', 'task', NEW.id,
389
+ json_object('title', NEW.title, 'columnId', NEW.column_id),
390
+ NEW.created_by);
391
+ END;
392
+
393
+ --> statement-breakpoint
394
+
395
+ -- Trigger: task UPDATE (single trigger with conditional inserts for each field)
396
+ CREATE TRIGGER audit_task_update
397
+ AFTER UPDATE ON tasks
398
+ BEGIN
399
+ -- Title changed
400
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
401
+ SELECT 'UPDATE', 'task', OLD.id, 'title', OLD.title, NEW.title, NEW.updated_by
402
+ WHERE (OLD.title IS NULL AND NEW.title IS NOT NULL)
403
+ OR (OLD.title IS NOT NULL AND NEW.title IS NULL)
404
+ OR (OLD.title <> NEW.title);
405
+
406
+ -- Column changed (task moved)
407
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
408
+ SELECT 'UPDATE', 'task', OLD.id, 'columnId', OLD.column_id, NEW.column_id, NEW.updated_by
409
+ WHERE (OLD.column_id IS NULL AND NEW.column_id IS NOT NULL)
410
+ OR (OLD.column_id IS NOT NULL AND NEW.column_id IS NULL)
411
+ OR (OLD.column_id <> NEW.column_id);
412
+
413
+ -- Assigned to changed
414
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
415
+ SELECT 'UPDATE', 'task', OLD.id, 'assignedTo', OLD.assigned_to, NEW.assigned_to, NEW.updated_by
416
+ WHERE (OLD.assigned_to IS NULL AND NEW.assigned_to IS NOT NULL)
417
+ OR (OLD.assigned_to IS NOT NULL AND NEW.assigned_to IS NULL)
418
+ OR (OLD.assigned_to <> NEW.assigned_to);
419
+
420
+ -- Description changed
421
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
422
+ SELECT 'UPDATE', 'task', OLD.id, 'description', OLD.description, NEW.description, NEW.updated_by
423
+ WHERE (OLD.description IS NULL AND NEW.description IS NOT NULL)
424
+ OR (OLD.description IS NOT NULL AND NEW.description IS NULL)
425
+ OR (OLD.description <> NEW.description);
426
+
427
+ -- Archived status changed
428
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
429
+ SELECT 'UPDATE', 'task', OLD.id, 'archived', OLD.archived, NEW.archived, NEW.updated_by
430
+ WHERE (OLD.archived IS NULL AND NEW.archived IS NOT NULL)
431
+ OR (OLD.archived IS NOT NULL AND NEW.archived IS NULL)
432
+ OR (OLD.archived <> NEW.archived);
433
+
434
+ -- Labels changed
435
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
436
+ SELECT 'UPDATE', 'task', OLD.id, 'labels', OLD.labels, NEW.labels, NEW.updated_by
437
+ WHERE (OLD.labels IS NULL AND NEW.labels IS NOT NULL)
438
+ OR (OLD.labels IS NOT NULL AND NEW.labels IS NULL)
439
+ OR (OLD.labels <> NEW.labels);
440
+ END;
441
+
442
+ --> statement-breakpoint
443
+
444
+ -- Trigger: task DELETE
445
+ CREATE TRIGGER audit_task_delete
446
+ AFTER DELETE ON tasks
447
+ BEGIN
448
+ INSERT INTO audits (event_type, object_type, object_id, old_value)
449
+ VALUES ('DELETE', 'task', OLD.id,
450
+ json_object('title', OLD.title, 'columnId', OLD.column_id));
451
+ END;
452
+ `;
453
+ var init_0005_audit_log = () => {};
454
+
455
+ // drizzle/0006_due_date.sql
456
+ var _0006_due_date_default = `ALTER TABLE tasks ADD COLUMN due_date INTEGER;
457
+ `;
458
+ var init_0006_due_date = () => {};
459
+
460
+ // drizzle/0007_task_links.sql
461
+ var _0007_task_links_default = `-- Create task_links table
462
+ CREATE TABLE task_links (
463
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
464
+ from_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
465
+ to_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
466
+ link_type TEXT NOT NULL CHECK (link_type IN ('blocks', 'blocked_by', 'related')),
467
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
468
+ UNIQUE(from_task_id, to_task_id, link_type)
469
+ );
470
+
471
+ --> statement-breakpoint
472
+
473
+ CREATE INDEX idx_task_links_from ON task_links(from_task_id);
474
+
475
+ --> statement-breakpoint
476
+
477
+ CREATE INDEX idx_task_links_to ON task_links(to_task_id);
478
+
479
+ --> statement-breakpoint
480
+
481
+ CREATE INDEX idx_task_links_type ON task_links(link_type);
482
+ `;
483
+ var init_0007_task_links = () => {};
484
+
214
485
  // src/db/migrator.ts
215
486
  var exports_migrator = {};
216
487
  __export(exports_migrator, {
@@ -220,7 +491,7 @@ import { readFileSync } from "node:fs";
220
491
  import { dirname as dirname2, isAbsolute, join } from "node:path";
221
492
  import { fileURLToPath as fileURLToPath2 } from "node:url";
222
493
  function resolveSqlContent(sqlOrPath) {
223
- if (sqlOrPath.includes("CREATE") || sqlOrPath.includes("INSERT") || sqlOrPath.includes("--")) {
494
+ if (sqlOrPath.includes("CREATE") || sqlOrPath.includes("INSERT") || sqlOrPath.includes("ALTER") || sqlOrPath.includes("--")) {
224
495
  return sqlOrPath;
225
496
  }
226
497
  let filePath = sqlOrPath;
@@ -327,10 +598,20 @@ var init_migrator = __esm(() => {
327
598
  init_0000_init();
328
599
  init_0001_add_archived();
329
600
  init_0002_add_fts5();
601
+ init_0003_fix_fts_compatibility();
602
+ init_0004_board_scoped_ids();
603
+ init_0005_audit_log();
604
+ init_0006_due_date();
605
+ init_0007_task_links();
330
606
  migrationSql = {
331
607
  "0000_init": _0000_init_default,
332
608
  "0001_add_archived": _0001_add_archived_default,
333
- "0002_add_fts5": _0002_add_fts5_default
609
+ "0002_add_fts5": _0002_add_fts5_default,
610
+ "0003_fix_fts_compatibility": _0003_fix_fts_compatibility_default,
611
+ "0004_board_scoped_ids": _0004_board_scoped_ids_default,
612
+ "0005_audit_log": _0005_audit_log_default,
613
+ "0006_due_date": _0006_due_date_default,
614
+ "0007_task_links": _0007_task_links_default
334
615
  };
335
616
  __dirname2 = dirname2(fileURLToPath2(import.meta.url));
336
617
  drizzleDir = join(__dirname2, "..", "..", "drizzle");
@@ -386,17 +667,174 @@ var init_libsql_adapter = __esm(() => {
386
667
  init_utils();
387
668
  });
388
669
 
670
+ // src/db/recovery.ts
671
+ import { existsSync as existsSync2, renameSync, unlinkSync } from "node:fs";
672
+ import { execSync } from "node:child_process";
673
+ function checkIntegrity(db) {
674
+ try {
675
+ const result = db.query("PRAGMA integrity_check").get();
676
+ return result?.integrity_check === "ok";
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+ function attemptWalRecovery(db) {
682
+ try {
683
+ db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
684
+ return checkIntegrity(db);
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+ function dumpAndRestore(dbPath) {
690
+ const backupPath = `${dbPath}.corrupted.${Date.now()}`;
691
+ const dumpPath = `${dbPath}.dump.sql`;
692
+ try {
693
+ execSync(`sqlite3 "${dbPath}" ".dump" > "${dumpPath}" 2>/dev/null`, {
694
+ encoding: "utf-8",
695
+ timeout: 30000
696
+ });
697
+ renameSync(dbPath, backupPath);
698
+ const walPath = `${dbPath}-wal`;
699
+ const shmPath = `${dbPath}-shm`;
700
+ if (existsSync2(walPath))
701
+ unlinkSync(walPath);
702
+ if (existsSync2(shmPath))
703
+ unlinkSync(shmPath);
704
+ execSync(`sqlite3 "${dbPath}" < "${dumpPath}"`, {
705
+ encoding: "utf-8",
706
+ timeout: 30000
707
+ });
708
+ unlinkSync(dumpPath);
709
+ return { success: true, backupPath };
710
+ } catch {
711
+ try {
712
+ if (existsSync2(dumpPath))
713
+ unlinkSync(dumpPath);
714
+ if (existsSync2(backupPath) && !existsSync2(dbPath)) {
715
+ renameSync(backupPath, dbPath);
716
+ }
717
+ } catch {}
718
+ return { success: false, backupPath };
719
+ }
720
+ }
721
+ function rebuildFts(db) {
722
+ try {
723
+ db.run("DROP TABLE IF EXISTS tasks_fts");
724
+ db.run("DROP TRIGGER IF EXISTS tasks_fts_insert");
725
+ db.run("DROP TRIGGER IF EXISTS tasks_fts_delete");
726
+ db.run("DROP TRIGGER IF EXISTS tasks_fts_update");
727
+ db.run(`
728
+ CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
729
+ id, title, description,
730
+ tokenize='unicode61 remove_diacritics 2'
731
+ )
732
+ `);
733
+ db.run(`
734
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_insert AFTER INSERT ON tasks BEGIN
735
+ INSERT INTO tasks_fts (id, title, description)
736
+ VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''));
737
+ END
738
+ `);
739
+ db.run(`
740
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_delete AFTER DELETE ON tasks BEGIN
741
+ DELETE FROM tasks_fts WHERE id = OLD.id;
742
+ END
743
+ `);
744
+ db.run(`
745
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_update AFTER UPDATE ON tasks BEGIN
746
+ UPDATE tasks_fts
747
+ SET title = NEW.title, description = COALESCE(NEW.description, '')
748
+ WHERE id = OLD.id;
749
+ END
750
+ `);
751
+ db.run(`
752
+ INSERT INTO tasks_fts (id, title, description)
753
+ SELECT id, title, COALESCE(description, '') FROM tasks
754
+ `);
755
+ return true;
756
+ } catch {
757
+ return false;
758
+ }
759
+ }
760
+ function checkFtsHealth(db) {
761
+ try {
762
+ db.query("SELECT COUNT(*) FROM tasks_fts").get();
763
+ return true;
764
+ } catch {
765
+ return false;
766
+ }
767
+ }
768
+ async function attemptRecovery(dbPath, openDb) {
769
+ let db = null;
770
+ try {
771
+ db = openDb();
772
+ const integrityOk = checkIntegrity(db);
773
+ const ftsOk = checkFtsHealth(db);
774
+ if (integrityOk && ftsOk) {
775
+ return { recovered: false };
776
+ }
777
+ if (integrityOk && !ftsOk) {
778
+ console.warn("[kaban] FTS corruption detected, rebuilding...");
779
+ if (rebuildFts(db)) {
780
+ console.warn("[kaban] FTS rebuilt successfully");
781
+ return { recovered: true, method: "fts_rebuild" };
782
+ }
783
+ }
784
+ if (integrityOk) {
785
+ return { recovered: false };
786
+ }
787
+ console.warn("[kaban] Database corruption detected, attempting recovery...");
788
+ if (attemptWalRecovery(db)) {
789
+ console.warn("[kaban] Recovery successful via WAL checkpoint");
790
+ return { recovered: true, method: "wal_checkpoint" };
791
+ }
792
+ db.close();
793
+ db = null;
794
+ const { success, backupPath } = dumpAndRestore(dbPath);
795
+ if (success) {
796
+ console.warn(`[kaban] Recovery successful via dump/restore. Backup: ${backupPath}`);
797
+ return { recovered: true, method: "dump_restore", backupPath };
798
+ }
799
+ throw new KabanError(`Database is corrupted and automatic recovery failed. Backup at: ${backupPath}`, ExitCode.GENERAL_ERROR);
800
+ } finally {
801
+ try {
802
+ db?.close();
803
+ } catch {}
804
+ }
805
+ }
806
+ var init_recovery = __esm(() => {
807
+ init_types();
808
+ });
809
+
389
810
  // src/db/bun-adapter.ts
390
811
  var exports_bun_adapter = {};
391
812
  __export(exports_bun_adapter, {
392
813
  createBunDb: () => createBunDb
393
814
  });
815
+ import { existsSync as existsSync3 } from "node:fs";
394
816
  async function createBunDb(filePath) {
395
817
  let sqlite;
396
818
  try {
397
819
  const { Database } = await import("bun:sqlite");
398
820
  const { drizzle } = await import("drizzle-orm/bun-sqlite");
399
821
  ensureDbDir(filePath);
822
+ if (existsSync3(filePath)) {
823
+ const recovery = await attemptRecovery(filePath, () => {
824
+ const tempDb = new Database(filePath);
825
+ return {
826
+ query: (sql) => ({
827
+ get: () => tempDb.query(sql).get(),
828
+ all: () => tempDb.query(sql).all()
829
+ }),
830
+ run: (sql) => tempDb.run(sql),
831
+ close: () => tempDb.close()
832
+ };
833
+ });
834
+ if (recovery.recovered) {
835
+ console.warn(`[kaban] Database recovered using ${recovery.method}`);
836
+ }
837
+ }
400
838
  sqlite = new Database(filePath);
401
839
  const db = drizzle({ client: sqlite, schema: exports_schema });
402
840
  let closed = false;
@@ -437,6 +875,7 @@ async function createBunDb(filePath) {
437
875
  }
438
876
  var init_bun_adapter = __esm(() => {
439
877
  init_types();
878
+ init_recovery();
440
879
  init_schema();
441
880
  init_utils();
442
881
  });
@@ -510,6 +949,7 @@ PRAGMA journal_mode = WAL;
510
949
  CREATE TABLE IF NOT EXISTS boards (
511
950
  id TEXT PRIMARY KEY,
512
951
  name TEXT NOT NULL,
952
+ max_board_task_id INTEGER NOT NULL DEFAULT 0,
513
953
  created_at INTEGER NOT NULL,
514
954
  updated_at INTEGER NOT NULL
515
955
  );
@@ -525,6 +965,7 @@ CREATE TABLE IF NOT EXISTS columns (
525
965
 
526
966
  CREATE TABLE IF NOT EXISTS tasks (
527
967
  id TEXT PRIMARY KEY,
968
+ board_task_id INTEGER,
528
969
  title TEXT NOT NULL,
529
970
  description TEXT,
530
971
  column_id TEXT NOT NULL REFERENCES columns(id),
@@ -541,8 +982,10 @@ CREATE TABLE IF NOT EXISTS tasks (
541
982
  updated_at INTEGER NOT NULL,
542
983
  started_at INTEGER,
543
984
  completed_at INTEGER,
985
+ due_date INTEGER,
544
986
  archived INTEGER NOT NULL DEFAULT 0,
545
- archived_at INTEGER
987
+ archived_at INTEGER,
988
+ updated_by TEXT
546
989
  );
547
990
 
548
991
  CREATE TABLE IF NOT EXISTS undo_log (
@@ -552,8 +995,95 @@ CREATE TABLE IF NOT EXISTS undo_log (
552
995
  created_at INTEGER NOT NULL
553
996
  );
554
997
 
998
+ CREATE TABLE IF NOT EXISTS audits (
999
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1000
+ timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
1001
+ event_type TEXT NOT NULL CHECK (event_type IN ('CREATE', 'UPDATE', 'DELETE')),
1002
+ object_type TEXT NOT NULL CHECK (object_type IN ('task', 'column', 'board')),
1003
+ object_id TEXT NOT NULL,
1004
+ field_name TEXT,
1005
+ old_value TEXT,
1006
+ new_value TEXT,
1007
+ actor TEXT
1008
+ );
1009
+
1010
+ CREATE TABLE IF NOT EXISTS task_links (
1011
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1012
+ from_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1013
+ to_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1014
+ link_type TEXT NOT NULL CHECK (link_type IN ('blocks', 'blocked_by', 'related')),
1015
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
1016
+ UNIQUE(from_task_id, to_task_id, link_type)
1017
+ );
1018
+
555
1019
  CREATE INDEX IF NOT EXISTS idx_tasks_column ON tasks(column_id);
556
1020
  CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
1021
+ CREATE INDEX IF NOT EXISTS idx_audits_object ON audits(object_type, object_id);
1022
+ CREATE INDEX IF NOT EXISTS idx_audits_timestamp ON audits(timestamp);
1023
+ CREATE INDEX IF NOT EXISTS idx_audits_actor ON audits(actor);
1024
+ CREATE INDEX IF NOT EXISTS idx_task_links_from ON task_links(from_task_id);
1025
+ CREATE INDEX IF NOT EXISTS idx_task_links_to ON task_links(to_task_id);
1026
+ CREATE INDEX IF NOT EXISTS idx_task_links_type ON task_links(link_type);
1027
+
1028
+ -- Trigger: task INSERT
1029
+ CREATE TRIGGER IF NOT EXISTS audit_task_insert
1030
+ AFTER INSERT ON tasks
1031
+ BEGIN
1032
+ INSERT INTO audits (event_type, object_type, object_id, new_value, actor)
1033
+ VALUES ('CREATE', 'task', NEW.id,
1034
+ json_object('title', NEW.title, 'columnId', NEW.column_id),
1035
+ NEW.created_by);
1036
+ END;
1037
+
1038
+ -- Trigger: task UPDATE
1039
+ CREATE TRIGGER IF NOT EXISTS audit_task_update
1040
+ AFTER UPDATE ON tasks
1041
+ BEGIN
1042
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
1043
+ SELECT 'UPDATE', 'task', OLD.id, 'title', OLD.title, NEW.title, NEW.updated_by
1044
+ WHERE (OLD.title IS NULL AND NEW.title IS NOT NULL)
1045
+ OR (OLD.title IS NOT NULL AND NEW.title IS NULL)
1046
+ OR (OLD.title <> NEW.title);
1047
+
1048
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
1049
+ SELECT 'UPDATE', 'task', OLD.id, 'columnId', OLD.column_id, NEW.column_id, NEW.updated_by
1050
+ WHERE (OLD.column_id IS NULL AND NEW.column_id IS NOT NULL)
1051
+ OR (OLD.column_id IS NOT NULL AND NEW.column_id IS NULL)
1052
+ OR (OLD.column_id <> NEW.column_id);
1053
+
1054
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
1055
+ SELECT 'UPDATE', 'task', OLD.id, 'assignedTo', OLD.assigned_to, NEW.assigned_to, NEW.updated_by
1056
+ WHERE (OLD.assigned_to IS NULL AND NEW.assigned_to IS NOT NULL)
1057
+ OR (OLD.assigned_to IS NOT NULL AND NEW.assigned_to IS NULL)
1058
+ OR (OLD.assigned_to <> NEW.assigned_to);
1059
+
1060
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
1061
+ SELECT 'UPDATE', 'task', OLD.id, 'description', OLD.description, NEW.description, NEW.updated_by
1062
+ WHERE (OLD.description IS NULL AND NEW.description IS NOT NULL)
1063
+ OR (OLD.description IS NOT NULL AND NEW.description IS NULL)
1064
+ OR (OLD.description <> NEW.description);
1065
+
1066
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
1067
+ SELECT 'UPDATE', 'task', OLD.id, 'archived', OLD.archived, NEW.archived, NEW.updated_by
1068
+ WHERE (OLD.archived IS NULL AND NEW.archived IS NOT NULL)
1069
+ OR (OLD.archived IS NOT NULL AND NEW.archived IS NULL)
1070
+ OR (OLD.archived <> NEW.archived);
1071
+
1072
+ INSERT INTO audits (event_type, object_type, object_id, field_name, old_value, new_value, actor)
1073
+ SELECT 'UPDATE', 'task', OLD.id, 'labels', OLD.labels, NEW.labels, NEW.updated_by
1074
+ WHERE (OLD.labels IS NULL AND NEW.labels IS NOT NULL)
1075
+ OR (OLD.labels IS NOT NULL AND NEW.labels IS NULL)
1076
+ OR (OLD.labels <> NEW.labels);
1077
+ END;
1078
+
1079
+ -- Trigger: task DELETE
1080
+ CREATE TRIGGER IF NOT EXISTS audit_task_delete
1081
+ AFTER DELETE ON tasks
1082
+ BEGIN
1083
+ INSERT INTO audits (event_type, object_type, object_id, old_value)
1084
+ VALUES ('DELETE', 'task', OLD.id,
1085
+ json_object('title', OLD.title, 'columnId', OLD.column_id));
1086
+ END;
557
1087
  `;
558
1088
  async function initializeSchema(db) {
559
1089
  await db.$runRaw(SCHEMA_SQL);
@@ -569,6 +1099,7 @@ var AgentNameSchema = AgentNameBaseSchema.transform((s) => s.toLowerCase());
569
1099
  var ColumnIdSchema = z.string().min(1, "Column ID cannot be empty").max(50, "Column ID cannot exceed 50 characters").regex(/^[a-z0-9_]+$/, "Column ID must be lowercase alphanumeric with underscores");
570
1100
  var TaskSchema = z.object({
571
1101
  id: UlidSchema,
1102
+ boardTaskId: z.number().int().positive().nullable(),
572
1103
  title: z.string(),
573
1104
  description: z.string().nullable(),
574
1105
  columnId: z.string(),
@@ -585,8 +1116,29 @@ var TaskSchema = z.object({
585
1116
  updatedAt: z.date(),
586
1117
  startedAt: z.date().nullable(),
587
1118
  completedAt: z.date().nullable(),
1119
+ dueDate: z.date().nullable(),
588
1120
  archived: z.boolean().default(false),
589
- archivedAt: z.date().nullable().optional()
1121
+ archivedAt: z.date().nullable().optional(),
1122
+ updatedBy: z.string().nullable()
1123
+ });
1124
+ var AuditSchema = z.object({
1125
+ id: z.number().int().positive(),
1126
+ timestamp: z.date(),
1127
+ eventType: z.enum(["CREATE", "UPDATE", "DELETE"]),
1128
+ objectType: z.enum(["task", "column", "board"]),
1129
+ objectId: z.string(),
1130
+ fieldName: z.string().nullable(),
1131
+ oldValue: z.string().nullable(),
1132
+ newValue: z.string().nullable(),
1133
+ actor: z.string().nullable()
1134
+ });
1135
+ var LinkTypeSchema = z.enum(["blocks", "blocked_by", "related"]);
1136
+ var TaskLinkSchema = z.object({
1137
+ id: z.number().int().positive(),
1138
+ fromTaskId: UlidSchema,
1139
+ toTaskId: UlidSchema,
1140
+ linkType: LinkTypeSchema,
1141
+ createdAt: z.date()
590
1142
  });
591
1143
  var ColumnSchema = z.object({
592
1144
  id: z.string(),
@@ -598,6 +1150,7 @@ var ColumnSchema = z.object({
598
1150
  var BoardSchema = z.object({
599
1151
  id: UlidSchema,
600
1152
  name: z.string(),
1153
+ maxBoardTaskId: z.number().int().nonnegative(),
601
1154
  createdAt: z.date(),
602
1155
  updatedAt: z.date()
603
1156
  });
@@ -632,14 +1185,16 @@ var AddTaskInputSchema = z.object({
632
1185
  assignedTo: AgentNameSchema.optional(),
633
1186
  dependsOn: z.array(UlidSchema).optional(),
634
1187
  files: z.array(z.string()).optional(),
635
- labels: z.array(z.string().max(50)).optional()
1188
+ labels: z.array(z.string().max(50)).optional(),
1189
+ dueDate: z.string().optional()
636
1190
  });
637
1191
  var UpdateTaskInputSchema = z.object({
638
1192
  title: TitleSchema.optional(),
639
1193
  description: z.string().max(5000).nullable().optional(),
640
1194
  assignedTo: AgentNameSchema.nullable().optional(),
641
1195
  files: z.array(z.string()).optional(),
642
- labels: z.array(z.string().max(50)).optional()
1196
+ labels: z.array(z.string().max(50)).optional(),
1197
+ dueDate: z.string().nullable().optional()
643
1198
  });
644
1199
  var MoveTaskInputSchema = z.object({
645
1200
  id: UlidSchema,
@@ -665,6 +1220,7 @@ var TaskResponseSchema = TaskSchema.extend({
665
1220
  updatedAt: z.string().datetime(),
666
1221
  startedAt: z.string().datetime().nullable(),
667
1222
  completedAt: z.string().datetime().nullable(),
1223
+ dueDate: z.string().datetime().nullable(),
668
1224
  archivedAt: z.string().datetime().nullable().optional()
669
1225
  });
670
1226
  var BoardStatusSchema = z.object({
@@ -691,14 +1247,16 @@ var AddTaskInputJsonSchema = z.object({
691
1247
  assignedTo: AgentNameBaseSchema.optional(),
692
1248
  dependsOn: z.array(UlidSchema).optional(),
693
1249
  files: z.array(z.string()).optional(),
694
- labels: z.array(z.string().max(50)).optional()
1250
+ labels: z.array(z.string().max(50)).optional(),
1251
+ dueDate: z.string().optional()
695
1252
  });
696
1253
  var UpdateTaskInputJsonSchema = z.object({
697
1254
  title: TitleBaseSchema.optional(),
698
1255
  description: z.string().max(5000).nullable().optional(),
699
1256
  assignedTo: AgentNameBaseSchema.nullable().optional(),
700
1257
  files: z.array(z.string()).optional(),
701
- labels: z.array(z.string().max(50)).optional()
1258
+ labels: z.array(z.string().max(50)).optional(),
1259
+ dueDate: z.string().nullable().optional()
702
1260
  });
703
1261
  var ListTasksFilterJsonSchema = z.object({
704
1262
  columnId: ColumnIdSchema.optional(),
@@ -738,9 +1296,103 @@ var jsonSchemas = {
738
1296
  function getJsonSchema(name) {
739
1297
  return jsonSchemas[name];
740
1298
  }
1299
+ // src/services/audit.ts
1300
+ init_schema();
1301
+ import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
1302
+
1303
+ class AuditService {
1304
+ db;
1305
+ constructor(db) {
1306
+ this.db = db;
1307
+ }
1308
+ async getHistory(filter = {}) {
1309
+ const conditions = [];
1310
+ if (filter.objectType) {
1311
+ conditions.push(eq(audits.objectType, filter.objectType));
1312
+ }
1313
+ if (filter.objectId) {
1314
+ conditions.push(eq(audits.objectId, filter.objectId));
1315
+ }
1316
+ if (filter.eventType) {
1317
+ conditions.push(eq(audits.eventType, filter.eventType));
1318
+ }
1319
+ if (filter.actor) {
1320
+ conditions.push(eq(audits.actor, filter.actor));
1321
+ }
1322
+ if (filter.since) {
1323
+ conditions.push(gte(audits.timestamp, filter.since));
1324
+ }
1325
+ if (filter.until) {
1326
+ conditions.push(lte(audits.timestamp, filter.until));
1327
+ }
1328
+ const limit = Math.min(filter.limit ?? 50, 1000);
1329
+ const offset = filter.offset ?? 0;
1330
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
1331
+ const entries = await this.db.select().from(audits).where(whereClause).orderBy(desc(audits.timestamp)).limit(limit + 1).offset(offset);
1332
+ const hasMore = entries.length > limit;
1333
+ if (hasMore)
1334
+ entries.pop();
1335
+ const countResult = await this.db.select({ count: sql`COUNT(*)` }).from(audits).where(whereClause);
1336
+ return {
1337
+ entries,
1338
+ total: countResult[0]?.count ?? 0,
1339
+ hasMore
1340
+ };
1341
+ }
1342
+ async getTaskHistory(taskId, limit = 50) {
1343
+ const result = await this.getHistory({
1344
+ objectType: "task",
1345
+ objectId: taskId,
1346
+ limit
1347
+ });
1348
+ return result.entries;
1349
+ }
1350
+ async getRecentChanges(limit = 50) {
1351
+ const result = await this.getHistory({ limit });
1352
+ return result.entries;
1353
+ }
1354
+ async getChangesByActor(actor, limit = 50) {
1355
+ const result = await this.getHistory({ actor, limit });
1356
+ return result.entries;
1357
+ }
1358
+ async getStats() {
1359
+ const total = await this.db.select({ count: sql`COUNT(*)` }).from(audits);
1360
+ const byEvent = await this.db.select({
1361
+ eventType: audits.eventType,
1362
+ count: sql`COUNT(*)`
1363
+ }).from(audits).groupBy(audits.eventType);
1364
+ const byObject = await this.db.select({
1365
+ objectType: audits.objectType,
1366
+ count: sql`COUNT(*)`
1367
+ }).from(audits).groupBy(audits.objectType);
1368
+ const recentActors = await this.db.selectDistinct({ actor: audits.actor }).from(audits).where(isNotNull(audits.actor)).orderBy(desc(audits.timestamp)).limit(10);
1369
+ const byEventType = {
1370
+ CREATE: 0,
1371
+ UPDATE: 0,
1372
+ DELETE: 0
1373
+ };
1374
+ for (const e of byEvent) {
1375
+ byEventType[e.eventType] = e.count;
1376
+ }
1377
+ const byObjectType = {
1378
+ task: 0,
1379
+ column: 0,
1380
+ board: 0
1381
+ };
1382
+ for (const o of byObject) {
1383
+ byObjectType[o.objectType] = o.count;
1384
+ }
1385
+ return {
1386
+ totalEntries: total[0]?.count ?? 0,
1387
+ byEventType,
1388
+ byObjectType,
1389
+ recentActors: recentActors.map((a) => a.actor).filter((a) => a !== null)
1390
+ };
1391
+ }
1392
+ }
741
1393
  // src/services/board.ts
742
1394
  init_schema();
743
- import { eq } from "drizzle-orm";
1395
+ import { eq as eq2 } from "drizzle-orm";
744
1396
  import { ulid } from "ulid";
745
1397
 
746
1398
  class BoardService {
@@ -771,6 +1423,7 @@ class BoardService {
771
1423
  return {
772
1424
  id: boardId,
773
1425
  name: config.board.name,
1426
+ maxBoardTaskId: 0,
774
1427
  createdAt: now,
775
1428
  updatedAt: now
776
1429
  };
@@ -783,23 +1436,493 @@ class BoardService {
783
1436
  return this.db.select().from(columns).orderBy(columns.position);
784
1437
  }
785
1438
  async getColumn(id) {
786
- const rows = await this.db.select().from(columns).where(eq(columns.id, id));
1439
+ const rows = await this.db.select().from(columns).where(eq2(columns.id, id));
787
1440
  return rows[0] ?? null;
788
1441
  }
789
1442
  async getTerminalColumn() {
790
- const rows = await this.db.select().from(columns).where(eq(columns.isTerminal, true));
1443
+ const rows = await this.db.select().from(columns).where(eq2(columns.isTerminal, true));
791
1444
  return rows[0] ?? null;
792
1445
  }
793
1446
  async getTerminalColumns() {
794
- return this.db.select().from(columns).where(eq(columns.isTerminal, true)).orderBy(columns.position);
1447
+ return this.db.select().from(columns).where(eq2(columns.isTerminal, true)).orderBy(columns.position);
1448
+ }
1449
+ }
1450
+ // src/services/dependency.ts
1451
+ class DependencyService {
1452
+ getTask;
1453
+ constructor(getTask) {
1454
+ this.getTask = getTask;
1455
+ }
1456
+ async wouldCreateCycle(taskId, dependsOnId) {
1457
+ if (taskId === dependsOnId) {
1458
+ return { hasCycle: true, cyclePath: [taskId, taskId] };
1459
+ }
1460
+ const visited = new Set;
1461
+ const path = [];
1462
+ const dfs = async (currentId) => {
1463
+ if (currentId === taskId) {
1464
+ path.push(currentId);
1465
+ return true;
1466
+ }
1467
+ if (visited.has(currentId)) {
1468
+ return false;
1469
+ }
1470
+ visited.add(currentId);
1471
+ path.push(currentId);
1472
+ const task = await this.getTask(currentId);
1473
+ if (!task?.dependsOn?.length) {
1474
+ path.pop();
1475
+ return false;
1476
+ }
1477
+ for (const depId of task.dependsOn) {
1478
+ if (await dfs(depId)) {
1479
+ return true;
1480
+ }
1481
+ }
1482
+ path.pop();
1483
+ return false;
1484
+ };
1485
+ const hasCycle = await dfs(dependsOnId);
1486
+ if (hasCycle) {
1487
+ return { hasCycle: true, cyclePath: [taskId, ...path] };
1488
+ }
1489
+ return { hasCycle: false };
1490
+ }
1491
+ formatCyclePath(path, getShortId) {
1492
+ return path.map((id) => {
1493
+ const shortId = getShortId?.(id);
1494
+ return shortId ? `#${shortId}` : id.slice(0, 8);
1495
+ }).join(" → ");
795
1496
  }
796
1497
  }
1498
+ // src/services/link.ts
1499
+ init_schema();
1500
+ init_types();
1501
+ import { and as and2, eq as eq3, or } from "drizzle-orm";
1502
+
1503
+ class LinkService {
1504
+ db;
1505
+ constructor(db) {
1506
+ this.db = db;
1507
+ }
1508
+ async addLink(fromTaskId, toTaskId, type) {
1509
+ if (fromTaskId === toTaskId) {
1510
+ throw new KabanError("Task cannot link to itself", ExitCode.VALIDATION);
1511
+ }
1512
+ const now = new Date;
1513
+ await this.db.insert(taskLinks).values({
1514
+ fromTaskId,
1515
+ toTaskId,
1516
+ linkType: type,
1517
+ createdAt: now
1518
+ }).onConflictDoNothing();
1519
+ if (type === "blocks") {
1520
+ await this.db.insert(taskLinks).values({
1521
+ fromTaskId: toTaskId,
1522
+ toTaskId: fromTaskId,
1523
+ linkType: "blocked_by",
1524
+ createdAt: now
1525
+ }).onConflictDoNothing();
1526
+ } else if (type === "blocked_by") {
1527
+ await this.db.insert(taskLinks).values({
1528
+ fromTaskId: toTaskId,
1529
+ toTaskId: fromTaskId,
1530
+ linkType: "blocks",
1531
+ createdAt: now
1532
+ }).onConflictDoNothing();
1533
+ }
1534
+ const result = await this.db.select().from(taskLinks).where(and2(eq3(taskLinks.fromTaskId, fromTaskId), eq3(taskLinks.toTaskId, toTaskId), eq3(taskLinks.linkType, type))).limit(1);
1535
+ return result[0];
1536
+ }
1537
+ async removeLink(fromTaskId, toTaskId, type) {
1538
+ await this.db.delete(taskLinks).where(and2(eq3(taskLinks.fromTaskId, fromTaskId), eq3(taskLinks.toTaskId, toTaskId), eq3(taskLinks.linkType, type)));
1539
+ if (type === "blocks") {
1540
+ await this.db.delete(taskLinks).where(and2(eq3(taskLinks.fromTaskId, toTaskId), eq3(taskLinks.toTaskId, fromTaskId), eq3(taskLinks.linkType, "blocked_by")));
1541
+ } else if (type === "blocked_by") {
1542
+ await this.db.delete(taskLinks).where(and2(eq3(taskLinks.fromTaskId, toTaskId), eq3(taskLinks.toTaskId, fromTaskId), eq3(taskLinks.linkType, "blocks")));
1543
+ }
1544
+ }
1545
+ async getLinksFrom(taskId, type) {
1546
+ if (type) {
1547
+ return this.db.select().from(taskLinks).where(and2(eq3(taskLinks.fromTaskId, taskId), eq3(taskLinks.linkType, type)));
1548
+ }
1549
+ return this.db.select().from(taskLinks).where(eq3(taskLinks.fromTaskId, taskId));
1550
+ }
1551
+ async getLinksTo(taskId, type) {
1552
+ if (type) {
1553
+ return this.db.select().from(taskLinks).where(and2(eq3(taskLinks.toTaskId, taskId), eq3(taskLinks.linkType, type)));
1554
+ }
1555
+ return this.db.select().from(taskLinks).where(eq3(taskLinks.toTaskId, taskId));
1556
+ }
1557
+ async getAllLinks(taskId) {
1558
+ return this.db.select().from(taskLinks).where(or(eq3(taskLinks.fromTaskId, taskId), eq3(taskLinks.toTaskId, taskId)));
1559
+ }
1560
+ async getBlockers(taskId) {
1561
+ const links = await this.getLinksFrom(taskId, "blocked_by");
1562
+ return links.map((l) => l.toTaskId);
1563
+ }
1564
+ async getBlocking(taskId) {
1565
+ const links = await this.getLinksFrom(taskId, "blocks");
1566
+ return links.map((l) => l.toTaskId);
1567
+ }
1568
+ }
1569
+ // src/services/markdown.ts
1570
+ class MarkdownService {
1571
+ exportBoard(board, columns2, tasksByColumn, options) {
1572
+ const lines = [];
1573
+ lines.push(`# ${escapeMarkdown(board.name)}`);
1574
+ lines.push("");
1575
+ const sortedColumns = [...columns2].sort((a, b) => a.position - b.position);
1576
+ for (const column of sortedColumns) {
1577
+ lines.push(`## ${escapeMarkdown(column.name)}`);
1578
+ if (column.wipLimit !== null) {
1579
+ lines.push(`<!-- WIP Limit: ${column.wipLimit} -->`);
1580
+ }
1581
+ if (column.isTerminal) {
1582
+ lines.push(`<!-- Terminal column -->`);
1583
+ }
1584
+ lines.push("");
1585
+ const tasks2 = tasksByColumn.get(column.id) || [];
1586
+ const sortedTasks = [...tasks2].sort((a, b) => a.position - b.position);
1587
+ for (const task of sortedTasks) {
1588
+ if (!options?.includeArchived && task.archived)
1589
+ continue;
1590
+ if (options?.includeMetadata) {
1591
+ lines.push(`- ${escapeMarkdown(task.title)} <!-- id:${task.id} -->`);
1592
+ } else {
1593
+ lines.push(`- ${escapeMarkdown(task.title)}`);
1594
+ }
1595
+ if (task.dueDate) {
1596
+ const checkmark = task.completedAt ? " ✓" : "";
1597
+ lines.push(` @ ${formatDate(task.dueDate)}${checkmark}`);
1598
+ }
1599
+ if (task.labels && task.labels.length > 0) {
1600
+ lines.push(` # ${task.labels.join(", ")}`);
1601
+ }
1602
+ if (task.assignedTo) {
1603
+ lines.push(` @ assigned: ${task.assignedTo}`);
1604
+ }
1605
+ if (task.description) {
1606
+ const descLines = task.description.split(`
1607
+ `);
1608
+ for (const line of descLines) {
1609
+ lines.push(` > ${escapeMarkdown(line)}`);
1610
+ }
1611
+ }
1612
+ lines.push("");
1613
+ }
1614
+ }
1615
+ return lines.join(`
1616
+ `).trimEnd() + `
1617
+ `;
1618
+ }
1619
+ parseMarkdown(content) {
1620
+ const lines = content.split(`
1621
+ `);
1622
+ const errors = [];
1623
+ let boardName = "Imported Board";
1624
+ const columns2 = [];
1625
+ let currentColumn = null;
1626
+ let currentTask = null;
1627
+ for (let i = 0;i < lines.length; i++) {
1628
+ const line = lines[i];
1629
+ const lineNum = i + 1;
1630
+ if (line.startsWith("# ") && !line.startsWith("## ")) {
1631
+ boardName = unescapeMarkdown(line.slice(2).trim());
1632
+ continue;
1633
+ }
1634
+ if (line.startsWith("## ")) {
1635
+ if (currentTask && currentColumn) {
1636
+ currentColumn.tasks.push(currentTask);
1637
+ currentTask = null;
1638
+ }
1639
+ if (currentColumn) {
1640
+ columns2.push(currentColumn);
1641
+ }
1642
+ currentColumn = {
1643
+ name: unescapeMarkdown(line.slice(3).trim()),
1644
+ tasks: []
1645
+ };
1646
+ continue;
1647
+ }
1648
+ if (line.includes("WIP Limit:") && currentColumn) {
1649
+ const match = line.match(/WIP Limit:\s*(\d+|none)/i);
1650
+ if (match) {
1651
+ currentColumn.wipLimit = match[1].toLowerCase() === "none" ? undefined : parseInt(match[1], 10);
1652
+ }
1653
+ continue;
1654
+ }
1655
+ if (line.includes("Terminal column") && currentColumn) {
1656
+ currentColumn.isTerminal = true;
1657
+ continue;
1658
+ }
1659
+ if (line.startsWith("- ")) {
1660
+ if (currentTask && currentColumn) {
1661
+ currentColumn.tasks.push(currentTask);
1662
+ }
1663
+ let title = line.slice(2);
1664
+ let id;
1665
+ const idMatch = title.match(/<!--\s*id:([^\s]+)\s*-->/);
1666
+ if (idMatch) {
1667
+ id = idMatch[1];
1668
+ title = title.replace(idMatch[0], "").trim();
1669
+ }
1670
+ currentTask = {
1671
+ title: unescapeMarkdown(title),
1672
+ id,
1673
+ description: null,
1674
+ dueDate: null,
1675
+ labels: []
1676
+ };
1677
+ continue;
1678
+ }
1679
+ const indentMatch = line.match(/^(\s{4}|\t)/);
1680
+ if (indentMatch && currentTask) {
1681
+ const content2 = line.slice(indentMatch[0].length);
1682
+ if (content2.startsWith("@ ") && !content2.includes("assigned:")) {
1683
+ const dueLine = content2.slice(2).trim();
1684
+ const dateStr = dueLine.replace("✓", "").trim();
1685
+ const parsed = parseISODate(dateStr);
1686
+ if (parsed) {
1687
+ currentTask.dueDate = parsed;
1688
+ } else {
1689
+ errors.push(`Line ${lineNum}: Invalid date format "${dateStr}"`);
1690
+ }
1691
+ continue;
1692
+ }
1693
+ if (content2.includes("assigned:")) {
1694
+ const match = content2.match(/assigned:\s*(\S+)/);
1695
+ if (match) {
1696
+ currentTask.assignedTo = match[1];
1697
+ }
1698
+ continue;
1699
+ }
1700
+ if (content2.startsWith("# ")) {
1701
+ const labelStr = content2.slice(2).trim();
1702
+ currentTask.labels = labelStr.split(",").map((l) => l.trim()).filter(Boolean);
1703
+ continue;
1704
+ }
1705
+ if (content2.startsWith("> ")) {
1706
+ const descLine = unescapeMarkdown(content2.slice(2));
1707
+ if (currentTask.description === null) {
1708
+ currentTask.description = descLine;
1709
+ } else {
1710
+ currentTask.description += `
1711
+ ` + descLine;
1712
+ }
1713
+ continue;
1714
+ }
1715
+ }
1716
+ }
1717
+ if (currentTask && currentColumn) {
1718
+ currentColumn.tasks.push(currentTask);
1719
+ }
1720
+ if (currentColumn) {
1721
+ columns2.push(currentColumn);
1722
+ }
1723
+ return { boardName, columns: columns2, errors };
1724
+ }
1725
+ }
1726
+ function escapeMarkdown(text2) {
1727
+ return text2.replace(/\\/g, "\\\\").replace(/\n/g, " ").replace(/<!--/g, "\\<!--");
1728
+ }
1729
+ function unescapeMarkdown(text2) {
1730
+ return text2.replace(/\\<!--/g, "<!--").replace(/\\\\/g, "\\");
1731
+ }
1732
+ function formatDate(date) {
1733
+ return date.toISOString().split("T")[0];
1734
+ }
1735
+ function parseISODate(str) {
1736
+ const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
1737
+ if (!match)
1738
+ return null;
1739
+ const date = new Date(str + "T00:00:00.000Z");
1740
+ if (isNaN(date.getTime()))
1741
+ return null;
1742
+ return date;
1743
+ }
1744
+ // src/services/scoring/scorers/fifo.ts
1745
+ var fifoScorer = {
1746
+ name: "fifo",
1747
+ description: "First In, First Out - oldest tasks scored higher",
1748
+ units: "age",
1749
+ async score(task) {
1750
+ const ageMs = Date.now() - task.createdAt.getTime();
1751
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
1752
+ return ageDays;
1753
+ }
1754
+ };
1755
+ // src/services/scoring/scorers/priority.ts
1756
+ var PRIORITY_WEIGHTS = {
1757
+ critical: 1000,
1758
+ urgent: 500,
1759
+ high: 100,
1760
+ medium: 50,
1761
+ low: 10,
1762
+ p0: 1000,
1763
+ p1: 500,
1764
+ p2: 100,
1765
+ p3: 50,
1766
+ p4: 10
1767
+ };
1768
+ var priorityScorer = {
1769
+ name: "priority",
1770
+ description: "Score based on priority labels (critical, urgent, high, p0-p4)",
1771
+ units: "priority",
1772
+ async score(task) {
1773
+ let maxPriority = 0;
1774
+ for (const label of task.labels) {
1775
+ const weight = PRIORITY_WEIGHTS[label.toLowerCase()];
1776
+ if (weight && weight > maxPriority) {
1777
+ maxPriority = weight;
1778
+ }
1779
+ }
1780
+ return maxPriority;
1781
+ }
1782
+ };
1783
+ // src/services/scoring/scorers/due-date.ts
1784
+ var dueDateScorer = {
1785
+ name: "due-date",
1786
+ description: "Score based on due date urgency (closer/overdue = higher)",
1787
+ units: "urgency",
1788
+ async score(task) {
1789
+ if (!task.dueDate) {
1790
+ return 0;
1791
+ }
1792
+ const now = Date.now();
1793
+ const dueTime = task.dueDate.getTime();
1794
+ const daysUntilDue = (dueTime - now) / (1000 * 60 * 60 * 24);
1795
+ if (daysUntilDue <= 0) {
1796
+ return 1000 + Math.abs(daysUntilDue) * 10;
1797
+ }
1798
+ if (daysUntilDue <= 1) {
1799
+ return 500;
1800
+ }
1801
+ if (daysUntilDue <= 7) {
1802
+ return 100 + (7 - daysUntilDue) * 10;
1803
+ }
1804
+ return Math.max(0, 50 - daysUntilDue);
1805
+ }
1806
+ };
1807
+ // src/services/scoring/scorers/blocking.ts
1808
+ function createBlockingScorer(getBlockingCount) {
1809
+ return {
1810
+ name: "blocking",
1811
+ description: "Score based on how many tasks this blocks",
1812
+ units: "blocked",
1813
+ async score(task) {
1814
+ const blockingCount = await getBlockingCount(task.id);
1815
+ return blockingCount * 50;
1816
+ }
1817
+ };
1818
+ }
1819
+
1820
+ // src/services/scoring/index.ts
1821
+ class ScoringService {
1822
+ scorers = [];
1823
+ addScorer(scorer) {
1824
+ this.scorers.push(scorer);
1825
+ }
1826
+ removeScorer(name) {
1827
+ this.scorers = this.scorers.filter((s) => s.name !== name);
1828
+ }
1829
+ getScorers() {
1830
+ return [...this.scorers];
1831
+ }
1832
+ async scoreTask(task) {
1833
+ const breakdown = {};
1834
+ let totalScore = 0;
1835
+ for (const scorer of this.scorers) {
1836
+ const score = await scorer.score(task);
1837
+ breakdown[scorer.name] = score;
1838
+ totalScore += score;
1839
+ }
1840
+ return {
1841
+ task,
1842
+ score: totalScore,
1843
+ breakdown
1844
+ };
1845
+ }
1846
+ async rankTasks(tasks2) {
1847
+ const scored = await Promise.all(tasks2.map((task) => this.scoreTask(task)));
1848
+ return scored.sort((a, b) => b.score - a.score);
1849
+ }
1850
+ }
1851
+ function createDefaultScoringService() {
1852
+ return new ScoringService;
1853
+ }
797
1854
  // src/services/task.ts
798
1855
  init_schema();
799
1856
  init_types();
800
- import { and, eq as eq2, inArray, lt, sql } from "drizzle-orm";
1857
+ import { and as and3, eq as eq4, inArray, lt, sql as sql2 } from "drizzle-orm";
801
1858
  import { ulid as ulid2 } from "ulid";
802
1859
 
1860
+ // src/utils/date-parser.ts
1861
+ import * as chrono from "chrono-node";
1862
+
1863
+ class DateParseError extends Error {
1864
+ input;
1865
+ suggestion;
1866
+ constructor(input, suggestion) {
1867
+ super(`Unable to parse date: "${input}"${suggestion ? `. Did you mean: ${suggestion}?` : ""}`);
1868
+ this.input = input;
1869
+ this.suggestion = suggestion;
1870
+ this.name = "DateParseError";
1871
+ }
1872
+ }
1873
+ var DURATION_REGEX = /^(\d+w)?\s*(\d+d)?\s*(\d+h)?\s*(\d+m)?$/i;
1874
+ function parseDate2(input, referenceDate) {
1875
+ const trimmed = input.trim().toLowerCase();
1876
+ if (!trimmed) {
1877
+ throw new DateParseError(input);
1878
+ }
1879
+ if (/^-\d|in\s+-\d/i.test(trimmed)) {
1880
+ throw new DateParseError(input, "Negative values not supported. Use positive durations.");
1881
+ }
1882
+ const durationMatch = trimmed.match(DURATION_REGEX);
1883
+ if (durationMatch && (durationMatch[1] || durationMatch[2] || durationMatch[3] || durationMatch[4])) {
1884
+ const weeks = parseInt(durationMatch[1] || "0", 10);
1885
+ const days = parseInt(durationMatch[2] || "0", 10);
1886
+ const hours = parseInt(durationMatch[3] || "0", 10);
1887
+ const minutes = parseInt(durationMatch[4] || "0", 10);
1888
+ const ref2 = referenceDate ?? new Date;
1889
+ const result = new Date(ref2);
1890
+ result.setDate(result.getDate() + weeks * 7 + days);
1891
+ result.setHours(result.getHours() + hours);
1892
+ result.setMinutes(result.getMinutes() + minutes);
1893
+ return { date: result, hasTime: hours > 0 || minutes > 0 };
1894
+ }
1895
+ const ref = referenceDate ?? new Date;
1896
+ const parsed = chrono.parseDate(input, ref, { forwardDate: true });
1897
+ if (!parsed) {
1898
+ const suggestions = {
1899
+ tmrw: "tomorrow",
1900
+ tom: "tomorrow",
1901
+ tomorow: "tomorrow",
1902
+ nxt: "next",
1903
+ wk: "week"
1904
+ };
1905
+ const words = trimmed.split(/\s+/);
1906
+ for (const word of words) {
1907
+ if (suggestions[word]) {
1908
+ throw new DateParseError(input, input.replace(word, suggestions[word]));
1909
+ }
1910
+ }
1911
+ throw new DateParseError(input);
1912
+ }
1913
+ const hasTime = /\d{1,2}:\d{2}|(\d{1,2})\s*(am|pm)/i.test(input) || /at\s+\d/i.test(input);
1914
+ return { date: parsed, hasTime };
1915
+ }
1916
+ function parseDateOrNull(input, referenceDate) {
1917
+ if (!input?.trim())
1918
+ return null;
1919
+ try {
1920
+ return parseDate2(input, referenceDate).date;
1921
+ } catch {
1922
+ return null;
1923
+ }
1924
+ }
1925
+
803
1926
  // src/utils/similarity.ts
804
1927
  function tokenize(text2) {
805
1928
  const words = text2.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((word) => word.length > 0);
@@ -855,9 +1978,11 @@ class TaskService {
855
1978
  threshold: 0.5,
856
1979
  warnThreshold: 0.5
857
1980
  };
1981
+ depService;
858
1982
  constructor(db, boardService) {
859
1983
  this.db = db;
860
1984
  this.boardService = boardService;
1985
+ this.depService = new DependencyService(this.getTask.bind(this));
861
1986
  }
862
1987
  async getTaskOrThrow(id) {
863
1988
  const task = await this.getTask(id);
@@ -870,61 +1995,80 @@ class TaskService {
870
1995
  const title = validateTitle(input.title);
871
1996
  const createdBy = input.createdBy ? validateAgentName(input.createdBy) : input.agent ? validateAgentName(input.agent) : "user";
872
1997
  const columnId = input.columnId ? validateColumnId(input.columnId) : "todo";
1998
+ const dueDate = parseDateOrNull(input.dueDate);
873
1999
  const column = await this.boardService.getColumn(columnId);
874
2000
  if (!column) {
875
2001
  throw new KabanError(`Column '${columnId}' does not exist`, ExitCode.VALIDATION);
876
2002
  }
2003
+ const board = await this.boardService.getBoard();
2004
+ if (!board) {
2005
+ throw new KabanError("No board found", ExitCode.NOT_FOUND);
2006
+ }
877
2007
  const now = new Date;
878
2008
  const id = ulid2();
879
- const maxPositionResult = await this.db.select({ max: sql`COALESCE(MAX(position), -1)` }).from(tasks).where(eq2(tasks.columnId, columnId));
2009
+ const maxPositionResult = await this.db.select({ max: sql2`COALESCE(MAX(position), -1)` }).from(tasks).where(eq4(tasks.columnId, columnId));
880
2010
  const position = (maxPositionResult[0]?.max ?? -1) + 1;
881
- await this.db.insert(tasks).values({
882
- id,
883
- title,
884
- description: input.description ?? null,
885
- columnId,
886
- position,
887
- createdBy,
888
- assignedTo: null,
889
- parentId: null,
890
- dependsOn: input.dependsOn ?? [],
891
- files: input.files ?? [],
892
- labels: input.labels ?? [],
893
- blockedReason: null,
894
- version: 1,
895
- createdAt: now,
896
- updatedAt: now,
897
- startedAt: null,
898
- completedAt: null,
899
- archived: false,
900
- archivedAt: null
901
- });
2011
+ await this.db.run(sql2`
2012
+ UPDATE boards
2013
+ SET max_board_task_id = max_board_task_id + 1
2014
+ WHERE id = ${board.id}
2015
+ `);
2016
+ await this.db.run(sql2`
2017
+ INSERT INTO tasks (
2018
+ id, board_task_id, title, description, column_id, position,
2019
+ created_by, assigned_to, parent_id, depends_on, files, labels,
2020
+ blocked_reason, version, created_at, updated_at, started_at,
2021
+ completed_at, due_date, archived, archived_at
2022
+ ) VALUES (
2023
+ ${id},
2024
+ (SELECT max_board_task_id FROM boards WHERE id = ${board.id}),
2025
+ ${title},
2026
+ ${input.description ?? null},
2027
+ ${columnId},
2028
+ ${position},
2029
+ ${createdBy},
2030
+ ${null},
2031
+ ${null},
2032
+ ${JSON.stringify(input.dependsOn ?? [])},
2033
+ ${JSON.stringify(input.files ?? [])},
2034
+ ${JSON.stringify(input.labels ?? [])},
2035
+ ${null},
2036
+ ${1},
2037
+ ${Math.floor(now.getTime() / 1000)},
2038
+ ${Math.floor(now.getTime() / 1000)},
2039
+ ${null},
2040
+ ${null},
2041
+ ${dueDate ? Math.floor(dueDate.getTime() / 1000) : null},
2042
+ ${0},
2043
+ ${null}
2044
+ )
2045
+ `);
902
2046
  return this.getTaskOrThrow(id);
903
2047
  }
904
2048
  async getTask(id) {
905
- const rows = await this.db.select().from(tasks).where(eq2(tasks.id, id));
2049
+ const rows = await this.db.select().from(tasks).where(eq4(tasks.id, id));
906
2050
  return rows[0] ?? null;
907
2051
  }
908
2052
  async listTasks(filter) {
909
2053
  const conditions = [];
910
2054
  if (!filter?.includeArchived) {
911
- conditions.push(eq2(tasks.archived, false));
2055
+ conditions.push(eq4(tasks.archived, false));
912
2056
  }
913
2057
  if (filter?.columnId) {
914
- conditions.push(eq2(tasks.columnId, filter.columnId));
2058
+ conditions.push(eq4(tasks.columnId, filter.columnId));
915
2059
  }
916
2060
  const creatorFilter = filter?.createdBy ?? filter?.agent;
917
2061
  if (creatorFilter) {
918
- conditions.push(eq2(tasks.createdBy, creatorFilter));
2062
+ conditions.push(eq4(tasks.createdBy, creatorFilter));
919
2063
  }
920
2064
  if (filter?.assignee) {
921
- conditions.push(eq2(tasks.assignedTo, filter.assignee));
2065
+ conditions.push(eq4(tasks.assignedTo, filter.assignee));
922
2066
  }
923
2067
  if (filter?.blocked === true) {
924
- conditions.push(sql`${tasks.blockedReason} IS NOT NULL`);
2068
+ conditions.push(sql2`${tasks.blockedReason} IS NOT NULL`);
925
2069
  }
926
2070
  if (conditions.length > 0) {
927
- return this.db.select().from(tasks).where(and(...conditions)).orderBy(tasks.columnId, tasks.position);
2071
+ return this.db.select().from(tasks).where(and3(...conditions)).orderBy(tasks.columnId, tasks.position);
928
2072
  }
929
2073
  return this.db.select().from(tasks).orderBy(tasks.columnId, tasks.position);
930
2074
  }
@@ -933,7 +2077,7 @@ class TaskService {
933
2077
  if (!task) {
934
2078
  throw new KabanError(`Task '${id}' not found`, ExitCode.NOT_FOUND);
935
2079
  }
936
- await this.db.delete(tasks).where(eq2(tasks.id, id));
2080
+ await this.db.delete(tasks).where(eq4(tasks.id, id));
937
2081
  }
938
2082
  async moveTask(id, columnId, options) {
939
2083
  const task = await this.getTask(id);
@@ -959,7 +2103,7 @@ class TaskService {
959
2103
  }
960
2104
  const now = new Date;
961
2105
  const isTerminal = column.isTerminal;
962
- const maxPositionResult = await this.db.select({ max: sql`COALESCE(MAX(position), -1)` }).from(tasks).where(eq2(tasks.columnId, columnId));
2106
+ const maxPositionResult = await this.db.select({ max: sql2`COALESCE(MAX(position), -1)` }).from(tasks).where(eq4(tasks.columnId, columnId));
963
2107
  const newPosition = (maxPositionResult[0]?.max ?? -1) + 1;
964
2108
  await this.db.update(tasks).set({
965
2109
  columnId,
@@ -967,11 +2111,12 @@ class TaskService {
967
2111
  version: task.version + 1,
968
2112
  updatedAt: now,
969
2113
  completedAt: isTerminal ? now : task.completedAt,
970
- startedAt: columnId === "in_progress" && !task.startedAt ? now : task.startedAt
971
- }).where(eq2(tasks.id, id));
2114
+ startedAt: columnId === "in_progress" && !task.startedAt ? now : task.startedAt,
2115
+ updatedBy: options?.actor ?? null
2116
+ }).where(eq4(tasks.id, id));
972
2117
  return this.getTaskOrThrow(id);
973
2118
  }
974
- async updateTask(id, input, expectedVersion) {
2119
+ async updateTask(id, input, expectedVersion, actor) {
975
2120
  const task = await this.getTask(id);
976
2121
  if (!task) {
977
2122
  throw new KabanError(`Task '${id}' not found`, ExitCode.NOT_FOUND);
@@ -981,7 +2126,8 @@ class TaskService {
981
2126
  }
982
2127
  const updates = {
983
2128
  version: task.version + 1,
984
- updatedAt: new Date
2129
+ updatedAt: new Date,
2130
+ updatedBy: actor ?? null
985
2131
  };
986
2132
  if (input.title !== undefined) {
987
2133
  updates.title = validateTitle(input.title);
@@ -998,11 +2144,14 @@ class TaskService {
998
2144
  if (input.labels !== undefined) {
999
2145
  updates.labels = input.labels;
1000
2146
  }
1001
- await this.db.update(tasks).set(updates).where(expectedVersion !== undefined ? and(eq2(tasks.id, id), eq2(tasks.version, expectedVersion)) : eq2(tasks.id, id));
2147
+ if (input.dueDate !== undefined) {
2148
+ updates.dueDate = input.dueDate ? parseDateOrNull(input.dueDate) : null;
2149
+ }
2150
+ await this.db.update(tasks).set(updates).where(expectedVersion !== undefined ? and3(eq4(tasks.id, id), eq4(tasks.version, expectedVersion)) : eq4(tasks.id, id));
1002
2151
  return this.getTaskOrThrow(id);
1003
2152
  }
1004
2153
  async getTaskCountInColumn(columnId) {
1005
- const result = await this.db.select({ count: sql`COUNT(*)` }).from(tasks).where(eq2(tasks.columnId, columnId));
2154
+ const result = await this.db.select({ count: sql2`COUNT(*)` }).from(tasks).where(eq4(tasks.columnId, columnId));
1006
2155
  return result[0]?.count ?? 0;
1007
2156
  }
1008
2157
  async archiveTasks(criteria) {
@@ -1010,9 +2159,9 @@ class TaskService {
1010
2159
  if (!hasCriteria) {
1011
2160
  throw new KabanError("At least one criteria must be provided", ExitCode.VALIDATION);
1012
2161
  }
1013
- const conditions = [eq2(tasks.archived, false)];
2162
+ const conditions = [eq4(tasks.archived, false)];
1014
2163
  if (criteria.status) {
1015
- conditions.push(eq2(tasks.columnId, criteria.status));
2164
+ conditions.push(eq4(tasks.columnId, criteria.status));
1016
2165
  }
1017
2166
  if (criteria.olderThan) {
1018
2167
  conditions.push(lt(tasks.createdAt, criteria.olderThan));
@@ -1020,7 +2169,7 @@ class TaskService {
1020
2169
  if (criteria.taskIds?.length) {
1021
2170
  conditions.push(inArray(tasks.id, criteria.taskIds));
1022
2171
  }
1023
- const matchingTasks = await this.db.select({ id: tasks.id }).from(tasks).where(and(...conditions));
2172
+ const matchingTasks = await this.db.select({ id: tasks.id }).from(tasks).where(and3(...conditions));
1024
2173
  if (matchingTasks.length === 0) {
1025
2174
  return { archivedCount: 0, taskIds: [] };
1026
2175
  }
@@ -1059,16 +2208,16 @@ class TaskService {
1059
2208
  columnId,
1060
2209
  version: task.version + 1,
1061
2210
  updatedAt: now
1062
- }).where(eq2(tasks.id, taskId));
2211
+ }).where(eq4(tasks.id, taskId));
1063
2212
  return this.getTaskOrThrow(taskId);
1064
2213
  }
1065
2214
  async searchArchive(query, options) {
1066
2215
  const limit = options?.limit ?? 50;
1067
2216
  const offset = options?.offset ?? 0;
1068
2217
  if (!query.trim()) {
1069
- const countResult = await this.db.select({ count: sql`COUNT(*)` }).from(tasks).where(eq2(tasks.archived, true));
2218
+ const countResult = await this.db.select({ count: sql2`COUNT(*)` }).from(tasks).where(eq4(tasks.archived, true));
1070
2219
  const total2 = countResult[0]?.count ?? 0;
1071
- const archivedTasks = await this.db.select().from(tasks).where(eq2(tasks.archived, true)).orderBy(tasks.archivedAt).limit(limit).offset(offset);
2220
+ const archivedTasks = await this.db.select().from(tasks).where(eq4(tasks.archived, true)).orderBy(tasks.archivedAt).limit(limit).offset(offset);
1072
2221
  return { tasks: archivedTasks, total: total2 };
1073
2222
  }
1074
2223
  const ftsQuery = query.trim().split(/\s+/).map((term) => `"${term}"`).join(" ");
@@ -1086,6 +2235,7 @@ class TaskService {
1086
2235
  LIMIT ? OFFSET ?`).all(ftsQuery, limit, offset);
1087
2236
  const searchTasks = rows.map((row) => ({
1088
2237
  id: row.id,
2238
+ boardTaskId: row.board_task_id,
1089
2239
  title: row.title,
1090
2240
  description: row.description,
1091
2241
  columnId: row.column_id,
@@ -1102,17 +2252,19 @@ class TaskService {
1102
2252
  updatedAt: new Date(row.updated_at),
1103
2253
  startedAt: row.started_at ? new Date(row.started_at) : null,
1104
2254
  completedAt: row.completed_at ? new Date(row.completed_at) : null,
2255
+ dueDate: row.due_date ? new Date(row.due_date * 1000) : null,
1105
2256
  archived: Boolean(row.archived),
1106
- archivedAt: row.archived_at ? new Date(row.archived_at) : null
2257
+ archivedAt: row.archived_at ? new Date(row.archived_at) : null,
2258
+ updatedBy: row.updated_by
1107
2259
  }));
1108
2260
  return { tasks: searchTasks, total };
1109
2261
  }
1110
2262
  async purgeArchive(criteria) {
1111
- const conditions = [eq2(tasks.archived, true)];
2263
+ const conditions = [eq4(tasks.archived, true)];
1112
2264
  if (criteria?.olderThan) {
1113
2265
  conditions.push(lt(tasks.archivedAt, criteria.olderThan));
1114
2266
  }
1115
- const matchingTasks = await this.db.select({ id: tasks.id }).from(tasks).where(and(...conditions));
2267
+ const matchingTasks = await this.db.select({ id: tasks.id }).from(tasks).where(and3(...conditions));
1116
2268
  if (matchingTasks.length === 0) {
1117
2269
  return { deletedCount: 0 };
1118
2270
  }
@@ -1125,7 +2277,7 @@ class TaskService {
1125
2277
  if (allTasks.length === 0) {
1126
2278
  return { deletedCount: 0 };
1127
2279
  }
1128
- await this.db.delete(tasks).where(sql`1=1`);
2280
+ await this.db.delete(tasks);
1129
2281
  return { deletedCount: allTasks.length };
1130
2282
  }
1131
2283
  async addDependency(taskId, dependsOnId) {
@@ -1137,13 +2289,18 @@ class TaskService {
1137
2289
  if (task.dependsOn.includes(dependsOnId)) {
1138
2290
  return task;
1139
2291
  }
2292
+ const cycleCheck = await this.depService.wouldCreateCycle(taskId, dependsOnId);
2293
+ if (cycleCheck.hasCycle && cycleCheck.cyclePath) {
2294
+ const cyclePath = this.depService.formatCyclePath(cycleCheck.cyclePath);
2295
+ throw new KabanError(`Cannot add dependency - would create cycle: ${cyclePath}`, ExitCode.VALIDATION);
2296
+ }
1140
2297
  const updatedDependsOn = [...task.dependsOn, dependsOnId];
1141
2298
  const now = new Date;
1142
2299
  await this.db.update(tasks).set({
1143
2300
  dependsOn: updatedDependsOn,
1144
2301
  version: task.version + 1,
1145
2302
  updatedAt: now
1146
- }).where(eq2(tasks.id, taskId));
2303
+ }).where(eq4(tasks.id, taskId));
1147
2304
  return this.getTaskOrThrow(taskId);
1148
2305
  }
1149
2306
  async removeDependency(taskId, dependsOnId) {
@@ -1157,7 +2314,7 @@ class TaskService {
1157
2314
  dependsOn: updatedDependsOn,
1158
2315
  version: task.version + 1,
1159
2316
  updatedAt: now
1160
- }).where(eq2(tasks.id, taskId));
2317
+ }).where(eq4(tasks.id, taskId));
1161
2318
  return this.getTaskOrThrow(taskId);
1162
2319
  }
1163
2320
  async validateDependencies(taskId) {
@@ -1213,7 +2370,7 @@ class TaskService {
1213
2370
  };
1214
2371
  }
1215
2372
  async getArchiveStats() {
1216
- const archived = await this.db.select().from(tasks).where(eq2(tasks.archived, true));
2373
+ const archived = await this.db.select().from(tasks).where(eq4(tasks.archived, true));
1217
2374
  const byColumn = {};
1218
2375
  let oldestArchivedAt = null;
1219
2376
  for (const task of archived) {
@@ -1228,6 +2385,37 @@ class TaskService {
1228
2385
  oldestArchivedAt
1229
2386
  };
1230
2387
  }
2388
+ async resolveTask(idOrShortId, boardId) {
2389
+ const cleanId = idOrShortId.replace(/^#/, "").trim();
2390
+ if (/^\d+$/.test(cleanId)) {
2391
+ const shortId = parseInt(cleanId, 10);
2392
+ const board = boardId ? { id: boardId } : await this.boardService.getBoard();
2393
+ if (board) {
2394
+ return this.getTaskByBoardTaskId(board.id, shortId);
2395
+ }
2396
+ return null;
2397
+ }
2398
+ if (/^[0-9A-Z]{26}$/i.test(cleanId)) {
2399
+ return this.getTask(cleanId);
2400
+ }
2401
+ if (cleanId.length >= 4 && /^[0-9A-Z]+$/i.test(cleanId)) {
2402
+ return this.getTaskByIdPrefix(cleanId);
2403
+ }
2404
+ return null;
2405
+ }
2406
+ async getTaskByBoardTaskId(boardId, boardTaskId) {
2407
+ const result = await this.db.select().from(tasks).innerJoin(columns, eq4(tasks.columnId, columns.id)).where(and3(eq4(columns.boardId, boardId), eq4(tasks.boardTaskId, boardTaskId))).limit(1);
2408
+ return result[0]?.tasks ?? null;
2409
+ }
2410
+ async getTaskByIdPrefix(prefix) {
2411
+ const result = await this.db.select().from(tasks).where(sql2`${tasks.id} LIKE ${prefix.toUpperCase() + "%"}`).limit(2);
2412
+ if (result.length === 0)
2413
+ return null;
2414
+ if (result.length > 1) {
2415
+ throw new KabanError(`Ambiguous task ID prefix: ${prefix}. Multiple matches found.`, ExitCode.VALIDATION);
2416
+ }
2417
+ return result[0];
2418
+ }
1231
2419
  }
1232
2420
 
1233
2421
  // src/index.ts
@@ -1240,26 +2428,38 @@ export {
1240
2428
  undoLog,
1241
2429
  tokenize,
1242
2430
  tasks,
2431
+ taskLinks,
1243
2432
  runMigrations,
2433
+ priorityScorer,
2434
+ linkTypes,
1244
2435
  jsonSchemas,
1245
2436
  jaccardSimilarity,
1246
2437
  isValidUlid,
1247
2438
  initializeSchema,
1248
2439
  getJsonSchema,
2440
+ fifoScorer,
2441
+ dueDateScorer,
2442
+ createDefaultScoringService,
1249
2443
  createDb,
2444
+ createBlockingScorer,
1250
2445
  columns,
1251
2446
  boards,
2447
+ audits,
1252
2448
  UpdateTaskInputSchema,
1253
2449
  UlidSchema,
1254
2450
  TitleSchema,
1255
2451
  TaskService,
1256
2452
  TaskSchema,
1257
2453
  TaskResponseSchema,
2454
+ ScoringService,
1258
2455
  MoveTaskInputSchema,
2456
+ MarkdownService,
1259
2457
  ListTasksFilterSchema,
2458
+ LinkService,
1260
2459
  KabanError,
1261
2460
  GetTaskInputSchema,
1262
2461
  ExitCode,
2462
+ DependencyService,
1263
2463
  DeleteTaskInputSchema,
1264
2464
  DEFAULT_CONFIG,
1265
2465
  ConfigSchema,
@@ -1269,6 +2469,7 @@ export {
1269
2469
  BoardStatusSchema,
1270
2470
  BoardService,
1271
2471
  BoardSchema,
2472
+ AuditService,
1272
2473
  ApiResponseSchema,
1273
2474
  AgentNameSchema,
1274
2475
  AddTaskInputSchema