@kaban-board/core 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 -80
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1267 -81
  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
@@ -1,20 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- var __create = Object.create;
3
- var __getProtoOf = Object.getPrototypeOf;
4
2
  var __defProp = Object.defineProperty;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __toESM = (mod, isNodeMode, target) => {
8
- target = mod != null ? __create(__getProtoOf(mod)) : {};
9
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
- for (let key of __getOwnPropNames(mod))
11
- if (!__hasOwnProp.call(to, key))
12
- __defProp(to, key, {
13
- get: () => mod[key],
14
- enumerable: true
15
- });
16
- return to;
17
- };
18
3
  var __export = (target, all) => {
19
4
  for (var name in all)
20
5
  __defProp(target, name, {
@@ -90,15 +75,19 @@ var exports_schema = {};
90
75
  __export(exports_schema, {
91
76
  undoLog: () => undoLog,
92
77
  tasks: () => tasks,
78
+ taskLinks: () => taskLinks,
79
+ linkTypes: () => linkTypes,
93
80
  columns: () => columns,
94
- boards: () => boards
81
+ boards: () => boards,
82
+ audits: () => audits
95
83
  });
96
84
  import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
97
- var boards, columns, tasks, undoLog;
85
+ var boards, columns, tasks, undoLog, audits, linkTypes, taskLinks;
98
86
  var init_schema = __esm(() => {
99
87
  boards = sqliteTable("boards", {
100
88
  id: text("id").primaryKey(),
101
89
  name: text("name").notNull(),
90
+ maxBoardTaskId: integer("max_board_task_id").notNull().default(0),
102
91
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
103
92
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
104
93
  });
@@ -112,6 +101,7 @@ var init_schema = __esm(() => {
112
101
  });
113
102
  tasks = sqliteTable("tasks", {
114
103
  id: text("id").primaryKey(),
104
+ boardTaskId: integer("board_task_id"),
115
105
  title: text("title").notNull(),
116
106
  description: text("description"),
117
107
  columnId: text("column_id").notNull().references(() => columns.id),
@@ -128,8 +118,10 @@ var init_schema = __esm(() => {
128
118
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
129
119
  startedAt: integer("started_at", { mode: "timestamp" }),
130
120
  completedAt: integer("completed_at", { mode: "timestamp" }),
121
+ dueDate: integer("due_date", { mode: "timestamp" }),
131
122
  archived: integer("archived", { mode: "boolean" }).notNull().default(false),
132
- archivedAt: integer("archived_at", { mode: "timestamp" })
123
+ archivedAt: integer("archived_at", { mode: "timestamp" }),
124
+ updatedBy: text("updated_by")
133
125
  });
134
126
  undoLog = sqliteTable("undo_log", {
135
127
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -137,6 +129,25 @@ var init_schema = __esm(() => {
137
129
  data: text("data", { mode: "json" }).notNull(),
138
130
  createdAt: integer("created_at", { mode: "timestamp" }).notNull()
139
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
+ });
140
151
  });
141
152
 
142
153
  // drizzle/meta/_journal.json
@@ -166,6 +177,41 @@ var init__journal = __esm(() => {
166
177
  when: 1769351200000,
167
178
  tag: "0002_add_fts5",
168
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
169
215
  }
170
216
  ]
171
217
  };
@@ -226,6 +272,216 @@ WHERE NOT EXISTS (SELECT 1 FROM tasks_fts LIMIT 1);
226
272
  `;
227
273
  var init_0002_add_fts5 = () => {};
228
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
+
229
485
  // src/db/migrator.ts
230
486
  var exports_migrator = {};
231
487
  __export(exports_migrator, {
@@ -235,7 +491,7 @@ import { readFileSync } from "node:fs";
235
491
  import { dirname as dirname2, isAbsolute, join } from "node:path";
236
492
  import { fileURLToPath as fileURLToPath2 } from "node:url";
237
493
  function resolveSqlContent(sqlOrPath) {
238
- if (sqlOrPath.includes("CREATE") || sqlOrPath.includes("INSERT") || sqlOrPath.includes("--")) {
494
+ if (sqlOrPath.includes("CREATE") || sqlOrPath.includes("INSERT") || sqlOrPath.includes("ALTER") || sqlOrPath.includes("--")) {
239
495
  return sqlOrPath;
240
496
  }
241
497
  let filePath = sqlOrPath;
@@ -342,10 +598,20 @@ var init_migrator = __esm(() => {
342
598
  init_0000_init();
343
599
  init_0001_add_archived();
344
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();
345
606
  migrationSql = {
346
607
  "0000_init": _0000_init_default,
347
608
  "0001_add_archived": _0001_add_archived_default,
348
- "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
349
615
  };
350
616
  __dirname2 = dirname2(fileURLToPath2(import.meta.url));
351
617
  drizzleDir = join(__dirname2, "..", "..", "drizzle");
@@ -401,17 +667,174 @@ var init_libsql_adapter = __esm(() => {
401
667
  init_utils();
402
668
  });
403
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
+
404
810
  // src/db/bun-adapter.ts
405
811
  var exports_bun_adapter = {};
406
812
  __export(exports_bun_adapter, {
407
813
  createBunDb: () => createBunDb
408
814
  });
815
+ import { existsSync as existsSync3 } from "node:fs";
409
816
  async function createBunDb(filePath) {
410
817
  let sqlite;
411
818
  try {
412
819
  const { Database } = await import("bun:sqlite");
413
820
  const { drizzle } = await import("drizzle-orm/bun-sqlite");
414
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
+ }
415
838
  sqlite = new Database(filePath);
416
839
  const db = drizzle({ client: sqlite, schema: exports_schema });
417
840
  let closed = false;
@@ -452,6 +875,7 @@ async function createBunDb(filePath) {
452
875
  }
453
876
  var init_bun_adapter = __esm(() => {
454
877
  init_types();
878
+ init_recovery();
455
879
  init_schema();
456
880
  init_utils();
457
881
  });
@@ -525,6 +949,7 @@ PRAGMA journal_mode = WAL;
525
949
  CREATE TABLE IF NOT EXISTS boards (
526
950
  id TEXT PRIMARY KEY,
527
951
  name TEXT NOT NULL,
952
+ max_board_task_id INTEGER NOT NULL DEFAULT 0,
528
953
  created_at INTEGER NOT NULL,
529
954
  updated_at INTEGER NOT NULL
530
955
  );
@@ -540,6 +965,7 @@ CREATE TABLE IF NOT EXISTS columns (
540
965
 
541
966
  CREATE TABLE IF NOT EXISTS tasks (
542
967
  id TEXT PRIMARY KEY,
968
+ board_task_id INTEGER,
543
969
  title TEXT NOT NULL,
544
970
  description TEXT,
545
971
  column_id TEXT NOT NULL REFERENCES columns(id),
@@ -556,8 +982,10 @@ CREATE TABLE IF NOT EXISTS tasks (
556
982
  updated_at INTEGER NOT NULL,
557
983
  started_at INTEGER,
558
984
  completed_at INTEGER,
985
+ due_date INTEGER,
559
986
  archived INTEGER NOT NULL DEFAULT 0,
560
- archived_at INTEGER
987
+ archived_at INTEGER,
988
+ updated_by TEXT
561
989
  );
562
990
 
563
991
  CREATE TABLE IF NOT EXISTS undo_log (
@@ -567,8 +995,95 @@ CREATE TABLE IF NOT EXISTS undo_log (
567
995
  created_at INTEGER NOT NULL
568
996
  );
569
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
+
570
1019
  CREATE INDEX IF NOT EXISTS idx_tasks_column ON tasks(column_id);
571
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;
572
1087
  `;
573
1088
  async function initializeSchema(db) {
574
1089
  await db.$runRaw(SCHEMA_SQL);
@@ -584,6 +1099,7 @@ var AgentNameSchema = AgentNameBaseSchema.transform((s) => s.toLowerCase());
584
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");
585
1100
  var TaskSchema = z.object({
586
1101
  id: UlidSchema,
1102
+ boardTaskId: z.number().int().positive().nullable(),
587
1103
  title: z.string(),
588
1104
  description: z.string().nullable(),
589
1105
  columnId: z.string(),
@@ -600,8 +1116,29 @@ var TaskSchema = z.object({
600
1116
  updatedAt: z.date(),
601
1117
  startedAt: z.date().nullable(),
602
1118
  completedAt: z.date().nullable(),
1119
+ dueDate: z.date().nullable(),
603
1120
  archived: z.boolean().default(false),
604
- 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()
605
1142
  });
606
1143
  var ColumnSchema = z.object({
607
1144
  id: z.string(),
@@ -613,6 +1150,7 @@ var ColumnSchema = z.object({
613
1150
  var BoardSchema = z.object({
614
1151
  id: UlidSchema,
615
1152
  name: z.string(),
1153
+ maxBoardTaskId: z.number().int().nonnegative(),
616
1154
  createdAt: z.date(),
617
1155
  updatedAt: z.date()
618
1156
  });
@@ -647,14 +1185,16 @@ var AddTaskInputSchema = z.object({
647
1185
  assignedTo: AgentNameSchema.optional(),
648
1186
  dependsOn: z.array(UlidSchema).optional(),
649
1187
  files: z.array(z.string()).optional(),
650
- labels: z.array(z.string().max(50)).optional()
1188
+ labels: z.array(z.string().max(50)).optional(),
1189
+ dueDate: z.string().optional()
651
1190
  });
652
1191
  var UpdateTaskInputSchema = z.object({
653
1192
  title: TitleSchema.optional(),
654
1193
  description: z.string().max(5000).nullable().optional(),
655
1194
  assignedTo: AgentNameSchema.nullable().optional(),
656
1195
  files: z.array(z.string()).optional(),
657
- labels: z.array(z.string().max(50)).optional()
1196
+ labels: z.array(z.string().max(50)).optional(),
1197
+ dueDate: z.string().nullable().optional()
658
1198
  });
659
1199
  var MoveTaskInputSchema = z.object({
660
1200
  id: UlidSchema,
@@ -680,6 +1220,7 @@ var TaskResponseSchema = TaskSchema.extend({
680
1220
  updatedAt: z.string().datetime(),
681
1221
  startedAt: z.string().datetime().nullable(),
682
1222
  completedAt: z.string().datetime().nullable(),
1223
+ dueDate: z.string().datetime().nullable(),
683
1224
  archivedAt: z.string().datetime().nullable().optional()
684
1225
  });
685
1226
  var BoardStatusSchema = z.object({
@@ -706,14 +1247,16 @@ var AddTaskInputJsonSchema = z.object({
706
1247
  assignedTo: AgentNameBaseSchema.optional(),
707
1248
  dependsOn: z.array(UlidSchema).optional(),
708
1249
  files: z.array(z.string()).optional(),
709
- labels: z.array(z.string().max(50)).optional()
1250
+ labels: z.array(z.string().max(50)).optional(),
1251
+ dueDate: z.string().optional()
710
1252
  });
711
1253
  var UpdateTaskInputJsonSchema = z.object({
712
1254
  title: TitleBaseSchema.optional(),
713
1255
  description: z.string().max(5000).nullable().optional(),
714
1256
  assignedTo: AgentNameBaseSchema.nullable().optional(),
715
1257
  files: z.array(z.string()).optional(),
716
- labels: z.array(z.string().max(50)).optional()
1258
+ labels: z.array(z.string().max(50)).optional(),
1259
+ dueDate: z.string().nullable().optional()
717
1260
  });
718
1261
  var ListTasksFilterJsonSchema = z.object({
719
1262
  columnId: ColumnIdSchema.optional(),
@@ -753,9 +1296,103 @@ var jsonSchemas = {
753
1296
  function getJsonSchema(name) {
754
1297
  return jsonSchemas[name];
755
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
+ }
756
1393
  // src/services/board.ts
757
1394
  init_schema();
758
- import { eq } from "drizzle-orm";
1395
+ import { eq as eq2 } from "drizzle-orm";
759
1396
  import { ulid } from "ulid";
760
1397
 
761
1398
  class BoardService {
@@ -786,6 +1423,7 @@ class BoardService {
786
1423
  return {
787
1424
  id: boardId,
788
1425
  name: config.board.name,
1426
+ maxBoardTaskId: 0,
789
1427
  createdAt: now,
790
1428
  updatedAt: now
791
1429
  };
@@ -798,23 +1436,493 @@ class BoardService {
798
1436
  return this.db.select().from(columns).orderBy(columns.position);
799
1437
  }
800
1438
  async getColumn(id) {
801
- 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));
802
1440
  return rows[0] ?? null;
803
1441
  }
804
1442
  async getTerminalColumn() {
805
- 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));
806
1444
  return rows[0] ?? null;
807
1445
  }
808
1446
  async getTerminalColumns() {
809
- 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(" → ");
1496
+ }
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 };
810
1724
  }
811
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
+ }
812
1854
  // src/services/task.ts
813
1855
  init_schema();
814
1856
  init_types();
815
- 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";
816
1858
  import { ulid as ulid2 } from "ulid";
817
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
+
818
1926
  // src/utils/similarity.ts
819
1927
  function tokenize(text2) {
820
1928
  const words = text2.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((word) => word.length > 0);
@@ -870,9 +1978,11 @@ class TaskService {
870
1978
  threshold: 0.5,
871
1979
  warnThreshold: 0.5
872
1980
  };
1981
+ depService;
873
1982
  constructor(db, boardService) {
874
1983
  this.db = db;
875
1984
  this.boardService = boardService;
1985
+ this.depService = new DependencyService(this.getTask.bind(this));
876
1986
  }
877
1987
  async getTaskOrThrow(id) {
878
1988
  const task = await this.getTask(id);
@@ -885,61 +1995,80 @@ class TaskService {
885
1995
  const title = validateTitle(input.title);
886
1996
  const createdBy = input.createdBy ? validateAgentName(input.createdBy) : input.agent ? validateAgentName(input.agent) : "user";
887
1997
  const columnId = input.columnId ? validateColumnId(input.columnId) : "todo";
1998
+ const dueDate = parseDateOrNull(input.dueDate);
888
1999
  const column = await this.boardService.getColumn(columnId);
889
2000
  if (!column) {
890
2001
  throw new KabanError(`Column '${columnId}' does not exist`, ExitCode.VALIDATION);
891
2002
  }
2003
+ const board = await this.boardService.getBoard();
2004
+ if (!board) {
2005
+ throw new KabanError("No board found", ExitCode.NOT_FOUND);
2006
+ }
892
2007
  const now = new Date;
893
2008
  const id = ulid2();
894
- 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));
895
2010
  const position = (maxPositionResult[0]?.max ?? -1) + 1;
896
- await this.db.insert(tasks).values({
897
- id,
898
- title,
899
- description: input.description ?? null,
900
- columnId,
901
- position,
902
- createdBy,
903
- assignedTo: null,
904
- parentId: null,
905
- dependsOn: input.dependsOn ?? [],
906
- files: input.files ?? [],
907
- labels: input.labels ?? [],
908
- blockedReason: null,
909
- version: 1,
910
- createdAt: now,
911
- updatedAt: now,
912
- startedAt: null,
913
- completedAt: null,
914
- archived: false,
915
- archivedAt: null
916
- });
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
+ `);
917
2046
  return this.getTaskOrThrow(id);
918
2047
  }
919
2048
  async getTask(id) {
920
- 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));
921
2050
  return rows[0] ?? null;
922
2051
  }
923
2052
  async listTasks(filter) {
924
2053
  const conditions = [];
925
2054
  if (!filter?.includeArchived) {
926
- conditions.push(eq2(tasks.archived, false));
2055
+ conditions.push(eq4(tasks.archived, false));
927
2056
  }
928
2057
  if (filter?.columnId) {
929
- conditions.push(eq2(tasks.columnId, filter.columnId));
2058
+ conditions.push(eq4(tasks.columnId, filter.columnId));
930
2059
  }
931
2060
  const creatorFilter = filter?.createdBy ?? filter?.agent;
932
2061
  if (creatorFilter) {
933
- conditions.push(eq2(tasks.createdBy, creatorFilter));
2062
+ conditions.push(eq4(tasks.createdBy, creatorFilter));
934
2063
  }
935
2064
  if (filter?.assignee) {
936
- conditions.push(eq2(tasks.assignedTo, filter.assignee));
2065
+ conditions.push(eq4(tasks.assignedTo, filter.assignee));
937
2066
  }
938
2067
  if (filter?.blocked === true) {
939
- conditions.push(sql`${tasks.blockedReason} IS NOT NULL`);
2068
+ conditions.push(sql2`${tasks.blockedReason} IS NOT NULL`);
940
2069
  }
941
2070
  if (conditions.length > 0) {
942
- 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);
943
2072
  }
944
2073
  return this.db.select().from(tasks).orderBy(tasks.columnId, tasks.position);
945
2074
  }
@@ -948,7 +2077,7 @@ class TaskService {
948
2077
  if (!task) {
949
2078
  throw new KabanError(`Task '${id}' not found`, ExitCode.NOT_FOUND);
950
2079
  }
951
- await this.db.delete(tasks).where(eq2(tasks.id, id));
2080
+ await this.db.delete(tasks).where(eq4(tasks.id, id));
952
2081
  }
953
2082
  async moveTask(id, columnId, options) {
954
2083
  const task = await this.getTask(id);
@@ -974,7 +2103,7 @@ class TaskService {
974
2103
  }
975
2104
  const now = new Date;
976
2105
  const isTerminal = column.isTerminal;
977
- 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));
978
2107
  const newPosition = (maxPositionResult[0]?.max ?? -1) + 1;
979
2108
  await this.db.update(tasks).set({
980
2109
  columnId,
@@ -982,11 +2111,12 @@ class TaskService {
982
2111
  version: task.version + 1,
983
2112
  updatedAt: now,
984
2113
  completedAt: isTerminal ? now : task.completedAt,
985
- startedAt: columnId === "in_progress" && !task.startedAt ? now : task.startedAt
986
- }).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));
987
2117
  return this.getTaskOrThrow(id);
988
2118
  }
989
- async updateTask(id, input, expectedVersion) {
2119
+ async updateTask(id, input, expectedVersion, actor) {
990
2120
  const task = await this.getTask(id);
991
2121
  if (!task) {
992
2122
  throw new KabanError(`Task '${id}' not found`, ExitCode.NOT_FOUND);
@@ -996,7 +2126,8 @@ class TaskService {
996
2126
  }
997
2127
  const updates = {
998
2128
  version: task.version + 1,
999
- updatedAt: new Date
2129
+ updatedAt: new Date,
2130
+ updatedBy: actor ?? null
1000
2131
  };
1001
2132
  if (input.title !== undefined) {
1002
2133
  updates.title = validateTitle(input.title);
@@ -1013,11 +2144,14 @@ class TaskService {
1013
2144
  if (input.labels !== undefined) {
1014
2145
  updates.labels = input.labels;
1015
2146
  }
1016
- 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));
1017
2151
  return this.getTaskOrThrow(id);
1018
2152
  }
1019
2153
  async getTaskCountInColumn(columnId) {
1020
- 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));
1021
2155
  return result[0]?.count ?? 0;
1022
2156
  }
1023
2157
  async archiveTasks(criteria) {
@@ -1025,9 +2159,9 @@ class TaskService {
1025
2159
  if (!hasCriteria) {
1026
2160
  throw new KabanError("At least one criteria must be provided", ExitCode.VALIDATION);
1027
2161
  }
1028
- const conditions = [eq2(tasks.archived, false)];
2162
+ const conditions = [eq4(tasks.archived, false)];
1029
2163
  if (criteria.status) {
1030
- conditions.push(eq2(tasks.columnId, criteria.status));
2164
+ conditions.push(eq4(tasks.columnId, criteria.status));
1031
2165
  }
1032
2166
  if (criteria.olderThan) {
1033
2167
  conditions.push(lt(tasks.createdAt, criteria.olderThan));
@@ -1035,7 +2169,7 @@ class TaskService {
1035
2169
  if (criteria.taskIds?.length) {
1036
2170
  conditions.push(inArray(tasks.id, criteria.taskIds));
1037
2171
  }
1038
- 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));
1039
2173
  if (matchingTasks.length === 0) {
1040
2174
  return { archivedCount: 0, taskIds: [] };
1041
2175
  }
@@ -1074,16 +2208,16 @@ class TaskService {
1074
2208
  columnId,
1075
2209
  version: task.version + 1,
1076
2210
  updatedAt: now
1077
- }).where(eq2(tasks.id, taskId));
2211
+ }).where(eq4(tasks.id, taskId));
1078
2212
  return this.getTaskOrThrow(taskId);
1079
2213
  }
1080
2214
  async searchArchive(query, options) {
1081
2215
  const limit = options?.limit ?? 50;
1082
2216
  const offset = options?.offset ?? 0;
1083
2217
  if (!query.trim()) {
1084
- 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));
1085
2219
  const total2 = countResult[0]?.count ?? 0;
1086
- 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);
1087
2221
  return { tasks: archivedTasks, total: total2 };
1088
2222
  }
1089
2223
  const ftsQuery = query.trim().split(/\s+/).map((term) => `"${term}"`).join(" ");
@@ -1101,6 +2235,7 @@ class TaskService {
1101
2235
  LIMIT ? OFFSET ?`).all(ftsQuery, limit, offset);
1102
2236
  const searchTasks = rows.map((row) => ({
1103
2237
  id: row.id,
2238
+ boardTaskId: row.board_task_id,
1104
2239
  title: row.title,
1105
2240
  description: row.description,
1106
2241
  columnId: row.column_id,
@@ -1117,17 +2252,19 @@ class TaskService {
1117
2252
  updatedAt: new Date(row.updated_at),
1118
2253
  startedAt: row.started_at ? new Date(row.started_at) : null,
1119
2254
  completedAt: row.completed_at ? new Date(row.completed_at) : null,
2255
+ dueDate: row.due_date ? new Date(row.due_date * 1000) : null,
1120
2256
  archived: Boolean(row.archived),
1121
- 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
1122
2259
  }));
1123
2260
  return { tasks: searchTasks, total };
1124
2261
  }
1125
2262
  async purgeArchive(criteria) {
1126
- const conditions = [eq2(tasks.archived, true)];
2263
+ const conditions = [eq4(tasks.archived, true)];
1127
2264
  if (criteria?.olderThan) {
1128
2265
  conditions.push(lt(tasks.archivedAt, criteria.olderThan));
1129
2266
  }
1130
- 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));
1131
2268
  if (matchingTasks.length === 0) {
1132
2269
  return { deletedCount: 0 };
1133
2270
  }
@@ -1140,7 +2277,7 @@ class TaskService {
1140
2277
  if (allTasks.length === 0) {
1141
2278
  return { deletedCount: 0 };
1142
2279
  }
1143
- await this.db.delete(tasks).where(sql`1=1`);
2280
+ await this.db.delete(tasks);
1144
2281
  return { deletedCount: allTasks.length };
1145
2282
  }
1146
2283
  async addDependency(taskId, dependsOnId) {
@@ -1152,13 +2289,18 @@ class TaskService {
1152
2289
  if (task.dependsOn.includes(dependsOnId)) {
1153
2290
  return task;
1154
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
+ }
1155
2297
  const updatedDependsOn = [...task.dependsOn, dependsOnId];
1156
2298
  const now = new Date;
1157
2299
  await this.db.update(tasks).set({
1158
2300
  dependsOn: updatedDependsOn,
1159
2301
  version: task.version + 1,
1160
2302
  updatedAt: now
1161
- }).where(eq2(tasks.id, taskId));
2303
+ }).where(eq4(tasks.id, taskId));
1162
2304
  return this.getTaskOrThrow(taskId);
1163
2305
  }
1164
2306
  async removeDependency(taskId, dependsOnId) {
@@ -1172,7 +2314,7 @@ class TaskService {
1172
2314
  dependsOn: updatedDependsOn,
1173
2315
  version: task.version + 1,
1174
2316
  updatedAt: now
1175
- }).where(eq2(tasks.id, taskId));
2317
+ }).where(eq4(tasks.id, taskId));
1176
2318
  return this.getTaskOrThrow(taskId);
1177
2319
  }
1178
2320
  async validateDependencies(taskId) {
@@ -1228,7 +2370,7 @@ class TaskService {
1228
2370
  };
1229
2371
  }
1230
2372
  async getArchiveStats() {
1231
- 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));
1232
2374
  const byColumn = {};
1233
2375
  let oldestArchivedAt = null;
1234
2376
  for (const task of archived) {
@@ -1243,6 +2385,37 @@ class TaskService {
1243
2385
  oldestArchivedAt
1244
2386
  };
1245
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
+ }
1246
2419
  }
1247
2420
 
1248
2421
  // src/index.ts
@@ -1255,26 +2428,38 @@ export {
1255
2428
  undoLog,
1256
2429
  tokenize,
1257
2430
  tasks,
2431
+ taskLinks,
1258
2432
  runMigrations,
2433
+ priorityScorer,
2434
+ linkTypes,
1259
2435
  jsonSchemas,
1260
2436
  jaccardSimilarity,
1261
2437
  isValidUlid,
1262
2438
  initializeSchema,
1263
2439
  getJsonSchema,
2440
+ fifoScorer,
2441
+ dueDateScorer,
2442
+ createDefaultScoringService,
1264
2443
  createDb,
2444
+ createBlockingScorer,
1265
2445
  columns,
1266
2446
  boards,
2447
+ audits,
1267
2448
  UpdateTaskInputSchema,
1268
2449
  UlidSchema,
1269
2450
  TitleSchema,
1270
2451
  TaskService,
1271
2452
  TaskSchema,
1272
2453
  TaskResponseSchema,
2454
+ ScoringService,
1273
2455
  MoveTaskInputSchema,
2456
+ MarkdownService,
1274
2457
  ListTasksFilterSchema,
2458
+ LinkService,
1275
2459
  KabanError,
1276
2460
  GetTaskInputSchema,
1277
2461
  ExitCode,
2462
+ DependencyService,
1278
2463
  DeleteTaskInputSchema,
1279
2464
  DEFAULT_CONFIG,
1280
2465
  ConfigSchema,
@@ -1284,6 +2469,7 @@ export {
1284
2469
  BoardStatusSchema,
1285
2470
  BoardService,
1286
2471
  BoardSchema,
2472
+ AuditService,
1287
2473
  ApiResponseSchema,
1288
2474
  AgentNameSchema,
1289
2475
  AddTaskInputSchema