@andypai/agent-kanban 0.6.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1262 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { existsSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors'
9
+ import { initSchema, seedDefaultColumns } from '../db'
10
+ import { run } from '../index'
11
+ import type { Task, TaskWithColumn } from '../types'
12
+
13
+ const CLI_ENTRY = fileURLToPath(new URL('../index.ts', import.meta.url))
14
+ const TEST_ENV_KEYS = [
15
+ 'KANBAN_PROVIDER',
16
+ 'KANBAN_STORAGE',
17
+ 'KANBAN_DB_PATH',
18
+ 'KANBAN_DEFAULT_COLUMNS',
19
+ 'KANBAN_DEFAULT_TASK_COLUMN',
20
+ 'HOME',
21
+ ]
22
+
23
+ const tempDirs: string[] = []
24
+
25
+ afterEach(() => {
26
+ while (tempDirs.length > 0) {
27
+ rmSync(tempDirs.pop()!, { recursive: true, force: true })
28
+ }
29
+ })
30
+
31
+ function tempDir(prefix = 'kanban-cli-matrix-'): string {
32
+ const dir = `${tmpdir()}/${prefix}${crypto.randomUUID()}`
33
+ mkdirSync(dir, { recursive: true })
34
+ tempDirs.push(dir)
35
+ return dir
36
+ }
37
+
38
+ function tempDbPath(): string {
39
+ return join(tempDir(), 'board.db')
40
+ }
41
+
42
+ async function withEnv<T>(
43
+ env: Record<string, string | undefined>,
44
+ fn: () => Promise<T> | T,
45
+ ): Promise<T> {
46
+ const previous = new Map<string, string | undefined>()
47
+ for (const key of TEST_ENV_KEYS) previous.set(key, process.env[key])
48
+ for (const key of TEST_ENV_KEYS) delete process.env[key]
49
+ process.env['KANBAN_PROVIDER'] = 'local'
50
+ process.env['KANBAN_STORAGE'] = 'sqlite'
51
+ for (const [key, value] of Object.entries(env)) {
52
+ if (value === undefined) delete process.env[key]
53
+ else process.env[key] = value
54
+ }
55
+ try {
56
+ return await fn()
57
+ } finally {
58
+ for (const key of TEST_ENV_KEYS) {
59
+ const value = previous.get(key)
60
+ if (value === undefined) delete process.env[key]
61
+ else process.env[key] = value
62
+ }
63
+ }
64
+ }
65
+
66
+ async function withTempDb<T>(
67
+ fn: (dbPath: string, dir: string) => Promise<T> | T,
68
+ env: Record<string, string | undefined> = {},
69
+ ): Promise<T> {
70
+ const dir = tempDir()
71
+ const dbPath = join(dir, 'board.db')
72
+ return withEnv({ KANBAN_DB_PATH: dbPath, ...env }, () => fn(dbPath, dir))
73
+ }
74
+
75
+ function cliEnv(overrides: Record<string, string> = {}): Record<string, string> {
76
+ const env: Record<string, string> = {}
77
+ for (const [key, value] of Object.entries(process.env)) {
78
+ if (value !== undefined) env[key] = value
79
+ }
80
+ for (const key of TEST_ENV_KEYS) delete env[key]
81
+ return {
82
+ ...env,
83
+ KANBAN_PROVIDER: 'local',
84
+ KANBAN_STORAGE: 'sqlite',
85
+ // Force color off in the spawned CLI. An ambient FORCE_COLOR (e.g. from the
86
+ // host terminal or CI) makes Bun wrap the error JSON on stderr in ANSI codes,
87
+ // which breaks the JSON.parse(result.stderr) assertions. Pinning NO_COLOR /
88
+ // FORCE_COLOR=0 here keeps stderr plain regardless of the host environment.
89
+ NO_COLOR: '1',
90
+ FORCE_COLOR: '0',
91
+ ...overrides,
92
+ }
93
+ }
94
+
95
+ function cli(
96
+ args: string[],
97
+ options: { cwd?: string; env?: Record<string, string> } = {},
98
+ ): { exitCode: number; stdout: string; stderr: string } {
99
+ const result = Bun.spawnSync({
100
+ cmd: [process.execPath, CLI_ENTRY, ...args],
101
+ cwd: options.cwd,
102
+ env: options.env ?? cliEnv(),
103
+ stdout: 'pipe',
104
+ stderr: 'pipe',
105
+ })
106
+ return {
107
+ exitCode: result.exitCode,
108
+ stdout: new TextDecoder().decode(result.stdout).trim(),
109
+ stderr: new TextDecoder().decode(result.stderr).trim(),
110
+ }
111
+ }
112
+
113
+ function initExistingDb(dbPath: string): void {
114
+ mkdirSync(dbPath.slice(0, dbPath.lastIndexOf('/')), { recursive: true })
115
+ const db = new Database(dbPath)
116
+ try {
117
+ db.run('PRAGMA foreign_keys = ON')
118
+ initSchema(db)
119
+ seedDefaultColumns(db)
120
+ } finally {
121
+ db.close()
122
+ }
123
+ }
124
+
125
+ function readDb<T>(dbPath: string, read: (db: Database) => T): T {
126
+ const db = new Database(dbPath)
127
+ try {
128
+ db.run('PRAGMA foreign_keys = ON')
129
+ return read(db)
130
+ } finally {
131
+ db.close()
132
+ }
133
+ }
134
+
135
+ function countRows(dbPath: string, table: string): number {
136
+ return readDb(dbPath, (db) => {
137
+ try {
138
+ const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }
139
+ return row.count
140
+ } catch {
141
+ return 0
142
+ }
143
+ })
144
+ }
145
+
146
+ function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
147
+ expect(result.exitCode).toBe(0)
148
+ expect(result.output.ok).toBe(true)
149
+ if (!result.output.ok) throw new Error('expected ok output')
150
+ return result.output.data as T
151
+ }
152
+
153
+ async function expectRunError(
154
+ promise: Promise<Awaited<ReturnType<typeof run>>>,
155
+ code: ErrorCodeValue,
156
+ ): Promise<KanbanError> {
157
+ const err = await promise.then(
158
+ () => null,
159
+ (caught: unknown) => caught,
160
+ )
161
+ expect(err).toBeInstanceOf(KanbanError)
162
+ expect((err as KanbanError).code).toBe(code)
163
+ return err as KanbanError
164
+ }
165
+
166
+ async function addTask(
167
+ dbPath: string,
168
+ title: string,
169
+ extra: string[] = [],
170
+ ): Promise<TaskWithColumn> {
171
+ return expectOk<TaskWithColumn>(await run(['--db', dbPath, 'task', 'add', title, ...extra]))
172
+ }
173
+
174
+ function activityCount(dbPath: string, where = ''): number {
175
+ return readDb(dbPath, (db) => {
176
+ const row = db.query(`SELECT COUNT(*) AS count FROM activity_log ${where}`).get() as {
177
+ count: number
178
+ }
179
+ return row.count
180
+ })
181
+ }
182
+
183
+ function columnTimeCount(dbPath: string, taskId: string): number {
184
+ return readDb(dbPath, (db) => {
185
+ const row = db
186
+ .query('SELECT COUNT(*) AS count FROM column_time_tracking WHERE task_id = $taskId')
187
+ .get({ $taskId: taskId }) as { count: number }
188
+ return row.count
189
+ })
190
+ }
191
+
192
+ describe('Phase 3 CLI task lifecycle execution matrix', () => {
193
+ test('TC-001-01 uses isolated KANBAN_DB_PATH on a fresh local SQLite Cache', async () => {
194
+ await withTempDb(async (dbPath, dir) => {
195
+ const home = join(dir, 'home')
196
+ mkdirSync(home, { recursive: true })
197
+ const task = expectOk<TaskWithColumn>(await run(['task', 'add', 'Isolated task']))
198
+
199
+ expect(existsSync(dbPath)).toBe(true)
200
+ expect(task.title).toBe('Isolated task')
201
+ expect(task.column_name).toBe('backlog')
202
+ expect(countRows(dbPath, 'columns')).toBe(5)
203
+ expect(countRows(dbPath, 'tasks')).toBe(1)
204
+ expect(readDb(dbPath, (db) => db.query('PRAGMA journal_mode').get())).toEqual({
205
+ journal_mode: 'wal',
206
+ })
207
+ expect(existsSync(join(home, '.kanban', 'board.db'))).toBe(false)
208
+ })
209
+ })
210
+
211
+ test('TC-001-02 --db selects explicit SQLite path over KANBAN_DB_PATH', async () => {
212
+ await withTempDb(async (envDbPath, dir) => {
213
+ const flagDbPath = join(dir, 'flag.db')
214
+ await addTask(flagDbPath, 'Flag path task')
215
+
216
+ expect(countRows(flagDbPath, 'tasks')).toBe(1)
217
+ expect(existsSync(envDbPath)).toBe(false)
218
+ })
219
+ })
220
+
221
+ test('TC-001-03 KANBAN_DEFAULT_COLUMNS bootstraps custom Columns', async () => {
222
+ await withTempDb(
223
+ async (dbPath) => {
224
+ const created = await addTask(dbPath, 'Custom column task', ['-c', 'Todo'])
225
+ const listed = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', '-c', 'Todo']))
226
+ const columns = readDb(dbPath, (db) =>
227
+ db.query('SELECT name, position FROM columns ORDER BY position').all(),
228
+ )
229
+
230
+ expect(created.column_name).toBe('Todo')
231
+ expect(listed.map((task) => task.id)).toEqual([created.id])
232
+ expect(columns).toEqual([
233
+ { name: 'Todo', position: 0 },
234
+ { name: 'Doing', position: 1 },
235
+ { name: 'Done', position: 2 },
236
+ ])
237
+ },
238
+ { KANBAN_DEFAULT_COLUMNS: 'Todo,Doing,Done' },
239
+ )
240
+ })
241
+
242
+ test('TC-001-04 duplicate KANBAN_DEFAULT_COLUMNS names are rejected', async () => {
243
+ await withTempDb(
244
+ async (dbPath) => {
245
+ await expectRunError(
246
+ run(['--db', dbPath, 'task', 'add', 'Duplicate config']),
247
+ ErrorCode.INVALID_CONFIG,
248
+ )
249
+ expect(existsSync(dbPath)).toBe(false)
250
+ },
251
+ { KANBAN_DEFAULT_COLUMNS: 'Todo,todo' },
252
+ )
253
+ })
254
+
255
+ test('TC-001-05 unknown top-level CLI option is rejected before runtime work', async () => {
256
+ await withTempDb(async (dbPath) => {
257
+ await expectRunError(run(['--bogus']), ErrorCode.INVALID_ARGUMENT)
258
+ expect(existsSync(dbPath)).toBe(false)
259
+ })
260
+ })
261
+
262
+ test('TC-001-06 default path creates local ./.kanban/board.db when no DB exists', () => {
263
+ const cwd = tempDir()
264
+ const home = tempDir()
265
+ const result = cli(['task', 'add', 'Auto local path'], {
266
+ cwd,
267
+ env: cliEnv({ HOME: home }),
268
+ })
269
+
270
+ expect(result.exitCode).toBe(0)
271
+ expect(countRows(join(cwd, '.kanban', 'board.db'), 'tasks')).toBe(1)
272
+ expect(existsSync(join(home, '.kanban', 'board.db'))).toBe(false)
273
+ })
274
+
275
+ test('TC-001-07 default path prefers existing local DB over existing HOME DB', () => {
276
+ const cwd = tempDir()
277
+ const home = tempDir()
278
+ const localDbPath = join(cwd, '.kanban', 'board.db')
279
+ const homeDbPath = join(home, '.kanban', 'board.db')
280
+ initExistingDb(localDbPath)
281
+ initExistingDb(homeDbPath)
282
+
283
+ const result = cli(['task', 'add', 'Prefer local path'], {
284
+ cwd,
285
+ env: cliEnv({ HOME: home }),
286
+ })
287
+
288
+ expect(result.exitCode).toBe(0)
289
+ expect(countRows(localDbPath, 'tasks')).toBe(1)
290
+ expect(countRows(homeDbPath, 'tasks')).toBe(0)
291
+ })
292
+
293
+ test('TC-001-08 default path uses existing HOME DB when local DB is absent', () => {
294
+ const cwd = tempDir()
295
+ const home = tempDir()
296
+ const homeDbPath = join(home, '.kanban', 'board.db')
297
+ initExistingDb(homeDbPath)
298
+
299
+ const result = cli(['task', 'add', 'Use home path'], {
300
+ cwd,
301
+ env: cliEnv({ HOME: home }),
302
+ })
303
+
304
+ expect(result.exitCode).toBe(0)
305
+ expect(countRows(homeDbPath, 'tasks')).toBe(1)
306
+ expect(existsSync(join(cwd, '.kanban', 'board.db'))).toBe(false)
307
+ })
308
+
309
+ test('TC-002-01 minimal task add creates a Task with defaults', async () => {
310
+ await withTempDb(async (dbPath) => {
311
+ const task = await addTask(dbPath, 'Minimal task')
312
+
313
+ expect(task.id).toMatch(/^t_/)
314
+ expect(task.title).toBe('Minimal task')
315
+ expect(task.column_name).toBe('backlog')
316
+ expect(task.priority).toBe('medium')
317
+ expect(task.description).toBe('')
318
+ expect(task.assignee).toBe('')
319
+ expect(task.project).toBe('')
320
+ expect(task.labels).toEqual([])
321
+ expect(task.metadata).toBe('{}')
322
+ expect(task.revision).toBe(0)
323
+ expect(activityCount(dbPath, "WHERE action = 'created'")).toBe(1)
324
+ expect(columnTimeCount(dbPath, task.id)).toBe(1)
325
+ })
326
+ })
327
+
328
+ test('TC-002-02 task add preserves all structured flags', async () => {
329
+ await withTempDb(async (dbPath) => {
330
+ const task = await addTask(dbPath, 'Full task', [
331
+ '-d',
332
+ 'Do work',
333
+ '-c',
334
+ 'recurring',
335
+ '-p',
336
+ 'high',
337
+ '-a',
338
+ 'alice',
339
+ '--project',
340
+ 'Platform',
341
+ '--label',
342
+ 'garage-smoke',
343
+ '--label',
344
+ 'owner-local,smoke-run',
345
+ '-m',
346
+ '{"sprint":5}',
347
+ ])
348
+
349
+ expect(task.description).toBe('Do work')
350
+ expect(task.column_name).toBe('recurring')
351
+ expect(task.priority).toBe('high')
352
+ expect(task.assignee).toBe('alice')
353
+ expect(task.project).toBe('Platform')
354
+ expect(task.labels).toEqual(['garage-smoke', 'owner-local', 'smoke-run'])
355
+ expect(task.metadata).toBe('{"sprint":5}')
356
+ })
357
+ })
358
+
359
+ test('TC-002-03 label normalization trims, splits, ignores empty labels, and de-duplicates', async () => {
360
+ await withTempDb(async (dbPath) => {
361
+ const task = await addTask(dbPath, 'Label task', [
362
+ '--label',
363
+ 'alpha, beta',
364
+ '--labels',
365
+ ' beta,,gamma ',
366
+ '--label',
367
+ 'alpha',
368
+ ])
369
+
370
+ expect(task.labels).toEqual(['alpha', 'beta', 'gamma'])
371
+ })
372
+ })
373
+
374
+ test('TC-002-09 public help documents both label flag spellings', async () => {
375
+ await withTempDb(async (dbPath) => {
376
+ const help = expectOk<{ message: string }>(await run(['--db', dbPath, '--help']))
377
+
378
+ expect(help.message).toContain('--label name')
379
+ expect(help.message).toContain('--labels names')
380
+ })
381
+ })
382
+
383
+ test('TC-002-04 task add without title is rejected', async () => {
384
+ await withTempDb(async (dbPath) => {
385
+ const err = await expectRunError(
386
+ run(['--db', dbPath, 'task', 'add']),
387
+ ErrorCode.MISSING_ARGUMENT,
388
+ )
389
+ expect(err.message).toContain('Task title is required')
390
+ })
391
+ })
392
+
393
+ test('TC-002-05 invalid create priority is rejected', async () => {
394
+ await withTempDb(async (dbPath) => {
395
+ await expectRunError(
396
+ run(['--db', dbPath, 'task', 'add', 'Bad priority', '-p', 'critical']),
397
+ ErrorCode.INVALID_PRIORITY,
398
+ )
399
+ expect(countRows(dbPath, 'tasks')).toBe(0)
400
+ })
401
+ })
402
+
403
+ test('TC-002-06 invalid create metadata JSON is rejected', async () => {
404
+ await withTempDb(async (dbPath) => {
405
+ await expectRunError(
406
+ run(['--db', dbPath, 'task', 'add', 'Bad metadata', '-m', 'not json']),
407
+ ErrorCode.INVALID_METADATA,
408
+ )
409
+ expect(countRows(dbPath, 'tasks')).toBe(0)
410
+ })
411
+ })
412
+
413
+ test('TC-002-07 unknown target Column is rejected on create', async () => {
414
+ await withTempDb(async (dbPath) => {
415
+ await expectRunError(
416
+ run(['--db', dbPath, 'task', 'add', 'Bad column', '-c', 'missing-column']),
417
+ ErrorCode.COLUMN_NOT_FOUND,
418
+ )
419
+ expect(countRows(dbPath, 'tasks')).toBe(0)
420
+ })
421
+ })
422
+
423
+ test('TC-002-08 KANBAN_DEFAULT_TASK_COLUMN controls created-task Column for SQLite', async () => {
424
+ await withTempDb(
425
+ async (dbPath) => {
426
+ const task = await addTask(dbPath, 'Default task column check')
427
+ expect(task.column_name).toBe('review')
428
+ },
429
+ { KANBAN_DEFAULT_TASK_COLUMN: 'review' },
430
+ )
431
+ })
432
+
433
+ test('TC-002-09 create falls back to first Column when KANBAN_DEFAULT_COLUMNS omits backlog', async () => {
434
+ // Parity with the Postgres store: when no `backlog` column is seeded and
435
+ // KANBAN_DEFAULT_TASK_COLUMN is unset, an un-flagged create lands in the
436
+ // first configured column instead of failing with COLUMN_NOT_FOUND.
437
+ await withTempDb(
438
+ async (dbPath) => {
439
+ const task = await addTask(dbPath, 'No backlog fallback')
440
+ expect(task.column_name).toBe('Todo')
441
+ },
442
+ { KANBAN_DEFAULT_COLUMNS: 'Todo,Doing,Done' },
443
+ )
444
+ })
445
+
446
+ test('TC-003-01 task list returns all Tasks in position order', async () => {
447
+ await withTempDb(async (dbPath) => {
448
+ await addTask(dbPath, 'A', ['-c', 'backlog'])
449
+ await addTask(dbPath, 'B', ['-c', 'recurring'])
450
+ await addTask(dbPath, 'C', ['-c', 'done'])
451
+
452
+ const tasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list']))
453
+
454
+ expect(tasks).toHaveLength(3)
455
+ expect(tasks.map((task) => task.position)).toEqual(
456
+ [...tasks].map((task) => task.position).sort((a, b) => a - b),
457
+ )
458
+ })
459
+ })
460
+
461
+ test('TC-003-02 task list filters by Column, priority, assignee, and project', async () => {
462
+ await withTempDb(async (dbPath) => {
463
+ const target = await addTask(dbPath, 'Target', [
464
+ '-c',
465
+ 'recurring',
466
+ '-p',
467
+ 'urgent',
468
+ '-a',
469
+ 'alice',
470
+ '--project',
471
+ 'Platform',
472
+ ])
473
+ await addTask(dbPath, 'Other', ['-c', 'done', '-p', 'low', '-a', 'bob', '--project', 'Ops'])
474
+
475
+ for (const args of [
476
+ ['-c', 'recurring'],
477
+ ['-p', 'urgent'],
478
+ ['-a', 'alice'],
479
+ ['--project', 'Platform'],
480
+ ['-c', 'recurring', '-p', 'urgent', '-a', 'alice', '--project', 'Platform'],
481
+ ]) {
482
+ const tasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', ...args]))
483
+ expect(tasks.map((task) => task.id)).toEqual([target.id])
484
+ }
485
+ })
486
+ })
487
+
488
+ test('TC-003-03 supported sort fields order Tasks as documented by code', async () => {
489
+ await withTempDb(async (dbPath) => {
490
+ const low = await addTask(dbPath, 'Zulu', ['-p', 'low'])
491
+ const urgent = await addTask(dbPath, 'Alpha', ['-p', 'urgent'])
492
+ const high = await addTask(dbPath, 'Mike', ['-p', 'high'])
493
+ readDb(dbPath, (db) => {
494
+ db.query(
495
+ "UPDATE tasks SET created_at = CASE id WHEN $low THEN '2026-01-03 00:00:00' WHEN $urgent THEN '2026-01-01 00:00:00' ELSE '2026-01-02 00:00:00' END",
496
+ ).run({ $low: low.id, $urgent: urgent.id })
497
+ db.query(
498
+ "UPDATE tasks SET updated_at = CASE id WHEN $low THEN '2026-01-02 00:00:00' WHEN $urgent THEN '2026-01-03 00:00:00' ELSE '2026-01-01 00:00:00' END",
499
+ ).run({ $low: low.id, $urgent: urgent.id })
500
+ })
501
+
502
+ const byPriority = expectOk<Task[]>(
503
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'priority']),
504
+ )
505
+ const byTitle = expectOk<Task[]>(
506
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'title']),
507
+ )
508
+ const byPosition = expectOk<Task[]>(
509
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'position']),
510
+ )
511
+ const byCreated = expectOk<Task[]>(
512
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'created']),
513
+ )
514
+ const byUpdated = expectOk<Task[]>(
515
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'updated']),
516
+ )
517
+
518
+ expect(byPriority.map((task) => task.priority)).toEqual(['urgent', 'high', 'low'])
519
+ expect(byTitle.map((task) => task.title)).toEqual(['Alpha', 'Mike', 'Zulu'])
520
+ expect(byPosition.map((task) => task.id)).toEqual([low.id, urgent.id, high.id])
521
+ expect(byCreated.map((task) => task.id)).toEqual([urgent.id, high.id, low.id])
522
+ expect(byUpdated.map((task) => task.id)).toEqual([high.id, low.id, urgent.id])
523
+ })
524
+ })
525
+
526
+ test('TC-003-04 positive integer limit caps returned rows', async () => {
527
+ await withTempDb(async (dbPath) => {
528
+ await addTask(dbPath, 'A')
529
+ await addTask(dbPath, 'B')
530
+ await addTask(dbPath, 'C')
531
+
532
+ const tasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', '-l', '2']))
533
+
534
+ expect(tasks).toHaveLength(2)
535
+ })
536
+ })
537
+
538
+ test('TC-003-05 invalid limits are rejected consistently', async () => {
539
+ await withTempDb(async (dbPath) => {
540
+ for (const value of ['0', '-1', '3.5', '1e3', '5abc', '9007199254740992']) {
541
+ await expectRunError(
542
+ run(['--db', dbPath, 'task', 'list', '-l', value]),
543
+ ErrorCode.INVALID_ARGUMENT,
544
+ )
545
+ }
546
+ })
547
+ })
548
+
549
+ test('TC-003-06 unknown Column filter is rejected', async () => {
550
+ await withTempDb(async (dbPath) => {
551
+ await expectRunError(
552
+ run(['--db', dbPath, 'task', 'list', '-c', 'missing-column']),
553
+ ErrorCode.COLUMN_NOT_FOUND,
554
+ )
555
+ })
556
+ })
557
+
558
+ test('TC-003-07 invalid priority filter returns no matches without mutation', async () => {
559
+ await withTempDb(async (dbPath) => {
560
+ await addTask(dbPath, 'Valid', ['-p', 'high'])
561
+
562
+ const tasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', '-p', 'critical']))
563
+
564
+ expect(tasks).toEqual([])
565
+ expect(countRows(dbPath, 'tasks')).toBe(1)
566
+ })
567
+ })
568
+
569
+ test('TC-003-08 list with sort and limit remains responsive on a modest local board', async () => {
570
+ await withTempDb(async (dbPath) => {
571
+ for (let i = 0; i < 100; i += 1) {
572
+ await addTask(dbPath, `Task ${String(i).padStart(3, '0')}`, [
573
+ '-p',
574
+ i % 2 === 0 ? 'high' : 'low',
575
+ ])
576
+ }
577
+ const start = Date.now()
578
+
579
+ const tasks = expectOk<Task[]>(
580
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'priority', '-l', '10']),
581
+ )
582
+ const elapsed = Date.now() - start
583
+
584
+ expect(tasks).toHaveLength(10)
585
+ expect(elapsed).toBeLessThan(2000)
586
+ })
587
+ })
588
+
589
+ test('TC-003-09 updated-time sorting reflects Task mutation time', async () => {
590
+ await withTempDb(async (dbPath) => {
591
+ const older = await addTask(dbPath, 'Older')
592
+ const newer = await addTask(dbPath, 'Newer')
593
+ readDb(dbPath, (db) => {
594
+ db.query(
595
+ "UPDATE tasks SET updated_at = CASE id WHEN $older THEN '2026-01-01 00:00:00' ELSE '2026-01-02 00:00:00' END",
596
+ ).run({ $older: older.id })
597
+ })
598
+
599
+ const tasks = expectOk<Task[]>(
600
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'updated']),
601
+ )
602
+
603
+ expect(tasks.map((task) => task.id)).toEqual([older.id, newer.id])
604
+ })
605
+ })
606
+
607
+ test('TC-003-10 unknown --sort falls back to position and does not error', async () => {
608
+ await withTempDb(async (dbPath) => {
609
+ await addTask(dbPath, 'A')
610
+ await addTask(dbPath, 'B')
611
+ await addTask(dbPath, 'C')
612
+
613
+ const byPosition = expectOk<Task[]>(
614
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'position']),
615
+ )
616
+ const byUnknown = expectOk<Task[]>(
617
+ await run(['--db', dbPath, 'task', 'list', '--sort', 'not-a-field']),
618
+ )
619
+
620
+ expect(byUnknown.map((task) => task.id)).toEqual(byPosition.map((task) => task.id))
621
+ })
622
+ })
623
+
624
+ test('TC-004-01 task view returns a created Task with enriched local fields', async () => {
625
+ await withTempDb(async (dbPath) => {
626
+ const created = await addTask(dbPath, 'View me', ['--label', 'x', '-m', '{"a":1}'])
627
+
628
+ const viewed = expectOk<TaskWithColumn>(
629
+ await run(['--db', dbPath, 'task', 'view', created.id]),
630
+ )
631
+
632
+ expect(viewed.id).toBe(created.id)
633
+ expect(viewed.column_name).toBe('backlog')
634
+ expect(viewed.labels).toEqual(['x'])
635
+ expect(viewed.providerId).toBe(created.id)
636
+ expect(viewed.externalRef).toBe(created.id)
637
+ expect(viewed.url).toBeNull()
638
+ expect(viewed.comment_count).toBe(0)
639
+ expect(viewed.version).toBe('0')
640
+ expect(viewed.source_updated_at).toBeNull()
641
+ })
642
+ })
643
+
644
+ test('TC-004-02 task view without id is rejected', async () => {
645
+ await withTempDb(async (dbPath) => {
646
+ const err = await expectRunError(
647
+ run(['--db', dbPath, 'task', 'view']),
648
+ ErrorCode.MISSING_ARGUMENT,
649
+ )
650
+ expect(err.message).toContain('Task ID is required')
651
+ })
652
+ })
653
+
654
+ test('TC-004-03 task view for unknown id is rejected', async () => {
655
+ await withTempDb(async (dbPath) => {
656
+ await expectRunError(
657
+ run(['--db', dbPath, 'task', 'view', 't_missing']),
658
+ ErrorCode.TASK_NOT_FOUND,
659
+ )
660
+ })
661
+ })
662
+
663
+ test('TC-004-04 pretty task view includes only non-empty optional fields', () => {
664
+ const dbPath = tempDbPath()
665
+ const full = cli(
666
+ [
667
+ '--db',
668
+ dbPath,
669
+ 'task',
670
+ 'add',
671
+ 'Pretty full',
672
+ '-d',
673
+ 'Details',
674
+ '-a',
675
+ 'alice',
676
+ '--project',
677
+ 'Platform',
678
+ '-m',
679
+ '{"ok":true}',
680
+ ],
681
+ { env: cliEnv() },
682
+ )
683
+ const fullId = JSON.parse(full.stdout).data.id as string
684
+ const minimal = cli(['--db', dbPath, 'task', 'add', 'Pretty minimal'], { env: cliEnv() })
685
+ const minimalId = JSON.parse(minimal.stdout).data.id as string
686
+
687
+ const fullView = cli(['--db', dbPath, 'task', 'view', fullId, '--pretty'], { env: cliEnv() })
688
+ const minimalView = cli(['--db', dbPath, 'task', 'view', minimalId, '--pretty'], {
689
+ env: cliEnv(),
690
+ })
691
+
692
+ expect(fullView.stdout).toContain('Assignee: alice')
693
+ expect(fullView.stdout).toContain('Project: Platform')
694
+ expect(fullView.stdout).toContain('Description: Details')
695
+ expect(fullView.stdout).toContain('Metadata: {"ok":true}')
696
+ expect(minimalView.stdout).not.toContain('Assignee:')
697
+ expect(minimalView.stdout).not.toContain('Project:')
698
+ expect(minimalView.stdout).not.toContain('Description:')
699
+ expect(minimalView.stdout).not.toContain('Metadata:')
700
+ })
701
+
702
+ test('TC-005-01 task update changes every supported field', async () => {
703
+ await withTempDb(async (dbPath) => {
704
+ const created = await addTask(dbPath, 'Original')
705
+
706
+ const updated = expectOk<Task>(
707
+ await run([
708
+ '--db',
709
+ dbPath,
710
+ 'task',
711
+ 'update',
712
+ created.id,
713
+ '--title',
714
+ 'Updated',
715
+ '-d',
716
+ 'New desc',
717
+ '-p',
718
+ 'urgent',
719
+ '-a',
720
+ 'bob',
721
+ '--project',
722
+ 'Infra',
723
+ '-m',
724
+ '{"sprint":6}',
725
+ ]),
726
+ )
727
+
728
+ expect(updated.title).toBe('Updated')
729
+ expect(updated.description).toBe('New desc')
730
+ expect(updated.priority).toBe('urgent')
731
+ expect(updated.assignee).toBe('bob')
732
+ expect(updated.project).toBe('Infra')
733
+ expect(updated.metadata).toBe('{"sprint":6}')
734
+ expect(updated.revision).toBe((created.revision ?? 0) + 1)
735
+ })
736
+ })
737
+
738
+ test('TC-005-02 task update changes only supplied fields', async () => {
739
+ await withTempDb(async (dbPath) => {
740
+ const created = await addTask(dbPath, 'Original', ['-d', 'Keep', '-p', 'high', '-a', 'alice'])
741
+
742
+ const updated = expectOk<Task>(
743
+ await run(['--db', dbPath, 'task', 'update', created.id, '--title', 'New title']),
744
+ )
745
+
746
+ expect(updated.title).toBe('New title')
747
+ expect(updated.description).toBe('Keep')
748
+ expect(updated.priority).toBe('high')
749
+ expect(updated.assignee).toBe('alice')
750
+ expect(updated.revision).toBe((created.revision ?? 0) + 1)
751
+ })
752
+ })
753
+
754
+ test('TC-005-03 task update with no field flags follows current code behavior', async () => {
755
+ await withTempDb(async (dbPath) => {
756
+ const created = await addTask(dbPath, 'No-op update')
757
+
758
+ const updated = expectOk<Task>(await run(['--db', dbPath, 'task', 'update', created.id]))
759
+
760
+ expect(updated.title).toBe(created.title)
761
+ expect(updated.revision).toBe((created.revision ?? 0) + 1)
762
+ })
763
+ })
764
+
765
+ test('TC-005-04 task update requires an existing Task id', async () => {
766
+ await withTempDb(async (dbPath) => {
767
+ await expectRunError(run(['--db', dbPath, 'task', 'update']), ErrorCode.MISSING_ARGUMENT)
768
+ await expectRunError(
769
+ run(['--db', dbPath, 'task', 'update', 't_missing', '--title', 'X']),
770
+ ErrorCode.TASK_NOT_FOUND,
771
+ )
772
+ })
773
+ })
774
+
775
+ test('TC-005-05 invalid update priority is rejected without mutation', async () => {
776
+ await withTempDb(async (dbPath) => {
777
+ const created = await addTask(dbPath, 'Priority update', ['-p', 'high'])
778
+
779
+ await expectRunError(
780
+ run(['--db', dbPath, 'task', 'update', created.id, '-p', 'critical']),
781
+ ErrorCode.INVALID_PRIORITY,
782
+ )
783
+ const viewed = expectOk<Task>(await run(['--db', dbPath, 'task', 'view', created.id]))
784
+ expect(viewed.priority).toBe('high')
785
+ })
786
+ })
787
+
788
+ test('TC-005-06 invalid update metadata is rejected without mutation', async () => {
789
+ await withTempDb(async (dbPath) => {
790
+ const created = await addTask(dbPath, 'Metadata update', ['-m', '{"a":1}'])
791
+
792
+ await expectRunError(
793
+ run(['--db', dbPath, 'task', 'update', created.id, '-m', 'not json']),
794
+ ErrorCode.INVALID_METADATA,
795
+ )
796
+ const viewed = expectOk<Task>(await run(['--db', dbPath, 'task', 'view', created.id]))
797
+ expect(viewed.metadata).toBe('{"a":1}')
798
+ })
799
+ })
800
+
801
+ test('TC-005-07 changed fields write activity and unchanged fields do not duplicate shortcut activity', async () => {
802
+ await withTempDb(async (dbPath) => {
803
+ const task = await addTask(dbPath, 'Activity task')
804
+
805
+ expectOk<Task>(
806
+ await run([
807
+ '--db',
808
+ dbPath,
809
+ 'task',
810
+ 'update',
811
+ task.id,
812
+ '--title',
813
+ 'Activity updated',
814
+ '-d',
815
+ 'desc',
816
+ '-p',
817
+ 'urgent',
818
+ '-a',
819
+ 'alice',
820
+ '--project',
821
+ 'Platform',
822
+ '-m',
823
+ '{"a":1}',
824
+ ]),
825
+ )
826
+ const assignedBefore = activityCount(dbPath, "WHERE action = 'assigned'")
827
+ const prioritizedBefore = activityCount(dbPath, "WHERE action = 'prioritized'")
828
+ expect(assignedBefore).toBe(1)
829
+ expect(prioritizedBefore).toBe(1)
830
+ expect(
831
+ activityCount(
832
+ dbPath,
833
+ "WHERE action = 'updated' AND field_changed IN ('project','title','description','metadata')",
834
+ ),
835
+ ).toBe(4)
836
+
837
+ expectOk<Task>(
838
+ await run(['--db', dbPath, 'task', 'update', task.id, '-a', 'alice', '-p', 'urgent']),
839
+ )
840
+
841
+ expect(activityCount(dbPath, "WHERE action = 'assigned'")).toBe(assignedBefore)
842
+ expect(activityCount(dbPath, "WHERE action = 'prioritized'")).toBe(prioritizedBefore)
843
+ })
844
+ })
845
+
846
+ test('TC-006-01 task move changes Column and appends to target Column', async () => {
847
+ await withTempDb(async (dbPath) => {
848
+ await addTask(dbPath, 'Existing target', ['-c', 'in-progress'])
849
+ const task = await addTask(dbPath, 'Move me')
850
+ const beforeTime = columnTimeCount(dbPath, task.id)
851
+
852
+ const moved = expectOk<TaskWithColumn>(
853
+ await run(['--db', dbPath, 'task', 'move', task.id, 'in-progress']),
854
+ )
855
+
856
+ expect(moved.column_name).toBe('in-progress')
857
+ expect(moved.position).toBe(1)
858
+ expect(moved.revision).toBe((task.revision ?? 0) + 1)
859
+ expect(activityCount(dbPath, "WHERE action = 'moved'")).toBe(1)
860
+ expect(columnTimeCount(dbPath, task.id)).toBe(beforeTime + 1)
861
+ })
862
+ })
863
+
864
+ test('TC-006-02 moving a Task to its current Column is a no-op', async () => {
865
+ await withTempDb(async (dbPath) => {
866
+ const task = await addTask(dbPath, 'Stay put')
867
+ const activityBefore = activityCount(dbPath)
868
+ const timeBefore = columnTimeCount(dbPath, task.id)
869
+
870
+ const moved = expectOk<TaskWithColumn>(
871
+ await run(['--db', dbPath, 'task', 'move', task.id, 'backlog']),
872
+ )
873
+
874
+ expect(moved.column_name).toBe('backlog')
875
+ expect(moved.revision).toBe(task.revision)
876
+ expect(activityCount(dbPath)).toBe(activityBefore)
877
+ expect(columnTimeCount(dbPath, task.id)).toBe(timeBefore)
878
+ })
879
+ })
880
+
881
+ test('TC-006-03 task move requires id and Column', async () => {
882
+ await withTempDb(async (dbPath) => {
883
+ await expectRunError(run(['--db', dbPath, 'task', 'move']), ErrorCode.MISSING_ARGUMENT)
884
+ await expectRunError(run(['--db', dbPath, 'task', 'move', 't_1']), ErrorCode.MISSING_ARGUMENT)
885
+ })
886
+ })
887
+
888
+ test('TC-006-04 task move rejects unknown Task id', async () => {
889
+ await withTempDb(async (dbPath) => {
890
+ await expectRunError(
891
+ run(['--db', dbPath, 'task', 'move', 't_missing', 'backlog']),
892
+ ErrorCode.TASK_NOT_FOUND,
893
+ )
894
+ })
895
+ })
896
+
897
+ test('TC-006-05 task move rejects unknown target Column without moving the Task', async () => {
898
+ await withTempDb(async (dbPath) => {
899
+ const task = await addTask(dbPath, 'Bad move')
900
+
901
+ await expectRunError(
902
+ run(['--db', dbPath, 'task', 'move', task.id, 'missing-column']),
903
+ ErrorCode.COLUMN_NOT_FOUND,
904
+ )
905
+ const viewed = expectOk<TaskWithColumn>(await run(['--db', dbPath, 'task', 'view', task.id]))
906
+ expect(viewed.column_name).toBe('backlog')
907
+ })
908
+ })
909
+
910
+ test('TC-007-01 task assign sets the assignee shortcut', async () => {
911
+ await withTempDb(async (dbPath) => {
912
+ const task = await addTask(dbPath, 'Assign me')
913
+
914
+ const assigned = expectOk<Task>(
915
+ await run(['--db', dbPath, 'task', 'assign', task.id, 'carol']),
916
+ )
917
+
918
+ expect(assigned.assignee).toBe('carol')
919
+ expect(assigned.revision).toBe((task.revision ?? 0) + 1)
920
+ expect(activityCount(dbPath, "WHERE action = 'assigned'")).toBe(1)
921
+ })
922
+ })
923
+
924
+ test('TC-007-02 assigning the same assignee does not duplicate assigned activity', async () => {
925
+ await withTempDb(async (dbPath) => {
926
+ const task = await addTask(dbPath, 'Already assigned', ['-a', 'carol'])
927
+ const assignedBefore = activityCount(dbPath, "WHERE action = 'assigned'")
928
+
929
+ const assigned = expectOk<Task>(
930
+ await run(['--db', dbPath, 'task', 'assign', task.id, 'carol']),
931
+ )
932
+
933
+ expect(assigned.assignee).toBe('carol')
934
+ expect(assigned.revision).toBe((task.revision ?? 0) + 1)
935
+ expect(activityCount(dbPath, "WHERE action = 'assigned'")).toBe(assignedBefore)
936
+ })
937
+ })
938
+
939
+ test('TC-007-03 task assign requires id and assignee', async () => {
940
+ await withTempDb(async (dbPath) => {
941
+ await expectRunError(run(['--db', dbPath, 'task', 'assign']), ErrorCode.MISSING_ARGUMENT)
942
+ await expectRunError(
943
+ run(['--db', dbPath, 'task', 'assign', 't_1']),
944
+ ErrorCode.MISSING_ARGUMENT,
945
+ )
946
+ })
947
+ })
948
+
949
+ test('TC-007-04 task assign rejects unknown Task id', async () => {
950
+ await withTempDb(async (dbPath) => {
951
+ await expectRunError(
952
+ run(['--db', dbPath, 'task', 'assign', 't_missing', 'alice']),
953
+ ErrorCode.TASK_NOT_FOUND,
954
+ )
955
+ })
956
+ })
957
+
958
+ test('TC-008-01 task prioritize sets a valid priority shortcut', async () => {
959
+ await withTempDb(async (dbPath) => {
960
+ const task = await addTask(dbPath, 'Prioritize me')
961
+
962
+ const prioritized = expectOk<Task>(
963
+ await run(['--db', dbPath, 'task', 'prioritize', task.id, 'urgent']),
964
+ )
965
+
966
+ expect(prioritized.priority).toBe('urgent')
967
+ expect(prioritized.revision).toBe((task.revision ?? 0) + 1)
968
+ expect(activityCount(dbPath, "WHERE action = 'prioritized'")).toBe(1)
969
+ })
970
+ })
971
+
972
+ test('TC-008-02 prioritizing to the same level does not duplicate prioritized activity', async () => {
973
+ await withTempDb(async (dbPath) => {
974
+ const task = await addTask(dbPath, 'Same priority', ['-p', 'high'])
975
+ const prioritizedBefore = activityCount(dbPath, "WHERE action = 'prioritized'")
976
+
977
+ const prioritized = expectOk<Task>(
978
+ await run(['--db', dbPath, 'task', 'prioritize', task.id, 'high']),
979
+ )
980
+
981
+ expect(prioritized.priority).toBe('high')
982
+ expect(prioritized.revision).toBe((task.revision ?? 0) + 1)
983
+ expect(activityCount(dbPath, "WHERE action = 'prioritized'")).toBe(prioritizedBefore)
984
+ })
985
+ })
986
+
987
+ test('TC-008-03 task prioritize requires id and level', async () => {
988
+ await withTempDb(async (dbPath) => {
989
+ await expectRunError(run(['--db', dbPath, 'task', 'prioritize']), ErrorCode.MISSING_ARGUMENT)
990
+ await expectRunError(
991
+ run(['--db', dbPath, 'task', 'prioritize', 't_1']),
992
+ ErrorCode.MISSING_ARGUMENT,
993
+ )
994
+ })
995
+ })
996
+
997
+ test('TC-008-04 task prioritize rejects invalid priority without mutation', async () => {
998
+ await withTempDb(async (dbPath) => {
999
+ const task = await addTask(dbPath, 'Bad priority shortcut', ['-p', 'medium'])
1000
+
1001
+ await expectRunError(
1002
+ run(['--db', dbPath, 'task', 'prioritize', task.id, 'critical']),
1003
+ ErrorCode.INVALID_PRIORITY,
1004
+ )
1005
+ const viewed = expectOk<Task>(await run(['--db', dbPath, 'task', 'view', task.id]))
1006
+ expect(viewed.priority).toBe('medium')
1007
+ })
1008
+ })
1009
+
1010
+ test('TC-008-05 task prioritize rejects unknown Task id for valid priority input', async () => {
1011
+ await withTempDb(async (dbPath) => {
1012
+ await expectRunError(
1013
+ run(['--db', dbPath, 'task', 'prioritize', 't_missing', 'high']),
1014
+ ErrorCode.TASK_NOT_FOUND,
1015
+ )
1016
+ })
1017
+ })
1018
+
1019
+ test('TC-009-01 task delete removes a Task and returns the deleted Task', async () => {
1020
+ await withTempDb(async (dbPath) => {
1021
+ const task = await addTask(dbPath, 'Delete me')
1022
+
1023
+ const deleted = expectOk<Task>(await run(['--db', dbPath, 'task', 'delete', task.id]))
1024
+
1025
+ expect(deleted.id).toBe(task.id)
1026
+ await expectRunError(run(['--db', dbPath, 'task', 'view', task.id]), ErrorCode.TASK_NOT_FOUND)
1027
+ expect(activityCount(dbPath, "WHERE action = 'deleted'")).toBe(1)
1028
+ })
1029
+ })
1030
+
1031
+ test('TC-009-02 deleting a Task renumbers remaining Tasks in the old Column', async () => {
1032
+ await withTempDb(async (dbPath) => {
1033
+ const first = await addTask(dbPath, 'First')
1034
+ const middle = await addTask(dbPath, 'Middle')
1035
+ const last = await addTask(dbPath, 'Last')
1036
+
1037
+ expectOk<Task>(await run(['--db', dbPath, 'task', 'delete', middle.id]))
1038
+ const tasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', '-c', 'backlog']))
1039
+
1040
+ expect(tasks.map((task) => [task.id, task.position])).toEqual([
1041
+ [first.id, 0],
1042
+ [last.id, 1],
1043
+ ])
1044
+ })
1045
+ })
1046
+
1047
+ test('TC-009-03 task delete requires an existing id', async () => {
1048
+ await withTempDb(async (dbPath) => {
1049
+ await expectRunError(run(['--db', dbPath, 'task', 'delete']), ErrorCode.MISSING_ARGUMENT)
1050
+ await expectRunError(
1051
+ run(['--db', dbPath, 'task', 'delete', 't_missing']),
1052
+ ErrorCode.TASK_NOT_FOUND,
1053
+ )
1054
+ })
1055
+ })
1056
+
1057
+ test('TC-009-04 deleting a Task cascades directly seeded dependent comments', async () => {
1058
+ await withTempDb(async (dbPath) => {
1059
+ const task = await addTask(dbPath, 'Cascade delete')
1060
+ readDb(dbPath, (db) => {
1061
+ db.query(
1062
+ "INSERT INTO comments (id, task_id, body, author) VALUES ('cm_direct', $taskId, 'body', NULL)",
1063
+ ).run({ $taskId: task.id })
1064
+ })
1065
+
1066
+ expectOk<Task>(await run(['--db', dbPath, 'task', 'delete', task.id]))
1067
+
1068
+ expect(
1069
+ readDb(dbPath, (db) => {
1070
+ const row = db
1071
+ .query('SELECT COUNT(*) AS count FROM comments WHERE task_id = $taskId')
1072
+ .get({ $taskId: task.id }) as { count: number }
1073
+ return row.count
1074
+ }),
1075
+ ).toBe(0)
1076
+ })
1077
+ })
1078
+
1079
+ test('TC-010-01 default CLI output is compact JSON success envelope', () => {
1080
+ const dbPath = tempDbPath()
1081
+
1082
+ const result = cli(['--db', dbPath, 'task', 'add', 'JSON output'], { env: cliEnv() })
1083
+
1084
+ expect(result.exitCode).toBe(0)
1085
+ expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, data: { title: 'JSON output' } })
1086
+ expect(result.stdout).not.toContain('Task:')
1087
+ })
1088
+
1089
+ test('TC-010-02 --pretty formats task list and task detail for humans', () => {
1090
+ const dbPath = tempDbPath()
1091
+ const created = cli(
1092
+ [
1093
+ '--db',
1094
+ dbPath,
1095
+ 'task',
1096
+ 'add',
1097
+ 'Pretty task',
1098
+ '-p',
1099
+ 'high',
1100
+ '-a',
1101
+ 'alice',
1102
+ '--project',
1103
+ 'Platform',
1104
+ ],
1105
+ { env: cliEnv() },
1106
+ )
1107
+ const taskId = JSON.parse(created.stdout).data.id as string
1108
+
1109
+ const list = cli(['--db', dbPath, 'task', 'list', '--pretty'], { env: cliEnv() })
1110
+ const view = cli(['--db', dbPath, 'task', 'view', taskId, '--pretty'], { env: cliEnv() })
1111
+
1112
+ expect(list.stdout).toContain('[!! ]')
1113
+ expect(list.stdout).toContain(taskId)
1114
+ expect(list.stdout).toContain('@alice')
1115
+ expect(list.stdout).toContain('[Platform]')
1116
+ expect(view.stdout).toContain(`Task: ${taskId}`)
1117
+ expect(view.stdout).toContain('Priority: high')
1118
+ })
1119
+
1120
+ test('TC-010-03 pretty empty arrays render as No items found.', () => {
1121
+ const dbPath = tempDbPath()
1122
+
1123
+ const result = cli(['--db', dbPath, 'task', 'list', '-p', 'urgent', '--pretty'], {
1124
+ env: cliEnv(),
1125
+ })
1126
+
1127
+ expect(result.exitCode).toBe(0)
1128
+ expect(result.stdout).toBe('No items found.')
1129
+ })
1130
+
1131
+ test('TC-010-04 unknown task action reports UNKNOWN_COMMAND', async () => {
1132
+ await withTempDb(async (dbPath) => {
1133
+ await expectRunError(run(['--db', dbPath, 'task', 'frobnicate']), ErrorCode.UNKNOWN_COMMAND)
1134
+ const result = cli(['--db', dbPath, 'task', 'frobnicate'], { env: cliEnv() })
1135
+ expect(result.exitCode).toBe(1)
1136
+ expect(JSON.parse(result.stderr)).toMatchObject({
1137
+ ok: false,
1138
+ error: { code: 'UNKNOWN_COMMAND' },
1139
+ })
1140
+ })
1141
+ })
1142
+
1143
+ test('TC-010-05 KanbanError process exits 1 and formats errors', () => {
1144
+ const dbPath = tempDbPath()
1145
+ const badDbPath = tempDir()
1146
+
1147
+ const json = cli(['--db', dbPath, 'task', 'view', 't_missing'], { env: cliEnv() })
1148
+ const pretty = cli(['--db', dbPath, 'task', 'view', 't_missing', '--pretty'], {
1149
+ env: cliEnv(),
1150
+ })
1151
+ const unexpected = cli(['--db', badDbPath, 'task', 'list'], { env: cliEnv() })
1152
+
1153
+ expect(json.exitCode).toBe(1)
1154
+ expect(JSON.parse(json.stderr)).toMatchObject({
1155
+ ok: false,
1156
+ error: { code: 'TASK_NOT_FOUND' },
1157
+ })
1158
+ expect(pretty.exitCode).toBe(1)
1159
+ expect(pretty.stderr).toContain('Error [TASK_NOT_FOUND]')
1160
+ expect(unexpected.exitCode).toBe(2)
1161
+ expect(JSON.parse(unexpected.stderr)).toMatchObject({
1162
+ ok: false,
1163
+ error: { code: 'INTERNAL_ERROR' },
1164
+ })
1165
+ expect(unexpected.stderr).toContain('unable to open database file')
1166
+ })
1167
+
1168
+ test('JRN-001 full local Task lifecycle from creation to deletion', async () => {
1169
+ await withTempDb(async (dbPath) => {
1170
+ const created = await addTask(dbPath, 'Journey task', [
1171
+ '-d',
1172
+ 'Journey details',
1173
+ '-p',
1174
+ 'high',
1175
+ '-a',
1176
+ 'agent',
1177
+ '--project',
1178
+ 'Slice',
1179
+ '-m',
1180
+ '{"journey":1}',
1181
+ ])
1182
+
1183
+ expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', '-c', 'backlog']))
1184
+ expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list', '--project', 'Slice']))
1185
+ expectOk<Task>(await run(['--db', dbPath, 'task', 'view', created.id]))
1186
+ const updated = expectOk<Task>(
1187
+ await run(['--db', dbPath, 'task', 'update', created.id, '--title', 'Journey updated']),
1188
+ )
1189
+ const assigned = expectOk<Task>(
1190
+ await run(['--db', dbPath, 'task', 'assign', updated.id, 'reviewer']),
1191
+ )
1192
+ const prioritized = expectOk<Task>(
1193
+ await run(['--db', dbPath, 'task', 'prioritize', assigned.id, 'urgent']),
1194
+ )
1195
+ for (const column of ['in-progress', 'review', 'done']) {
1196
+ expectOk<Task>(await run(['--db', dbPath, 'task', 'move', prioritized.id, column]))
1197
+ }
1198
+ const deleted = expectOk<Task>(await run(['--db', dbPath, 'task', 'delete', prioritized.id]))
1199
+
1200
+ expect(deleted.id).toBe(created.id)
1201
+ await expectRunError(
1202
+ run(['--db', dbPath, 'task', 'view', created.id]),
1203
+ ErrorCode.TASK_NOT_FOUND,
1204
+ )
1205
+ expect(activityCount(dbPath, "WHERE action = 'moved'")).toBe(3)
1206
+ expect(activityCount(dbPath, "WHERE action = 'deleted'")).toBe(1)
1207
+ })
1208
+ })
1209
+
1210
+ test('JRN-002 automation JSON capture and reuse', () => {
1211
+ const dbPath = tempDbPath()
1212
+
1213
+ const created = cli(['--db', dbPath, 'task', 'add', 'Automation task'], { env: cliEnv() })
1214
+ const parsed = JSON.parse(created.stdout) as { ok: true; data: { id: string } }
1215
+ const viewed = cli(['--db', dbPath, 'task', 'view', parsed.data.id], { env: cliEnv() })
1216
+ const viewedParsed = JSON.parse(viewed.stdout) as { ok: true; data: { id: string } }
1217
+
1218
+ expect(created.exitCode).toBe(0)
1219
+ expect(viewed.exitCode).toBe(0)
1220
+ expect(parsed.ok).toBe(true)
1221
+ expect(viewedParsed.ok).toBe(true)
1222
+ expect(viewedParsed.data.id).toBe(parsed.data.id)
1223
+ expect(created.stdout).not.toContain('Task:')
1224
+ expect(viewed.stdout).not.toContain('Task:')
1225
+ })
1226
+
1227
+ test('JRN-003 human review workflow with pretty output', () => {
1228
+ const dbPath = tempDbPath()
1229
+ const created = cli(
1230
+ [
1231
+ '--db',
1232
+ dbPath,
1233
+ 'task',
1234
+ 'add',
1235
+ 'Human review',
1236
+ '-d',
1237
+ 'Reviewable',
1238
+ '-p',
1239
+ 'high',
1240
+ '-a',
1241
+ 'alice',
1242
+ '--project',
1243
+ 'Docs',
1244
+ ],
1245
+ { env: cliEnv() },
1246
+ )
1247
+ const taskId = JSON.parse(created.stdout).data.id as string
1248
+
1249
+ const list = cli(['--db', dbPath, 'task', 'list', '--pretty'], { env: cliEnv() })
1250
+ const view = cli(['--db', dbPath, 'task', 'view', taskId, '--pretty'], { env: cliEnv() })
1251
+ const empty = cli(['--db', dbPath, 'task', 'list', '-p', 'urgent', '--pretty'], {
1252
+ env: cliEnv(),
1253
+ })
1254
+
1255
+ expect(list.stdout).toContain('Human review')
1256
+ expect(view.stdout).toContain(`Task: ${taskId}`)
1257
+ expect(view.stdout).toContain('Description: Reviewable')
1258
+ expect(empty.stdout).toBe('No items found.')
1259
+ expect(list.stdout).not.toContain('"ok"')
1260
+ expect(view.stdout).not.toContain('"ok"')
1261
+ })
1262
+ })