kanbango 3.3.0 → 3.5.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.
package/kanban.js CHANGED
@@ -18,6 +18,7 @@ const VIEW_FIELDS = {
18
18
  'column',
19
19
  'epic_id',
20
20
  'epic_group',
21
+ 'epic_goals',
21
22
  'created',
22
23
  'progress',
23
24
  'description',
@@ -33,6 +34,7 @@ const VIEW_FIELDS = {
33
34
  'column',
34
35
  'epic_id',
35
36
  'epic_group',
37
+ 'epic_goals',
36
38
  'created',
37
39
  'progress',
38
40
  'description',
@@ -42,7 +44,7 @@ const VIEW_FIELDS = {
42
44
  'acceptance_criteria',
43
45
  'test_cases',
44
46
  'subtasks',
45
- 'comments'
47
+ 'adr'
46
48
  ],
47
49
  full: [
48
50
  'task_number',
@@ -50,6 +52,7 @@ const VIEW_FIELDS = {
50
52
  'column',
51
53
  'epic_id',
52
54
  'epic_group',
55
+ 'epic_goals',
53
56
  'created',
54
57
  'progress',
55
58
  'description',
@@ -59,8 +62,8 @@ const VIEW_FIELDS = {
59
62
  'acceptance_criteria',
60
63
  'test_cases',
61
64
  'subtasks',
62
- 'notes',
63
- 'comments'
65
+ 'adr',
66
+ 'notes'
64
67
  ]
65
68
  };
66
69
 
@@ -90,7 +93,8 @@ const EPIC_VIEW_FIELDS = {
90
93
  'in_scope',
91
94
  'out_of_scope',
92
95
  'notes',
93
- 'tasks'
96
+ 'tasks',
97
+ 'adrs'
94
98
  ]
95
99
  };
96
100
 
@@ -255,28 +259,6 @@ function normalizeSubtasks(value) {
255
259
  })).filter((subtask) => subtask.text);
256
260
  }
257
261
 
258
- function nowIso() {
259
- return new Date().toISOString();
260
- }
261
-
262
- function nextCommentId(comments) {
263
- const max = comments.reduce((highest, comment) => {
264
- const match = String(comment && comment.id || '').match(/^c-(\d+)$/i);
265
- return match ? Math.max(highest, parseInt(match[1], 10)) : highest;
266
- }, 0);
267
- return `c-${max + 1}`;
268
- }
269
-
270
- function normalizeComments(value) {
271
- if (!Array.isArray(value)) return [];
272
- return value.map((item, idx) => ({
273
- id: normalizeString(item && item.id, `c-${idx + 1}`),
274
- created: normalizeString(item && item.created) || nowIso(),
275
- author: normalizeString(item && item.author, 'user') || 'user',
276
- text: normalizeString(item && item.text)
277
- })).filter((item) => item.text);
278
- }
279
-
280
262
  function normalizeEvidence(value) {
281
263
  if (!Array.isArray(value)) return [];
282
264
  return value.map((item) => ({
@@ -289,6 +271,60 @@ function normalizeEvidence(value) {
289
271
  }));
290
272
  }
291
273
 
274
+ function normalizeAdr(value) {
275
+ if (!Array.isArray(value)) return [];
276
+ return value
277
+ .map((item, idx) => ({
278
+ id: normalizeString(item && item.id, `adr-${idx + 1}`),
279
+ decision: normalizeString(item && item.decision),
280
+ why: normalizeString(item && item.why),
281
+ created: normalizeString(item && item.created) || todayIso()
282
+ }))
283
+ .filter((item) => item.decision && item.why);
284
+ }
285
+
286
+ function nextAdrId(existing) {
287
+ let max = 0;
288
+ for (const item of existing) {
289
+ const match = normalizeString(item && item.id).match(/^adr-(\d+)$/i);
290
+ if (match) max = Math.max(max, parseInt(match[1], 10));
291
+ }
292
+ return `adr-${max + 1}`;
293
+ }
294
+
295
+ function appendAdrEntry(existing, entry) {
296
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
297
+ throw createKanbanError(
298
+ 'VALIDATION_ERROR',
299
+ 'adr must be an object with decision and why, or an array of such objects',
300
+ 'Send adr: { decision: "...", why: "..." } to append one entry',
301
+ { field: 'adr' },
302
+ false,
303
+ 400
304
+ );
305
+ }
306
+ const decision = normalizeString(entry.decision);
307
+ const why = normalizeString(entry.why);
308
+ if (!decision || !why) {
309
+ throw createKanbanError(
310
+ 'VALIDATION_ERROR',
311
+ 'adr.decision and adr.why must be non-empty strings',
312
+ 'Provide both decision and why when appending an ADR',
313
+ { field: 'adr' },
314
+ false,
315
+ 400
316
+ );
317
+ }
318
+ const list = normalizeAdr(existing);
319
+ list.push({
320
+ id: nextAdrId(list),
321
+ decision,
322
+ why,
323
+ created: normalizeString(entry.created) || todayIso()
324
+ });
325
+ return list;
326
+ }
327
+
292
328
  function normalizePlan(value) {
293
329
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
294
330
  return {
@@ -315,8 +351,8 @@ function normalizeTask(task) {
315
351
  acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
316
352
  test_cases: normalizeStringArray(task.test_cases),
317
353
  subtasks: normalizeSubtasks(task.subtasks),
354
+ adr: normalizeAdr(task.adr),
318
355
  notes: normalizeString(task.notes),
319
- comments: normalizeComments(task.comments),
320
356
  plan: normalizePlan(task.plan),
321
357
  evidence: normalizeEvidence(task.evidence),
322
358
  task_number: extractTaskNumber(id)
@@ -341,8 +377,8 @@ function serializeTask(task) {
341
377
  acceptance_criteria: normalized.acceptance_criteria,
342
378
  test_cases: normalized.test_cases,
343
379
  subtasks: normalized.subtasks,
380
+ adr: normalized.adr,
344
381
  notes: normalized.notes,
345
- comments: normalized.comments,
346
382
  plan: normalized.plan,
347
383
  evidence: normalized.evidence,
348
384
  task_number: normalized.task_number
@@ -421,6 +457,24 @@ function pickEpicFields(epicPayload, fieldNames) {
421
457
  return picked;
422
458
  }
423
459
 
460
+ function collectEpicAdrs(childTasks) {
461
+ const adrs = [];
462
+ for (const task of childTasks) {
463
+ const entries = normalizeAdr(task.adr);
464
+ for (const entry of entries) {
465
+ adrs.push({
466
+ task_id: task.id,
467
+ task_title: task.title,
468
+ id: entry.id,
469
+ decision: entry.decision,
470
+ why: entry.why,
471
+ created: entry.created
472
+ });
473
+ }
474
+ }
475
+ return adrs;
476
+ }
477
+
424
478
  function shapeEpic(epic, tasks = [], options = {}) {
425
479
  const normalized = normalizeEpic(epic);
426
480
  const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
@@ -428,7 +482,8 @@ function shapeEpic(epic, tasks = [], options = {}) {
428
482
  ...normalized,
429
483
  status: deriveEpicStatus(childTasks, normalized),
430
484
  progress: getEpicProgress(childTasks),
431
- tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
485
+ tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' })),
486
+ adrs: collectEpicAdrs(childTasks)
432
487
  };
433
488
  const fields = Array.isArray(options.fields) && options.fields.length > 0
434
489
  ? options.fields
@@ -505,6 +560,14 @@ function getProgress(task) {
505
560
  return { done, total };
506
561
  }
507
562
 
563
+ function resolveEpicGoals(task, epicLookup) {
564
+ if (!task.epic_id) return '';
565
+ if (!epicLookup || typeof epicLookup !== 'object') return '';
566
+ const epic = epicLookup[task.epic_id];
567
+ if (!epic) return '';
568
+ return normalizeString(epic.goals);
569
+ }
570
+
508
571
  function pickFields(task, fieldNames) {
509
572
  const picked = {};
510
573
 
@@ -513,6 +576,10 @@ function pickFields(task, fieldNames) {
513
576
  picked.progress = getProgress(task);
514
577
  continue;
515
578
  }
579
+ if (field === 'epic_goals') {
580
+ picked.epic_goals = task.epic_goals;
581
+ continue;
582
+ }
516
583
  if (field in task) {
517
584
  picked[field] = task[field];
518
585
  }
@@ -527,7 +594,11 @@ function shapeTask(task, options = {}) {
527
594
  ? options.fields
528
595
  : (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
529
596
 
530
- return pickFields(normalized, fields);
597
+ const withGoals = {
598
+ ...normalized,
599
+ epic_goals: resolveEpicGoals(normalized, options.epicLookup)
600
+ };
601
+ return pickFields(withGoals, fields);
531
602
  }
532
603
 
533
604
  function escapeRegex(value) {
@@ -1341,7 +1412,6 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1341
1412
  test_cases: extra.test_cases,
1342
1413
  subtasks: extra.subtasks,
1343
1414
  notes: extra.notes,
1344
- comments: extra.comments,
1345
1415
  plan: extra.plan,
1346
1416
  evidence: extra.evidence
1347
1417
  });
@@ -1487,20 +1557,14 @@ async function updateTaskRecord(taskId, patch) {
1487
1557
  }
1488
1558
  next.subtasks = patch.subtasks;
1489
1559
  }
1490
- if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1491
- if (patch.comments !== undefined) {
1492
- if (!Array.isArray(patch.comments)) {
1493
- throw createKanbanError(
1494
- 'VALIDATION_ERROR',
1495
- 'comments must be an array',
1496
- 'Send comments as an array of comment objects',
1497
- { field: 'comments' },
1498
- false,
1499
- 400
1500
- );
1560
+ if (patch.adr !== undefined) {
1561
+ if (Array.isArray(patch.adr)) {
1562
+ next.adr = normalizeAdr(patch.adr);
1563
+ } else {
1564
+ next.adr = appendAdrEntry(next.adr, patch.adr);
1501
1565
  }
1502
- next.comments = patch.comments;
1503
1566
  }
1567
+ if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1504
1568
  if (patch.plan !== undefined) next.plan = patch.plan;
1505
1569
  if (patch.evidence !== undefined) {
1506
1570
  if (!Array.isArray(patch.evidence)) {
@@ -1524,44 +1588,6 @@ async function updateTask(taskId, patch) {
1524
1588
  return withBoardLock(() => updateTaskRecord(taskId, patch));
1525
1589
  }
1526
1590
 
1527
- async function addComment(taskId, text, author = 'user') {
1528
- const body = normalizeString(text);
1529
- if (!body) {
1530
- throw createKanbanError(
1531
- 'MISSING_REQUIRED_FIELD',
1532
- 'text is required',
1533
- 'Provide a non-empty comment text',
1534
- { field: 'text' },
1535
- false,
1536
- 400
1537
- );
1538
- }
1539
- return withBoardLock(async () => {
1540
- const resolvedId = await resolveTaskId(taskId);
1541
- const previousFilePath = await findFile(resolvedId);
1542
- if (!previousFilePath) {
1543
- throw createKanbanError(
1544
- 'TASK_NOT_FOUND',
1545
- `Task ${taskId} was not found`,
1546
- 'Call kanban_read with operation=list to discover valid task ids',
1547
- { task_id: taskId },
1548
- false,
1549
- 404
1550
- );
1551
- }
1552
- const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1553
- const comments = normalizeComments(current.comments);
1554
- const comment = {
1555
- id: nextCommentId(comments),
1556
- created: nowIso(),
1557
- author: normalizeString(author, 'user') || 'user',
1558
- text: body
1559
- };
1560
- const saved = await writeTask({ ...current, comments: [...comments, comment] }, previousFilePath);
1561
- return { comment, comments: saved.comments, task_id: saved.id };
1562
- });
1563
- }
1564
-
1565
1591
  async function doMove(epicId, target) {
1566
1592
  try {
1567
1593
  await updateTask(epicId, { column: target });
@@ -1627,7 +1653,6 @@ module.exports = {
1627
1653
  shapeTask,
1628
1654
  shapeEpic,
1629
1655
  updateTask,
1630
- addComment,
1631
1656
  migrateAll,
1632
1657
  migrateEpicGroups,
1633
1658
  doMove,
package/mcp-server.js CHANGED
@@ -76,101 +76,6 @@ function invalidRequest(message, hint, details) {
76
76
  return kanban.createKanbanError('VALIDATION_ERROR', message, hint, details, false, 400);
77
77
  }
78
78
 
79
- const MANAGE_ACTIONS = [
80
- 'create',
81
- 'move',
82
- 'update',
83
- 'delete',
84
- 'epic_create',
85
- 'epic_update',
86
- 'epic_archive',
87
- 'epic_unarchive',
88
- 'epic_delete',
89
- 'plan_create',
90
- 'plan_advance',
91
- 'plan_evidence',
92
- 'plan_done',
93
- 'plan_status',
94
- 'comment_add'
95
- ];
96
-
97
- const GUI_ACTIONS = ['start', 'stop', 'status'];
98
-
99
- const ACTION_EXAMPLES = {
100
- kanban_manage: '{"action":"create","title":"Ship image","col":"planned"}',
101
- kanban_gui: '{"action":"status"}'
102
- };
103
-
104
- function receivedKeys(args) {
105
- return Object.keys(args && typeof args === 'object' ? args : {});
106
- }
107
-
108
- function actionRecipeMessage(toolName, allowed, opts = {}) {
109
- const keys = opts.received_keys || [];
110
- const example = ACTION_EXAMPLES[toolName] || `{"action":"${allowed[0]}"}`;
111
- const lines = [];
112
-
113
- if (opts.kind === 'unknown') {
114
- lines.push(`Unknown action "${opts.action}" on ${toolName}.`);
115
- } else {
116
- lines.push(`Missing required top-level field "action" on ${toolName}.`);
117
- }
118
-
119
- lines.push('Pass action next to other args (not nested under params).');
120
- lines.push(`Valid: ${allowed.join(', ')}.`);
121
- lines.push(`Example: ${example}`);
122
- lines.push(keys.length > 0 ? `You sent keys: ${keys.join(', ')}` : 'You sent keys: (none)');
123
- return lines.join(' ');
124
- }
125
-
126
- function actionRecipeHint() {
127
- return [
128
- 'Retry the same tool with top-level action set to one Valid value.',
129
- 'Common mistake: omitting action, or putting it under params (jira/gitlab style) — kanban uses top-level action.'
130
- ].join(' ');
131
- }
132
-
133
- function requireToolAction(args, allowed, toolName) {
134
- const action = args && args.action;
135
- const keys = receivedKeys(args);
136
- if (action === undefined || action === null || action === '') {
137
- throw kanban.createKanbanError(
138
- 'MISSING_REQUIRED_FIELD',
139
- actionRecipeMessage(toolName, allowed, {
140
- kind: 'missing',
141
- received_keys: keys,
142
- action: action === undefined ? null : action
143
- }),
144
- actionRecipeHint(),
145
- {
146
- field: 'action',
147
- tool: toolName,
148
- received_keys: keys,
149
- allowed_actions: allowed,
150
- action: action === undefined ? null : action
151
- },
152
- false,
153
- 400
154
- );
155
- }
156
- return action;
157
- }
158
-
159
- function unknownToolAction(action, args, allowed, toolName) {
160
- const keys = receivedKeys(args);
161
- return invalidRequest(
162
- actionRecipeMessage(toolName, allowed, { kind: 'unknown', action, received_keys: keys }),
163
- actionRecipeHint(),
164
- {
165
- field: 'action',
166
- tool: toolName,
167
- action,
168
- received_keys: keys,
169
- allowed_actions: allowed
170
- }
171
- );
172
- }
173
-
174
79
  function serializeResult(result) {
175
80
  if (typeof result === 'string') return result;
176
81
 
@@ -236,9 +141,22 @@ function normalizeReadOptions(args, defaultView) {
236
141
  return { view };
237
142
  }
238
143
 
239
- 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) {
240
154
  if (returnShape === 'none') return { ok: true };
241
- 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
+ });
242
160
  }
243
161
 
244
162
  function guiIdentity(extra = {}) {
@@ -465,8 +383,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
465
383
  properties: {
466
384
  action: {
467
385
  type: 'string',
468
- enum: MANAGE_ACTIONS,
469
- description: 'create|move|update|delete daily; epic_*; plan_*; comment_add'
386
+ enum: [
387
+ 'create',
388
+ 'move',
389
+ 'update',
390
+ 'delete',
391
+ 'epic_create',
392
+ 'epic_update',
393
+ 'epic_archive',
394
+ 'epic_unarchive',
395
+ 'epic_delete',
396
+ 'plan_create',
397
+ 'plan_advance',
398
+ 'plan_evidence',
399
+ 'plan_done',
400
+ 'plan_status'
401
+ ],
402
+ description: 'create|move|update|delete daily; epic_create|epic_update|epic_archive|epic_unarchive|epic_delete; plan_* multi-step'
470
403
  },
471
404
  title: {
472
405
  type: 'string',
@@ -536,17 +469,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
536
469
  type: 'string',
537
470
  description: 'Freeform notes'
538
471
  },
539
- text: {
540
- type: 'string',
541
- description: 'Required for comment_add. Non-empty comment body.'
542
- },
543
- author: {
544
- type: 'string',
545
- description: 'Optional comment author (default user). e.g. kocur-reviewer, worker'
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
+ ]
546
497
  },
547
498
  task_id: {
548
499
  type: 'string',
549
- description: "Required for move/update/delete/comment_add/plan_* except plan_create. '014' or '14'."
500
+ description: "Required for move/update/delete/plan_* except plan_create. '014' or '14'."
550
501
  },
551
502
  column: {
552
503
  type: 'string',
@@ -608,7 +559,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
608
559
  properties: {
609
560
  action: {
610
561
  type: 'string',
611
- enum: GUI_ACTIONS,
562
+ enum: ['start', 'stop', 'status'],
612
563
  description: 'start | stop (owned only) | status'
613
564
  },
614
565
  port: {
@@ -659,6 +610,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
659
610
  });
660
611
  }
661
612
 
613
+ // summary omits epic_goals; no lookup needed for list tokens
662
614
  result = tasks.map((task) => kanban.shapeTask(task, readOptions));
663
615
  } else if (operation === 'show') {
664
616
  if (!args.task_id) {
@@ -669,7 +621,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
669
621
  );
670
622
  }
671
623
 
672
- result = kanban.shapeTask(await kanban.getTask(args.task_id), readOptions);
624
+ await kanban.migrateEpicGroups();
625
+ const epicLookup = await buildEpicLookup();
626
+ result = kanban.shapeTask(await kanban.getTask(args.task_id), {
627
+ ...readOptions,
628
+ epicLookup
629
+ });
673
630
  } else if (operation === 'list_epics') {
674
631
  await kanban.migrateEpicGroups();
675
632
  const tasks = await kanban.allTasks();
@@ -714,7 +671,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
714
671
  }
715
672
 
716
673
  case 'kanban_manage': {
717
- const action = requireToolAction(args, MANAGE_ACTIONS, 'kanban_manage');
674
+ const action = args.action;
718
675
  const returnShape = normalizeReturnShape(args.return);
719
676
 
720
677
  switch (action) {
@@ -731,7 +688,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
731
688
  };
732
689
  const epicRef = args.epic_id || args.epic || '—';
733
690
  const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
734
- const shaped = kanban.shapeTask(created, { view: 'full' });
691
+ const shaped = kanban.shapeTask(created, {
692
+ view: 'full',
693
+ epicLookup: await buildEpicLookup()
694
+ });
735
695
  const warnings = kanban.createFieldWarnings(createPayload);
736
696
  result = warnings.length > 0
737
697
  ? { ...shaped, warnings, missing_recommended: kanban.missingRecommendedCreateFields(createPayload) }
@@ -857,7 +817,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
857
817
  );
858
818
  }
859
819
  const updated = await kanban.updateTask(args.task_id, { column: args.column });
860
- result = formatTaskResult(updated, returnShape);
820
+ result = await formatTaskResult(updated, returnShape);
861
821
  break;
862
822
  }
863
823
 
@@ -879,11 +839,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
879
839
  if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
880
840
  if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
881
841
  if (args.notes !== undefined) patch.notes = args.notes;
842
+ if (args.adr !== undefined) patch.adr = args.adr;
882
843
  if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
883
844
  else if (args.epic !== undefined) patch.epic = args.epic;
884
845
  if (args.col !== undefined) patch.column = args.col;
885
846
  const updated = await kanban.updateTask(args.task_id, patch);
886
- result = formatTaskResult(updated, returnShape);
847
+ result = await formatTaskResult(updated, returnShape);
887
848
  break;
888
849
  }
889
850
 
@@ -912,38 +873,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
912
873
  result = await plan.status(args.task_id);
913
874
  break;
914
875
 
915
- case 'comment_add': {
916
- if (!args.task_id) {
917
- throw invalidRequest(
918
- "task_id is required for 'comment_add'",
919
- 'Provide a task ID',
920
- { action }
921
- );
922
- }
923
- if (!args.text) {
924
- throw invalidRequest(
925
- "text is required for 'comment_add'",
926
- 'Provide a non-empty comment body',
927
- { action }
928
- );
929
- }
930
- const added = await kanban.addComment(args.task_id, args.text, args.author);
931
- result = returnShape === 'none'
932
- ? { ok: true, comment_id: added.comment.id }
933
- : returnShape === 'full'
934
- ? added
935
- : { ok: true, task_id: added.task_id, comment_id: added.comment.id, count: added.comments.length };
936
- break;
937
- }
938
-
939
876
  default:
940
- throw unknownToolAction(action, args, MANAGE_ACTIONS, 'kanban_manage');
877
+ throw invalidRequest(
878
+ `Unknown action: ${action}`,
879
+ 'Use create, move, update, delete, epic_create, epic_update, epic_archive, epic_unarchive, epic_delete, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
880
+ { action }
881
+ );
941
882
  }
942
883
  break;
943
884
  }
944
885
 
945
886
  case 'kanban_gui': {
946
- const action = requireToolAction(args, GUI_ACTIONS, 'kanban_gui');
887
+ const action = args.action;
947
888
 
948
889
  switch (action) {
949
890
  case 'start': {
@@ -959,7 +900,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
959
900
  break;
960
901
  }
961
902
  default:
962
- throw unknownToolAction(action, args, GUI_ACTIONS, 'kanban_gui');
903
+ throw invalidRequest(
904
+ `Unknown action: ${action}`,
905
+ 'Use one of: start, stop, status',
906
+ { action }
907
+ );
963
908
  }
964
909
  break;
965
910
  }
@@ -996,8 +941,8 @@ function installGuiShutdownHooks() {
996
941
  if (!ownsGuiProcess()) return;
997
942
  try {
998
943
  await stopGuiServer();
999
- } catch {
1000
- // best-effort
944
+ } catch (error) {
945
+ console.error(`kanbango GUI shutdown failed: ${error.message}`);
1001
946
  }
1002
947
  }
1003
948
 
@@ -1005,8 +950,8 @@ function installGuiShutdownHooks() {
1005
950
  if (ownsGuiProcess()) {
1006
951
  try {
1007
952
  guiProcess.kill();
1008
- } catch {
1009
- // ignore
953
+ } catch (error) {
954
+ console.error(`kanbango GUI kill on exit failed: ${error.message}`);
1010
955
  }
1011
956
  }
1012
957
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "node": ">=16.0.0"
37
37
  },
38
38
  "dependencies": {
39
- "@modelcontextprotocol/sdk": "^1.0.4"
39
+ "@modelcontextprotocol/sdk": "^1.0.4",
40
+ "mermaid": "^11.17.0"
40
41
  }
41
42
  }
package/tests/run.js CHANGED
@@ -15,12 +15,15 @@ function runNode(scriptPath, args, label) {
15
15
 
16
16
  runNode(path.join('bin', 'kanban.js'), ['list', '--json'], 'CLI list');
17
17
  runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
18
- runNode(path.join('tests', 'comments.test.js'), [], 'Comments test');
19
18
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
20
19
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
21
20
  runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
21
+ runNode(path.join('tests', 'gui-cockpit.test.js'), [], 'GUI cockpit layout + workflow test');
22
+ runNode(path.join('tests', 'fenced-text.test.js'), [], 'Fenced text test');
23
+ runNode(path.join('tests', 'kanban-mermaid-vendor.test.js'), [], 'Kanban mermaid vendor route test');
22
24
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
23
25
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
24
26
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
27
+ runNode(path.join('tests', 'kanban-epic-goals-adr.test.js'), [], 'Epic goals + ADR test');
25
28
  runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');
26
29
  runNode(path.join('tests', 'race-conditions.test.js'), [], 'Race conditions test');