kanbango 3.0.2 → 3.2.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
@@ -146,30 +146,42 @@ function formatTaskResult(task, returnShape) {
146
146
  return kanban.shapeTask(task, { view: returnShape === 'full' ? 'full' : 'summary' });
147
147
  }
148
148
 
149
+ function guiIdentity(extra = {}) {
150
+ const cwd = extra.cwd || process.cwd();
151
+ return {
152
+ ...extra,
153
+ cwd,
154
+ project: extra.project || guiRegistry.projectLabel(cwd)
155
+ };
156
+ }
157
+
149
158
  async function startGuiServer(port) {
150
159
  if (port !== undefined && port !== null && port !== '' && !normalizePort(port)) {
151
160
  throw invalidRequest('Invalid port', 'Use an integer between 1 and 65535', { port });
152
161
  }
153
162
 
154
163
  if (ownsGuiProcess() && guiPort) {
155
- return {
164
+ return guiIdentity({
156
165
  status: 'already_running',
157
166
  owned: true,
158
167
  port: guiPort,
159
168
  pid: guiProcess.pid,
160
169
  url: `http://localhost:${guiPort}`
161
- };
170
+ });
162
171
  }
163
172
 
164
173
  const existing = await guiRegistry.discoverRunningGui();
165
174
  if (existing) {
166
- return {
175
+ return guiIdentity({
167
176
  status: 'already_running',
168
177
  owned: false,
169
178
  port: existing.port,
170
179
  pid: existing.pid,
171
- url: existing.url
172
- };
180
+ url: existing.url,
181
+ cwd: existing.cwd,
182
+ project: existing.project,
183
+ started_at: existing.started_at
184
+ });
173
185
  }
174
186
 
175
187
  const desiredPort = guiRegistry.resolvePreferredGuiPort(port);
@@ -194,19 +206,24 @@ async function startGuiServer(port) {
194
206
  const ready = await waitForGuiReady(childPid);
195
207
  guiPort = ready.port;
196
208
 
197
- return {
209
+ return guiIdentity({
198
210
  status: 'started',
199
211
  owned: true,
200
212
  port: ready.port,
201
213
  pid: ready.pid,
202
- url: ready.url
203
- };
214
+ url: ready.url,
215
+ cwd: ready.cwd,
216
+ project: ready.project,
217
+ started_at: ready.started_at
218
+ });
204
219
  }
205
220
 
206
221
  async function stopGuiServer() {
207
222
  if (ownsGuiProcess()) {
208
223
  const port = guiPort;
209
224
  const pid = guiProcess.pid;
225
+ const project = guiRegistry.projectLabel();
226
+ const cwd = process.cwd();
210
227
  guiProcess.kill();
211
228
 
212
229
  const deadline = Date.now() + 2000;
@@ -219,7 +236,7 @@ async function stopGuiServer() {
219
236
  await guiRegistry.clearGuiPortFile({ force: true });
220
237
  guiProcess = null;
221
238
  guiPort = null;
222
- return { status: 'stopping', owned: true, port, pid };
239
+ return { status: 'stopping', owned: true, port, pid, project, cwd };
223
240
  }
224
241
 
225
242
  guiProcess = null;
@@ -230,25 +247,28 @@ async function stopGuiServer() {
230
247
  return { status: 'not_running' };
231
248
  }
232
249
 
233
- return {
250
+ return guiIdentity({
234
251
  status: 'external_running',
235
252
  owned: false,
236
253
  port: discovered.port,
237
254
  pid: discovered.pid,
238
255
  url: discovered.url,
256
+ cwd: discovered.cwd,
257
+ project: discovered.project,
258
+ started_at: discovered.started_at,
239
259
  hint: 'GUI was not started by this MCP process; stop refused. Stop it from the owning terminal or kill that PID manually.'
240
- };
260
+ });
241
261
  }
242
262
 
243
263
  async function guiStatus() {
244
264
  if (ownsGuiProcess() && guiPort) {
245
- return {
265
+ return guiIdentity({
246
266
  status: 'running',
247
267
  owned: true,
248
268
  port: guiPort,
249
269
  pid: guiProcess.pid,
250
270
  url: `http://localhost:${guiPort}`
251
- };
271
+ });
252
272
  }
253
273
 
254
274
  guiProcess = null;
@@ -259,15 +279,16 @@ async function guiStatus() {
259
279
  return { status: 'not_running' };
260
280
  }
261
281
 
262
- return {
282
+ return guiIdentity({
263
283
  status: 'external_running',
264
284
  owned: false,
265
285
  port: discovered.port,
266
286
  pid: discovered.pid,
267
287
  url: discovered.url,
268
288
  cwd: discovered.cwd,
289
+ project: discovered.project,
269
290
  started_at: discovered.started_at
270
- };
291
+ });
271
292
  }
272
293
 
273
294
  const server = new Server(
@@ -293,14 +314,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
293
314
  properties: {
294
315
  operation: {
295
316
  type: 'string',
296
- enum: ['list', 'show', 'help'],
297
- description: 'list=scan board; show=one task (needs task_id); help=token playbook (no board I/O)',
317
+ enum: ['list', 'show', 'list_epics', 'show_epic', 'help'],
318
+ description: 'list/show=tasks; list_epics/show_epic=initiative containers; help=token playbook',
298
319
  default: 'list'
299
320
  },
300
321
  task_id: {
301
322
  type: 'string',
302
323
  description: "Required for show. Numeric id: '014' or '14'."
303
324
  },
325
+ epic_id: {
326
+ type: 'string',
327
+ description: "Required for show_epic. Epic id like 'E001'."
328
+ },
304
329
  col: {
305
330
  type: 'string',
306
331
  enum: COLS,
@@ -308,7 +333,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
308
333
  },
309
334
  epic: {
310
335
  type: 'string',
311
- description: 'Filter list by epic group'
336
+ description: 'Filter list by epic id or title'
337
+ },
338
+ include_archived: {
339
+ type: 'boolean',
340
+ description: 'list/list_epics: include archived epics and their tasks (default false)'
341
+ },
342
+ include_done: {
343
+ type: 'boolean',
344
+ description: 'list_epics: include status=done epics without archived (default false)'
345
+ },
346
+ status: {
347
+ type: 'string',
348
+ enum: ['empty', 'planned', 'active', 'done', 'archived'],
349
+ description: 'list_epics: exact status filter (overrides live-only default)'
312
350
  },
313
351
  view: {
314
352
  type: 'string',
@@ -332,12 +370,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
332
370
  properties: {
333
371
  action: {
334
372
  type: 'string',
335
- enum: ['create', 'move', 'update', 'plan_create', 'plan_advance', 'plan_evidence', 'plan_done', 'plan_status'],
336
- description: 'create|move|update daily; plan_* only for accepted multi-step work with tests'
373
+ enum: [
374
+ 'create',
375
+ 'move',
376
+ 'update',
377
+ 'delete',
378
+ 'epic_create',
379
+ 'epic_update',
380
+ 'epic_archive',
381
+ 'epic_unarchive',
382
+ 'epic_delete',
383
+ 'plan_create',
384
+ 'plan_advance',
385
+ 'plan_evidence',
386
+ 'plan_done',
387
+ 'plan_status'
388
+ ],
389
+ description: 'create|move|update|delete daily; epic_create|epic_update|epic_archive|epic_unarchive|epic_delete; plan_* multi-step'
337
390
  },
338
391
  title: {
339
392
  type: 'string',
340
- description: 'Required for create and plan_create'
393
+ description: 'Required for create, plan_create, epic_create'
341
394
  },
342
395
  col: {
343
396
  type: 'string',
@@ -348,11 +401,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
348
401
  epic: {
349
402
  type: 'string',
350
403
  default: '—',
351
- description: 'Epic group (create/update/plan_create)'
404
+ description: 'Epic id or title (create/update/plan_create). Prefer E001.'
405
+ },
406
+ epic_id: {
407
+ type: 'string',
408
+ description: 'Required for epic_update|epic_archive|epic_unarchive|epic_delete; optional link id'
352
409
  },
353
410
  description: {
354
411
  type: 'string',
355
- description: 'Why/context (recommended on create)'
412
+ description: 'Why/context (recommended on create / epic_create)'
413
+ },
414
+ goals: {
415
+ type: 'string',
416
+ description: 'Epic outcome one-liner (recommended on epic_create)'
356
417
  },
357
418
  specs: {
358
419
  type: 'string',
@@ -397,7 +458,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
397
458
  },
398
459
  task_id: {
399
460
  type: 'string',
400
- description: "Required for move/update/plan_* except plan_create. '014' or '14'."
461
+ description: "Required for move/update/delete/plan_* except plan_create. '014' or '14'."
401
462
  },
402
463
  column: {
403
464
  type: 'string',
@@ -411,7 +472,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
411
472
  return: {
412
473
  type: 'string',
413
474
  enum: ['none', 'summary', 'full'],
414
- description: 'move/update response size. Prefer none. Default summary. create always returns full task once.'
475
+ description: 'move/update/delete/epic_* response size. Prefer none. Default summary. create/epic_create return full once.'
415
476
  },
416
477
  index: {
417
478
  type: 'integer',
@@ -493,12 +554,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
493
554
  const readOptions = normalizeReadOptions(args, 'summary');
494
555
 
495
556
  if (operation === 'list') {
496
- let tasks = await kanban.allEpics();
557
+ await kanban.migrateEpicGroups();
558
+ const epics = await kanban.listEpicEntities();
559
+ let tasks = await kanban.allTasks();
497
560
  if (args.col) {
498
561
  tasks = tasks.filter((task) => task.column === args.col);
499
562
  }
500
- if (args.epic) {
501
- tasks = tasks.filter((task) => task.epic_group === args.epic);
563
+ // Explicit epic filter bypasses live-only hide (agent asked for that initiative)
564
+ if (args.epic || args.epic_id) {
565
+ const filter = args.epic_id || args.epic;
566
+ tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, filter));
567
+ } else {
568
+ tasks = kanban.filterTasksForList(tasks, epics, {
569
+ include_archived: args.include_archived,
570
+ include_done: args.include_done
571
+ });
502
572
  }
503
573
 
504
574
  result = tasks.map((task) => kanban.shapeTask(task, readOptions));
@@ -512,10 +582,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
512
582
  }
513
583
 
514
584
  result = kanban.shapeTask(await kanban.getTask(args.task_id), readOptions);
585
+ } else if (operation === 'list_epics') {
586
+ await kanban.migrateEpicGroups();
587
+ const tasks = await kanban.allTasks();
588
+ const epics = await kanban.listEpicEntities();
589
+ const epicView = args.view === 'full' || args.view === 'planning' || args.view === 'execution'
590
+ ? (args.view === 'execution' ? 'planning' : args.view)
591
+ : 'summary';
592
+ const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, {
593
+ view: Array.isArray(args.fields) && args.fields.length > 0 ? undefined : epicView,
594
+ fields: args.fields
595
+ }));
596
+ result = kanban.filterShapedEpics(shaped, {
597
+ include_archived: args.include_archived,
598
+ include_done: args.include_done,
599
+ status: args.status
600
+ });
601
+ } else if (operation === 'show_epic') {
602
+ const epicRef = args.epic_id || args.epic;
603
+ if (!epicRef) {
604
+ throw invalidRequest(
605
+ "epic_id is required for 'show_epic' operation",
606
+ 'Provide an epic id like E001',
607
+ { operation }
608
+ );
609
+ }
610
+ await kanban.migrateEpicGroups();
611
+ const link = await kanban.resolveEpicRef(epicRef, { createIfMissing: false });
612
+ const epic = await kanban.getEpicEntity(link.epic_id);
613
+ const tasks = await kanban.allTasks();
614
+ result = kanban.shapeEpic(epic, tasks, {
615
+ view: args.view || 'full',
616
+ fields: args.fields
617
+ });
515
618
  } else {
516
619
  throw invalidRequest(
517
620
  `Unknown operation: ${operation}`,
518
- 'Use one of: list, show, help',
621
+ 'Use one of: list, show, list_epics, show_epic, help',
519
622
  { operation }
520
623
  );
521
624
  }
@@ -538,7 +641,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
538
641
  subtasks: args.subtasks,
539
642
  notes: args.notes
540
643
  };
541
- const created = await kanban.doCreate(args.title, args.col || 'planned', args.epic || '—', createPayload);
644
+ const epicRef = args.epic_id || args.epic || '—';
645
+ const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
542
646
  const shaped = kanban.shapeTask(created, { view: 'full' });
543
647
  const warnings = kanban.createFieldWarnings(createPayload);
544
648
  result = warnings.length > 0
@@ -547,6 +651,108 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
547
651
  break;
548
652
  }
549
653
 
654
+ case 'epic_create': {
655
+ const epicPayload = {
656
+ description: args.description,
657
+ goals: args.goals,
658
+ in_scope: args.in_scope,
659
+ out_of_scope: args.out_of_scope,
660
+ notes: args.notes
661
+ };
662
+ const createdEpic = await kanban.doCreateEpic(args.title, epicPayload);
663
+ const shapedEpic = kanban.shapeEpic(createdEpic, [], { view: 'full' });
664
+ const epicWarnings = kanban.createEpicFieldWarnings(epicPayload);
665
+ result = epicWarnings.length > 0
666
+ ? {
667
+ ...shapedEpic,
668
+ warnings: epicWarnings,
669
+ missing_recommended: kanban.missingRecommendedEpicCreateFields(epicPayload)
670
+ }
671
+ : shapedEpic;
672
+ break;
673
+ }
674
+
675
+ case 'epic_update': {
676
+ const epicId = args.epic_id || args.epic;
677
+ if (!epicId) {
678
+ throw invalidRequest(
679
+ "epic_id is required for 'epic_update'",
680
+ 'Provide an epic id like E001',
681
+ { action }
682
+ );
683
+ }
684
+ const epicPatch = args.patch ? { ...args.patch } : {};
685
+ if (args.title !== undefined) epicPatch.title = args.title;
686
+ if (args.description !== undefined) epicPatch.description = args.description;
687
+ if (args.goals !== undefined) epicPatch.goals = args.goals;
688
+ if (args.in_scope !== undefined) epicPatch.in_scope = args.in_scope;
689
+ if (args.out_of_scope !== undefined) epicPatch.out_of_scope = args.out_of_scope;
690
+ if (args.notes !== undefined) epicPatch.notes = args.notes;
691
+ if (args.patch && args.patch.archived !== undefined) {
692
+ epicPatch.archived = args.patch.archived;
693
+ }
694
+ const updatedEpic = await kanban.updateEpicEntity(epicId, epicPatch);
695
+ if (returnShape === 'none') {
696
+ result = { ok: true, epic_id: updatedEpic.id };
697
+ } else if (returnShape === 'summary') {
698
+ result = kanban.shapeEpic(updatedEpic, await kanban.allTasks(), { view: 'summary' });
699
+ } else {
700
+ result = kanban.shapeEpic(updatedEpic, await kanban.allTasks(), { view: 'full' });
701
+ }
702
+ break;
703
+ }
704
+
705
+ case 'epic_archive':
706
+ case 'epic_unarchive': {
707
+ const epicId = args.epic_id || args.epic;
708
+ if (!epicId) {
709
+ throw invalidRequest(
710
+ `epic_id is required for '${action}'`,
711
+ 'Provide an epic id like E001',
712
+ { action }
713
+ );
714
+ }
715
+ const toggled = action === 'epic_archive'
716
+ ? await kanban.archiveEpic(epicId)
717
+ : await kanban.unarchiveEpic(epicId);
718
+ if (returnShape === 'none') {
719
+ result = { ok: true, epic_id: toggled.id, archived: toggled.archived };
720
+ } else if (returnShape === 'summary') {
721
+ result = kanban.shapeEpic(toggled, await kanban.allTasks(), { view: 'summary' });
722
+ } else {
723
+ result = kanban.shapeEpic(toggled, await kanban.allTasks(), { view: 'full' });
724
+ }
725
+ break;
726
+ }
727
+
728
+ case 'epic_delete': {
729
+ const epicId = args.epic_id || args.epic;
730
+ if (!epicId) {
731
+ throw invalidRequest(
732
+ "epic_id is required for 'epic_delete'",
733
+ 'Provide an epic id like E001',
734
+ { action }
735
+ );
736
+ }
737
+ result = await kanban.deleteEpic(epicId);
738
+ break;
739
+ }
740
+
741
+ case 'delete': {
742
+ if (!args.task_id) {
743
+ throw invalidRequest(
744
+ "task_id is required for 'delete'",
745
+ 'Provide a task ID',
746
+ { action }
747
+ );
748
+ }
749
+ const deleted = await kanban.deleteTask(args.task_id);
750
+ result = returnShape === 'none'
751
+ ? { ok: true, task_id: deleted.task_id }
752
+ : deleted;
753
+ break;
754
+ }
755
+
550
756
  case 'move': {
551
757
  if (!args.task_id) {
552
758
  throw invalidRequest(
@@ -585,7 +791,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
585
791
  if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
586
792
  if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
587
793
  if (args.notes !== undefined) patch.notes = args.notes;
588
- if (args.epic !== undefined) patch.epic_group = args.epic;
794
+ if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
795
+ else if (args.epic !== undefined) patch.epic = args.epic;
589
796
  if (args.col !== undefined) patch.column = args.col;
590
797
  const updated = await kanban.updateTask(args.task_id, patch);
591
798
  result = formatTaskResult(updated, returnShape);
@@ -620,7 +827,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
620
827
  default:
621
828
  throw invalidRequest(
622
829
  `Unknown action: ${action}`,
623
- 'Use create, move, update, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
830
+ 'Use create, move, update, delete, epic_create, epic_update, epic_archive, epic_unarchive, epic_delete, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
624
831
  { action }
625
832
  );
626
833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.0.2",
3
+ "version": "3.2.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/tests/run.js CHANGED
@@ -20,3 +20,5 @@ 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
21
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
22
22
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
23
+ runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
24
+ runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');