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/.ai/lessons.jsonl +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +25 -0
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +32 -4
- package/README.md +2 -2
- package/agent-playbook.js +12 -6
- package/bin/kanban.js +49 -14
- package/fenced-text.js +62 -0
- package/index.html +1305 -1168
- package/kanban.js +107 -82
- package/mcp-server.js +89 -144
- package/package.json +3 -2
- package/tests/run.js +4 -1
- package/.opencode/plans/action-error-recipe.md +0 -121
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
|
-
'
|
|
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
|
-
'
|
|
63
|
-
'
|
|
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
|
-
|
|
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.
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
469
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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/
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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, {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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');
|