kanbango 2.5.0 → 3.1.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 +4 -0
- package/.ai/retro/last-run.json +1 -1
- package/AGENTS.md +14 -0
- package/CHANGELOG.md +54 -0
- package/LLM_AGENTS.md +73 -18
- package/README.md +24 -6
- package/agent-playbook.js +79 -0
- package/bin/kanban-cmd.js +1 -1
- package/bin/kanban.js +169 -26
- package/gui-registry.js +148 -0
- package/index.html +61 -31
- package/index.js +4 -0
- package/kanban.js +525 -139
- package/mcp-server.js +215 -87
- package/package.json +1 -1
- package/plan.js +1 -1
- package/tests/run.js +3 -0
package/mcp-server.js
CHANGED
|
@@ -8,6 +8,8 @@ const path = require('path');
|
|
|
8
8
|
const pkg = require('./package.json');
|
|
9
9
|
const kanban = require('./kanban.js');
|
|
10
10
|
const plan = require('./plan.js');
|
|
11
|
+
const guiRegistry = require('./gui-registry.js');
|
|
12
|
+
const playbook = require('./agent-playbook.js');
|
|
11
13
|
|
|
12
14
|
const COLS = kanban.COLS;
|
|
13
15
|
const READ_VIEWS = Object.keys(kanban.VIEW_FIELDS);
|
|
@@ -17,7 +19,11 @@ let guiProcess = null;
|
|
|
17
19
|
let guiPort = null;
|
|
18
20
|
|
|
19
21
|
function normalizePort(value) {
|
|
20
|
-
return
|
|
22
|
+
return guiRegistry.normalizeGuiPort(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ownsGuiProcess() {
|
|
26
|
+
return Boolean(guiProcess && guiProcess.exitCode === null);
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
function sleep(ms) {
|
|
@@ -41,7 +47,7 @@ async function waitForGuiReady(pid, timeoutMs = GUI_READY_TIMEOUT_MS) {
|
|
|
41
47
|
);
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
const info = await
|
|
50
|
+
const info = await guiRegistry.discoverRunningGui();
|
|
45
51
|
if (info && info.pid === pid) return info;
|
|
46
52
|
|
|
47
53
|
await sleep(GUI_READY_POLL_MS);
|
|
@@ -145,27 +151,28 @@ async function startGuiServer(port) {
|
|
|
145
151
|
throw invalidRequest('Invalid port', 'Use an integer between 1 and 65535', { port });
|
|
146
152
|
}
|
|
147
153
|
|
|
148
|
-
if (
|
|
154
|
+
if (ownsGuiProcess() && guiPort) {
|
|
149
155
|
return {
|
|
150
156
|
status: 'already_running',
|
|
157
|
+
owned: true,
|
|
151
158
|
port: guiPort,
|
|
152
159
|
pid: guiProcess.pid,
|
|
153
160
|
url: `http://localhost:${guiPort}`
|
|
154
161
|
};
|
|
155
162
|
}
|
|
156
163
|
|
|
157
|
-
const existing = await
|
|
164
|
+
const existing = await guiRegistry.discoverRunningGui();
|
|
158
165
|
if (existing) {
|
|
159
|
-
guiPort = existing.port;
|
|
160
166
|
return {
|
|
161
167
|
status: 'already_running',
|
|
168
|
+
owned: false,
|
|
162
169
|
port: existing.port,
|
|
163
170
|
pid: existing.pid,
|
|
164
171
|
url: existing.url
|
|
165
172
|
};
|
|
166
173
|
}
|
|
167
174
|
|
|
168
|
-
const desiredPort =
|
|
175
|
+
const desiredPort = guiRegistry.resolvePreferredGuiPort(port);
|
|
169
176
|
await kanban.ensureBacklogDir();
|
|
170
177
|
|
|
171
178
|
const scriptPath = path.join(__dirname, 'bin', 'kanban.js');
|
|
@@ -189,6 +196,7 @@ async function startGuiServer(port) {
|
|
|
189
196
|
|
|
190
197
|
return {
|
|
191
198
|
status: 'started',
|
|
199
|
+
owned: true,
|
|
192
200
|
port: ready.port,
|
|
193
201
|
pid: ready.pid,
|
|
194
202
|
url: ready.url
|
|
@@ -196,61 +204,70 @@ async function startGuiServer(port) {
|
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
async function stopGuiServer() {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (!trackedRunning && !discovered) {
|
|
203
|
-
guiProcess = null;
|
|
204
|
-
guiPort = null;
|
|
205
|
-
return { status: 'not_running' };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const port = trackedRunning ? guiPort : discovered.port;
|
|
209
|
-
const pid = trackedRunning ? guiProcess.pid : discovered.pid;
|
|
210
|
-
|
|
211
|
-
if (trackedRunning) {
|
|
207
|
+
if (ownsGuiProcess()) {
|
|
208
|
+
const port = guiPort;
|
|
209
|
+
const pid = guiProcess.pid;
|
|
212
210
|
guiProcess.kill();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
211
|
+
|
|
212
|
+
const deadline = Date.now() + 2000;
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
const still = await guiRegistry.discoverRunningGui();
|
|
215
|
+
if (!still || still.pid !== pid) break;
|
|
216
|
+
await sleep(50);
|
|
218
217
|
}
|
|
219
|
-
}
|
|
220
218
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
await sleep(50);
|
|
219
|
+
await guiRegistry.clearGuiPortFile({ force: true });
|
|
220
|
+
guiProcess = null;
|
|
221
|
+
guiPort = null;
|
|
222
|
+
return { status: 'stopping', owned: true, port, pid };
|
|
226
223
|
}
|
|
227
224
|
|
|
228
|
-
await kanban.clearGuiPortFile({ force: true });
|
|
229
225
|
guiProcess = null;
|
|
230
226
|
guiPort = null;
|
|
231
227
|
|
|
232
|
-
|
|
228
|
+
const discovered = await guiRegistry.discoverRunningGui();
|
|
229
|
+
if (!discovered) {
|
|
230
|
+
return { status: 'not_running' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
status: 'external_running',
|
|
235
|
+
owned: false,
|
|
236
|
+
port: discovered.port,
|
|
237
|
+
pid: discovered.pid,
|
|
238
|
+
url: discovered.url,
|
|
239
|
+
hint: 'GUI was not started by this MCP process; stop refused. Stop it from the owning terminal or kill that PID manually.'
|
|
240
|
+
};
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
async function guiStatus() {
|
|
236
|
-
if (
|
|
244
|
+
if (ownsGuiProcess() && guiPort) {
|
|
237
245
|
return {
|
|
238
246
|
status: 'running',
|
|
247
|
+
owned: true,
|
|
239
248
|
port: guiPort,
|
|
240
249
|
pid: guiProcess.pid,
|
|
241
250
|
url: `http://localhost:${guiPort}`
|
|
242
251
|
};
|
|
243
252
|
}
|
|
244
253
|
|
|
245
|
-
|
|
254
|
+
guiProcess = null;
|
|
255
|
+
guiPort = null;
|
|
256
|
+
|
|
257
|
+
const discovered = await guiRegistry.discoverRunningGui();
|
|
246
258
|
if (!discovered) {
|
|
247
|
-
guiProcess = null;
|
|
248
|
-
guiPort = null;
|
|
249
259
|
return { status: 'not_running' };
|
|
250
260
|
}
|
|
251
261
|
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
return {
|
|
263
|
+
status: 'external_running',
|
|
264
|
+
owned: false,
|
|
265
|
+
port: discovered.port,
|
|
266
|
+
pid: discovered.pid,
|
|
267
|
+
url: discovered.url,
|
|
268
|
+
cwd: discovered.cwd,
|
|
269
|
+
started_at: discovered.started_at
|
|
270
|
+
};
|
|
254
271
|
}
|
|
255
272
|
|
|
256
273
|
const server = new Server(
|
|
@@ -270,37 +287,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
270
287
|
tools: [
|
|
271
288
|
{
|
|
272
289
|
name: 'kanban_read',
|
|
273
|
-
description:
|
|
290
|
+
description: playbook.TOOL_DESCRIPTIONS.kanban_read,
|
|
274
291
|
inputSchema: {
|
|
275
292
|
type: 'object',
|
|
276
293
|
properties: {
|
|
277
294
|
operation: {
|
|
278
295
|
type: 'string',
|
|
279
|
-
enum: ['list', 'show'],
|
|
280
|
-
description:
|
|
296
|
+
enum: ['list', 'show', 'list_epics', 'show_epic', 'help'],
|
|
297
|
+
description: 'list/show=tasks; list_epics/show_epic=initiative containers; help=token playbook',
|
|
281
298
|
default: 'list'
|
|
282
299
|
},
|
|
283
300
|
task_id: {
|
|
284
301
|
type: 'string',
|
|
285
|
-
|
|
302
|
+
description: "Required for show. Numeric id: '014' or '14'."
|
|
303
|
+
},
|
|
304
|
+
epic_id: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
description: "Required for show_epic. Epic id like 'E001'."
|
|
286
307
|
},
|
|
287
308
|
col: {
|
|
288
309
|
type: 'string',
|
|
289
310
|
enum: COLS,
|
|
290
|
-
description: '
|
|
311
|
+
description: 'Filter list by column (saves tokens — prefer this)'
|
|
291
312
|
},
|
|
292
313
|
epic: {
|
|
293
314
|
type: 'string',
|
|
294
|
-
description: '
|
|
315
|
+
description: 'Filter list by epic id or title'
|
|
295
316
|
},
|
|
296
317
|
view: {
|
|
297
318
|
type: 'string',
|
|
298
319
|
enum: READ_VIEWS,
|
|
299
|
-
description: '
|
|
320
|
+
description: 'summary=board scan (default); planning=scope/AC; execution=+subtasks; full=everything. Prefer smallest that works.'
|
|
300
321
|
},
|
|
301
322
|
fields: {
|
|
302
323
|
type: 'array',
|
|
303
|
-
description: '
|
|
324
|
+
description: 'Exact fields only (overrides view). Use when you need 1–2 fields.',
|
|
304
325
|
items: { type: 'string' }
|
|
305
326
|
}
|
|
306
327
|
},
|
|
@@ -309,61 +330,80 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
309
330
|
},
|
|
310
331
|
{
|
|
311
332
|
name: 'kanban_manage',
|
|
312
|
-
description:
|
|
333
|
+
description: playbook.TOOL_DESCRIPTIONS.kanban_manage,
|
|
313
334
|
inputSchema: {
|
|
314
335
|
type: 'object',
|
|
315
336
|
properties: {
|
|
316
337
|
action: {
|
|
317
338
|
type: 'string',
|
|
318
|
-
|
|
319
|
-
|
|
339
|
+
enum: [
|
|
340
|
+
'create',
|
|
341
|
+
'move',
|
|
342
|
+
'update',
|
|
343
|
+
'epic_create',
|
|
344
|
+
'epic_update',
|
|
345
|
+
'plan_create',
|
|
346
|
+
'plan_advance',
|
|
347
|
+
'plan_evidence',
|
|
348
|
+
'plan_done',
|
|
349
|
+
'plan_status'
|
|
350
|
+
],
|
|
351
|
+
description: 'create|move|update daily; epic_create|epic_update for containers; plan_* for multi-step work'
|
|
320
352
|
},
|
|
321
353
|
title: {
|
|
322
354
|
type: 'string',
|
|
323
|
-
description:
|
|
355
|
+
description: 'Required for create, plan_create, epic_create'
|
|
324
356
|
},
|
|
325
357
|
col: {
|
|
326
358
|
type: 'string',
|
|
327
359
|
enum: COLS,
|
|
328
360
|
default: 'planned',
|
|
329
|
-
description:
|
|
361
|
+
description: 'create column, or update shortcut for column'
|
|
330
362
|
},
|
|
331
363
|
epic: {
|
|
332
364
|
type: 'string',
|
|
333
365
|
default: '—',
|
|
334
|
-
description:
|
|
366
|
+
description: 'Epic id or title (create/update/plan_create). Prefer E001.'
|
|
367
|
+
},
|
|
368
|
+
epic_id: {
|
|
369
|
+
type: 'string',
|
|
370
|
+
description: 'Required for epic_update; optional filter/link id'
|
|
335
371
|
},
|
|
336
372
|
description: {
|
|
337
373
|
type: 'string',
|
|
338
|
-
description:
|
|
374
|
+
description: 'Why/context (recommended on create / epic_create)'
|
|
375
|
+
},
|
|
376
|
+
goals: {
|
|
377
|
+
type: 'string',
|
|
378
|
+
description: 'Epic outcome one-liner (recommended on epic_create)'
|
|
339
379
|
},
|
|
340
380
|
specs: {
|
|
341
381
|
type: 'string',
|
|
342
|
-
description:
|
|
382
|
+
description: 'Technical constraints (recommended on create)'
|
|
343
383
|
},
|
|
344
384
|
in_scope: {
|
|
345
385
|
type: 'array',
|
|
346
|
-
description:
|
|
386
|
+
description: 'In-scope bullets (recommended on create)',
|
|
347
387
|
items: { type: 'string' }
|
|
348
388
|
},
|
|
349
389
|
out_of_scope: {
|
|
350
390
|
type: 'array',
|
|
351
|
-
description:
|
|
391
|
+
description: 'Out-of-scope bullets (recommended on create)',
|
|
352
392
|
items: { type: 'string' }
|
|
353
393
|
},
|
|
354
394
|
acceptance_criteria: {
|
|
355
395
|
type: 'array',
|
|
356
|
-
description:
|
|
396
|
+
description: 'Done criteria (recommended on create)',
|
|
357
397
|
items: { type: 'string' }
|
|
358
398
|
},
|
|
359
399
|
test_cases: {
|
|
360
400
|
type: 'array',
|
|
361
|
-
description:
|
|
401
|
+
description: 'Verification scenarios',
|
|
362
402
|
items: { type: 'string' }
|
|
363
403
|
},
|
|
364
404
|
subtasks: {
|
|
365
405
|
type: 'array',
|
|
366
|
-
description:
|
|
406
|
+
description: 'Full subtask list replace on update (send complete array, not a single toggle)',
|
|
367
407
|
items: {
|
|
368
408
|
type: 'object',
|
|
369
409
|
properties: {
|
|
@@ -376,58 +416,58 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
376
416
|
},
|
|
377
417
|
notes: {
|
|
378
418
|
type: 'string',
|
|
379
|
-
description:
|
|
419
|
+
description: 'Freeform notes'
|
|
380
420
|
},
|
|
381
421
|
task_id: {
|
|
382
422
|
type: 'string',
|
|
383
|
-
|
|
423
|
+
description: "Required for move/update/plan_* except plan_create. '014' or '14'."
|
|
384
424
|
},
|
|
385
425
|
column: {
|
|
386
426
|
type: 'string',
|
|
387
427
|
enum: COLS,
|
|
388
|
-
description:
|
|
428
|
+
description: 'Target column for move (not col)'
|
|
389
429
|
},
|
|
390
430
|
patch: {
|
|
391
431
|
type: 'object',
|
|
392
|
-
description:
|
|
432
|
+
description: 'Bulk update object; merged with top-level field shortcuts'
|
|
393
433
|
},
|
|
394
434
|
return: {
|
|
395
435
|
type: 'string',
|
|
396
436
|
enum: ['none', 'summary', 'full'],
|
|
397
|
-
description:
|
|
437
|
+
description: 'move/update/epic_update response size. Prefer none. Default summary. create/epic_create return full once.'
|
|
398
438
|
},
|
|
399
439
|
index: {
|
|
400
440
|
type: 'integer',
|
|
401
|
-
description:
|
|
441
|
+
description: 'plan_advance: subtask index; omit = first incomplete'
|
|
402
442
|
},
|
|
403
443
|
steps: {
|
|
404
444
|
type: 'array',
|
|
405
445
|
items: { type: 'string' },
|
|
406
|
-
description:
|
|
446
|
+
description: 'plan_create: implementation steps between red/green test steps'
|
|
407
447
|
},
|
|
408
448
|
project_root: {
|
|
409
449
|
type: 'string',
|
|
410
|
-
description:
|
|
450
|
+
description: 'plan_create: root for test-runner detect (default cwd)'
|
|
411
451
|
},
|
|
412
452
|
diff: {
|
|
413
453
|
type: 'string',
|
|
414
|
-
description:
|
|
454
|
+
description: 'plan_evidence: short diff or summary (not whole repo)'
|
|
415
455
|
},
|
|
416
456
|
test_command: {
|
|
417
457
|
type: 'string',
|
|
418
|
-
description:
|
|
458
|
+
description: 'plan_evidence: exact command run'
|
|
419
459
|
},
|
|
420
460
|
stdout: {
|
|
421
461
|
type: 'string',
|
|
422
|
-
description:
|
|
462
|
+
description: 'plan_evidence: test stdout (truncate to last ~2KB if huge)'
|
|
423
463
|
},
|
|
424
464
|
stderr: {
|
|
425
465
|
type: 'string',
|
|
426
|
-
description:
|
|
466
|
+
description: 'plan_evidence: test stderr (truncate if huge)'
|
|
427
467
|
},
|
|
428
468
|
exit_code: {
|
|
429
469
|
type: 'integer',
|
|
430
|
-
description:
|
|
470
|
+
description: 'plan_evidence: process exit code'
|
|
431
471
|
}
|
|
432
472
|
},
|
|
433
473
|
required: ['action'],
|
|
@@ -436,18 +476,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
436
476
|
},
|
|
437
477
|
{
|
|
438
478
|
name: 'kanban_gui',
|
|
439
|
-
description:
|
|
479
|
+
description: playbook.TOOL_DESCRIPTIONS.kanban_gui,
|
|
440
480
|
inputSchema: {
|
|
441
481
|
type: 'object',
|
|
442
482
|
properties: {
|
|
443
483
|
action: {
|
|
444
484
|
type: 'string',
|
|
445
485
|
enum: ['start', 'stop', 'status'],
|
|
446
|
-
description:
|
|
486
|
+
description: 'start | stop (owned only) | status'
|
|
447
487
|
},
|
|
448
488
|
port: {
|
|
449
489
|
type: 'integer',
|
|
450
|
-
description:
|
|
490
|
+
description: 'Optional start port; else KANBANGO_GUI_PORT or stable 5510-5999'
|
|
451
491
|
}
|
|
452
492
|
},
|
|
453
493
|
required: ['action'],
|
|
@@ -467,15 +507,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
467
507
|
switch (name) {
|
|
468
508
|
case 'kanban_read': {
|
|
469
509
|
const operation = args.operation || 'list';
|
|
510
|
+
|
|
511
|
+
if (operation === 'help') {
|
|
512
|
+
result = playbook.playbookHelpPayload();
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
|
|
470
516
|
const readOptions = normalizeReadOptions(args, 'summary');
|
|
471
517
|
|
|
472
518
|
if (operation === 'list') {
|
|
473
|
-
|
|
519
|
+
await kanban.migrateEpicGroups();
|
|
520
|
+
let tasks = await kanban.allTasks();
|
|
474
521
|
if (args.col) {
|
|
475
522
|
tasks = tasks.filter((task) => task.column === args.col);
|
|
476
523
|
}
|
|
477
|
-
if (args.epic) {
|
|
478
|
-
|
|
524
|
+
if (args.epic || args.epic_id) {
|
|
525
|
+
const filter = args.epic_id || args.epic;
|
|
526
|
+
tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, filter));
|
|
479
527
|
}
|
|
480
528
|
|
|
481
529
|
result = tasks.map((task) => kanban.shapeTask(task, readOptions));
|
|
@@ -489,10 +537,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
489
537
|
}
|
|
490
538
|
|
|
491
539
|
result = kanban.shapeTask(await kanban.getTask(args.task_id), readOptions);
|
|
540
|
+
} else if (operation === 'list_epics') {
|
|
541
|
+
await kanban.migrateEpicGroups();
|
|
542
|
+
const tasks = await kanban.allTasks();
|
|
543
|
+
const epics = await kanban.listEpicEntities();
|
|
544
|
+
const epicView = args.view === 'full' || args.view === 'planning' || args.view === 'execution'
|
|
545
|
+
? (args.view === 'execution' ? 'planning' : args.view)
|
|
546
|
+
: 'summary';
|
|
547
|
+
result = epics.map((epic) => kanban.shapeEpic(epic, tasks, {
|
|
548
|
+
view: Array.isArray(args.fields) && args.fields.length > 0 ? undefined : epicView,
|
|
549
|
+
fields: args.fields
|
|
550
|
+
}));
|
|
551
|
+
} else if (operation === 'show_epic') {
|
|
552
|
+
const epicRef = args.epic_id || args.epic;
|
|
553
|
+
if (!epicRef) {
|
|
554
|
+
throw invalidRequest(
|
|
555
|
+
"epic_id is required for 'show_epic' operation",
|
|
556
|
+
'Provide an epic id like E001',
|
|
557
|
+
{ operation }
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
await kanban.migrateEpicGroups();
|
|
561
|
+
const link = await kanban.resolveEpicRef(epicRef, { createIfMissing: false });
|
|
562
|
+
const epic = await kanban.getEpicEntity(link.epic_id);
|
|
563
|
+
const tasks = await kanban.allTasks();
|
|
564
|
+
result = kanban.shapeEpic(epic, tasks, {
|
|
565
|
+
view: args.view || 'full',
|
|
566
|
+
fields: args.fields
|
|
567
|
+
});
|
|
492
568
|
} else {
|
|
493
569
|
throw invalidRequest(
|
|
494
570
|
`Unknown operation: ${operation}`,
|
|
495
|
-
'Use one of: list, show',
|
|
571
|
+
'Use one of: list, show, list_epics, show_epic, help',
|
|
496
572
|
{ operation }
|
|
497
573
|
);
|
|
498
574
|
}
|
|
@@ -515,7 +591,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
515
591
|
subtasks: args.subtasks,
|
|
516
592
|
notes: args.notes
|
|
517
593
|
};
|
|
518
|
-
const
|
|
594
|
+
const epicRef = args.epic_id || args.epic || '—';
|
|
595
|
+
const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
|
|
519
596
|
const shaped = kanban.shapeTask(created, { view: 'full' });
|
|
520
597
|
const warnings = kanban.createFieldWarnings(createPayload);
|
|
521
598
|
result = warnings.length > 0
|
|
@@ -524,6 +601,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
524
601
|
break;
|
|
525
602
|
}
|
|
526
603
|
|
|
604
|
+
case 'epic_create': {
|
|
605
|
+
const epicPayload = {
|
|
606
|
+
description: args.description,
|
|
607
|
+
goals: args.goals,
|
|
608
|
+
in_scope: args.in_scope,
|
|
609
|
+
out_of_scope: args.out_of_scope,
|
|
610
|
+
notes: args.notes
|
|
611
|
+
};
|
|
612
|
+
const createdEpic = await kanban.doCreateEpic(args.title, epicPayload);
|
|
613
|
+
const shapedEpic = kanban.shapeEpic(createdEpic, [], { view: 'full' });
|
|
614
|
+
const epicWarnings = kanban.createEpicFieldWarnings(epicPayload);
|
|
615
|
+
result = epicWarnings.length > 0
|
|
616
|
+
? {
|
|
617
|
+
...shapedEpic,
|
|
618
|
+
warnings: epicWarnings,
|
|
619
|
+
missing_recommended: kanban.missingRecommendedEpicCreateFields(epicPayload)
|
|
620
|
+
}
|
|
621
|
+
: shapedEpic;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
case 'epic_update': {
|
|
626
|
+
const epicId = args.epic_id || args.epic;
|
|
627
|
+
if (!epicId) {
|
|
628
|
+
throw invalidRequest(
|
|
629
|
+
"epic_id is required for 'epic_update'",
|
|
630
|
+
'Provide an epic id like E001',
|
|
631
|
+
{ action }
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const epicPatch = args.patch ? { ...args.patch } : {};
|
|
635
|
+
if (args.title !== undefined) epicPatch.title = args.title;
|
|
636
|
+
if (args.description !== undefined) epicPatch.description = args.description;
|
|
637
|
+
if (args.goals !== undefined) epicPatch.goals = args.goals;
|
|
638
|
+
if (args.in_scope !== undefined) epicPatch.in_scope = args.in_scope;
|
|
639
|
+
if (args.out_of_scope !== undefined) epicPatch.out_of_scope = args.out_of_scope;
|
|
640
|
+
if (args.notes !== undefined) epicPatch.notes = args.notes;
|
|
641
|
+
const updatedEpic = await kanban.updateEpicEntity(epicId, epicPatch);
|
|
642
|
+
if (returnShape === 'none') {
|
|
643
|
+
result = { ok: true, epic_id: updatedEpic.id };
|
|
644
|
+
} else if (returnShape === 'summary') {
|
|
645
|
+
result = kanban.shapeEpic(updatedEpic, await kanban.allTasks(), { view: 'summary' });
|
|
646
|
+
} else {
|
|
647
|
+
result = kanban.shapeEpic(updatedEpic, await kanban.allTasks(), { view: 'full' });
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
|
|
527
652
|
case 'move': {
|
|
528
653
|
if (!args.task_id) {
|
|
529
654
|
throw invalidRequest(
|
|
@@ -562,7 +687,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
562
687
|
if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
|
|
563
688
|
if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
|
|
564
689
|
if (args.notes !== undefined) patch.notes = args.notes;
|
|
565
|
-
if (args.
|
|
690
|
+
if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
|
|
691
|
+
else if (args.epic !== undefined) patch.epic = args.epic;
|
|
566
692
|
if (args.col !== undefined) patch.column = args.col;
|
|
567
693
|
const updated = await kanban.updateTask(args.task_id, patch);
|
|
568
694
|
result = formatTaskResult(updated, returnShape);
|
|
@@ -597,7 +723,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
597
723
|
default:
|
|
598
724
|
throw invalidRequest(
|
|
599
725
|
`Unknown action: ${action}`,
|
|
600
|
-
'Use create, move, update, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
|
|
726
|
+
'Use create, move, update, epic_create, epic_update, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
|
|
601
727
|
{ action }
|
|
602
728
|
);
|
|
603
729
|
}
|
|
@@ -656,9 +782,10 @@ async function maybeAutoStartGui() {
|
|
|
656
782
|
function installGuiShutdownHooks() {
|
|
657
783
|
let shuttingDown = false;
|
|
658
784
|
|
|
659
|
-
async function
|
|
785
|
+
async function shutdownOwnedGui() {
|
|
660
786
|
if (shuttingDown) return;
|
|
661
787
|
shuttingDown = true;
|
|
788
|
+
if (!ownsGuiProcess()) return;
|
|
662
789
|
try {
|
|
663
790
|
await stopGuiServer();
|
|
664
791
|
} catch {
|
|
@@ -667,7 +794,7 @@ function installGuiShutdownHooks() {
|
|
|
667
794
|
}
|
|
668
795
|
|
|
669
796
|
process.once('exit', () => {
|
|
670
|
-
if (
|
|
797
|
+
if (ownsGuiProcess()) {
|
|
671
798
|
try {
|
|
672
799
|
guiProcess.kill();
|
|
673
800
|
} catch {
|
|
@@ -676,10 +803,10 @@ function installGuiShutdownHooks() {
|
|
|
676
803
|
}
|
|
677
804
|
});
|
|
678
805
|
process.once('SIGINT', () => {
|
|
679
|
-
|
|
806
|
+
shutdownOwnedGui().finally(() => process.exit(0));
|
|
680
807
|
});
|
|
681
808
|
process.once('SIGTERM', () => {
|
|
682
|
-
|
|
809
|
+
shutdownOwnedGui().finally(() => process.exit(0));
|
|
683
810
|
});
|
|
684
811
|
}
|
|
685
812
|
|
|
@@ -699,7 +826,8 @@ module.exports = {
|
|
|
699
826
|
startGuiServer,
|
|
700
827
|
stopGuiServer,
|
|
701
828
|
guiStatus,
|
|
702
|
-
resolvePreferredGuiPort:
|
|
829
|
+
resolvePreferredGuiPort: guiRegistry.resolvePreferredGuiPort,
|
|
830
|
+
playbook,
|
|
703
831
|
server,
|
|
704
832
|
main
|
|
705
833
|
};
|
package/package.json
CHANGED
package/plan.js
CHANGED
|
@@ -147,7 +147,7 @@ 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, { column: 'done', plan: { ...
|
|
150
|
+
const updated = await kanban.updateTask(task.id, { column: 'done', plan: { ...task.plan, status: 'done' } });
|
|
151
151
|
return result(updated, { status: 'done' });
|
|
152
152
|
}
|
|
153
153
|
|
package/tests/run.js
CHANGED
|
@@ -18,3 +18,6 @@ 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', 'plan-workflow.test.js'), [], 'Plan workflow test');
|
|
22
|
+
runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
|
|
23
|
+
runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
|