kanbango 3.0.2 → 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/AGENTS.md +3 -0
- package/CHANGELOG.md +18 -0
- package/README.md +18 -3
- package/agent-playbook.js +13 -5
- package/bin/kanban.js +163 -20
- package/index.html +61 -31
- package/kanban.js +525 -7
- package/mcp-server.js +119 -16
- package/package.json +1 -1
- package/tests/run.js +1 -0
package/AGENTS.md
CHANGED
|
@@ -6,8 +6,11 @@ This document provides build commands, testing procedures, and code style guidel
|
|
|
6
6
|
|
|
7
7
|
Source of truth: `agent-playbook.js` (also MCP tool descriptions + `kanban_read` `operation=help`).
|
|
8
8
|
|
|
9
|
+
- hierarchy: epic (context) → task (work+plan) → subtasks (steps)
|
|
9
10
|
- list: `col` filter, `view=summary`; keep `task_id`s; no full-board re-list after every write
|
|
11
|
+
- list_epics / show_epic for initiatives; filter tasks with `epic=E001`
|
|
10
12
|
- show: `view=execution` while coding; `full` only if needed
|
|
13
|
+
- epic_create with description/goals; create tasks with `epic=E001` (prefer id)
|
|
11
14
|
- create once with `description`, `specs`, `in_scope`, `out_of_scope`, `acceptance_criteria`
|
|
12
15
|
- move/update: `return=none`; subtasks = full array replace (no toggle)
|
|
13
16
|
- non-trivial work: `plan_create` → `plan_advance` → `plan_evidence` (real tests, truncated logs) → `plan_done`
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to kanbango will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.1.0] - 2026-07-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- First-class epic containers under `backlog/epics/E00N.json` (description, goals, in/out scope, notes)
|
|
12
|
+
- Task field `epic_id` with denormalized `epic_group` title for lists/GUI
|
|
13
|
+
- Derived epic status/progress from child task columns (no epic board column)
|
|
14
|
+
- MCP: `list_epics`, `show_epic`, `epic_create`, `epic_update`
|
|
15
|
+
- CLI: `kanban epic list|show|add|update`
|
|
16
|
+
- HTTP: `GET/POST /api/epics` for real epics; `POST /api/tasks` for tasks (legacy `/api/epics` + `column` still creates tasks)
|
|
17
|
+
- Auto-migration of legacy `epic_group` string labels into epic entities
|
|
18
|
+
- GUI swimlanes group by `epic_id` and show epic context
|
|
19
|
+
- Regression tests in `tests/epics.test.js`
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Hierarchy documented for agents: epic → task → subtasks
|
|
23
|
+
- Task summary view includes `epic_id`
|
|
24
|
+
- Agent playbook includes epic discovery/create rules
|
|
25
|
+
|
|
8
26
|
## [3.0.2] - 2026-07-29
|
|
9
27
|
|
|
10
28
|
### Added
|
package/README.md
CHANGED
|
@@ -31,8 +31,13 @@ kanban serve
|
|
|
31
31
|
# List all tasks
|
|
32
32
|
kanban list --json
|
|
33
33
|
|
|
34
|
-
# Add a task
|
|
35
|
-
kanban add "
|
|
34
|
+
# Add an epic (initiative container), then a task under it
|
|
35
|
+
kanban epic add "Phase 1" --description "Why this initiative" --goals "Ship X"
|
|
36
|
+
kanban add "My task" --col planned --epic E001
|
|
37
|
+
|
|
38
|
+
# List epics / show epic with child rollup
|
|
39
|
+
kanban epic list --json
|
|
40
|
+
kanban epic show E001
|
|
36
41
|
|
|
37
42
|
# Show details
|
|
38
43
|
kanban show 001
|
|
@@ -44,6 +49,16 @@ kanban move 001 active
|
|
|
44
49
|
kanban update 001 '{"subtasks":[{"done":true,"text":"Research"},{"done":false,"text":"Implementation"}]}'
|
|
45
50
|
```
|
|
46
51
|
|
|
52
|
+
### Hierarchy
|
|
53
|
+
|
|
54
|
+
| Level | What | Storage |
|
|
55
|
+
|-------|------|---------|
|
|
56
|
+
| **Epic** | Initiative context (description, goals) | `backlog/epics/E001.json` |
|
|
57
|
+
| **Task** | Kanban card (column, AC, plan) | `backlog/{col}/001.json` |
|
|
58
|
+
| **Subtask** | Checklist / plan steps | `subtasks[]` on task |
|
|
59
|
+
|
|
60
|
+
Epic status is **derived** from child task columns (not a board column).
|
|
61
|
+
|
|
47
62
|
### Columns
|
|
48
63
|
|
|
49
64
|
| Column | Purpose |
|
|
@@ -163,7 +178,7 @@ kanban serve
|
|
|
163
178
|
|
|
164
179
|
```js
|
|
165
180
|
const kanban = require('kanbango');
|
|
166
|
-
const tasks = await kanban.
|
|
181
|
+
const tasks = await kanban.allTasks();
|
|
167
182
|
```
|
|
168
183
|
|
|
169
184
|
## Requirements
|
package/agent-playbook.js
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
const DROP_IN_RULE = [
|
|
8
8
|
'Kanbango MCP — token rules:',
|
|
9
|
+
'- hierarchy: epic (context) → task (work+plan) → subtasks (steps)',
|
|
9
10
|
'- list: col filter, view=summary; keep task_ids; no full-board re-list after writes',
|
|
11
|
+
'- list_epics / show_epic for initiatives; list tasks with epic=E001 for work',
|
|
10
12
|
'- show: view=execution while coding; full only if needed',
|
|
13
|
+
'- epic_create with description/goals; create tasks with epic=E001 (prefer id)',
|
|
11
14
|
'- create once with description,specs,in_scope,out_of_scope,acceptance_criteria',
|
|
12
15
|
'- move/update: return=none; subtasks=full array replace',
|
|
13
16
|
'- non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done',
|
|
@@ -17,21 +20,25 @@ const DROP_IN_RULE = [
|
|
|
17
20
|
const TOOL_DESCRIPTIONS = {
|
|
18
21
|
kanban_read: [
|
|
19
22
|
'Read board. TOKEN RULES: list defaults to view=summary (id/title/col/progress only).',
|
|
23
|
+
'Hierarchy: epic (container/context) → task (work) → subtasks (steps).',
|
|
20
24
|
'Always pass col when possible. Prefer show+view=execution over full.',
|
|
25
|
+
'list_epics/show_epic for initiatives; filter list with epic=E001 or epic title.',
|
|
21
26
|
'Do not re-list the whole board after every write — keep task_id from create/move.',
|
|
22
|
-
'IDs
|
|
27
|
+
'Task IDs numeric ("014"); epic IDs "E001". views: summary|planning|execution|full; fields[] overrides view.',
|
|
23
28
|
'operation=help returns this playbook as short text (no board I/O).'
|
|
24
29
|
].join(' '),
|
|
25
30
|
|
|
26
31
|
kanban_manage: [
|
|
27
32
|
'Write board / plan. TOKEN RULES: one create with all planning fields beats many updates;',
|
|
28
33
|
'after write use return=none (or summary). Do not dump full task unless needed.',
|
|
29
|
-
'Actions: create|move|update (daily);
|
|
34
|
+
'Actions: create|move|update (daily); epic_create|epic_update (containers);',
|
|
35
|
+
'plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
|
|
36
|
+
'epic_create: title + description/goals/in_scope/out_of_scope. Link tasks via epic=E001.',
|
|
30
37
|
'create/plan_create: title required; also send description,specs,in_scope,out_of_scope,acceptance_criteria',
|
|
31
38
|
'(missing → warnings, not failure). move: task_id+column. update: task_id + fields or subtasks[] full list',
|
|
32
39
|
'(no toggle). plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
|
|
33
|
-
'Example create: {"action":"create","title":"Ship image","
|
|
34
|
-
'"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned"}'
|
|
40
|
+
'Example create: {"action":"create","title":"Ship image","epic":"E001","description":"...","specs":"...",',
|
|
41
|
+
'"in_scope":["CLI"],"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned"}'
|
|
35
42
|
].join(' '),
|
|
36
43
|
|
|
37
44
|
kanban_gui: [
|
|
@@ -47,7 +54,8 @@ const MUST_CONTAIN = [
|
|
|
47
54
|
'return=none',
|
|
48
55
|
'plan_create',
|
|
49
56
|
'external_running',
|
|
50
|
-
'subtasks'
|
|
57
|
+
'subtasks',
|
|
58
|
+
'epic_create'
|
|
51
59
|
];
|
|
52
60
|
|
|
53
61
|
function playbookHelpPayload() {
|
package/bin/kanban.js
CHANGED
|
@@ -99,7 +99,8 @@ async function cliInit() {
|
|
|
99
99
|
+ '- `active/` — w trakcie (max 1-2)\n'
|
|
100
100
|
+ '- `planned/` — zaplanowane\n'
|
|
101
101
|
+ '- `icebox/` — zamrozone / nice-to-have\n'
|
|
102
|
-
+ '- `done/` — ukonczone\n'
|
|
102
|
+
+ '- `done/` — ukonczone\n'
|
|
103
|
+
+ '- `epics/` — first-class epic containers (context for initiatives)\n',
|
|
103
104
|
'utf-8'
|
|
104
105
|
);
|
|
105
106
|
}
|
|
@@ -145,13 +146,14 @@ async function cliMcpInit(options) {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
async function cliList(colFilter, epicFilter, asJson) {
|
|
148
|
-
|
|
149
|
+
await kanban.migrateEpicGroups();
|
|
150
|
+
let tasks = await kanban.allTasks();
|
|
149
151
|
|
|
150
152
|
if (colFilter) {
|
|
151
153
|
tasks = tasks.filter((task) => task.column === colFilter);
|
|
152
154
|
}
|
|
153
155
|
if (epicFilter) {
|
|
154
|
-
tasks = tasks.filter((task) => task
|
|
156
|
+
tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, epicFilter));
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
if (asJson) {
|
|
@@ -168,8 +170,11 @@ async function cliList(colFilter, epicFilter, asJson) {
|
|
|
168
170
|
const progress = kanban.getProgress(task);
|
|
169
171
|
const prog = progress.total ? `${progress.done}/${progress.total}` : '—';
|
|
170
172
|
const title = displayTitle(task).substring(0, 42);
|
|
173
|
+
const epicLabel = task.epic_id
|
|
174
|
+
? `${task.epic_id}:${task.epic_group}`
|
|
175
|
+
: task.epic_group;
|
|
171
176
|
console.log(
|
|
172
|
-
` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${
|
|
177
|
+
` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${epicLabel}]`
|
|
173
178
|
);
|
|
174
179
|
}
|
|
175
180
|
}
|
|
@@ -181,7 +186,7 @@ async function cliShow(taskId) {
|
|
|
181
186
|
console.log(`Plik: ${taskFilePath(task)}`);
|
|
182
187
|
console.log(`Tytuł: ${displayTitle(task)}`);
|
|
183
188
|
console.log(`Kolumna: ${task.column}`);
|
|
184
|
-
console.log(`Epik: ${task.epic_group}`);
|
|
189
|
+
console.log(`Epik: ${task.epic_id ? `${task.epic_id} (${task.epic_group})` : task.epic_group}`);
|
|
185
190
|
console.log(`Worzono: ${task.created || '—'}`);
|
|
186
191
|
|
|
187
192
|
if (task.description) {
|
|
@@ -202,6 +207,64 @@ async function cliShow(taskId) {
|
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
async function cliEpicList(asJson) {
|
|
211
|
+
await kanban.migrateEpicGroups();
|
|
212
|
+
const tasks = await kanban.allTasks();
|
|
213
|
+
const epics = await kanban.listEpicEntities();
|
|
214
|
+
const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'summary' }));
|
|
215
|
+
|
|
216
|
+
if (asJson) {
|
|
217
|
+
console.log(JSON.stringify(shaped, null, 2));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (shaped.length === 0) {
|
|
222
|
+
console.log('(brak epików)');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const epic of shaped) {
|
|
227
|
+
const p = epic.progress || {};
|
|
228
|
+
console.log(
|
|
229
|
+
` ${epic.id.padEnd(6)} ${epic.status.padEnd(8)} ${String(epic.title).substring(0, 40).padEnd(40)} ${p.tasks_done || 0}/${p.tasks_total || 0} tasks`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function cliEpicShow(epicId) {
|
|
235
|
+
try {
|
|
236
|
+
await kanban.migrateEpicGroups();
|
|
237
|
+
const link = await kanban.resolveEpicRef(epicId, { createIfMissing: false });
|
|
238
|
+
const epic = await kanban.getEpicEntity(link.epic_id);
|
|
239
|
+
const tasks = await kanban.allTasks();
|
|
240
|
+
const shaped = kanban.shapeEpic(epic, tasks, { view: 'full' });
|
|
241
|
+
console.log(JSON.stringify(shaped, null, 2));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error(`✗ ${error.message}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function cliEpicAdd(title, options = {}) {
|
|
249
|
+
try {
|
|
250
|
+
const epic = await kanban.doCreateEpic(title, options);
|
|
251
|
+
console.log(`✓ Epic ${epic.id}: ${epic.title}`);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error(`✗ ${error.message}`);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function cliEpicUpdate(epicId, patch) {
|
|
259
|
+
try {
|
|
260
|
+
const epic = await kanban.updateEpicEntity(epicId, patch);
|
|
261
|
+
console.log(`✓ Epic ${epic.id} updated`);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error(`✗ ${error.message}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
205
268
|
async function cliMove(taskId, column) {
|
|
206
269
|
const success = await kanban.doMove(taskId, column);
|
|
207
270
|
if (success) {
|
|
@@ -238,29 +301,87 @@ async function serveWeb(port) {
|
|
|
238
301
|
}
|
|
239
302
|
|
|
240
303
|
if (requestPath === '/api/board') {
|
|
241
|
-
|
|
304
|
+
await kanban.migrateEpicGroups();
|
|
305
|
+
const tasks = await kanban.allTasks();
|
|
242
306
|
sendJson(res, 200, tasks);
|
|
243
307
|
return;
|
|
244
308
|
}
|
|
245
309
|
|
|
310
|
+
if (requestPath === '/api/epics' && req.method === 'GET') {
|
|
311
|
+
await kanban.migrateEpicGroups();
|
|
312
|
+
const tasks = await kanban.allTasks();
|
|
313
|
+
const epics = await kanban.listEpicEntities();
|
|
314
|
+
sendJson(res, 200, epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' })));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
246
318
|
if (requestPath === '/api/epics' && req.method === 'POST') {
|
|
247
319
|
const body = await readBody(req);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
// Real epic container when no task column is provided.
|
|
321
|
+
if (body.column === undefined && body.as_task !== true) {
|
|
322
|
+
const epic = await kanban.doCreateEpic(body.title || '', {
|
|
323
|
+
description: body.description,
|
|
324
|
+
goals: body.goals,
|
|
325
|
+
in_scope: body.in_scope,
|
|
326
|
+
out_of_scope: body.out_of_scope,
|
|
327
|
+
notes: body.notes
|
|
328
|
+
});
|
|
329
|
+
sendJson(res, 201, kanban.shapeEpic(epic, [], { view: 'full' }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const task = await kanban.doCreate(
|
|
334
|
+
body.title || '',
|
|
335
|
+
body.column || 'planned',
|
|
336
|
+
body.epic_id || body.epic || body.epic_group || '—',
|
|
337
|
+
{
|
|
338
|
+
description: body.description,
|
|
339
|
+
specs: body.specs,
|
|
340
|
+
in_scope: body.in_scope,
|
|
341
|
+
out_of_scope: body.out_of_scope,
|
|
342
|
+
acceptance_criteria: body.acceptance_criteria,
|
|
343
|
+
test_cases: body.test_cases,
|
|
344
|
+
subtasks: body.subtasks,
|
|
345
|
+
notes: body.notes
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
sendJson(res, 201, task);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (requestPath === '/api/tasks' && req.method === 'POST') {
|
|
353
|
+
const body = await readBody(req);
|
|
354
|
+
const task = await kanban.doCreate(
|
|
355
|
+
body.title || '',
|
|
356
|
+
body.column || 'planned',
|
|
357
|
+
body.epic_id || body.epic || body.epic_group || '—',
|
|
358
|
+
{
|
|
359
|
+
description: body.description,
|
|
360
|
+
specs: body.specs,
|
|
361
|
+
in_scope: body.in_scope,
|
|
362
|
+
out_of_scope: body.out_of_scope,
|
|
363
|
+
acceptance_criteria: body.acceptance_criteria,
|
|
364
|
+
test_cases: body.test_cases,
|
|
365
|
+
subtasks: body.subtasks,
|
|
366
|
+
notes: body.notes
|
|
367
|
+
}
|
|
368
|
+
);
|
|
258
369
|
sendJson(res, 201, task);
|
|
259
370
|
return;
|
|
260
371
|
}
|
|
261
372
|
|
|
262
373
|
if (req.method === 'PATCH') {
|
|
263
|
-
const
|
|
374
|
+
const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
|
|
375
|
+
if (epicUpdateMatch) {
|
|
376
|
+
const body = await readBody(req);
|
|
377
|
+
const patch = body.patch ? { ...body.patch } : { ...body };
|
|
378
|
+
delete patch.patch;
|
|
379
|
+
const epic = await kanban.updateEpicEntity(epicUpdateMatch[1], patch);
|
|
380
|
+
sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const moveMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/move$/);
|
|
264
385
|
if (moveMatch) {
|
|
265
386
|
const taskId = moveMatch[1];
|
|
266
387
|
const body = await readBody(req);
|
|
@@ -269,7 +390,7 @@ async function serveWeb(port) {
|
|
|
269
390
|
return;
|
|
270
391
|
}
|
|
271
392
|
|
|
272
|
-
const toggleMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/tasks\/(\d+)$/);
|
|
393
|
+
const toggleMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/tasks\/(\d+)$/);
|
|
273
394
|
if (toggleMatch) {
|
|
274
395
|
const taskId = toggleMatch[1];
|
|
275
396
|
const idx = parseInt(toggleMatch[2], 10);
|
|
@@ -294,7 +415,7 @@ async function serveWeb(port) {
|
|
|
294
415
|
return;
|
|
295
416
|
}
|
|
296
417
|
|
|
297
|
-
const updateMatch = requestPath.match(/^\/api\/epics\/([^/]+)$/);
|
|
418
|
+
const updateMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
|
|
298
419
|
if (updateMatch) {
|
|
299
420
|
const taskId = updateMatch[1];
|
|
300
421
|
const body = await readBody(req);
|
|
@@ -309,7 +430,9 @@ async function serveWeb(port) {
|
|
|
309
430
|
if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
|
|
310
431
|
if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
|
|
311
432
|
if (body.notes !== undefined) patch.notes = body.notes;
|
|
312
|
-
if (body.
|
|
433
|
+
if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
|
|
434
|
+
else if (body.epic !== undefined) patch.epic = body.epic;
|
|
435
|
+
else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
|
|
313
436
|
|
|
314
437
|
const task = await kanban.updateTask(taskId, patch);
|
|
315
438
|
sendJson(res, 200, task);
|
|
@@ -506,6 +629,26 @@ async function main() {
|
|
|
506
629
|
}
|
|
507
630
|
|
|
508
631
|
await cliList(colFilter, epicFilter, asJson);
|
|
632
|
+
} else if (cmd === 'epic') {
|
|
633
|
+
const sub = args[1];
|
|
634
|
+
if (sub === 'list') {
|
|
635
|
+
await cliEpicList(args.includes('--json'));
|
|
636
|
+
} else if (sub === 'show' && args[2]) {
|
|
637
|
+
await cliEpicShow(args[2]);
|
|
638
|
+
} else if (sub === 'add' && args[2]) {
|
|
639
|
+
let description = '';
|
|
640
|
+
let goals = '';
|
|
641
|
+
for (let i = 3; i < args.length; i++) {
|
|
642
|
+
if (args[i] === '--description' && args[i + 1]) description = args[++i];
|
|
643
|
+
else if (args[i] === '--goals' && args[i + 1]) goals = args[++i];
|
|
644
|
+
}
|
|
645
|
+
await cliEpicAdd(args[2], { description, goals });
|
|
646
|
+
} else if (sub === 'update' && args[2] && args[3]) {
|
|
647
|
+
await cliEpicUpdate(args[2], parseJsonPayload(args[3]));
|
|
648
|
+
} else {
|
|
649
|
+
console.error('Usage: kanban epic list|show|add|update ...');
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
509
652
|
} else if (cmd === 'show' && args[1]) {
|
|
510
653
|
await cliShow(args[1]);
|
|
511
654
|
} else if (cmd === 'move' && args[1] && args[2]) {
|
package/index.html
CHANGED
|
@@ -529,29 +529,34 @@ const COL_DEFS = [
|
|
|
529
529
|
{ id: "done", label: "✅ Done", icon: "✅" },
|
|
530
530
|
];
|
|
531
531
|
|
|
532
|
-
let
|
|
532
|
+
let allTasks = [];
|
|
533
|
+
let allEpicEntities = [];
|
|
533
534
|
let colCounts = {};
|
|
534
535
|
let collapsed = new Set();
|
|
535
536
|
let expanded = new Set();
|
|
536
537
|
let editingId = null;
|
|
537
|
-
let dirtyEdits = {}; //
|
|
538
|
+
let dirtyEdits = {}; // taskId -> editable task draft
|
|
538
539
|
|
|
539
540
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
540
541
|
|
|
541
542
|
async function load() {
|
|
542
|
-
const
|
|
543
|
-
|
|
543
|
+
const [boardRes, epicsRes] = await Promise.all([
|
|
544
|
+
fetch("/api/board"),
|
|
545
|
+
fetch("/api/epics")
|
|
546
|
+
]);
|
|
547
|
+
allTasks = await boardRes.json();
|
|
548
|
+
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
544
549
|
render();
|
|
545
550
|
}
|
|
546
551
|
|
|
547
552
|
function render() {
|
|
548
553
|
colCounts = {};
|
|
549
554
|
COL_DEFS.forEach(c => (colCounts[c.id] = 0));
|
|
550
|
-
|
|
555
|
+
allTasks.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
|
|
551
556
|
|
|
552
557
|
// Header stats
|
|
553
|
-
const total =
|
|
554
|
-
const done =
|
|
558
|
+
const total = allTasks.length;
|
|
559
|
+
const done = allTasks.filter(e => e.column === "done").length;
|
|
555
560
|
document.getElementById("hstats").textContent = `${done}/${total} done`;
|
|
556
561
|
|
|
557
562
|
// Col headers
|
|
@@ -567,59 +572,82 @@ function render() {
|
|
|
567
572
|
// Swimlanes
|
|
568
573
|
const sl = document.getElementById("swimlanes");
|
|
569
574
|
sl.innerHTML = "";
|
|
570
|
-
|
|
575
|
+
groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
|
|
571
576
|
}
|
|
572
577
|
|
|
573
|
-
function
|
|
578
|
+
function groupTasks(tasks) {
|
|
574
579
|
const map = new Map();
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
if (!map.has(
|
|
578
|
-
map.get(
|
|
580
|
+
tasks.forEach(t => {
|
|
581
|
+
const key = t.epic_id || "—";
|
|
582
|
+
if (!map.has(key)) map.set(key, []);
|
|
583
|
+
map.get(key).push(t);
|
|
579
584
|
});
|
|
580
|
-
// Sort: known phases first, "—" last
|
|
581
585
|
return [...map.entries()]
|
|
582
586
|
.sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
|
|
583
|
-
.map(([
|
|
587
|
+
.map(([key, laneTasks]) => {
|
|
588
|
+
const entity = allEpicEntities.find(e => e.id === key);
|
|
589
|
+
const name = key === "—"
|
|
590
|
+
? "—"
|
|
591
|
+
: (entity ? entity.title : (laneTasks[0] && laneTasks[0].epic_group) || key);
|
|
592
|
+
return {
|
|
593
|
+
key,
|
|
594
|
+
name,
|
|
595
|
+
epicId: key === "—" ? null : key,
|
|
596
|
+
status: entity ? entity.status : null,
|
|
597
|
+
description: entity ? entity.description : "",
|
|
598
|
+
goals: entity ? entity.goals : "",
|
|
599
|
+
tasks: laneTasks
|
|
600
|
+
};
|
|
601
|
+
});
|
|
584
602
|
}
|
|
585
603
|
|
|
586
604
|
// ── Swimlane ──────────────────────────────────────────────────────────────────
|
|
587
605
|
|
|
588
|
-
function renderSwimlane({ name,
|
|
589
|
-
const isCollapsed = collapsed.has(
|
|
606
|
+
function renderSwimlane({ key, name, epicId, status, description, goals, tasks }) {
|
|
607
|
+
const isCollapsed = collapsed.has(key);
|
|
590
608
|
|
|
591
609
|
const wrap = el("div", "swimlane");
|
|
592
610
|
|
|
593
611
|
// Header
|
|
594
612
|
const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
|
|
595
|
-
const totalTasks =
|
|
596
|
-
const doneTasks =
|
|
613
|
+
const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
|
|
614
|
+
const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
|
|
597
615
|
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
616
|
+
const cardsDone = tasks.filter(t => t.column === "done").length;
|
|
617
|
+
const statusBadge = status ? `<span class="sl-cnt" title="derived status">${escHtml(status)}</span>` : "";
|
|
598
618
|
|
|
599
619
|
hdr.innerHTML = `
|
|
600
620
|
<span class="sl-arrow">▼</span>
|
|
601
|
-
<span class="sl-name">${escHtml(name === "—" ? "Uncategorized (no epic)" : name)}</span>
|
|
602
|
-
<span class="sl-cnt">${
|
|
603
|
-
${
|
|
621
|
+
<span class="sl-name">${escHtml(name === "—" ? "Uncategorized (no epic)" : name)}${epicId ? ` <small style="opacity:.6">${escHtml(epicId)}</small>` : ""}</span>
|
|
622
|
+
<span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
|
|
623
|
+
${statusBadge}
|
|
624
|
+
${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% subtasks</span>` : ""}
|
|
604
625
|
`;
|
|
605
626
|
hdr.onclick = () => {
|
|
606
|
-
if (collapsed.has(
|
|
607
|
-
else collapsed.add(
|
|
627
|
+
if (collapsed.has(key)) collapsed.delete(key);
|
|
628
|
+
else collapsed.add(key);
|
|
608
629
|
render();
|
|
609
630
|
};
|
|
610
631
|
wrap.appendChild(hdr);
|
|
611
632
|
|
|
633
|
+
if (!isCollapsed && (description || goals)) {
|
|
634
|
+
const meta = el("div", "sl-body");
|
|
635
|
+
meta.style.cssText = "display:block;padding:6px 12px 0;font-size:12px;opacity:.85";
|
|
636
|
+
meta.innerHTML = `${goals ? `<div><strong>Goals:</strong> ${escHtml(goals)}</div>` : ""}${description ? `<div>${escHtml(description)}</div>` : ""}`;
|
|
637
|
+
wrap.appendChild(meta);
|
|
638
|
+
}
|
|
639
|
+
|
|
612
640
|
// Body
|
|
613
641
|
const body = el("div", `sl-body${isCollapsed ? " hidden" : ""}`);
|
|
614
642
|
COL_DEFS.forEach(col => {
|
|
615
643
|
const lane = el("div", `lane col-${col.id}`);
|
|
616
|
-
|
|
644
|
+
tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
|
|
617
645
|
// Add button only in non-done columns
|
|
618
646
|
if (col.id !== "done") {
|
|
619
647
|
const addBtn = document.createElement("button");
|
|
620
648
|
addBtn.className = "lane-add";
|
|
621
649
|
addBtn.textContent = "+ Add here";
|
|
622
|
-
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, name, addBtn); };
|
|
650
|
+
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, epicId || name, addBtn); };
|
|
623
651
|
lane.appendChild(addBtn);
|
|
624
652
|
}
|
|
625
653
|
body.appendChild(lane);
|
|
@@ -931,7 +959,7 @@ async function saveEdit(epicId) {
|
|
|
931
959
|
if (!dirty) { editingId = null; render(); return; }
|
|
932
960
|
|
|
933
961
|
const body = {};
|
|
934
|
-
const epic =
|
|
962
|
+
const epic = allTasks.find(e => e.id === epicId);
|
|
935
963
|
if (epic) {
|
|
936
964
|
const origTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
937
965
|
if (dirty.title !== origTitle) body.title = dirty.title;
|
|
@@ -1007,13 +1035,15 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1007
1035
|
if (!title) return;
|
|
1008
1036
|
inp.disabled = true;
|
|
1009
1037
|
description.disabled = true;
|
|
1010
|
-
const
|
|
1038
|
+
const epicRef = groupName === "—" ? "" : groupName;
|
|
1039
|
+
const res = await fetch("/api/tasks", {
|
|
1011
1040
|
method: "POST",
|
|
1012
1041
|
headers: { "Content-Type": "application/json" },
|
|
1013
1042
|
body: JSON.stringify({
|
|
1014
1043
|
title,
|
|
1015
1044
|
column: colId,
|
|
1016
|
-
|
|
1045
|
+
epic: epicRef,
|
|
1046
|
+
epic_id: epicRef && /^E\d+/i.test(epicRef) ? epicRef : undefined,
|
|
1017
1047
|
description: description.value.trim()
|
|
1018
1048
|
}),
|
|
1019
1049
|
});
|
|
@@ -1058,10 +1088,10 @@ async function submitNew() {
|
|
|
1058
1088
|
const group = document.getElementById("ne-group").value.trim();
|
|
1059
1089
|
if (!title) { document.getElementById("ne-title").focus(); return; }
|
|
1060
1090
|
|
|
1061
|
-
const res = await fetch("/api/
|
|
1091
|
+
const res = await fetch("/api/tasks", {
|
|
1062
1092
|
method: "POST",
|
|
1063
1093
|
headers: { "Content-Type": "application/json" },
|
|
1064
|
-
body: JSON.stringify({ title, column: col,
|
|
1094
|
+
body: JSON.stringify({ title, column: col, epic: group || "—" }),
|
|
1065
1095
|
});
|
|
1066
1096
|
if (res.ok) {
|
|
1067
1097
|
toast("Task added ✓");
|