kanbango 3.4.1 → 3.6.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.
package/index.js CHANGED
@@ -10,12 +10,14 @@
10
10
 
11
11
  const kanban = require('./kanban.js');
12
12
  const plan = require('./plan.js');
13
+ const workflow = require('./workflow.js');
13
14
  const guiRegistry = require('./gui-registry.js');
14
15
  const playbook = require('./agent-playbook.js');
15
16
 
16
17
  module.exports = {
17
18
  kanban,
18
19
  plan,
20
+ workflow,
19
21
  guiRegistry,
20
22
  playbook,
21
23
  };
package/kanban.js CHANGED
@@ -3,13 +3,27 @@ const path = require('path');
3
3
 
4
4
  const BACKLOG = path.join(process.cwd(), 'backlog');
5
5
  const EPICS_DIR = path.join(BACKLOG, 'epics');
6
- const COLS = ['active', 'planned', 'icebox', 'done'];
6
+ const COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
7
7
  const STATUS_MAP = {
8
8
  active: 'in_progress',
9
9
  planned: 'planned',
10
10
  icebox: 'icebox',
11
+ testing: 'testing',
12
+ review: 'review',
11
13
  done: 'done'
12
14
  };
15
+ const WORKFLOW_STAGES = ['testing', 'review'];
16
+ const WORKFLOW_STATUSES = ['idle', 'running', 'pass', 'fail', 'blocked'];
17
+ const EVIDENCE_VERDICTS = ['pass', 'fail', 'blocked', ''];
18
+ // Agent/human move contract. Same column is always a no-op.
19
+ const COLUMN_TRANSITIONS = {
20
+ icebox: ['planned'],
21
+ planned: ['active', 'icebox', 'testing'],
22
+ active: ['planned', 'testing', 'icebox'],
23
+ testing: ['active', 'review'],
24
+ review: ['active', 'done'],
25
+ done: ['active']
26
+ };
13
27
  const VIEW_FIELDS = {
14
28
  summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
15
29
  planning: [
@@ -18,6 +32,7 @@ const VIEW_FIELDS = {
18
32
  'column',
19
33
  'epic_id',
20
34
  'epic_group',
35
+ 'epic_goals',
21
36
  'created',
22
37
  'progress',
23
38
  'description',
@@ -33,6 +48,7 @@ const VIEW_FIELDS = {
33
48
  'column',
34
49
  'epic_id',
35
50
  'epic_group',
51
+ 'epic_goals',
36
52
  'created',
37
53
  'progress',
38
54
  'description',
@@ -41,7 +57,11 @@ const VIEW_FIELDS = {
41
57
  'out_of_scope',
42
58
  'acceptance_criteria',
43
59
  'test_cases',
44
- 'subtasks'
60
+ 'subtasks',
61
+ 'adr',
62
+ 'evidence',
63
+ 'plan',
64
+ 'workflow'
45
65
  ],
46
66
  full: [
47
67
  'task_number',
@@ -49,6 +69,7 @@ const VIEW_FIELDS = {
49
69
  'column',
50
70
  'epic_id',
51
71
  'epic_group',
72
+ 'epic_goals',
52
73
  'created',
53
74
  'progress',
54
75
  'description',
@@ -58,7 +79,11 @@ const VIEW_FIELDS = {
58
79
  'acceptance_criteria',
59
80
  'test_cases',
60
81
  'subtasks',
61
- 'notes'
82
+ 'adr',
83
+ 'notes',
84
+ 'evidence',
85
+ 'plan',
86
+ 'workflow'
62
87
  ]
63
88
  };
64
89
 
@@ -88,7 +113,8 @@ const EPIC_VIEW_FIELDS = {
88
113
  'in_scope',
89
114
  'out_of_scope',
90
115
  'notes',
91
- 'tasks'
116
+ 'tasks',
117
+ 'adrs'
92
118
  ]
93
119
  };
94
120
 
@@ -255,14 +281,93 @@ function normalizeSubtasks(value) {
255
281
 
256
282
  function normalizeEvidence(value) {
257
283
  if (!Array.isArray(value)) return [];
258
- return value.map((item) => ({
259
- diff: normalizeString(item && item.diff),
260
- test_command: normalizeString(item && item.test_command),
261
- stdout: normalizeString(item && item.stdout),
262
- stderr: normalizeString(item && item.stderr),
263
- exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
264
- created: normalizeString(item && item.created) || todayIso()
265
- }));
284
+ return value.map((item) => {
285
+ const stage = normalizeString(item && item.stage);
286
+ const verdict = normalizeString(item && item.verdict);
287
+ return {
288
+ diff: normalizeString(item && item.diff),
289
+ test_command: normalizeString(item && item.test_command),
290
+ stdout: normalizeString(item && item.stdout),
291
+ stderr: normalizeString(item && item.stderr),
292
+ exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
293
+ created: normalizeString(item && item.created) || todayIso(),
294
+ stage: WORKFLOW_STAGES.includes(stage) || stage === 'plan' ? stage : '',
295
+ agent: normalizeString(item && item.agent),
296
+ verdict: EVIDENCE_VERDICTS.includes(verdict) ? verdict : '',
297
+ summary: normalizeString(item && item.summary)
298
+ };
299
+ });
300
+ }
301
+
302
+ function normalizeWorkflow(value) {
303
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
304
+ const stage = normalizeString(value.stage);
305
+ const status = normalizeString(value.status);
306
+ const agent = normalizeString(value.agent);
307
+ const runId = normalizeString(value.run_id);
308
+ if (!stage && !status && !agent && !runId) return null;
309
+ return {
310
+ stage: WORKFLOW_STAGES.includes(stage) ? stage : null,
311
+ status: WORKFLOW_STATUSES.includes(status) ? status : 'idle',
312
+ agent,
313
+ run_id: runId,
314
+ started_at: normalizeString(value.started_at) || undefined,
315
+ finished_at: normalizeString(value.finished_at) || undefined
316
+ };
317
+ }
318
+
319
+ function normalizeAdr(value) {
320
+ if (!Array.isArray(value)) return [];
321
+ return value
322
+ .map((item, idx) => ({
323
+ id: normalizeString(item && item.id, `adr-${idx + 1}`),
324
+ decision: normalizeString(item && item.decision),
325
+ why: normalizeString(item && item.why),
326
+ created: normalizeString(item && item.created) || todayIso()
327
+ }))
328
+ .filter((item) => item.decision && item.why);
329
+ }
330
+
331
+ function nextAdrId(existing) {
332
+ let max = 0;
333
+ for (const item of existing) {
334
+ const match = normalizeString(item && item.id).match(/^adr-(\d+)$/i);
335
+ if (match) max = Math.max(max, parseInt(match[1], 10));
336
+ }
337
+ return `adr-${max + 1}`;
338
+ }
339
+
340
+ function appendAdrEntry(existing, entry) {
341
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
342
+ throw createKanbanError(
343
+ 'VALIDATION_ERROR',
344
+ 'adr must be an object with decision and why, or an array of such objects',
345
+ 'Send adr: { decision: "...", why: "..." } to append one entry',
346
+ { field: 'adr' },
347
+ false,
348
+ 400
349
+ );
350
+ }
351
+ const decision = normalizeString(entry.decision);
352
+ const why = normalizeString(entry.why);
353
+ if (!decision || !why) {
354
+ throw createKanbanError(
355
+ 'VALIDATION_ERROR',
356
+ 'adr.decision and adr.why must be non-empty strings',
357
+ 'Provide both decision and why when appending an ADR',
358
+ { field: 'adr' },
359
+ false,
360
+ 400
361
+ );
362
+ }
363
+ const list = normalizeAdr(existing);
364
+ list.push({
365
+ id: nextAdrId(list),
366
+ decision,
367
+ why,
368
+ created: normalizeString(entry.created) || todayIso()
369
+ });
370
+ return list;
266
371
  }
267
372
 
268
373
  function normalizePlan(value) {
@@ -291,9 +396,11 @@ function normalizeTask(task) {
291
396
  acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
292
397
  test_cases: normalizeStringArray(task.test_cases),
293
398
  subtasks: normalizeSubtasks(task.subtasks),
399
+ adr: normalizeAdr(task.adr),
294
400
  notes: normalizeString(task.notes),
295
401
  plan: normalizePlan(task.plan),
296
402
  evidence: normalizeEvidence(task.evidence),
403
+ workflow: normalizeWorkflow(task.workflow),
297
404
  task_number: extractTaskNumber(id)
298
405
  };
299
406
 
@@ -316,9 +423,11 @@ function serializeTask(task) {
316
423
  acceptance_criteria: normalized.acceptance_criteria,
317
424
  test_cases: normalized.test_cases,
318
425
  subtasks: normalized.subtasks,
426
+ adr: normalized.adr,
319
427
  notes: normalized.notes,
320
428
  plan: normalized.plan,
321
429
  evidence: normalized.evidence,
430
+ workflow: normalized.workflow,
322
431
  task_number: normalized.task_number
323
432
  };
324
433
  }
@@ -356,7 +465,9 @@ function serializeEpic(epic) {
356
465
  function deriveEpicStatus(tasks, epic) {
357
466
  if (epic && epic.archived) return 'archived';
358
467
  if (!tasks || tasks.length === 0) return 'empty';
359
- if (tasks.some((task) => task.column === 'active')) return 'active';
468
+ if (tasks.some((task) => WORKFLOW_STAGES.includes(task.column) || task.column === 'active')) {
469
+ return 'active';
470
+ }
360
471
  if (tasks.every((task) => task.column === 'done')) return 'done';
361
472
  return 'planned';
362
473
  }
@@ -374,13 +485,21 @@ function getEpicProgress(tasks) {
374
485
  tasks_done: 0,
375
486
  tasks_active: 0,
376
487
  tasks_planned: 0,
377
- tasks_icebox: 0
488
+ tasks_icebox: 0,
489
+ tasks_testing: 0,
490
+ tasks_review: 0
491
+ };
492
+ const keyByCol = {
493
+ done: 'tasks_done',
494
+ active: 'tasks_active',
495
+ planned: 'tasks_planned',
496
+ icebox: 'tasks_icebox',
497
+ testing: 'tasks_testing',
498
+ review: 'tasks_review'
378
499
  };
379
500
  for (const task of tasks) {
380
- if (task.column === 'done') progress.tasks_done += 1;
381
- else if (task.column === 'active') progress.tasks_active += 1;
382
- else if (task.column === 'planned') progress.tasks_planned += 1;
383
- else if (task.column === 'icebox') progress.tasks_icebox += 1;
501
+ const key = keyByCol[task.column];
502
+ if (key) progress[key] += 1;
384
503
  }
385
504
  return progress;
386
505
  }
@@ -395,6 +514,24 @@ function pickEpicFields(epicPayload, fieldNames) {
395
514
  return picked;
396
515
  }
397
516
 
517
+ function collectEpicAdrs(childTasks) {
518
+ const adrs = [];
519
+ for (const task of childTasks) {
520
+ const entries = normalizeAdr(task.adr);
521
+ for (const entry of entries) {
522
+ adrs.push({
523
+ task_id: task.id,
524
+ task_title: task.title,
525
+ id: entry.id,
526
+ decision: entry.decision,
527
+ why: entry.why,
528
+ created: entry.created
529
+ });
530
+ }
531
+ }
532
+ return adrs;
533
+ }
534
+
398
535
  function shapeEpic(epic, tasks = [], options = {}) {
399
536
  const normalized = normalizeEpic(epic);
400
537
  const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
@@ -402,7 +539,8 @@ function shapeEpic(epic, tasks = [], options = {}) {
402
539
  ...normalized,
403
540
  status: deriveEpicStatus(childTasks, normalized),
404
541
  progress: getEpicProgress(childTasks),
405
- tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
542
+ tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' })),
543
+ adrs: collectEpicAdrs(childTasks)
406
544
  };
407
545
  const fields = Array.isArray(options.fields) && options.fields.length > 0
408
546
  ? options.fields
@@ -479,6 +617,14 @@ function getProgress(task) {
479
617
  return { done, total };
480
618
  }
481
619
 
620
+ function resolveEpicGoals(task, epicLookup) {
621
+ if (!task.epic_id) return '';
622
+ if (!epicLookup || typeof epicLookup !== 'object') return '';
623
+ const epic = epicLookup[task.epic_id];
624
+ if (!epic) return '';
625
+ return normalizeString(epic.goals);
626
+ }
627
+
482
628
  function pickFields(task, fieldNames) {
483
629
  const picked = {};
484
630
 
@@ -487,6 +633,10 @@ function pickFields(task, fieldNames) {
487
633
  picked.progress = getProgress(task);
488
634
  continue;
489
635
  }
636
+ if (field === 'epic_goals') {
637
+ picked.epic_goals = task.epic_goals;
638
+ continue;
639
+ }
490
640
  if (field in task) {
491
641
  picked[field] = task[field];
492
642
  }
@@ -501,7 +651,11 @@ function shapeTask(task, options = {}) {
501
651
  ? options.fields
502
652
  : (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
503
653
 
504
- return pickFields(normalized, fields);
654
+ const withGoals = {
655
+ ...normalized,
656
+ epic_goals: resolveEpicGoals(normalized, options.epicLookup)
657
+ };
658
+ return pickFields(withGoals, fields);
505
659
  }
506
660
 
507
661
  function escapeRegex(value) {
@@ -1263,6 +1417,32 @@ function validateColumn(column, fieldName = 'column') {
1263
1417
  }
1264
1418
  }
1265
1419
 
1420
+ function allowedColumnsFrom(fromColumn) {
1421
+ return COLUMN_TRANSITIONS[fromColumn] ? COLUMN_TRANSITIONS[fromColumn].slice() : [];
1422
+ }
1423
+
1424
+ function validateTransition(fromColumn, toColumn, taskId) {
1425
+ if (fromColumn === toColumn) return;
1426
+ validateColumn(toColumn);
1427
+ const allowed = allowedColumnsFrom(fromColumn);
1428
+ if (allowed.includes(toColumn)) return;
1429
+ throw createKanbanError(
1430
+ 'INVALID_TRANSITION',
1431
+ taskId
1432
+ ? `Cannot move task ${taskId} from ${fromColumn} to ${toColumn}`
1433
+ : `Cannot move from ${fromColumn} to ${toColumn}`,
1434
+ `From ${fromColumn} you can move only to: ${allowed.join(', ') || '(none)'}`,
1435
+ {
1436
+ task_id: taskId || undefined,
1437
+ from: fromColumn,
1438
+ to: toColumn,
1439
+ allowed_columns: allowed
1440
+ },
1441
+ false,
1442
+ 400
1443
+ );
1444
+ }
1445
+
1266
1446
  function validatePatch(patch) {
1267
1447
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1268
1448
  throw createKanbanError(
@@ -1316,6 +1496,7 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1316
1496
  subtasks: extra.subtasks,
1317
1497
  notes: extra.notes,
1318
1498
  plan: extra.plan,
1499
+ adr: extra.adr,
1319
1500
  evidence: extra.evidence
1320
1501
  });
1321
1502
  try {
@@ -1354,6 +1535,9 @@ async function updateTaskRecord(taskId, patch) {
1354
1535
 
1355
1536
  if (patch.column !== undefined) {
1356
1537
  validateColumn(patch.column);
1538
+ if (patch.column !== current.column) {
1539
+ validateTransition(current.column, patch.column, current.id);
1540
+ }
1357
1541
  next.column = patch.column;
1358
1542
  }
1359
1543
  if (patch.title !== undefined) {
@@ -1460,8 +1644,31 @@ async function updateTaskRecord(taskId, patch) {
1460
1644
  }
1461
1645
  next.subtasks = patch.subtasks;
1462
1646
  }
1647
+ if (patch.adr !== undefined) {
1648
+ if (Array.isArray(patch.adr)) {
1649
+ next.adr = normalizeAdr(patch.adr);
1650
+ } else {
1651
+ next.adr = appendAdrEntry(next.adr, patch.adr);
1652
+ }
1653
+ }
1463
1654
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1464
1655
  if (patch.plan !== undefined) next.plan = patch.plan;
1656
+ if (patch.workflow !== undefined) {
1657
+ next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
1658
+ }
1659
+ if (patch.appendEvidence !== undefined) {
1660
+ if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
1661
+ throw createKanbanError(
1662
+ 'VALIDATION_ERROR',
1663
+ 'appendEvidence must be an evidence object',
1664
+ 'Send a single evidence entry to append',
1665
+ { field: 'appendEvidence' },
1666
+ false,
1667
+ 400
1668
+ );
1669
+ }
1670
+ next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
1671
+ }
1465
1672
  if (patch.evidence !== undefined) {
1466
1673
  if (!Array.isArray(patch.evidence)) {
1467
1674
  throw createKanbanError(
@@ -1479,9 +1686,36 @@ async function updateTaskRecord(taskId, patch) {
1479
1686
  return writeTask(next, previousFilePath);
1480
1687
  }
1481
1688
 
1689
+ function scheduleWorkflowEnqueue(previousColumn, updated) {
1690
+ if (!updated || !WORKFLOW_STAGES.includes(updated.column)) return;
1691
+ if (previousColumn === updated.column) return;
1692
+ // Lazy require avoids circular load: workflow.js requires kanban.js.
1693
+ setImmediate(() => {
1694
+ try {
1695
+ const workflow = require('./workflow.js');
1696
+ Promise.resolve(workflow.maybeEnqueueOnColumnEnter(updated, previousColumn)).catch((err) => {
1697
+ console.error('workflow enqueue failed:', err && err.message ? err.message : err);
1698
+ });
1699
+ } catch (err) {
1700
+ console.error('workflow load failed:', err && err.message ? err.message : err);
1701
+ }
1702
+ });
1703
+ }
1704
+
1482
1705
  async function updateTask(taskId, patch) {
1483
1706
  validatePatch(patch);
1484
- return withBoardLock(() => updateTaskRecord(taskId, patch));
1707
+ let previousColumn = null;
1708
+ const updated = await withBoardLock(async () => {
1709
+ const resolvedId = await resolveTaskId(taskId);
1710
+ const previousFilePath = await findFile(resolvedId);
1711
+ if (previousFilePath) {
1712
+ const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1713
+ previousColumn = current.column;
1714
+ }
1715
+ return updateTaskRecord(taskId, patch);
1716
+ });
1717
+ scheduleWorkflowEnqueue(previousColumn, updated);
1718
+ return updated;
1485
1719
  }
1486
1720
 
1487
1721
  async function doMove(epicId, target) {
@@ -1489,7 +1723,7 @@ async function doMove(epicId, target) {
1489
1723
  await updateTask(epicId, { column: target });
1490
1724
  return true;
1491
1725
  } catch (error) {
1492
- if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN') {
1726
+ if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN' || error.code === 'INVALID_TRANSITION') {
1493
1727
  return false;
1494
1728
  }
1495
1729
  throw error;
@@ -1579,8 +1813,14 @@ module.exports = {
1579
1813
  resolveTaskId,
1580
1814
  COLS,
1581
1815
  STATUS_MAP,
1816
+ WORKFLOW_STAGES,
1817
+ COLUMN_TRANSITIONS,
1818
+ allowedColumnsFrom,
1819
+ validateTransition,
1582
1820
  VIEW_FIELDS,
1583
1821
  EPIC_VIEW_FIELDS,
1822
+ normalizeEvidence,
1823
+ normalizeWorkflow,
1584
1824
  LIVE_EPIC_STATUSES,
1585
1825
  RECOMMENDED_CREATE_FIELDS,
1586
1826
  RECOMMENDED_EPIC_CREATE_FIELDS,
package/mcp-server.js CHANGED
@@ -141,9 +141,22 @@ function normalizeReadOptions(args, defaultView) {
141
141
  return { view };
142
142
  }
143
143
 
144
- function formatTaskResult(task, returnShape) {
144
+ async function buildEpicLookup() {
145
+ const epics = await kanban.listEpicEntities();
146
+ const lookup = {};
147
+ for (const epic of epics) {
148
+ lookup[epic.id] = epic;
149
+ }
150
+ return lookup;
151
+ }
152
+
153
+ async function formatTaskResult(task, returnShape) {
145
154
  if (returnShape === 'none') return { ok: true };
146
- return kanban.shapeTask(task, { view: returnShape === 'full' ? 'full' : 'summary' });
155
+ const epicLookup = await buildEpicLookup();
156
+ return kanban.shapeTask(task, {
157
+ view: returnShape === 'full' ? 'full' : 'summary',
158
+ epicLookup
159
+ });
147
160
  }
148
161
 
149
162
  function guiIdentity(extra = {}) {
@@ -456,6 +469,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
456
469
  type: 'string',
457
470
  description: 'Freeform notes'
458
471
  },
472
+ adr: {
473
+ description:
474
+ 'Append one ADR: { decision, why }. Or replace full array of { id?, decision, why, created? }.',
475
+ oneOf: [
476
+ {
477
+ type: 'object',
478
+ properties: {
479
+ decision: { type: 'string' },
480
+ why: { type: 'string' }
481
+ },
482
+ required: ['decision', 'why']
483
+ },
484
+ {
485
+ type: 'array',
486
+ items: {
487
+ type: 'object',
488
+ properties: {
489
+ id: { type: 'string' },
490
+ decision: { type: 'string' },
491
+ why: { type: 'string' },
492
+ created: { type: 'string' }
493
+ }
494
+ }
495
+ }
496
+ ]
497
+ },
459
498
  task_id: {
460
499
  type: 'string',
461
500
  description: "Required for move/update/delete/plan_* except plan_create. '014' or '14'."
@@ -463,7 +502,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
463
502
  column: {
464
503
  type: 'string',
465
504
  enum: COLS,
466
- description: 'Target column for move (not col)'
505
+ description:
506
+ 'Target column for move (not col). Must be a legal transition from the current column. '
507
+ + 'icebox→planned; planned→active|icebox|testing; active→planned|testing|icebox; '
508
+ + 'testing→active|review; review→active|done; done→active. Illegal → INVALID_TRANSITION + allowed_columns.'
467
509
  },
468
510
  patch: {
469
511
  type: 'object',
@@ -571,6 +613,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
571
613
  });
572
614
  }
573
615
 
616
+ // summary omits epic_goals; no lookup needed for list tokens
574
617
  result = tasks.map((task) => kanban.shapeTask(task, readOptions));
575
618
  } else if (operation === 'show') {
576
619
  if (!args.task_id) {
@@ -581,7 +624,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
581
624
  );
582
625
  }
583
626
 
584
- result = kanban.shapeTask(await kanban.getTask(args.task_id), readOptions);
627
+ await kanban.migrateEpicGroups();
628
+ const epicLookup = await buildEpicLookup();
629
+ result = kanban.shapeTask(await kanban.getTask(args.task_id), {
630
+ ...readOptions,
631
+ epicLookup
632
+ });
585
633
  } else if (operation === 'list_epics') {
586
634
  await kanban.migrateEpicGroups();
587
635
  const tasks = await kanban.allTasks();
@@ -643,7 +691,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
643
691
  };
644
692
  const epicRef = args.epic_id || args.epic || '—';
645
693
  const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
646
- const shaped = kanban.shapeTask(created, { view: 'full' });
694
+ const shaped = kanban.shapeTask(created, {
695
+ view: 'full',
696
+ epicLookup: await buildEpicLookup()
697
+ });
647
698
  const warnings = kanban.createFieldWarnings(createPayload);
648
699
  result = warnings.length > 0
649
700
  ? { ...shaped, warnings, missing_recommended: kanban.missingRecommendedCreateFields(createPayload) }
@@ -769,7 +820,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
769
820
  );
770
821
  }
771
822
  const updated = await kanban.updateTask(args.task_id, { column: args.column });
772
- result = formatTaskResult(updated, returnShape);
823
+ result = await formatTaskResult(updated, returnShape);
773
824
  break;
774
825
  }
775
826
 
@@ -791,11 +842,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
791
842
  if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
792
843
  if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
793
844
  if (args.notes !== undefined) patch.notes = args.notes;
845
+ if (args.adr !== undefined) patch.adr = args.adr;
794
846
  if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
795
847
  else if (args.epic !== undefined) patch.epic = args.epic;
796
848
  if (args.col !== undefined) patch.column = args.col;
797
849
  const updated = await kanban.updateTask(args.task_id, patch);
798
- result = formatTaskResult(updated, returnShape);
850
+ result = await formatTaskResult(updated, returnShape);
799
851
  break;
800
852
  }
801
853
 
@@ -892,8 +944,8 @@ function installGuiShutdownHooks() {
892
944
  if (!ownsGuiProcess()) return;
893
945
  try {
894
946
  await stopGuiServer();
895
- } catch {
896
- // best-effort
947
+ } catch (error) {
948
+ console.error(`kanbango GUI shutdown failed: ${error.message}`);
897
949
  }
898
950
  }
899
951
 
@@ -901,8 +953,8 @@ function installGuiShutdownHooks() {
901
953
  if (ownsGuiProcess()) {
902
954
  try {
903
955
  guiProcess.kill();
904
- } catch {
905
- // ignore
956
+ } catch (error) {
957
+ console.error(`kanbango GUI kill on exit failed: ${error.message}`);
906
958
  }
907
959
  }
908
960
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.4.1",
3
+ "version": "3.6.2",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
package/plan.js CHANGED
@@ -147,8 +147,11 @@ async function done(payload = {}) {
147
147
  throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
148
148
  'Advance every plan step before marking the workflow done', { incomplete });
149
149
  }
150
- const updated = await kanban.updateTask(task.id, { column: 'done', plan: { ...task.plan, status: 'done' } });
151
- return result(updated, { status: 'done' });
150
+ const updated = await kanban.updateTask(task.id, {
151
+ column: 'testing',
152
+ plan: { ...(task.plan || {}), status: 'done' }
153
+ });
154
+ return result(updated, { status: 'done', column: updated.column });
152
155
  }
153
156
 
154
157
  async function status(taskId) {