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/.ai/lessons.jsonl +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +18 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +9 -2
- package/agent-playbook.js +13 -5
- package/bin/kanban.js +131 -10
- package/gui-registry.js +14 -1
- package/index.html +645 -39
- package/kanban.js +157 -5
- package/mcp-server.js +125 -21
- package/package.json +1 -1
- package/tests/run.js +1 -0
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
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
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');
|