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/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 kanban.normalizeGuiPort(value);
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 kanban.discoverRunningGui();
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 (guiProcess && guiProcess.exitCode === null && guiPort) {
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 kanban.discoverRunningGui();
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 = kanban.resolvePreferredGuiPort(port);
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
- const trackedRunning = guiProcess && guiProcess.exitCode === null;
200
- const discovered = trackedRunning ? null : await kanban.discoverRunningGui();
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
- } else if (pid) {
214
- try {
215
- process.kill(pid, 'SIGTERM');
216
- } catch {
217
- // process may already be gone
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
- 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);
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
- return { status: 'stopping', port, pid };
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 (guiProcess && guiProcess.exitCode === null && guiPort) {
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
- const discovered = await kanban.discoverRunningGui();
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
- guiPort = discovered.port;
253
- return discovered;
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: '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.',
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: "Operation to perform: 'list' for all tasks, 'show' for a specific task",
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
- description: "Task ID (optional for 'list', required for 'show'). Use a numeric ID like '014' or just a number like '14'."
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: 'Optional column filter for list'
311
+ description: 'Filter list by column (saves tokens — prefer this)'
291
312
  },
292
313
  epic: {
293
314
  type: 'string',
294
- description: 'Optional epic group filter for list'
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: 'Preset response view. Defaults to summary.'
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: 'Explicit fields to return. When provided, fields override view.',
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: '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"}.',
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
- 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'
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: "Non-empty title. Required for 'create' and 'plan_create'."
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: "Column for 'create' or shortcut patch field for 'update' (default: 'planned')."
361
+ description: 'create column, or update shortcut for column'
330
362
  },
331
363
  epic: {
332
364
  type: 'string',
333
365
  default: '—',
334
- description: "Epic group for 'create', 'update', or 'plan_create' (optional)."
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: "Strongly recommended. High-level context/why for 'create', 'update', or 'plan_create'."
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: "Strongly recommended. Technical constraints, APIs, and edge cases for 'create', 'update', or 'plan_create'."
382
+ description: 'Technical constraints (recommended on create)'
343
383
  },
344
384
  in_scope: {
345
385
  type: 'array',
346
- description: "Strongly recommended. What this task includes (boundaries) for 'create', 'update', or 'plan_create'.",
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: "Strongly recommended. Explicit non-goals / exclusions for 'create', 'update', or 'plan_create'.",
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: "Strongly recommended. Completion requirements for 'create', 'update', or 'plan_create'.",
396
+ description: 'Done criteria (recommended on create)',
357
397
  items: { type: 'string' }
358
398
  },
359
399
  test_cases: {
360
400
  type: 'array',
361
- description: "Recommended. Verification scenarios for 'create', 'update', or 'plan_create'.",
401
+ description: 'Verification scenarios',
362
402
  items: { type: 'string' }
363
403
  },
364
404
  subtasks: {
365
405
  type: 'array',
366
- description: "Optional subtask list for 'create', 'update', or internally generated by 'plan_create'.",
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: "Optional freeform notes for 'create', 'update', or 'plan_create'."
419
+ description: 'Freeform notes'
380
420
  },
381
421
  task_id: {
382
422
  type: 'string',
383
- description: "Task ID required for 'move', 'update', and all plan_* actions except 'plan_create'. Use '014' or '14'."
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: "Target column required for 'move'."
428
+ description: 'Target column for move (not col)'
389
429
  },
390
430
  patch: {
391
431
  type: 'object',
392
- description: "Patch payload for 'update'. Use this for bulk field changes; top-level shortcuts are merged into the patch."
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: "Response shape for 'move' and 'update'. Defaults to summary. 'create' returns the full created task."
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: "Zero-based plan subtask index for 'plan_advance'. Defaults to the first incomplete step when omitted."
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: "Implementation steps inserted between the default plan workflow steps for 'plan_create'."
446
+ description: 'plan_create: implementation steps between red/green test steps'
407
447
  },
408
448
  project_root: {
409
449
  type: 'string',
410
- description: "Project root used for test runner detection in 'plan_create'. Defaults to the MCP server working directory."
450
+ description: 'plan_create: root for test-runner detect (default cwd)'
411
451
  },
412
452
  diff: {
413
453
  type: 'string',
414
- description: "Required for 'plan_evidence'. Include the relevant code diff or summary."
454
+ description: 'plan_evidence: short diff or summary (not whole repo)'
415
455
  },
416
456
  test_command: {
417
457
  type: 'string',
418
- description: "Required for 'plan_evidence'. The exact verification command that was run."
458
+ description: 'plan_evidence: exact command run'
419
459
  },
420
460
  stdout: {
421
461
  type: 'string',
422
- description: "Required for 'plan_evidence'. Captured standard output from the verification command."
462
+ description: 'plan_evidence: test stdout (truncate to last ~2KB if huge)'
423
463
  },
424
464
  stderr: {
425
465
  type: 'string',
426
- description: "Required for 'plan_evidence'. Captured standard error from the verification command."
466
+ description: 'plan_evidence: test stderr (truncate if huge)'
427
467
  },
428
468
  exit_code: {
429
469
  type: 'integer',
430
- description: "Required for 'plan_evidence'. Integer process exit code from the verification command."
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: 'Control the web GUI server: start, stop, or check status.',
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: "Action to perform: 'start' launches GUI, 'stop' kills it, 'status' checks if running"
486
+ description: 'start | stop (owned only) | status'
447
487
  },
448
488
  port: {
449
489
  type: 'integer',
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)."
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
- let tasks = await kanban.allEpics();
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
- tasks = tasks.filter((task) => task.epic_group === args.epic);
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 created = await kanban.doCreate(args.title, args.col || 'planned', args.epic || '—', createPayload);
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.epic !== undefined) patch.epic_group = args.epic;
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 shutdown() {
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 (guiProcess && guiProcess.exitCode === null) {
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
- shutdown().finally(() => process.exit(0));
806
+ shutdownOwnedGui().finally(() => process.exit(0));
680
807
  });
681
808
  process.once('SIGTERM', () => {
682
- shutdown().finally(() => process.exit(0));
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: kanban.resolvePreferredGuiPort,
829
+ resolvePreferredGuiPort: guiRegistry.resolvePreferredGuiPort,
830
+ playbook,
703
831
  server,
704
832
  main
705
833
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "2.5.0",
3
+ "version": "3.1.0",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
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: { ...(task.plan || {}), status: 'done' } });
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');