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/.ai/lessons.jsonl +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +52 -0
- package/agent-playbook.js +16 -2
- package/agents/qa-e2e-tester.md +315 -0
- package/agents/qa-tester.md +182 -0
- package/agents/temida.md +81 -0
- package/bin/kanban.js +140 -2
- package/index.html +1283 -1314
- package/index.js +2 -0
- package/kanban.js +262 -22
- package/mcp-server.js +63 -11
- package/package.json +1 -1
- package/plan.js +5 -2
- package/tests/fixtures/fake-opencode.js +69 -0
- package/tests/index.js +19 -0
- package/tests/kanban-cli.js +118 -0
- package/tests/kanban.js +104 -0
- package/tests/run.js +6 -0
- package/workflow.js +460 -0
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
|
-
'
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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'))
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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'."
|
|
@@ -463,7 +502,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
463
502
|
column: {
|
|
464
503
|
type: 'string',
|
|
465
504
|
enum: COLS,
|
|
466
|
-
description:
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
956
|
+
} catch (error) {
|
|
957
|
+
console.error(`kanbango GUI kill on exit failed: ${error.message}`);
|
|
906
958
|
}
|
|
907
959
|
}
|
|
908
960
|
});
|
package/package.json
CHANGED
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, {
|
|
151
|
-
|
|
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) {
|