kanbango 3.4.1 → 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',
@@ -41,7 +43,8 @@ const VIEW_FIELDS = {
41
43
  'out_of_scope',
42
44
  'acceptance_criteria',
43
45
  'test_cases',
44
- 'subtasks'
46
+ 'subtasks',
47
+ 'adr'
45
48
  ],
46
49
  full: [
47
50
  'task_number',
@@ -49,6 +52,7 @@ const VIEW_FIELDS = {
49
52
  'column',
50
53
  'epic_id',
51
54
  'epic_group',
55
+ 'epic_goals',
52
56
  'created',
53
57
  'progress',
54
58
  'description',
@@ -58,6 +62,7 @@ const VIEW_FIELDS = {
58
62
  'acceptance_criteria',
59
63
  'test_cases',
60
64
  'subtasks',
65
+ 'adr',
61
66
  'notes'
62
67
  ]
63
68
  };
@@ -88,7 +93,8 @@ const EPIC_VIEW_FIELDS = {
88
93
  'in_scope',
89
94
  'out_of_scope',
90
95
  'notes',
91
- 'tasks'
96
+ 'tasks',
97
+ 'adrs'
92
98
  ]
93
99
  };
94
100
 
@@ -265,6 +271,60 @@ function normalizeEvidence(value) {
265
271
  }));
266
272
  }
267
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
+
268
328
  function normalizePlan(value) {
269
329
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
270
330
  return {
@@ -291,6 +351,7 @@ function normalizeTask(task) {
291
351
  acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
292
352
  test_cases: normalizeStringArray(task.test_cases),
293
353
  subtasks: normalizeSubtasks(task.subtasks),
354
+ adr: normalizeAdr(task.adr),
294
355
  notes: normalizeString(task.notes),
295
356
  plan: normalizePlan(task.plan),
296
357
  evidence: normalizeEvidence(task.evidence),
@@ -316,6 +377,7 @@ function serializeTask(task) {
316
377
  acceptance_criteria: normalized.acceptance_criteria,
317
378
  test_cases: normalized.test_cases,
318
379
  subtasks: normalized.subtasks,
380
+ adr: normalized.adr,
319
381
  notes: normalized.notes,
320
382
  plan: normalized.plan,
321
383
  evidence: normalized.evidence,
@@ -395,6 +457,24 @@ function pickEpicFields(epicPayload, fieldNames) {
395
457
  return picked;
396
458
  }
397
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
+
398
478
  function shapeEpic(epic, tasks = [], options = {}) {
399
479
  const normalized = normalizeEpic(epic);
400
480
  const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
@@ -402,7 +482,8 @@ function shapeEpic(epic, tasks = [], options = {}) {
402
482
  ...normalized,
403
483
  status: deriveEpicStatus(childTasks, normalized),
404
484
  progress: getEpicProgress(childTasks),
405
- tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
485
+ tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' })),
486
+ adrs: collectEpicAdrs(childTasks)
406
487
  };
407
488
  const fields = Array.isArray(options.fields) && options.fields.length > 0
408
489
  ? options.fields
@@ -479,6 +560,14 @@ function getProgress(task) {
479
560
  return { done, total };
480
561
  }
481
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
+
482
571
  function pickFields(task, fieldNames) {
483
572
  const picked = {};
484
573
 
@@ -487,6 +576,10 @@ function pickFields(task, fieldNames) {
487
576
  picked.progress = getProgress(task);
488
577
  continue;
489
578
  }
579
+ if (field === 'epic_goals') {
580
+ picked.epic_goals = task.epic_goals;
581
+ continue;
582
+ }
490
583
  if (field in task) {
491
584
  picked[field] = task[field];
492
585
  }
@@ -501,7 +594,11 @@ function shapeTask(task, options = {}) {
501
594
  ? options.fields
502
595
  : (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
503
596
 
504
- return pickFields(normalized, fields);
597
+ const withGoals = {
598
+ ...normalized,
599
+ epic_goals: resolveEpicGoals(normalized, options.epicLookup)
600
+ };
601
+ return pickFields(withGoals, fields);
505
602
  }
506
603
 
507
604
  function escapeRegex(value) {
@@ -1460,6 +1557,13 @@ async function updateTaskRecord(taskId, patch) {
1460
1557
  }
1461
1558
  next.subtasks = patch.subtasks;
1462
1559
  }
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);
1565
+ }
1566
+ }
1463
1567
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1464
1568
  if (patch.plan !== undefined) next.plan = patch.plan;
1465
1569
  if (patch.evidence !== undefined) {
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'."
@@ -571,6 +610,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
571
610
  });
572
611
  }
573
612
 
613
+ // summary omits epic_goals; no lookup needed for list tokens
574
614
  result = tasks.map((task) => kanban.shapeTask(task, readOptions));
575
615
  } else if (operation === 'show') {
576
616
  if (!args.task_id) {
@@ -581,7 +621,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
581
621
  );
582
622
  }
583
623
 
584
- 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
+ });
585
630
  } else if (operation === 'list_epics') {
586
631
  await kanban.migrateEpicGroups();
587
632
  const tasks = await kanban.allTasks();
@@ -643,7 +688,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
643
688
  };
644
689
  const epicRef = args.epic_id || args.epic || '—';
645
690
  const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
646
- const shaped = kanban.shapeTask(created, { view: 'full' });
691
+ const shaped = kanban.shapeTask(created, {
692
+ view: 'full',
693
+ epicLookup: await buildEpicLookup()
694
+ });
647
695
  const warnings = kanban.createFieldWarnings(createPayload);
648
696
  result = warnings.length > 0
649
697
  ? { ...shaped, warnings, missing_recommended: kanban.missingRecommendedCreateFields(createPayload) }
@@ -769,7 +817,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
769
817
  );
770
818
  }
771
819
  const updated = await kanban.updateTask(args.task_id, { column: args.column });
772
- result = formatTaskResult(updated, returnShape);
820
+ result = await formatTaskResult(updated, returnShape);
773
821
  break;
774
822
  }
775
823
 
@@ -791,11 +839,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
791
839
  if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
792
840
  if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
793
841
  if (args.notes !== undefined) patch.notes = args.notes;
842
+ if (args.adr !== undefined) patch.adr = args.adr;
794
843
  if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
795
844
  else if (args.epic !== undefined) patch.epic = args.epic;
796
845
  if (args.col !== undefined) patch.column = args.col;
797
846
  const updated = await kanban.updateTask(args.task_id, patch);
798
- result = formatTaskResult(updated, returnShape);
847
+ result = await formatTaskResult(updated, returnShape);
799
848
  break;
800
849
  }
801
850
 
@@ -892,8 +941,8 @@ function installGuiShutdownHooks() {
892
941
  if (!ownsGuiProcess()) return;
893
942
  try {
894
943
  await stopGuiServer();
895
- } catch {
896
- // best-effort
944
+ } catch (error) {
945
+ console.error(`kanbango GUI shutdown failed: ${error.message}`);
897
946
  }
898
947
  }
899
948
 
@@ -901,8 +950,8 @@ function installGuiShutdownHooks() {
901
950
  if (ownsGuiProcess()) {
902
951
  try {
903
952
  guiProcess.kill();
904
- } catch {
905
- // ignore
953
+ } catch (error) {
954
+ console.error(`kanbango GUI kill on exit failed: ${error.message}`);
906
955
  }
907
956
  }
908
957
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.4.1",
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": {
package/tests/run.js CHANGED
@@ -18,10 +18,12 @@ runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
18
18
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
19
19
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
20
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');
21
22
  runNode(path.join('tests', 'fenced-text.test.js'), [], 'Fenced text test');
22
23
  runNode(path.join('tests', 'kanban-mermaid-vendor.test.js'), [], 'Kanban mermaid vendor route test');
23
24
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
24
25
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
25
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');
26
28
  runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');
27
29
  runNode(path.join('tests', 'race-conditions.test.js'), [], 'Race conditions test');