kanbango 2.1.0 → 2.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 +0 -0
- package/.ai/retro/close.json +1 -0
- package/.ai/retro/last-run.json +1 -0
- package/AGENTS.md +7 -7
- package/API.md +20 -12
- package/CHANGELOG.md +39 -0
- package/LLM_AGENTS.md +170 -177
- package/README.md +31 -14
- package/bin/kanban.js +101 -26
- package/index.html +162 -85
- package/index.js +2 -0
- package/kan2.md +76 -0
- package/kanban.js +306 -34
- package/mcp-server.js +403 -185
- package/package.json +1 -1
- package/plan.js +159 -0
- package/planv2.md +17 -7
- package/tests/run.js +1 -0
package/mcp-server.js
CHANGED
|
@@ -5,19 +5,53 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
|
|
|
5
5
|
const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
6
6
|
const { spawn } = require('child_process');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const pkg = require('./package.json');
|
|
8
9
|
const kanban = require('./kanban.js');
|
|
10
|
+
const plan = require('./plan.js');
|
|
9
11
|
|
|
10
12
|
const COLS = kanban.COLS;
|
|
11
13
|
const READ_VIEWS = Object.keys(kanban.VIEW_FIELDS);
|
|
14
|
+
const GUI_READY_TIMEOUT_MS = 8000;
|
|
15
|
+
const GUI_READY_POLL_MS = 50;
|
|
12
16
|
let guiProcess = null;
|
|
13
17
|
let guiPort = null;
|
|
14
18
|
|
|
15
19
|
function normalizePort(value) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
return kanban.normalizeGuiPort(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sleep(ms) {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function envFlagEnabled(name) {
|
|
28
|
+
const raw = process.env[name];
|
|
29
|
+
if (raw === undefined || raw === null || raw === '') return false;
|
|
30
|
+
return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function waitForGuiReady(pid, timeoutMs = GUI_READY_TIMEOUT_MS) {
|
|
34
|
+
const deadline = Date.now() + timeoutMs;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
if (guiProcess && guiProcess.pid === pid && guiProcess.exitCode !== null) {
|
|
37
|
+
throw invalidRequest(
|
|
38
|
+
'GUI process exited before becoming ready',
|
|
39
|
+
'Check whether another process holds the port or inspect MCP stderr',
|
|
40
|
+
{ pid }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const info = await kanban.discoverRunningGui();
|
|
45
|
+
if (info && info.pid === pid) return info;
|
|
46
|
+
|
|
47
|
+
await sleep(GUI_READY_POLL_MS);
|
|
19
48
|
}
|
|
20
|
-
|
|
49
|
+
|
|
50
|
+
throw invalidRequest(
|
|
51
|
+
'Timed out waiting for GUI to publish its port',
|
|
52
|
+
'Retry kanban_gui start or set KANBANGO_GUI_PORT to a free port',
|
|
53
|
+
{ pid, timeout_ms: timeoutMs }
|
|
54
|
+
);
|
|
21
55
|
}
|
|
22
56
|
|
|
23
57
|
function serializeError(error) {
|
|
@@ -36,6 +70,34 @@ function invalidRequest(message, hint, details) {
|
|
|
36
70
|
return kanban.createKanbanError('VALIDATION_ERROR', message, hint, details, false, 400);
|
|
37
71
|
}
|
|
38
72
|
|
|
73
|
+
function serializeResult(result) {
|
|
74
|
+
if (typeof result === 'string') return result;
|
|
75
|
+
|
|
76
|
+
const text = JSON.stringify(result, null, 2);
|
|
77
|
+
if (typeof text === 'string') return text;
|
|
78
|
+
|
|
79
|
+
throw kanban.createKanbanError(
|
|
80
|
+
'INTERNAL_ERROR',
|
|
81
|
+
'Tool completed without a response payload',
|
|
82
|
+
'This is a server bug. Inspect the MCP handler for the requested tool/action.',
|
|
83
|
+
{ result_type: typeof result },
|
|
84
|
+
true,
|
|
85
|
+
500
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function textResponse(result, isError = false) {
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: serializeResult(result)
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
...(isError ? { isError: true } : {})
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
39
101
|
function normalizeReturnShape(returnShape) {
|
|
40
102
|
if (returnShape === undefined) return 'summary';
|
|
41
103
|
if (!['none', 'summary', 'full'].includes(returnShape)) {
|
|
@@ -79,63 +141,122 @@ function formatTaskResult(task, returnShape) {
|
|
|
79
141
|
}
|
|
80
142
|
|
|
81
143
|
async function startGuiServer(port) {
|
|
82
|
-
|
|
83
|
-
if (!desiredPort) {
|
|
144
|
+
if (port !== undefined && port !== null && port !== '' && !normalizePort(port)) {
|
|
84
145
|
throw invalidRequest('Invalid port', 'Use an integer between 1 and 65535', { port });
|
|
85
146
|
}
|
|
86
147
|
|
|
87
|
-
if (guiProcess && guiProcess.exitCode === null) {
|
|
148
|
+
if (guiProcess && guiProcess.exitCode === null && guiPort) {
|
|
88
149
|
return {
|
|
89
150
|
status: 'already_running',
|
|
90
151
|
port: guiPort,
|
|
152
|
+
pid: guiProcess.pid,
|
|
91
153
|
url: `http://localhost:${guiPort}`
|
|
92
154
|
};
|
|
93
155
|
}
|
|
94
156
|
|
|
157
|
+
const existing = await kanban.discoverRunningGui();
|
|
158
|
+
if (existing) {
|
|
159
|
+
guiPort = existing.port;
|
|
160
|
+
return {
|
|
161
|
+
status: 'already_running',
|
|
162
|
+
port: existing.port,
|
|
163
|
+
pid: existing.pid,
|
|
164
|
+
url: existing.url
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const desiredPort = kanban.resolvePreferredGuiPort(port);
|
|
95
169
|
await kanban.ensureBacklogDir();
|
|
96
170
|
|
|
97
171
|
const scriptPath = path.join(__dirname, 'bin', 'kanban.js');
|
|
98
172
|
guiProcess = spawn(process.execPath, [scriptPath, 'serve', String(desiredPort)], {
|
|
173
|
+
cwd: process.cwd(),
|
|
99
174
|
stdio: 'ignore',
|
|
100
|
-
windowsHide: true
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
detached: false
|
|
101
177
|
});
|
|
102
|
-
guiPort = desiredPort;
|
|
103
178
|
|
|
179
|
+
const childPid = guiProcess.pid;
|
|
104
180
|
guiProcess.on('exit', () => {
|
|
105
|
-
guiProcess
|
|
106
|
-
|
|
181
|
+
if (guiProcess && guiProcess.pid === childPid) {
|
|
182
|
+
guiProcess = null;
|
|
183
|
+
guiPort = null;
|
|
184
|
+
}
|
|
107
185
|
});
|
|
108
186
|
|
|
187
|
+
const ready = await waitForGuiReady(childPid);
|
|
188
|
+
guiPort = ready.port;
|
|
189
|
+
|
|
109
190
|
return {
|
|
110
191
|
status: 'started',
|
|
111
|
-
port:
|
|
112
|
-
pid:
|
|
113
|
-
url:
|
|
192
|
+
port: ready.port,
|
|
193
|
+
pid: ready.pid,
|
|
194
|
+
url: ready.url
|
|
114
195
|
};
|
|
115
196
|
}
|
|
116
197
|
|
|
117
|
-
function stopGuiServer() {
|
|
118
|
-
|
|
198
|
+
async function stopGuiServer() {
|
|
199
|
+
const trackedRunning = guiProcess && guiProcess.exitCode === null;
|
|
200
|
+
const discovered = trackedRunning ? null : await kanban.discoverRunningGui();
|
|
201
|
+
|
|
202
|
+
if (!trackedRunning && !discovered) {
|
|
119
203
|
guiProcess = null;
|
|
120
204
|
guiPort = null;
|
|
121
205
|
return { status: 'not_running' };
|
|
122
206
|
}
|
|
123
207
|
|
|
124
|
-
|
|
125
|
-
|
|
208
|
+
const port = trackedRunning ? guiPort : discovered.port;
|
|
209
|
+
const pid = trackedRunning ? guiProcess.pid : discovered.pid;
|
|
210
|
+
|
|
211
|
+
if (trackedRunning) {
|
|
212
|
+
guiProcess.kill();
|
|
213
|
+
} else if (pid) {
|
|
214
|
+
try {
|
|
215
|
+
process.kill(pid, 'SIGTERM');
|
|
216
|
+
} catch {
|
|
217
|
+
// process may already be gone
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const deadline = Date.now() + 2000;
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
const still = await kanban.discoverRunningGui();
|
|
224
|
+
if (!still || still.pid !== pid) break;
|
|
225
|
+
await sleep(50);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await kanban.clearGuiPortFile({ force: true });
|
|
229
|
+
guiProcess = null;
|
|
230
|
+
guiPort = null;
|
|
231
|
+
|
|
232
|
+
return { status: 'stopping', port, pid };
|
|
126
233
|
}
|
|
127
234
|
|
|
128
|
-
function guiStatus() {
|
|
129
|
-
if (
|
|
235
|
+
async function guiStatus() {
|
|
236
|
+
if (guiProcess && guiProcess.exitCode === null && guiPort) {
|
|
237
|
+
return {
|
|
238
|
+
status: 'running',
|
|
239
|
+
port: guiPort,
|
|
240
|
+
pid: guiProcess.pid,
|
|
241
|
+
url: `http://localhost:${guiPort}`
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const discovered = await kanban.discoverRunningGui();
|
|
246
|
+
if (!discovered) {
|
|
247
|
+
guiProcess = null;
|
|
248
|
+
guiPort = null;
|
|
130
249
|
return { status: 'not_running' };
|
|
131
250
|
}
|
|
132
|
-
|
|
251
|
+
|
|
252
|
+
guiPort = discovered.port;
|
|
253
|
+
return discovered;
|
|
133
254
|
}
|
|
134
255
|
|
|
135
256
|
const server = new Server(
|
|
136
257
|
{
|
|
137
258
|
name: 'kanbango',
|
|
138
|
-
version:
|
|
259
|
+
version: pkg.version
|
|
139
260
|
},
|
|
140
261
|
{
|
|
141
262
|
capabilities: {
|
|
@@ -149,7 +270,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
149
270
|
tools: [
|
|
150
271
|
{
|
|
151
272
|
name: 'kanban_read',
|
|
152
|
-
description: 'Read tasks from
|
|
273
|
+
description: 'Read tasks from the board. operation=list returns multiple tasks with optional col/epic filters. operation=show requires task_id. Use view for preset payload sizes or fields for exact field selection.',
|
|
153
274
|
inputSchema: {
|
|
154
275
|
type: 'object',
|
|
155
276
|
properties: {
|
|
@@ -161,7 +282,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
161
282
|
},
|
|
162
283
|
task_id: {
|
|
163
284
|
type: 'string',
|
|
164
|
-
|
|
285
|
+
description: "Task ID (optional for 'list', required for 'show'). Use a numeric ID like '014' or just a number like '14'."
|
|
165
286
|
},
|
|
166
287
|
col: {
|
|
167
288
|
type: 'string',
|
|
@@ -182,51 +303,67 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
182
303
|
description: 'Explicit fields to return. When provided, fields override view.',
|
|
183
304
|
items: { type: 'string' }
|
|
184
305
|
}
|
|
185
|
-
}
|
|
306
|
+
},
|
|
307
|
+
additionalProperties: false
|
|
186
308
|
}
|
|
187
309
|
},
|
|
188
310
|
{
|
|
189
|
-
name: '
|
|
190
|
-
description: '
|
|
311
|
+
name: 'kanban_manage',
|
|
312
|
+
description: 'Mutate tasks and accepted plans. Required by action: create/plan_create -> title only; move -> task_id + column; update -> task_id + patch/shortcuts; plan_advance/plan_done/plan_status -> task_id; plan_evidence -> task_id + diff + test_command + stdout + stderr + exit_code. Strongly recommended on create/plan_create: description, specs, in_scope, out_of_scope, acceptance_criteria (missing fields return warnings, not errors). Example: {"action":"create","title":"Ship Docker image","description":"...","specs":"...","in_scope":["CLI"],"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned","epic":"Release"}.',
|
|
191
313
|
inputSchema: {
|
|
192
314
|
type: 'object',
|
|
193
315
|
properties: {
|
|
316
|
+
action: {
|
|
317
|
+
type: 'string',
|
|
318
|
+
enum: ['create', 'move', 'update', 'plan_create', 'plan_advance', 'plan_evidence', 'plan_done', 'plan_status'],
|
|
319
|
+
description: 'Create, move, update, or operate the accepted-plan workflow'
|
|
320
|
+
},
|
|
194
321
|
title: {
|
|
195
322
|
type: 'string',
|
|
196
|
-
description:
|
|
323
|
+
description: "Non-empty title. Required for 'create' and 'plan_create'."
|
|
197
324
|
},
|
|
198
325
|
col: {
|
|
199
326
|
type: 'string',
|
|
200
327
|
enum: COLS,
|
|
201
328
|
default: 'planned',
|
|
202
|
-
description:
|
|
329
|
+
description: "Column for 'create' or shortcut patch field for 'update' (default: 'planned')."
|
|
203
330
|
},
|
|
204
331
|
epic: {
|
|
205
332
|
type: 'string',
|
|
206
333
|
default: '—',
|
|
207
|
-
description:
|
|
334
|
+
description: "Epic group for 'create', 'update', or 'plan_create' (optional)."
|
|
208
335
|
},
|
|
209
336
|
description: {
|
|
210
337
|
type: 'string',
|
|
211
|
-
description:
|
|
338
|
+
description: "Strongly recommended. High-level context/why for 'create', 'update', or 'plan_create'."
|
|
212
339
|
},
|
|
213
340
|
specs: {
|
|
214
341
|
type: 'string',
|
|
215
|
-
description:
|
|
342
|
+
description: "Strongly recommended. Technical constraints, APIs, and edge cases for 'create', 'update', or 'plan_create'."
|
|
343
|
+
},
|
|
344
|
+
in_scope: {
|
|
345
|
+
type: 'array',
|
|
346
|
+
description: "Strongly recommended. What this task includes (boundaries) for 'create', 'update', or 'plan_create'.",
|
|
347
|
+
items: { type: 'string' }
|
|
348
|
+
},
|
|
349
|
+
out_of_scope: {
|
|
350
|
+
type: 'array',
|
|
351
|
+
description: "Strongly recommended. Explicit non-goals / exclusions for 'create', 'update', or 'plan_create'.",
|
|
352
|
+
items: { type: 'string' }
|
|
216
353
|
},
|
|
217
354
|
acceptance_criteria: {
|
|
218
355
|
type: 'array',
|
|
219
|
-
description:
|
|
356
|
+
description: "Strongly recommended. Completion requirements for 'create', 'update', or 'plan_create'.",
|
|
220
357
|
items: { type: 'string' }
|
|
221
358
|
},
|
|
222
359
|
test_cases: {
|
|
223
360
|
type: 'array',
|
|
224
|
-
description:
|
|
361
|
+
description: "Recommended. Verification scenarios for 'create', 'update', or 'plan_create'.",
|
|
225
362
|
items: { type: 'string' }
|
|
226
363
|
},
|
|
227
364
|
subtasks: {
|
|
228
365
|
type: 'array',
|
|
229
|
-
description:
|
|
366
|
+
description: "Optional subtask list for 'create', 'update', or internally generated by 'plan_create'.",
|
|
230
367
|
items: {
|
|
231
368
|
type: 'object',
|
|
232
369
|
properties: {
|
|
@@ -239,93 +376,82 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
239
376
|
},
|
|
240
377
|
notes: {
|
|
241
378
|
type: 'string',
|
|
242
|
-
description:
|
|
243
|
-
}
|
|
244
|
-
},
|
|
245
|
-
required: ['title']
|
|
246
|
-
}
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
name: 'kanban_update',
|
|
250
|
-
description: 'Move tasks, toggle subtasks, or apply patch-style updates with configurable response size.',
|
|
251
|
-
inputSchema: {
|
|
252
|
-
type: 'object',
|
|
253
|
-
properties: {
|
|
254
|
-
operation: {
|
|
255
|
-
type: 'string',
|
|
256
|
-
enum: ['move', 'toggle', 'update'],
|
|
257
|
-
description: "'move' changes column, 'toggle' flips one subtask, 'update' applies a patch"
|
|
379
|
+
description: "Optional freeform notes for 'create', 'update', or 'plan_create'."
|
|
258
380
|
},
|
|
259
381
|
task_id: {
|
|
260
382
|
type: 'string',
|
|
261
|
-
|
|
383
|
+
description: "Task ID required for 'move', 'update', and all plan_* actions except 'plan_create'. Use '014' or '14'."
|
|
262
384
|
},
|
|
263
385
|
column: {
|
|
264
386
|
type: 'string',
|
|
265
387
|
enum: COLS,
|
|
266
|
-
description: "
|
|
267
|
-
},
|
|
268
|
-
idx: {
|
|
269
|
-
type: 'integer',
|
|
270
|
-
description: "Subtask index for 'toggle'"
|
|
388
|
+
description: "Target column required for 'move'."
|
|
271
389
|
},
|
|
272
390
|
patch: {
|
|
273
391
|
type: 'object',
|
|
274
|
-
description: "Patch payload for 'update'"
|
|
392
|
+
description: "Patch payload for 'update'. Use this for bulk field changes; top-level shortcuts are merged into the patch."
|
|
275
393
|
},
|
|
276
|
-
|
|
394
|
+
return: {
|
|
277
395
|
type: 'string',
|
|
278
|
-
|
|
396
|
+
enum: ['none', 'summary', 'full'],
|
|
397
|
+
description: "Response shape for 'move' and 'update'. Defaults to summary. 'create' returns the full created task."
|
|
398
|
+
},
|
|
399
|
+
index: {
|
|
400
|
+
type: 'integer',
|
|
401
|
+
description: "Zero-based plan subtask index for 'plan_advance'. Defaults to the first incomplete step when omitted."
|
|
279
402
|
},
|
|
280
|
-
|
|
403
|
+
steps: {
|
|
281
404
|
type: 'array',
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
type: 'object',
|
|
285
|
-
properties: {
|
|
286
|
-
id: { type: 'string' },
|
|
287
|
-
done: { type: 'boolean' },
|
|
288
|
-
text: { type: 'string' },
|
|
289
|
-
description: { type: 'string' }
|
|
290
|
-
}
|
|
291
|
-
}
|
|
405
|
+
items: { type: 'string' },
|
|
406
|
+
description: "Implementation steps inserted between the default plan workflow steps for 'plan_create'."
|
|
292
407
|
},
|
|
293
|
-
|
|
408
|
+
project_root: {
|
|
294
409
|
type: 'string',
|
|
295
|
-
|
|
296
|
-
|
|
410
|
+
description: "Project root used for test runner detection in 'plan_create'. Defaults to the MCP server working directory."
|
|
411
|
+
},
|
|
412
|
+
diff: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: "Required for 'plan_evidence'. Include the relevant code diff or summary."
|
|
415
|
+
},
|
|
416
|
+
test_command: {
|
|
417
|
+
type: 'string',
|
|
418
|
+
description: "Required for 'plan_evidence'. The exact verification command that was run."
|
|
419
|
+
},
|
|
420
|
+
stdout: {
|
|
421
|
+
type: 'string',
|
|
422
|
+
description: "Required for 'plan_evidence'. Captured standard output from the verification command."
|
|
423
|
+
},
|
|
424
|
+
stderr: {
|
|
425
|
+
type: 'string',
|
|
426
|
+
description: "Required for 'plan_evidence'. Captured standard error from the verification command."
|
|
427
|
+
},
|
|
428
|
+
exit_code: {
|
|
429
|
+
type: 'integer',
|
|
430
|
+
description: "Required for 'plan_evidence'. Integer process exit code from the verification command."
|
|
297
431
|
}
|
|
298
432
|
},
|
|
299
|
-
required: ['
|
|
433
|
+
required: ['action'],
|
|
434
|
+
additionalProperties: false
|
|
300
435
|
}
|
|
301
436
|
},
|
|
302
437
|
{
|
|
303
|
-
name: '
|
|
304
|
-
description: '
|
|
438
|
+
name: 'kanban_gui',
|
|
439
|
+
description: 'Control the web GUI server: start, stop, or check status.',
|
|
305
440
|
inputSchema: {
|
|
306
441
|
type: 'object',
|
|
307
442
|
properties: {
|
|
443
|
+
action: {
|
|
444
|
+
type: 'string',
|
|
445
|
+
enum: ['start', 'stop', 'status'],
|
|
446
|
+
description: "Action to perform: 'start' launches GUI, 'stop' kills it, 'status' checks if running"
|
|
447
|
+
},
|
|
308
448
|
port: {
|
|
309
449
|
type: 'integer',
|
|
310
|
-
description:
|
|
450
|
+
description: "Port for the GUI server (only for 'start'). Defaults to KANBANGO_GUI_PORT or a stable hash of the project cwd (5510-5999)."
|
|
311
451
|
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
{
|
|
316
|
-
name: 'kanban_gui_stop',
|
|
317
|
-
description: 'Stop the web GUI server if it is running.',
|
|
318
|
-
inputSchema: {
|
|
319
|
-
type: 'object',
|
|
320
|
-
properties: {}
|
|
321
|
-
}
|
|
322
|
-
},
|
|
323
|
-
{
|
|
324
|
-
name: 'kanban_gui_status',
|
|
325
|
-
description: 'Get status of the web GUI server.',
|
|
326
|
-
inputSchema: {
|
|
327
|
-
type: 'object',
|
|
328
|
-
properties: {}
|
|
452
|
+
},
|
|
453
|
+
required: ['action'],
|
|
454
|
+
additionalProperties: false
|
|
329
455
|
}
|
|
330
456
|
}
|
|
331
457
|
]
|
|
@@ -373,86 +499,134 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
373
499
|
break;
|
|
374
500
|
}
|
|
375
501
|
|
|
376
|
-
case '
|
|
377
|
-
const
|
|
378
|
-
description: args.description,
|
|
379
|
-
specs: args.specs,
|
|
380
|
-
acceptance_criteria: args.acceptance_criteria,
|
|
381
|
-
subtasks: args.subtasks,
|
|
382
|
-
notes: args.notes
|
|
383
|
-
});
|
|
384
|
-
result = kanban.shapeTask(created, { view: 'full' });
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
case 'kanban_update': {
|
|
389
|
-
const operation = args.operation;
|
|
502
|
+
case 'kanban_manage': {
|
|
503
|
+
const action = args.action;
|
|
390
504
|
const returnShape = normalizeReturnShape(args.return);
|
|
391
505
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
506
|
+
switch (action) {
|
|
507
|
+
case 'create': {
|
|
508
|
+
const createPayload = {
|
|
509
|
+
description: args.description,
|
|
510
|
+
specs: args.specs,
|
|
511
|
+
in_scope: args.in_scope,
|
|
512
|
+
out_of_scope: args.out_of_scope,
|
|
513
|
+
acceptance_criteria: args.acceptance_criteria,
|
|
514
|
+
test_cases: args.test_cases,
|
|
515
|
+
subtasks: args.subtasks,
|
|
516
|
+
notes: args.notes
|
|
517
|
+
};
|
|
518
|
+
const created = await kanban.doCreate(args.title, args.col || 'planned', args.epic || '—', createPayload);
|
|
519
|
+
const shaped = kanban.shapeTask(created, { view: 'full' });
|
|
520
|
+
const warnings = kanban.createFieldWarnings(createPayload);
|
|
521
|
+
result = warnings.length > 0
|
|
522
|
+
? { ...shaped, warnings, missing_recommended: kanban.missingRecommendedCreateFields(createPayload) }
|
|
523
|
+
: shaped;
|
|
524
|
+
break;
|
|
399
525
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
526
|
+
|
|
527
|
+
case 'move': {
|
|
528
|
+
if (!args.task_id) {
|
|
529
|
+
throw invalidRequest(
|
|
530
|
+
"task_id is required for 'move'",
|
|
531
|
+
'Provide a task ID',
|
|
532
|
+
{ action }
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
if (!args.column) {
|
|
536
|
+
throw invalidRequest(
|
|
537
|
+
"column is required for 'move'",
|
|
538
|
+
'Provide one target column',
|
|
539
|
+
{ action }
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const updated = await kanban.updateTask(args.task_id, { column: args.column });
|
|
543
|
+
result = formatTaskResult(updated, returnShape);
|
|
544
|
+
break;
|
|
409
545
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
546
|
+
|
|
547
|
+
case 'update': {
|
|
548
|
+
if (!args.task_id) {
|
|
549
|
+
throw invalidRequest(
|
|
550
|
+
"task_id is required for 'update'",
|
|
551
|
+
'Provide a task ID',
|
|
552
|
+
{ action }
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const patch = args.patch ? { ...args.patch } : {};
|
|
556
|
+
if (args.title !== undefined) patch.title = args.title;
|
|
557
|
+
if (args.description !== undefined) patch.description = args.description;
|
|
558
|
+
if (args.specs !== undefined) patch.specs = args.specs;
|
|
559
|
+
if (args.in_scope !== undefined) patch.in_scope = args.in_scope;
|
|
560
|
+
if (args.out_of_scope !== undefined) patch.out_of_scope = args.out_of_scope;
|
|
561
|
+
if (args.acceptance_criteria !== undefined) patch.acceptance_criteria = args.acceptance_criteria;
|
|
562
|
+
if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
|
|
563
|
+
if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
|
|
564
|
+
if (args.notes !== undefined) patch.notes = args.notes;
|
|
565
|
+
if (args.epic !== undefined) patch.epic_group = args.epic;
|
|
566
|
+
if (args.col !== undefined) patch.column = args.col;
|
|
567
|
+
const updated = await kanban.updateTask(args.task_id, patch);
|
|
568
|
+
result = formatTaskResult(updated, returnShape);
|
|
569
|
+
break;
|
|
420
570
|
}
|
|
421
571
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
572
|
+
case 'plan_create': {
|
|
573
|
+
result = await plan.create(args);
|
|
574
|
+
const planWarnings = kanban.createFieldWarnings(args);
|
|
575
|
+
if (planWarnings.length > 0 && result && typeof result === 'object') {
|
|
576
|
+
result = {
|
|
577
|
+
...result,
|
|
578
|
+
warnings: planWarnings,
|
|
579
|
+
missing_recommended: kanban.missingRecommendedCreateFields(args)
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case 'plan_advance':
|
|
585
|
+
result = await plan.advance({ task_id: args.task_id, index: args.index });
|
|
586
|
+
break;
|
|
587
|
+
case 'plan_evidence':
|
|
588
|
+
result = await plan.evidence(args);
|
|
589
|
+
break;
|
|
590
|
+
case 'plan_done':
|
|
591
|
+
result = await plan.done({ task_id: args.task_id });
|
|
592
|
+
break;
|
|
593
|
+
case 'plan_status':
|
|
594
|
+
result = await plan.status(args.task_id);
|
|
595
|
+
break;
|
|
596
|
+
|
|
597
|
+
default:
|
|
598
|
+
throw invalidRequest(
|
|
599
|
+
`Unknown action: ${action}`,
|
|
600
|
+
'Use create, move, update, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
|
|
601
|
+
{ action }
|
|
602
|
+
);
|
|
440
603
|
}
|
|
441
604
|
break;
|
|
442
605
|
}
|
|
443
606
|
|
|
444
|
-
case '
|
|
445
|
-
|
|
446
|
-
break;
|
|
447
|
-
}
|
|
607
|
+
case 'kanban_gui': {
|
|
608
|
+
const action = args.action;
|
|
448
609
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
610
|
+
switch (action) {
|
|
611
|
+
case 'start': {
|
|
612
|
+
result = await startGuiServer(args.port);
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
case 'stop': {
|
|
616
|
+
result = await stopGuiServer();
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
case 'status': {
|
|
620
|
+
result = await guiStatus();
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
default:
|
|
624
|
+
throw invalidRequest(
|
|
625
|
+
`Unknown action: ${action}`,
|
|
626
|
+
'Use one of: start, stop, status',
|
|
627
|
+
{ action }
|
|
628
|
+
);
|
|
629
|
+
}
|
|
456
630
|
break;
|
|
457
631
|
}
|
|
458
632
|
|
|
@@ -460,35 +634,79 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
460
634
|
throw invalidRequest(`Unknown tool: ${name}`, 'Call tools/list to discover available tools', { name });
|
|
461
635
|
}
|
|
462
636
|
|
|
463
|
-
return
|
|
464
|
-
content: [
|
|
465
|
-
{
|
|
466
|
-
type: 'text',
|
|
467
|
-
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
468
|
-
}
|
|
469
|
-
]
|
|
470
|
-
};
|
|
637
|
+
return textResponse(result);
|
|
471
638
|
} catch (error) {
|
|
472
|
-
return
|
|
473
|
-
content: [
|
|
474
|
-
{
|
|
475
|
-
type: 'text',
|
|
476
|
-
text: JSON.stringify(serializeError(error), null, 2)
|
|
477
|
-
}
|
|
478
|
-
],
|
|
479
|
-
isError: true
|
|
480
|
-
};
|
|
639
|
+
return textResponse(serializeError(error), true);
|
|
481
640
|
}
|
|
482
641
|
});
|
|
483
642
|
|
|
643
|
+
async function maybeAutoStartGui() {
|
|
644
|
+
if (!envFlagEnabled('KANBANGO_AUTO_GUI')) return null;
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
const result = await startGuiServer();
|
|
648
|
+
console.error(`kanbango GUI ${result.status}: ${result.url}`);
|
|
649
|
+
return result;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.error(`kanbango GUI auto-start failed: ${error.message}`);
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function installGuiShutdownHooks() {
|
|
657
|
+
let shuttingDown = false;
|
|
658
|
+
|
|
659
|
+
async function shutdown() {
|
|
660
|
+
if (shuttingDown) return;
|
|
661
|
+
shuttingDown = true;
|
|
662
|
+
try {
|
|
663
|
+
await stopGuiServer();
|
|
664
|
+
} catch {
|
|
665
|
+
// best-effort
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
process.once('exit', () => {
|
|
670
|
+
if (guiProcess && guiProcess.exitCode === null) {
|
|
671
|
+
try {
|
|
672
|
+
guiProcess.kill();
|
|
673
|
+
} catch {
|
|
674
|
+
// ignore
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
process.once('SIGINT', () => {
|
|
679
|
+
shutdown().finally(() => process.exit(0));
|
|
680
|
+
});
|
|
681
|
+
process.once('SIGTERM', () => {
|
|
682
|
+
shutdown().finally(() => process.exit(0));
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
484
686
|
async function main() {
|
|
485
687
|
await kanban.ensureBacklogDir();
|
|
688
|
+
installGuiShutdownHooks();
|
|
689
|
+
await maybeAutoStartGui();
|
|
486
690
|
const transport = new StdioServerTransport();
|
|
487
691
|
await server.connect(transport);
|
|
488
692
|
console.error('kanbango MCP server running');
|
|
489
693
|
}
|
|
490
694
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
695
|
+
module.exports = {
|
|
696
|
+
serializeError,
|
|
697
|
+
serializeResult,
|
|
698
|
+
textResponse,
|
|
699
|
+
startGuiServer,
|
|
700
|
+
stopGuiServer,
|
|
701
|
+
guiStatus,
|
|
702
|
+
resolvePreferredGuiPort: kanban.resolvePreferredGuiPort,
|
|
703
|
+
server,
|
|
704
|
+
main
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
if (require.main === module) {
|
|
708
|
+
main().catch((error) => {
|
|
709
|
+
console.error('Fatal error in main():', error);
|
|
710
|
+
process.exit(1);
|
|
711
|
+
});
|
|
712
|
+
}
|