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/AGENTS.md +2 -0
- package/CHANGELOG.md +13 -0
- package/agent-playbook.js +8 -1
- package/index.html +1222 -1314
- package/kanban.js +108 -4
- package/mcp-server.js +59 -10
- package/package.json +1 -1
- package/tests/run.js +2 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
953
|
+
} catch (error) {
|
|
954
|
+
console.error(`kanbango GUI kill on exit failed: ${error.message}`);
|
|
906
955
|
}
|
|
907
956
|
}
|
|
908
957
|
});
|
package/package.json
CHANGED
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');
|