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 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 "My task" --col planned --epic "Phase 1"
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.allEpics();
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 are numeric ("014" or "14"). views: summary|planning|execution|full; fields[] overrides view.',
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); plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
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","description":"...","specs":"...","in_scope":["CLI"],',
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
- let tasks = await kanban.allEpics();
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.epic_group === epicFilter);
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)} [${task.epic_group}]`
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
- const tasks = await kanban.allEpics();
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
- const task = await kanban.doCreate(body.title || '', body.column || 'planned', body.epic_group || '—', {
249
- description: body.description,
250
- specs: body.specs,
251
- in_scope: body.in_scope,
252
- out_of_scope: body.out_of_scope,
253
- acceptance_criteria: body.acceptance_criteria,
254
- test_cases: body.test_cases,
255
- subtasks: body.subtasks,
256
- notes: body.notes
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 moveMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/move$/);
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.epic_group !== undefined) patch.epic_group = body.epic_group;
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 allEpics = [];
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 = {}; // epicId -> editable task draft
538
+ let dirtyEdits = {}; // taskId -> editable task draft
538
539
 
539
540
  // ── Load & render ─────────────────────────────────────────────────────────────
540
541
 
541
542
  async function load() {
542
- const res = await fetch("/api/board");
543
- allEpics = await res.json();
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
- allEpics.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
555
+ allTasks.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
551
556
 
552
557
  // Header stats
553
- const total = allEpics.length;
554
- const done = allEpics.filter(e => e.column === "done").length;
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
- groupEpics(allEpics).forEach(g => sl.appendChild(renderSwimlane(g)));
575
+ groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
571
576
  }
572
577
 
573
- function groupEpics(epics) {
578
+ function groupTasks(tasks) {
574
579
  const map = new Map();
575
- epics.forEach(e => {
576
- const g = e.epic_group || "—";
577
- if (!map.has(g)) map.set(g, []);
578
- map.get(g).push(e);
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(([name, epics]) => ({ name, epics }));
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, epics }) {
589
- const isCollapsed = collapsed.has(name);
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 = epics.reduce((s, e) => s + e.subtasks.length, 0);
596
- const doneTasks = epics.reduce((s, e) => s + e.subtasks.filter(t => t.done).length, 0);
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">${epics.length}</span>
603
- ${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% tasks done</span>` : ""}
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(name)) collapsed.delete(name);
607
- else collapsed.add(name);
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
- epics.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
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 = allEpics.find(e => e.id === epicId);
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 res = await fetch("/api/epics", {
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
- epic_group: groupName === "—" ? "" : groupName,
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/epics", {
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, epic_group: group || "—" }),
1094
+ body: JSON.stringify({ title, column: col, epic: group || "—" }),
1065
1095
  });
1066
1096
  if (res.ok) {
1067
1097
  toast("Task added ✓");