kanbango 3.1.0 → 3.3.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
@@ -76,6 +76,101 @@ function invalidRequest(message, hint, details) {
76
76
  return kanban.createKanbanError('VALIDATION_ERROR', message, hint, details, false, 400);
77
77
  }
78
78
 
79
+ const MANAGE_ACTIONS = [
80
+ 'create',
81
+ 'move',
82
+ 'update',
83
+ 'delete',
84
+ 'epic_create',
85
+ 'epic_update',
86
+ 'epic_archive',
87
+ 'epic_unarchive',
88
+ 'epic_delete',
89
+ 'plan_create',
90
+ 'plan_advance',
91
+ 'plan_evidence',
92
+ 'plan_done',
93
+ 'plan_status',
94
+ 'comment_add'
95
+ ];
96
+
97
+ const GUI_ACTIONS = ['start', 'stop', 'status'];
98
+
99
+ const ACTION_EXAMPLES = {
100
+ kanban_manage: '{"action":"create","title":"Ship image","col":"planned"}',
101
+ kanban_gui: '{"action":"status"}'
102
+ };
103
+
104
+ function receivedKeys(args) {
105
+ return Object.keys(args && typeof args === 'object' ? args : {});
106
+ }
107
+
108
+ function actionRecipeMessage(toolName, allowed, opts = {}) {
109
+ const keys = opts.received_keys || [];
110
+ const example = ACTION_EXAMPLES[toolName] || `{"action":"${allowed[0]}"}`;
111
+ const lines = [];
112
+
113
+ if (opts.kind === 'unknown') {
114
+ lines.push(`Unknown action "${opts.action}" on ${toolName}.`);
115
+ } else {
116
+ lines.push(`Missing required top-level field "action" on ${toolName}.`);
117
+ }
118
+
119
+ lines.push('Pass action next to other args (not nested under params).');
120
+ lines.push(`Valid: ${allowed.join(', ')}.`);
121
+ lines.push(`Example: ${example}`);
122
+ lines.push(keys.length > 0 ? `You sent keys: ${keys.join(', ')}` : 'You sent keys: (none)');
123
+ return lines.join(' ');
124
+ }
125
+
126
+ function actionRecipeHint() {
127
+ return [
128
+ 'Retry the same tool with top-level action set to one Valid value.',
129
+ 'Common mistake: omitting action, or putting it under params (jira/gitlab style) — kanban uses top-level action.'
130
+ ].join(' ');
131
+ }
132
+
133
+ function requireToolAction(args, allowed, toolName) {
134
+ const action = args && args.action;
135
+ const keys = receivedKeys(args);
136
+ if (action === undefined || action === null || action === '') {
137
+ throw kanban.createKanbanError(
138
+ 'MISSING_REQUIRED_FIELD',
139
+ actionRecipeMessage(toolName, allowed, {
140
+ kind: 'missing',
141
+ received_keys: keys,
142
+ action: action === undefined ? null : action
143
+ }),
144
+ actionRecipeHint(),
145
+ {
146
+ field: 'action',
147
+ tool: toolName,
148
+ received_keys: keys,
149
+ allowed_actions: allowed,
150
+ action: action === undefined ? null : action
151
+ },
152
+ false,
153
+ 400
154
+ );
155
+ }
156
+ return action;
157
+ }
158
+
159
+ function unknownToolAction(action, args, allowed, toolName) {
160
+ const keys = receivedKeys(args);
161
+ return invalidRequest(
162
+ actionRecipeMessage(toolName, allowed, { kind: 'unknown', action, received_keys: keys }),
163
+ actionRecipeHint(),
164
+ {
165
+ field: 'action',
166
+ tool: toolName,
167
+ action,
168
+ received_keys: keys,
169
+ allowed_actions: allowed
170
+ }
171
+ );
172
+ }
173
+
79
174
  function serializeResult(result) {
80
175
  if (typeof result === 'string') return result;
81
176
 
@@ -146,30 +241,42 @@ function formatTaskResult(task, returnShape) {
146
241
  return kanban.shapeTask(task, { view: returnShape === 'full' ? 'full' : 'summary' });
147
242
  }
148
243
 
244
+ function guiIdentity(extra = {}) {
245
+ const cwd = extra.cwd || process.cwd();
246
+ return {
247
+ ...extra,
248
+ cwd,
249
+ project: extra.project || guiRegistry.projectLabel(cwd)
250
+ };
251
+ }
252
+
149
253
  async function startGuiServer(port) {
150
254
  if (port !== undefined && port !== null && port !== '' && !normalizePort(port)) {
151
255
  throw invalidRequest('Invalid port', 'Use an integer between 1 and 65535', { port });
152
256
  }
153
257
 
154
258
  if (ownsGuiProcess() && guiPort) {
155
- return {
259
+ return guiIdentity({
156
260
  status: 'already_running',
157
261
  owned: true,
158
262
  port: guiPort,
159
263
  pid: guiProcess.pid,
160
264
  url: `http://localhost:${guiPort}`
161
- };
265
+ });
162
266
  }
163
267
 
164
268
  const existing = await guiRegistry.discoverRunningGui();
165
269
  if (existing) {
166
- return {
270
+ return guiIdentity({
167
271
  status: 'already_running',
168
272
  owned: false,
169
273
  port: existing.port,
170
274
  pid: existing.pid,
171
- url: existing.url
172
- };
275
+ url: existing.url,
276
+ cwd: existing.cwd,
277
+ project: existing.project,
278
+ started_at: existing.started_at
279
+ });
173
280
  }
174
281
 
175
282
  const desiredPort = guiRegistry.resolvePreferredGuiPort(port);
@@ -194,19 +301,24 @@ async function startGuiServer(port) {
194
301
  const ready = await waitForGuiReady(childPid);
195
302
  guiPort = ready.port;
196
303
 
197
- return {
304
+ return guiIdentity({
198
305
  status: 'started',
199
306
  owned: true,
200
307
  port: ready.port,
201
308
  pid: ready.pid,
202
- url: ready.url
203
- };
309
+ url: ready.url,
310
+ cwd: ready.cwd,
311
+ project: ready.project,
312
+ started_at: ready.started_at
313
+ });
204
314
  }
205
315
 
206
316
  async function stopGuiServer() {
207
317
  if (ownsGuiProcess()) {
208
318
  const port = guiPort;
209
319
  const pid = guiProcess.pid;
320
+ const project = guiRegistry.projectLabel();
321
+ const cwd = process.cwd();
210
322
  guiProcess.kill();
211
323
 
212
324
  const deadline = Date.now() + 2000;
@@ -219,7 +331,7 @@ async function stopGuiServer() {
219
331
  await guiRegistry.clearGuiPortFile({ force: true });
220
332
  guiProcess = null;
221
333
  guiPort = null;
222
- return { status: 'stopping', owned: true, port, pid };
334
+ return { status: 'stopping', owned: true, port, pid, project, cwd };
223
335
  }
224
336
 
225
337
  guiProcess = null;
@@ -230,25 +342,28 @@ async function stopGuiServer() {
230
342
  return { status: 'not_running' };
231
343
  }
232
344
 
233
- return {
345
+ return guiIdentity({
234
346
  status: 'external_running',
235
347
  owned: false,
236
348
  port: discovered.port,
237
349
  pid: discovered.pid,
238
350
  url: discovered.url,
351
+ cwd: discovered.cwd,
352
+ project: discovered.project,
353
+ started_at: discovered.started_at,
239
354
  hint: 'GUI was not started by this MCP process; stop refused. Stop it from the owning terminal or kill that PID manually.'
240
- };
355
+ });
241
356
  }
242
357
 
243
358
  async function guiStatus() {
244
359
  if (ownsGuiProcess() && guiPort) {
245
- return {
360
+ return guiIdentity({
246
361
  status: 'running',
247
362
  owned: true,
248
363
  port: guiPort,
249
364
  pid: guiProcess.pid,
250
365
  url: `http://localhost:${guiPort}`
251
- };
366
+ });
252
367
  }
253
368
 
254
369
  guiProcess = null;
@@ -259,15 +374,16 @@ async function guiStatus() {
259
374
  return { status: 'not_running' };
260
375
  }
261
376
 
262
- return {
377
+ return guiIdentity({
263
378
  status: 'external_running',
264
379
  owned: false,
265
380
  port: discovered.port,
266
381
  pid: discovered.pid,
267
382
  url: discovered.url,
268
383
  cwd: discovered.cwd,
384
+ project: discovered.project,
269
385
  started_at: discovered.started_at
270
- };
386
+ });
271
387
  }
272
388
 
273
389
  const server = new Server(
@@ -314,6 +430,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
314
430
  type: 'string',
315
431
  description: 'Filter list by epic id or title'
316
432
  },
433
+ include_archived: {
434
+ type: 'boolean',
435
+ description: 'list/list_epics: include archived epics and their tasks (default false)'
436
+ },
437
+ include_done: {
438
+ type: 'boolean',
439
+ description: 'list_epics: include status=done epics without archived (default false)'
440
+ },
441
+ status: {
442
+ type: 'string',
443
+ enum: ['empty', 'planned', 'active', 'done', 'archived'],
444
+ description: 'list_epics: exact status filter (overrides live-only default)'
445
+ },
317
446
  view: {
318
447
  type: 'string',
319
448
  enum: READ_VIEWS,
@@ -336,19 +465,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
336
465
  properties: {
337
466
  action: {
338
467
  type: 'string',
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'
468
+ enum: MANAGE_ACTIONS,
469
+ description: 'create|move|update|delete daily; epic_*; plan_*; comment_add'
352
470
  },
353
471
  title: {
354
472
  type: 'string',
@@ -367,7 +485,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
367
485
  },
368
486
  epic_id: {
369
487
  type: 'string',
370
- description: 'Required for epic_update; optional filter/link id'
488
+ description: 'Required for epic_update|epic_archive|epic_unarchive|epic_delete; optional link id'
371
489
  },
372
490
  description: {
373
491
  type: 'string',
@@ -418,9 +536,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
418
536
  type: 'string',
419
537
  description: 'Freeform notes'
420
538
  },
539
+ text: {
540
+ type: 'string',
541
+ description: 'Required for comment_add. Non-empty comment body.'
542
+ },
543
+ author: {
544
+ type: 'string',
545
+ description: 'Optional comment author (default user). e.g. kocur-reviewer, worker'
546
+ },
421
547
  task_id: {
422
548
  type: 'string',
423
- description: "Required for move/update/plan_* except plan_create. '014' or '14'."
549
+ description: "Required for move/update/delete/comment_add/plan_* except plan_create. '014' or '14'."
424
550
  },
425
551
  column: {
426
552
  type: 'string',
@@ -434,7 +560,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
434
560
  return: {
435
561
  type: 'string',
436
562
  enum: ['none', 'summary', 'full'],
437
- description: 'move/update/epic_update response size. Prefer none. Default summary. create/epic_create return full once.'
563
+ description: 'move/update/delete/epic_* response size. Prefer none. Default summary. create/epic_create return full once.'
438
564
  },
439
565
  index: {
440
566
  type: 'integer',
@@ -482,7 +608,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
482
608
  properties: {
483
609
  action: {
484
610
  type: 'string',
485
- enum: ['start', 'stop', 'status'],
611
+ enum: GUI_ACTIONS,
486
612
  description: 'start | stop (owned only) | status'
487
613
  },
488
614
  port: {
@@ -517,13 +643,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
517
643
 
518
644
  if (operation === 'list') {
519
645
  await kanban.migrateEpicGroups();
646
+ const epics = await kanban.listEpicEntities();
520
647
  let tasks = await kanban.allTasks();
521
648
  if (args.col) {
522
649
  tasks = tasks.filter((task) => task.column === args.col);
523
650
  }
651
+ // Explicit epic filter bypasses live-only hide (agent asked for that initiative)
524
652
  if (args.epic || args.epic_id) {
525
653
  const filter = args.epic_id || args.epic;
526
654
  tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, filter));
655
+ } else {
656
+ tasks = kanban.filterTasksForList(tasks, epics, {
657
+ include_archived: args.include_archived,
658
+ include_done: args.include_done
659
+ });
527
660
  }
528
661
 
529
662
  result = tasks.map((task) => kanban.shapeTask(task, readOptions));
@@ -544,10 +677,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
544
677
  const epicView = args.view === 'full' || args.view === 'planning' || args.view === 'execution'
545
678
  ? (args.view === 'execution' ? 'planning' : args.view)
546
679
  : 'summary';
547
- result = epics.map((epic) => kanban.shapeEpic(epic, tasks, {
680
+ const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, {
548
681
  view: Array.isArray(args.fields) && args.fields.length > 0 ? undefined : epicView,
549
682
  fields: args.fields
550
683
  }));
684
+ result = kanban.filterShapedEpics(shaped, {
685
+ include_archived: args.include_archived,
686
+ include_done: args.include_done,
687
+ status: args.status
688
+ });
551
689
  } else if (operation === 'show_epic') {
552
690
  const epicRef = args.epic_id || args.epic;
553
691
  if (!epicRef) {
@@ -576,7 +714,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
576
714
  }
577
715
 
578
716
  case 'kanban_manage': {
579
- const action = args.action;
717
+ const action = requireToolAction(args, MANAGE_ACTIONS, 'kanban_manage');
580
718
  const returnShape = normalizeReturnShape(args.return);
581
719
 
582
720
  switch (action) {
@@ -638,6 +776,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
638
776
  if (args.in_scope !== undefined) epicPatch.in_scope = args.in_scope;
639
777
  if (args.out_of_scope !== undefined) epicPatch.out_of_scope = args.out_of_scope;
640
778
  if (args.notes !== undefined) epicPatch.notes = args.notes;
779
+ if (args.patch && args.patch.archived !== undefined) {
780
+ epicPatch.archived = args.patch.archived;
781
+ }
641
782
  const updatedEpic = await kanban.updateEpicEntity(epicId, epicPatch);
642
783
  if (returnShape === 'none') {
643
784
  result = { ok: true, epic_id: updatedEpic.id };
@@ -649,6 +790,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
649
790
  break;
650
791
  }
651
792
 
793
+ case 'epic_archive':
794
+ case 'epic_unarchive': {
795
+ const epicId = args.epic_id || args.epic;
796
+ if (!epicId) {
797
+ throw invalidRequest(
798
+ `epic_id is required for '${action}'`,
799
+ 'Provide an epic id like E001',
800
+ { action }
801
+ );
802
+ }
803
+ const toggled = action === 'epic_archive'
804
+ ? await kanban.archiveEpic(epicId)
805
+ : await kanban.unarchiveEpic(epicId);
806
+ if (returnShape === 'none') {
807
+ result = { ok: true, epic_id: toggled.id, archived: toggled.archived };
808
+ } else if (returnShape === 'summary') {
809
+ result = kanban.shapeEpic(toggled, await kanban.allTasks(), { view: 'summary' });
810
+ } else {
811
+ result = kanban.shapeEpic(toggled, await kanban.allTasks(), { view: 'full' });
812
+ }
813
+ break;
814
+ }
815
+
816
+ case 'epic_delete': {
817
+ const epicId = args.epic_id || args.epic;
818
+ if (!epicId) {
819
+ throw invalidRequest(
820
+ "epic_id is required for 'epic_delete'",
821
+ 'Provide an epic id like E001',
822
+ { action }
823
+ );
824
+ }
825
+ result = await kanban.deleteEpic(epicId);
826
+ break;
827
+ }
828
+
829
+ case 'delete': {
830
+ if (!args.task_id) {
831
+ throw invalidRequest(
832
+ "task_id is required for 'delete'",
833
+ 'Provide a task ID',
834
+ { action }
835
+ );
836
+ }
837
+ const deleted = await kanban.deleteTask(args.task_id);
838
+ result = returnShape === 'none'
839
+ ? { ok: true, task_id: deleted.task_id }
840
+ : deleted;
841
+ break;
842
+ }
843
+
652
844
  case 'move': {
653
845
  if (!args.task_id) {
654
846
  throw invalidRequest(
@@ -720,18 +912,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
720
912
  result = await plan.status(args.task_id);
721
913
  break;
722
914
 
915
+ case 'comment_add': {
916
+ if (!args.task_id) {
917
+ throw invalidRequest(
918
+ "task_id is required for 'comment_add'",
919
+ 'Provide a task ID',
920
+ { action }
921
+ );
922
+ }
923
+ if (!args.text) {
924
+ throw invalidRequest(
925
+ "text is required for 'comment_add'",
926
+ 'Provide a non-empty comment body',
927
+ { action }
928
+ );
929
+ }
930
+ const added = await kanban.addComment(args.task_id, args.text, args.author);
931
+ result = returnShape === 'none'
932
+ ? { ok: true, comment_id: added.comment.id }
933
+ : returnShape === 'full'
934
+ ? added
935
+ : { ok: true, task_id: added.task_id, comment_id: added.comment.id, count: added.comments.length };
936
+ break;
937
+ }
938
+
723
939
  default:
724
- throw invalidRequest(
725
- `Unknown action: ${action}`,
726
- 'Use create, move, update, epic_create, epic_update, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
727
- { action }
728
- );
940
+ throw unknownToolAction(action, args, MANAGE_ACTIONS, 'kanban_manage');
729
941
  }
730
942
  break;
731
943
  }
732
944
 
733
945
  case 'kanban_gui': {
734
- const action = args.action;
946
+ const action = requireToolAction(args, GUI_ACTIONS, 'kanban_gui');
735
947
 
736
948
  switch (action) {
737
949
  case 'start': {
@@ -747,11 +959,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
747
959
  break;
748
960
  }
749
961
  default:
750
- throw invalidRequest(
751
- `Unknown action: ${action}`,
752
- 'Use one of: start, stop, status',
753
- { action }
754
- );
962
+ throw unknownToolAction(action, args, GUI_ACTIONS, 'kanban_gui');
755
963
  }
756
964
  break;
757
965
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.1.0",
3
+ "version": "3.3.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
@@ -15,9 +15,12 @@ function runNode(scriptPath, args, label) {
15
15
 
16
16
  runNode(path.join('bin', 'kanban.js'), ['list', '--json'], 'CLI list');
17
17
  runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
18
+ runNode(path.join('tests', 'comments.test.js'), [], 'Comments test');
18
19
  runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
19
20
  runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
20
21
  runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
21
22
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
22
23
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
23
24
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
25
+ runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');
26
+ runNode(path.join('tests', 'race-conditions.test.js'), [], 'Race conditions test');