kanbango 2.5.0 → 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/bin/kanban.js CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  const kanban = require('../kanban.js');
4
4
  const plan = require('../plan.js');
5
+ const guiRegistry = require('../gui-registry.js');
5
6
  const http = require('http');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
9
 
9
10
  const BACKLOG = path.join(process.cwd(), 'backlog');
10
- const COLS = kanban.COLS;
11
11
 
12
12
  function shortId(taskId) {
13
13
  const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
@@ -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);
@@ -357,11 +480,11 @@ async function serveWeb(port) {
357
480
  }
358
481
 
359
482
  const actualPort = server.address().port;
360
- const portInfo = await kanban.writeGuiPortFile({ port: actualPort, pid: process.pid });
483
+ const portInfo = await guiRegistry.writeGuiPortFile({ port: actualPort, pid: process.pid });
361
484
 
362
485
  async function cleanupGuiPortFile() {
363
486
  try {
364
- await kanban.clearGuiPortFile({ pid: process.pid });
487
+ await guiRegistry.clearGuiPortFile({ pid: process.pid });
365
488
  } catch {
366
489
  // best-effort cleanup
367
490
  }
@@ -458,9 +581,9 @@ async function main() {
458
581
  const args = process.argv.slice(2);
459
582
  const cmd = args[0];
460
583
 
461
- if (!cmd || cmd === 'serve') {
462
- const port = kanban.resolvePreferredGuiPort(args[1]);
463
- await serveWeb(port);
584
+ if (!cmd || cmd === 'serve') {
585
+ const port = guiRegistry.resolvePreferredGuiPort(args[1]);
586
+ await serveWeb(port);
464
587
  } else if (cmd === 'init') {
465
588
  await cliInit();
466
589
  } else if (cmd === 'mcp-init') {
@@ -506,6 +629,26 @@ if (!cmd || cmd === 'serve') {
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]) {
@@ -0,0 +1,148 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+
4
+ const BACKLOG = path.join(process.cwd(), 'backlog');
5
+ const GUI_PORT_FILE = '.kanbango-gui.json';
6
+ const GUI_PORT_MIN = 5510;
7
+ const GUI_PORT_MAX = 5999;
8
+ const GUI_PORT_SPAN = GUI_PORT_MAX - GUI_PORT_MIN + 1;
9
+
10
+ function createGuiError(code, message, hint, details = {}, retryable = false, status = 400) {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ error.hint = hint;
14
+ error.details = details;
15
+ error.retryable = retryable;
16
+ error.status = status;
17
+ return error;
18
+ }
19
+
20
+ function guiPortFilePath() {
21
+ return path.join(BACKLOG, GUI_PORT_FILE);
22
+ }
23
+
24
+ function hashCwdToPort(cwd = process.cwd()) {
25
+ let hash = 0;
26
+ const input = String(cwd);
27
+ for (let i = 0; i < input.length; i++) {
28
+ hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
29
+ }
30
+ return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
31
+ }
32
+
33
+ function normalizeGuiPort(value) {
34
+ const parsed = Number.parseInt(value, 10);
35
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
36
+ return null;
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ function resolvePreferredGuiPort(explicitPort) {
42
+ if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
43
+ const fromArg = normalizeGuiPort(explicitPort);
44
+ if (fromArg) return fromArg;
45
+ }
46
+
47
+ const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
48
+ if (fromEnv) return fromEnv;
49
+
50
+ return hashCwdToPort(process.cwd());
51
+ }
52
+
53
+ function isPidAlive(pid) {
54
+ const n = Number.parseInt(pid, 10);
55
+ if (!Number.isFinite(n) || n <= 0) return false;
56
+ try {
57
+ process.kill(n, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ async function ensureBacklogDir() {
65
+ await fs.mkdir(BACKLOG, { recursive: true });
66
+ }
67
+
68
+ async function writeGuiPortFile({ port, pid = process.pid } = {}) {
69
+ const normalizedPort = normalizeGuiPort(port);
70
+ if (!normalizedPort) {
71
+ throw createGuiError(
72
+ 'VALIDATION_ERROR',
73
+ 'Invalid GUI port',
74
+ 'Use an integer between 1 and 65535',
75
+ { port },
76
+ false,
77
+ 400
78
+ );
79
+ }
80
+
81
+ await ensureBacklogDir();
82
+ const data = {
83
+ port: normalizedPort,
84
+ pid,
85
+ url: `http://localhost:${normalizedPort}`,
86
+ cwd: process.cwd(),
87
+ started_at: new Date().toISOString()
88
+ };
89
+ await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
90
+ return data;
91
+ }
92
+
93
+ async function readGuiPortFile() {
94
+ try {
95
+ const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
96
+ const data = JSON.parse(raw);
97
+ if (!data || !normalizeGuiPort(data.port)) return null;
98
+ return data;
99
+ } catch (error) {
100
+ if (error.code === 'ENOENT') return null;
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function clearGuiPortFile({ pid, force = false } = {}) {
106
+ const info = await readGuiPortFile();
107
+ if (!info) return false;
108
+ if (!force && pid !== undefined && info.pid !== pid) return false;
109
+ if (!force && pid === undefined && info.pid !== process.pid) return false;
110
+
111
+ try {
112
+ await fs.unlink(guiPortFilePath());
113
+ return true;
114
+ } catch (error) {
115
+ if (error.code === 'ENOENT') return false;
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ async function discoverRunningGui() {
121
+ const info = await readGuiPortFile();
122
+ if (!info || !isPidAlive(info.pid)) {
123
+ if (info) await clearGuiPortFile({ force: true });
124
+ return null;
125
+ }
126
+ return {
127
+ status: 'running',
128
+ port: info.port,
129
+ pid: info.pid,
130
+ url: info.url || `http://localhost:${info.port}`,
131
+ cwd: info.cwd,
132
+ started_at: info.started_at
133
+ };
134
+ }
135
+
136
+ module.exports = {
137
+ hashCwdToPort,
138
+ normalizeGuiPort,
139
+ resolvePreferredGuiPort,
140
+ isPidAlive,
141
+ writeGuiPortFile,
142
+ readGuiPortFile,
143
+ clearGuiPortFile,
144
+ discoverRunningGui,
145
+ guiPortFilePath,
146
+ GUI_PORT_MIN,
147
+ GUI_PORT_MAX
148
+ };
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 ✓");
package/index.js CHANGED
@@ -10,8 +10,12 @@
10
10
 
11
11
  const kanban = require('./kanban.js');
12
12
  const plan = require('./plan.js');
13
+ const guiRegistry = require('./gui-registry.js');
14
+ const playbook = require('./agent-playbook.js');
13
15
 
14
16
  module.exports = {
15
17
  kanban,
16
18
  plan,
19
+ guiRegistry,
20
+ playbook,
17
21
  };