kanbango 3.1.0 → 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/kanban.js CHANGED
@@ -63,12 +63,13 @@ const VIEW_FIELDS = {
63
63
  };
64
64
 
65
65
  const EPIC_VIEW_FIELDS = {
66
- summary: ['id', 'title', 'created', 'status', 'progress'],
66
+ summary: ['id', 'title', 'created', 'status', 'archived', 'progress'],
67
67
  planning: [
68
68
  'id',
69
69
  'title',
70
70
  'created',
71
71
  'status',
72
+ 'archived',
72
73
  'progress',
73
74
  'description',
74
75
  'goals',
@@ -80,6 +81,7 @@ const EPIC_VIEW_FIELDS = {
80
81
  'title',
81
82
  'created',
82
83
  'status',
84
+ 'archived',
83
85
  'progress',
84
86
  'description',
85
87
  'goals',
@@ -90,6 +92,8 @@ const EPIC_VIEW_FIELDS = {
90
92
  ]
91
93
  };
92
94
 
95
+ const LIVE_EPIC_STATUSES = ['empty', 'planned', 'active'];
96
+
93
97
  // Hard-required on create: title only (keeps GUI/CLI quick-add usable).
94
98
  // Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
95
99
  const RECOMMENDED_CREATE_FIELDS = [
@@ -292,7 +296,8 @@ function normalizeEpic(epic) {
292
296
  goals: normalizeString(epic && epic.goals),
293
297
  in_scope: normalizeStringArray(epic && epic.in_scope),
294
298
  out_of_scope: normalizeStringArray(epic && epic.out_of_scope),
295
- notes: normalizeString(epic && epic.notes)
299
+ notes: normalizeString(epic && epic.notes),
300
+ archived: Boolean(epic && epic.archived)
296
301
  };
297
302
  }
298
303
 
@@ -306,17 +311,26 @@ function serializeEpic(epic) {
306
311
  goals: normalized.goals,
307
312
  in_scope: normalized.in_scope,
308
313
  out_of_scope: normalized.out_of_scope,
309
- notes: normalized.notes
314
+ notes: normalized.notes,
315
+ archived: normalized.archived
310
316
  };
311
317
  }
312
318
 
313
- function deriveEpicStatus(tasks) {
319
+ function deriveEpicStatus(tasks, epic) {
320
+ if (epic && epic.archived) return 'archived';
314
321
  if (!tasks || tasks.length === 0) return 'empty';
315
322
  if (tasks.some((task) => task.column === 'active')) return 'active';
316
323
  if (tasks.every((task) => task.column === 'done')) return 'done';
317
324
  return 'planned';
318
325
  }
319
326
 
327
+ function isLiveEpic(epicOrShaped) {
328
+ if (!epicOrShaped) return false;
329
+ if (epicOrShaped.archived) return false;
330
+ if (epicOrShaped.status) return LIVE_EPIC_STATUSES.includes(epicOrShaped.status);
331
+ return true;
332
+ }
333
+
320
334
  function getEpicProgress(tasks) {
321
335
  const progress = {
322
336
  tasks_total: tasks.length,
@@ -349,7 +363,7 @@ function shapeEpic(epic, tasks = [], options = {}) {
349
363
  const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
350
364
  const payload = {
351
365
  ...normalized,
352
- status: deriveEpicStatus(childTasks),
366
+ status: deriveEpicStatus(childTasks, normalized),
353
367
  progress: getEpicProgress(childTasks),
354
368
  tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
355
369
  };
@@ -359,6 +373,69 @@ function shapeEpic(epic, tasks = [], options = {}) {
359
373
  return pickEpicFields(payload, fields);
360
374
  }
361
375
 
376
+ function filterShapedEpics(shapedEpics, options = {}) {
377
+ const statusFilter = normalizeString(options.status).toLowerCase() || null;
378
+ if (statusFilter) {
379
+ return shapedEpics.filter((epic) => epic.status === statusFilter);
380
+ }
381
+
382
+ // live_only=false: human/GUI board — everything except archived unless include_archived
383
+ if (options.live_only === false) {
384
+ if (options.include_archived) return shapedEpics;
385
+ return shapedEpics.filter((epic) => !epic.archived);
386
+ }
387
+
388
+ // Default (agents): only empty|planned|active
389
+ const includeArchived = Boolean(options.include_archived);
390
+ const includeDone = Boolean(options.include_done);
391
+
392
+ return shapedEpics.filter((epic) => {
393
+ if (epic.archived) return includeArchived;
394
+ if (epic.status === 'done') return includeDone || includeArchived;
395
+ return isLiveEpic(epic);
396
+ });
397
+ }
398
+
399
+ function archivedEpicIdSet(epics) {
400
+ const set = new Set();
401
+ for (const epic of epics) {
402
+ if (epic.archived) set.add(epic.id);
403
+ }
404
+ return set;
405
+ }
406
+
407
+ function nonLiveEpicIdSet(epics, tasks, options = {}) {
408
+ const includeArchived = Boolean(options.include_archived);
409
+ const includeDone = Boolean(options.include_done);
410
+ if (includeArchived && includeDone) return new Set();
411
+
412
+ const hidden = new Set();
413
+ for (const epic of epics) {
414
+ const childTasks = tasks.filter((task) => task.epic_id === epic.id);
415
+ const status = deriveEpicStatus(childTasks, epic);
416
+ if (status === 'archived' && !includeArchived) hidden.add(epic.id);
417
+ else if (status === 'done' && !includeDone && !includeArchived) hidden.add(epic.id);
418
+ }
419
+ return hidden;
420
+ }
421
+
422
+ function filterTasksForList(tasks, epics, options = {}) {
423
+ // Explicit epic filter (show that epic's tasks) is applied by caller after this.
424
+ // Default agent list: hide tasks under done/archived epics.
425
+ if (options.include_archived && options.include_done) return tasks;
426
+ // GUI path: hide only archived-epic tasks (done epics still show done cards)
427
+ if (options.live_only === false) {
428
+ if (options.include_archived) return tasks;
429
+ const archivedIds = archivedEpicIdSet(epics);
430
+ if (archivedIds.size === 0) return tasks;
431
+ return tasks.filter((task) => !task.epic_id || !archivedIds.has(task.epic_id));
432
+ }
433
+
434
+ const hiddenIds = nonLiveEpicIdSet(epics, tasks, options);
435
+ if (hiddenIds.size === 0) return tasks;
436
+ return tasks.filter((task) => !task.epic_id || !hiddenIds.has(task.epic_id));
437
+ }
438
+
362
439
  function getProgress(task) {
363
440
  const total = task.subtasks.length;
364
441
  const done = task.subtasks.filter((subtask) => subtask.done).length;
@@ -771,6 +848,7 @@ async function updateEpicEntity(epicId, patch) {
771
848
  next.out_of_scope = normalizeStringArray(patch.out_of_scope);
772
849
  }
773
850
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
851
+ if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
774
852
 
775
853
  const saved = await writeEpic(next);
776
854
 
@@ -786,6 +864,72 @@ async function updateEpicEntity(epicId, patch) {
786
864
  return saved;
787
865
  }
788
866
 
867
+ async function archiveEpic(epicId) {
868
+ return updateEpicEntity(epicId, { archived: true });
869
+ }
870
+
871
+ async function unarchiveEpic(epicId) {
872
+ return updateEpicEntity(epicId, { archived: false });
873
+ }
874
+
875
+ async function deleteTask(taskId) {
876
+ const resolvedId = await resolveTaskId(taskId);
877
+ const filePath = await findFile(resolvedId);
878
+ if (!filePath) {
879
+ throw createKanbanError(
880
+ 'TASK_NOT_FOUND',
881
+ `Task ${taskId} was not found`,
882
+ 'Call kanban_read with operation=list to discover valid task ids',
883
+ { task_id: taskId },
884
+ false,
885
+ 404
886
+ );
887
+ }
888
+
889
+ const column = path.basename(path.dirname(filePath));
890
+ const task = await parseEpic(filePath, column);
891
+ await fs.unlink(filePath).catch((error) => {
892
+ if (error.code !== 'ENOENT') throw error;
893
+ });
894
+
895
+ return {
896
+ ok: true,
897
+ task_id: task.id,
898
+ task_number: task.task_number,
899
+ title: task.title,
900
+ column: task.column
901
+ };
902
+ }
903
+
904
+ async function deleteEpic(epicId) {
905
+ const epic = await getEpicEntity(epicId);
906
+ const tasks = await allTasks();
907
+ const children = tasks.filter((task) => task.epic_id === epic.id);
908
+ const deletedTasks = [];
909
+
910
+ for (const child of children) {
911
+ const result = await deleteTask(child.id);
912
+ deletedTasks.push({
913
+ task_id: result.task_id,
914
+ title: result.title,
915
+ column: result.column
916
+ });
917
+ }
918
+
919
+ const filePath = epicFilePath(epic.id);
920
+ await fs.unlink(filePath).catch((error) => {
921
+ if (error.code !== 'ENOENT') throw error;
922
+ });
923
+
924
+ return {
925
+ ok: true,
926
+ epic_id: epic.id,
927
+ title: epic.title,
928
+ deleted_tasks: deletedTasks,
929
+ deleted_task_count: deletedTasks.length
930
+ };
931
+ }
932
+
789
933
  async function migrateEpicGroups(options = {}) {
790
934
  await ensureBacklogDir();
791
935
  const tasks = await allTasks();
@@ -1280,10 +1424,17 @@ module.exports = {
1280
1424
  doCreate,
1281
1425
  doCreateEpic,
1282
1426
  updateEpicEntity,
1427
+ archiveEpic,
1428
+ unarchiveEpic,
1429
+ deleteTask,
1430
+ deleteEpic,
1283
1431
  getEpicEntity,
1284
1432
  listEpicEntities,
1285
1433
  resolveEpicRef,
1286
1434
  taskMatchesEpicFilter,
1435
+ filterShapedEpics,
1436
+ filterTasksForList,
1437
+ isLiveEpic,
1287
1438
  createKanbanError,
1288
1439
  createFieldWarnings,
1289
1440
  createEpicFieldWarnings,
@@ -1297,6 +1448,7 @@ module.exports = {
1297
1448
  STATUS_MAP,
1298
1449
  VIEW_FIELDS,
1299
1450
  EPIC_VIEW_FIELDS,
1451
+ LIVE_EPIC_STATUSES,
1300
1452
  RECOMMENDED_CREATE_FIELDS,
1301
1453
  RECOMMENDED_EPIC_CREATE_FIELDS,
1302
1454
  EPICS_DIR
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(
@@ -314,6 +335,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
314
335
  type: 'string',
315
336
  description: 'Filter list by epic id or title'
316
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)'
350
+ },
317
351
  view: {
318
352
  type: 'string',
319
353
  enum: READ_VIEWS,
@@ -340,15 +374,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
340
374
  'create',
341
375
  'move',
342
376
  'update',
377
+ 'delete',
343
378
  'epic_create',
344
379
  'epic_update',
380
+ 'epic_archive',
381
+ 'epic_unarchive',
382
+ 'epic_delete',
345
383
  'plan_create',
346
384
  'plan_advance',
347
385
  'plan_evidence',
348
386
  'plan_done',
349
387
  'plan_status'
350
388
  ],
351
- description: 'create|move|update daily; epic_create|epic_update for containers; plan_* for multi-step work'
389
+ description: 'create|move|update|delete daily; epic_create|epic_update|epic_archive|epic_unarchive|epic_delete; plan_* multi-step'
352
390
  },
353
391
  title: {
354
392
  type: 'string',
@@ -367,7 +405,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
367
405
  },
368
406
  epic_id: {
369
407
  type: 'string',
370
- description: 'Required for epic_update; optional filter/link id'
408
+ description: 'Required for epic_update|epic_archive|epic_unarchive|epic_delete; optional link id'
371
409
  },
372
410
  description: {
373
411
  type: 'string',
@@ -420,7 +458,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
420
458
  },
421
459
  task_id: {
422
460
  type: 'string',
423
- 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'."
424
462
  },
425
463
  column: {
426
464
  type: 'string',
@@ -434,7 +472,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
434
472
  return: {
435
473
  type: 'string',
436
474
  enum: ['none', 'summary', 'full'],
437
- description: 'move/update/epic_update response size. Prefer none. Default summary. create/epic_create return full once.'
475
+ description: 'move/update/delete/epic_* response size. Prefer none. Default summary. create/epic_create return full once.'
438
476
  },
439
477
  index: {
440
478
  type: 'integer',
@@ -517,13 +555,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
517
555
 
518
556
  if (operation === 'list') {
519
557
  await kanban.migrateEpicGroups();
558
+ const epics = await kanban.listEpicEntities();
520
559
  let tasks = await kanban.allTasks();
521
560
  if (args.col) {
522
561
  tasks = tasks.filter((task) => task.column === args.col);
523
562
  }
563
+ // Explicit epic filter bypasses live-only hide (agent asked for that initiative)
524
564
  if (args.epic || args.epic_id) {
525
565
  const filter = args.epic_id || args.epic;
526
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
+ });
527
572
  }
528
573
 
529
574
  result = tasks.map((task) => kanban.shapeTask(task, readOptions));
@@ -544,10 +589,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
544
589
  const epicView = args.view === 'full' || args.view === 'planning' || args.view === 'execution'
545
590
  ? (args.view === 'execution' ? 'planning' : args.view)
546
591
  : 'summary';
547
- result = epics.map((epic) => kanban.shapeEpic(epic, tasks, {
592
+ const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, {
548
593
  view: Array.isArray(args.fields) && args.fields.length > 0 ? undefined : epicView,
549
594
  fields: args.fields
550
595
  }));
596
+ result = kanban.filterShapedEpics(shaped, {
597
+ include_archived: args.include_archived,
598
+ include_done: args.include_done,
599
+ status: args.status
600
+ });
551
601
  } else if (operation === 'show_epic') {
552
602
  const epicRef = args.epic_id || args.epic;
553
603
  if (!epicRef) {
@@ -638,6 +688,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
638
688
  if (args.in_scope !== undefined) epicPatch.in_scope = args.in_scope;
639
689
  if (args.out_of_scope !== undefined) epicPatch.out_of_scope = args.out_of_scope;
640
690
  if (args.notes !== undefined) epicPatch.notes = args.notes;
691
+ if (args.patch && args.patch.archived !== undefined) {
692
+ epicPatch.archived = args.patch.archived;
693
+ }
641
694
  const updatedEpic = await kanban.updateEpicEntity(epicId, epicPatch);
642
695
  if (returnShape === 'none') {
643
696
  result = { ok: true, epic_id: updatedEpic.id };
@@ -649,6 +702,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
649
702
  break;
650
703
  }
651
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
+
652
756
  case 'move': {
653
757
  if (!args.task_id) {
654
758
  throw invalidRequest(
@@ -723,7 +827,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
723
827
  default:
724
828
  throw invalidRequest(
725
829
  `Unknown action: ${action}`,
726
- 'Use create, move, update, epic_create, epic_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',
727
831
  { action }
728
832
  );
729
833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.1.0",
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
@@ -21,3 +21,4 @@ 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
23
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
24
+ runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');