kanbango 3.0.2 → 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 CHANGED
@@ -2,3 +2,4 @@
2
2
  {"id":"260729-1ur","ts":"2026-07-29","scope":"proj","tags":["javascript","objects"],"rule":"DON'T Use empty object fallbacks in spreads like `...(obj || {})` since spreading falsy is safe","when":"object spread","sev":2,"hits":1}
3
3
  {"id":"260729-5eo","ts":"2026-07-29","scope":"proj","tags":["processes","mcp","ownership"],"rule":"DO Track process ownership before stopping or cleaning up discovered services","when":"gui-process management","sev":2,"hits":1}
4
4
  {"id":"260729-1ch","ts":"2026-07-29","scope":"proj","tags":["gui","process","mcp"],"rule":"DO GUI/MCP stop must only SIGTERM processes spawned by the current process; treat port-file PIDs as external_running","when":"gui-process management","sev":2,"hits":1}
5
+ {"id":"260731-o6g","ts":"2026-07-31","scope":"proj","tags":["kanban","epics","mcp"],"rule":"DO Default list/list_epics hide done+archived; hard-delete cascades epic children; archive is reversible flag","when":"kanban cleanup context","sev":2,"hits":1}
@@ -1 +1 @@
1
- {"ts":"2026-07-29T10:38:42.184Z","sessionID":"ses_052c1be3fffeNPoV7VO6z40X2I","added":["260729-1ch"],"bumped":[]}
1
+ {"ts":"2026-07-31T13:58:00.629Z","sessionID":"ses_04792cf77ffec14LhSNoSJReg9","added":["260731-o6g"],"bumped":[]}
package/AGENTS.md CHANGED
@@ -6,10 +6,15 @@ 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 default = live only (hides done + archived); `include_archived` / `status` to widen
12
+ - list tasks default hides tasks under done/archived epics; show/show_epic by id always works
10
13
  - show: `view=execution` while coding; `full` only if needed
14
+ - epic_create with description/goals; create tasks with `epic=E001` (prefer id)
11
15
  - create once with `description`, `specs`, `in_scope`, `out_of_scope`, `acceptance_criteria`
12
- - move/update: `return=none`; subtasks = full array replace (no toggle)
16
+ - move/update/delete: `return=none`; subtasks = full array replace (no toggle)
17
+ - cleanup: `delete` (task), `epic_delete` (cascade), `epic_archive` / `epic_unarchive`
13
18
  - non-trivial work: `plan_create` → `plan_advance` → `plan_evidence` (real tests, truncated logs) → `plan_done`
14
19
  - gui: `status` before `start`; `stop` only owned; `external_running` = do not kill
15
20
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [3.2.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - GUI project label: browser tab title, header, port file, and `kanban_gui` start/status include `project` (basename of cwd; override with `KANBANGO_PROJECT_NAME`)
14
+ - Epic field `archived` + status `archived` (overrides derived child rollup)
15
+ - Hard delete: MCP `delete` / `epic_delete` (cascade child tasks), CLI `kanban rm` / `kanban epic rm`, HTTP `DELETE`
16
+ - Archive: MCP `epic_archive` / `epic_unarchive`, CLI `kanban epic archive|unarchive`, HTTP `POST .../archive|unarchive`
17
+ - `list_epics` default live-only (empty|planned|active); `include_archived`, `include_done`, `status` filters
18
+ - Task `list` hides tasks under done/archived epics unless `include_archived`/`include_done` (explicit `epic=` filter still shows them)
19
+ - GUI: Archive/Unarchive/Delete on epic swimlane, Delete on task card, Show archived toggle
20
+ - Tests: `tests/delete-archive.test.js`
21
+
22
+ ### Changed
23
+ - Agent playbook documents cleanup + live-only epic discovery
24
+ - `show` / `show_epic` by id still return archived items (explicit lookup)
25
+
26
+ ## [3.1.0] - 2026-07-30
27
+
28
+ ### Added
29
+ - First-class epic containers under `backlog/epics/E00N.json` (description, goals, in/out scope, notes)
30
+ - Task field `epic_id` with denormalized `epic_group` title for lists/GUI
31
+ - Derived epic status/progress from child task columns (no epic board column)
32
+ - MCP: `list_epics`, `show_epic`, `epic_create`, `epic_update`
33
+ - CLI: `kanban epic list|show|add|update`
34
+ - HTTP: `GET/POST /api/epics` for real epics; `POST /api/tasks` for tasks (legacy `/api/epics` + `column` still creates tasks)
35
+ - Auto-migration of legacy `epic_group` string labels into epic entities
36
+ - GUI swimlanes group by `epic_id` and show epic context
37
+ - Regression tests in `tests/epics.test.js`
38
+
39
+ ### Changed
40
+ - Hierarchy documented for agents: epic → task → subtasks
41
+ - Task summary view includes `epic_id`
42
+ - Agent playbook includes epic discovery/create rules
43
+
8
44
  ## [3.0.2] - 2026-07-29
9
45
 
10
46
  ### Added
package/LLM_AGENTS.md CHANGED
@@ -249,7 +249,7 @@ Control the web GUI server: start, stop, or check status.
249
249
  2. Else `KANBANGO_GUI_PORT` env
250
250
  3. Else stable hash of project cwd in range `5510–5999`
251
251
 
252
- If the preferred port is busy, the server picks the next free port. Always trust the returned `url` / `port` (also written to `backlog/.kanbango-gui.json`).
252
+ If the preferred port is busy, the server picks the next free port. Always trust the returned `url` / `port` (also written to `backlog/.kanbango-gui.json`). Responses also include `project` (basename of project cwd, or `KANBANGO_PROJECT_NAME`) so agents and humans can tell boards apart; the GUI tab title and header show the same label.
253
253
 
254
254
  **Auto-start with MCP:** set `KANBANGO_AUTO_GUI=1` in the MCP server env. GUI starts when MCP starts; use `status` to read the URL.
255
255
 
package/README.md CHANGED
@@ -31,8 +31,20 @@ 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 (default: live only)
39
+ kanban epic list --json
40
+ kanban epic list --all --json
41
+ kanban epic show E001
42
+
43
+ # Archive or delete (epic_delete cascades child tasks)
44
+ kanban epic archive E001
45
+ kanban epic unarchive E001
46
+ kanban epic rm E001
47
+ kanban rm 001
36
48
 
37
49
  # Show details
38
50
  kanban show 001
@@ -44,6 +56,16 @@ kanban move 001 active
44
56
  kanban update 001 '{"subtasks":[{"done":true,"text":"Research"},{"done":false,"text":"Implementation"}]}'
45
57
  ```
46
58
 
59
+ ### Hierarchy
60
+
61
+ | Level | What | Storage |
62
+ |-------|------|---------|
63
+ | **Epic** | Initiative context (description, goals) | `backlog/epics/E001.json` |
64
+ | **Task** | Kanban card (column, AC, plan) | `backlog/{col}/001.json` |
65
+ | **Subtask** | Checklist / plan steps | `subtasks[]` on task |
66
+
67
+ Epic status is **derived** from child task columns (not a board column).
68
+
47
69
  ### Columns
48
70
 
49
71
  | Column | Purpose |
@@ -115,7 +137,7 @@ Auto-start the web GUI with MCP (opt-in):
115
137
  }
116
138
  ```
117
139
 
118
- Optional: pin the port with `KANBANGO_GUI_PORT` (e.g. `"5821"`). Without it, each project gets a stable port in `5510–5999` derived from the project path. The real URL is always available via `kanban_gui` → `status` (and written to `backlog/.kanbango-gui.json` while the GUI runs).
140
+ Optional: pin the port with `KANBANGO_GUI_PORT` (e.g. `"5821"`). Without it, each project gets a stable port in `5510–5999` derived from the project path. The real URL is always available via `kanban_gui` → `status` (and written to `backlog/.kanbango-gui.json` while the GUI runs). The browser tab title, GUI header, and `kanban_gui` responses include `project` (folder name of the project cwd; override with `KANBANGO_PROJECT_NAME`).
119
141
 
120
142
  Or generate the config files automatically:
121
143
 
@@ -163,7 +185,7 @@ kanban serve
163
185
 
164
186
  ```js
165
187
  const kanban = require('kanbango');
166
- const tasks = await kanban.allEpics();
188
+ const tasks = await kanban.allTasks();
167
189
  ```
168
190
 
169
191
  ## Requirements
package/agent-playbook.js CHANGED
@@ -6,10 +6,15 @@
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 default = live only (hides done + archived); include_archived / status to widen',
12
+ '- list tasks default hides tasks under done/archived epics; show/show_epic by id always works',
10
13
  '- show: view=execution while coding; full only if needed',
14
+ '- epic_create with description/goals; create tasks with epic=E001 (prefer id)',
11
15
  '- create once with description,specs,in_scope,out_of_scope,acceptance_criteria',
12
- '- move/update: return=none; subtasks=full array replace',
16
+ '- move/update/delete: return=none; subtasks=full array replace',
17
+ '- cleanup: delete (task), epic_delete (cascade tasks), epic_archive / epic_unarchive',
13
18
  '- non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done',
14
19
  '- gui: status before start; stop only owned; external_running = do not kill'
15
20
  ].join('\n');
@@ -17,21 +22,29 @@ const DROP_IN_RULE = [
17
22
  const TOOL_DESCRIPTIONS = {
18
23
  kanban_read: [
19
24
  'Read board. TOKEN RULES: list defaults to view=summary (id/title/col/progress only).',
25
+ 'Hierarchy: epic (container/context) → task (work) → subtasks (steps).',
20
26
  'Always pass col when possible. Prefer show+view=execution over full.',
27
+ 'list_epics default live only (empty|planned|active; hides done+archived).',
28
+ 'Pass include_archived=true or status=done|archived to see closed initiatives.',
29
+ 'list hides tasks under done/archived epics unless include_archived/include_done; show/show_epic by id always works.',
21
30
  '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.',
31
+ 'Task IDs numeric ("014"); epic IDs "E001". views: summary|planning|execution|full; fields[] overrides view.',
23
32
  'operation=help returns this playbook as short text (no board I/O).'
24
33
  ].join(' '),
25
34
 
26
35
  kanban_manage: [
27
36
  'Write board / plan. TOKEN RULES: one create with all planning fields beats many updates;',
28
37
  '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).',
38
+ 'Actions: create|move|update|delete (daily); epic_create|epic_update|epic_archive|epic_unarchive|epic_delete;',
39
+ 'plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
40
+ 'epic_create: title + description/goals/in_scope/out_of_scope. Link tasks via epic=E001.',
41
+ 'delete: task_id (hard remove). epic_delete: epic_id, always cascades child tasks.',
42
+ 'epic_archive/epic_unarchive: epic_id — hide/restore initiative without deleting history.',
30
43
  'create/plan_create: title required; also send description,specs,in_scope,out_of_scope,acceptance_criteria',
31
44
  '(missing → warnings, not failure). move: task_id+column. update: task_id + fields or subtasks[] full list',
32
45
  '(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"}'
46
+ 'Example create: {"action":"create","title":"Ship image","epic":"E001","description":"...","specs":"...",',
47
+ '"in_scope":["CLI"],"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned"}'
35
48
  ].join(' '),
36
49
 
37
50
  kanban_gui: [
@@ -47,7 +60,10 @@ const MUST_CONTAIN = [
47
60
  'return=none',
48
61
  'plan_create',
49
62
  'external_running',
50
- 'subtasks'
63
+ 'subtasks',
64
+ 'epic_create',
65
+ 'epic_archive',
66
+ 'epic_delete'
51
67
  ];
52
68
 
53
69
  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
  }
@@ -144,14 +145,18 @@ async function cliMcpInit(options) {
144
145
  }
145
146
  }
146
147
 
147
- async function cliList(colFilter, epicFilter, asJson) {
148
- let tasks = await kanban.allEpics();
148
+ async function cliList(colFilter, epicFilter, asJson, listOptions = {}) {
149
+ await kanban.migrateEpicGroups();
150
+ const epics = await kanban.listEpicEntities();
151
+ let tasks = await kanban.allTasks();
149
152
 
150
153
  if (colFilter) {
151
154
  tasks = tasks.filter((task) => task.column === colFilter);
152
155
  }
153
156
  if (epicFilter) {
154
- tasks = tasks.filter((task) => task.epic_group === epicFilter);
157
+ tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, epicFilter));
158
+ } else {
159
+ tasks = kanban.filterTasksForList(tasks, epics, listOptions);
155
160
  }
156
161
 
157
162
  if (asJson) {
@@ -168,8 +173,11 @@ async function cliList(colFilter, epicFilter, asJson) {
168
173
  const progress = kanban.getProgress(task);
169
174
  const prog = progress.total ? `${progress.done}/${progress.total}` : '—';
170
175
  const title = displayTitle(task).substring(0, 42);
176
+ const epicLabel = task.epic_id
177
+ ? `${task.epic_id}:${task.epic_group}`
178
+ : task.epic_group;
171
179
  console.log(
172
- ` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${task.epic_group}]`
180
+ ` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${epicLabel}]`
173
181
  );
174
182
  }
175
183
  }
@@ -181,7 +189,7 @@ async function cliShow(taskId) {
181
189
  console.log(`Plik: ${taskFilePath(task)}`);
182
190
  console.log(`Tytuł: ${displayTitle(task)}`);
183
191
  console.log(`Kolumna: ${task.column}`);
184
- console.log(`Epik: ${task.epic_group}`);
192
+ console.log(`Epik: ${task.epic_id ? `${task.epic_id} (${task.epic_group})` : task.epic_group}`);
185
193
  console.log(`Worzono: ${task.created || '—'}`);
186
194
 
187
195
  if (task.description) {
@@ -202,6 +210,100 @@ async function cliShow(taskId) {
202
210
  }
203
211
  }
204
212
 
213
+ async function cliEpicList(asJson, listOptions = {}) {
214
+ await kanban.migrateEpicGroups();
215
+ const tasks = await kanban.allTasks();
216
+ const epics = await kanban.listEpicEntities();
217
+ const shaped = kanban.filterShapedEpics(
218
+ epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'summary' })),
219
+ listOptions
220
+ );
221
+
222
+ if (asJson) {
223
+ console.log(JSON.stringify(shaped, null, 2));
224
+ return;
225
+ }
226
+
227
+ if (shaped.length === 0) {
228
+ console.log('(brak epików)');
229
+ return;
230
+ }
231
+
232
+ for (const epic of shaped) {
233
+ const p = epic.progress || {};
234
+ const flag = epic.archived ? ' [archived]' : '';
235
+ console.log(
236
+ ` ${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${flag}`
237
+ );
238
+ }
239
+ }
240
+
241
+ async function cliEpicShow(epicId) {
242
+ try {
243
+ await kanban.migrateEpicGroups();
244
+ const link = await kanban.resolveEpicRef(epicId, { createIfMissing: false });
245
+ const epic = await kanban.getEpicEntity(link.epic_id);
246
+ const tasks = await kanban.allTasks();
247
+ const shaped = kanban.shapeEpic(epic, tasks, { view: 'full' });
248
+ console.log(JSON.stringify(shaped, null, 2));
249
+ } catch (error) {
250
+ console.error(`✗ ${error.message}`);
251
+ process.exit(1);
252
+ }
253
+ }
254
+
255
+ async function cliEpicAdd(title, options = {}) {
256
+ try {
257
+ const epic = await kanban.doCreateEpic(title, options);
258
+ console.log(`✓ Epic ${epic.id}: ${epic.title}`);
259
+ } catch (error) {
260
+ console.error(`✗ ${error.message}`);
261
+ process.exit(1);
262
+ }
263
+ }
264
+
265
+ async function cliEpicUpdate(epicId, patch) {
266
+ try {
267
+ const epic = await kanban.updateEpicEntity(epicId, patch);
268
+ console.log(`✓ Epic ${epic.id} updated`);
269
+ } catch (error) {
270
+ console.error(`✗ ${error.message}`);
271
+ process.exit(1);
272
+ }
273
+ }
274
+
275
+ async function cliEpicArchive(epicId, archived) {
276
+ try {
277
+ const epic = archived
278
+ ? await kanban.archiveEpic(epicId)
279
+ : await kanban.unarchiveEpic(epicId);
280
+ console.log(`✓ Epic ${epic.id} ${archived ? 'archived' : 'unarchived'}`);
281
+ } catch (error) {
282
+ console.error(`✗ ${error.message}`);
283
+ process.exit(1);
284
+ }
285
+ }
286
+
287
+ async function cliEpicDelete(epicId) {
288
+ try {
289
+ const result = await kanban.deleteEpic(epicId);
290
+ console.log(`✓ Deleted epic ${result.epic_id} (+${result.deleted_task_count} tasks)`);
291
+ } catch (error) {
292
+ console.error(`✗ ${error.message}`);
293
+ process.exit(1);
294
+ }
295
+ }
296
+
297
+ async function cliDelete(taskId) {
298
+ try {
299
+ const result = await kanban.deleteTask(taskId);
300
+ console.log(`✓ Deleted task ${shortId(result.task_id)}: ${result.title}`);
301
+ } catch (error) {
302
+ console.error(`✗ ${error.message}`);
303
+ process.exit(1);
304
+ }
305
+ }
306
+
205
307
  async function cliMove(taskId, column) {
206
308
  const success = await kanban.doMove(taskId, column);
207
309
  if (success) {
@@ -223,8 +325,21 @@ async function cliAdd(title, column, epicGroup) {
223
325
  }
224
326
  }
225
327
 
328
+ function injectProjectIntoHtml(html, project) {
329
+ const safe = String(project)
330
+ .replace(/&/g, '&')
331
+ .replace(/</g, '&lt;')
332
+ .replace(/>/g, '&gt;')
333
+ .replace(/"/g, '&quot;');
334
+ return html
335
+ .replace(/<title>[^<]*<\/title>/, `<title>${safe} · Kanban</title>`)
336
+ .replace(/<h1>Donna Kanban<\/h1>/, `<h1>${safe}</h1>`);
337
+ }
338
+
226
339
  async function serveWeb(port) {
227
- const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
340
+ const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
341
+ const project = guiRegistry.projectLabel();
342
+ const html = injectProjectIntoHtml(htmlTemplate, project);
228
343
 
229
344
  const server = http.createServer(async (req, res) => {
230
345
  const url = new URL(req.url, `http://localhost:${port}`);
@@ -238,29 +353,129 @@ async function serveWeb(port) {
238
353
  }
239
354
 
240
355
  if (requestPath === '/api/board') {
241
- const tasks = await kanban.allEpics();
356
+ await kanban.migrateEpicGroups();
357
+ const includeArchived = url.searchParams.get('include_archived') === 'true';
358
+ const epics = await kanban.listEpicEntities();
359
+ // GUI: show done-epic tasks; only hide archived unless toggled
360
+ const tasks = kanban.filterTasksForList(await kanban.allTasks(), epics, {
361
+ live_only: false,
362
+ include_archived: includeArchived
363
+ });
242
364
  sendJson(res, 200, tasks);
243
365
  return;
244
366
  }
245
367
 
368
+ if (requestPath === '/api/epics' && req.method === 'GET') {
369
+ await kanban.migrateEpicGroups();
370
+ const tasks = await kanban.allTasks();
371
+ const epics = await kanban.listEpicEntities();
372
+ const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' }));
373
+ const includeArchived = url.searchParams.get('include_archived') === 'true';
374
+ const includeDone = url.searchParams.get('include_done') === 'true';
375
+ const status = url.searchParams.get('status') || undefined;
376
+ // GUI default: all non-archived (live + done). Agents use MCP live-only.
377
+ const liveOnly = url.searchParams.get('live_only') === 'true';
378
+ const filtered = kanban.filterShapedEpics(shaped, liveOnly
379
+ ? { include_archived: includeArchived, include_done: includeDone, status }
380
+ : { live_only: false, include_archived: includeArchived, status });
381
+ sendJson(res, 200, filtered);
382
+ return;
383
+ }
384
+
246
385
  if (requestPath === '/api/epics' && req.method === 'POST') {
247
386
  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
- });
387
+ // Real epic container when no task column is provided.
388
+ if (body.column === undefined && body.as_task !== true) {
389
+ const epic = await kanban.doCreateEpic(body.title || '', {
390
+ description: body.description,
391
+ goals: body.goals,
392
+ in_scope: body.in_scope,
393
+ out_of_scope: body.out_of_scope,
394
+ notes: body.notes
395
+ });
396
+ sendJson(res, 201, kanban.shapeEpic(epic, [], { view: 'full' }));
397
+ return;
398
+ }
399
+
400
+ const task = await kanban.doCreate(
401
+ body.title || '',
402
+ body.column || 'planned',
403
+ body.epic_id || body.epic || body.epic_group || '—',
404
+ {
405
+ description: body.description,
406
+ specs: body.specs,
407
+ in_scope: body.in_scope,
408
+ out_of_scope: body.out_of_scope,
409
+ acceptance_criteria: body.acceptance_criteria,
410
+ test_cases: body.test_cases,
411
+ subtasks: body.subtasks,
412
+ notes: body.notes
413
+ }
414
+ );
415
+ sendJson(res, 201, task);
416
+ return;
417
+ }
418
+
419
+ if (requestPath === '/api/tasks' && req.method === 'POST') {
420
+ const body = await readBody(req);
421
+ const task = await kanban.doCreate(
422
+ body.title || '',
423
+ body.column || 'planned',
424
+ body.epic_id || body.epic || body.epic_group || '—',
425
+ {
426
+ description: body.description,
427
+ specs: body.specs,
428
+ in_scope: body.in_scope,
429
+ out_of_scope: body.out_of_scope,
430
+ acceptance_criteria: body.acceptance_criteria,
431
+ test_cases: body.test_cases,
432
+ subtasks: body.subtasks,
433
+ notes: body.notes
434
+ }
435
+ );
258
436
  sendJson(res, 201, task);
259
437
  return;
260
438
  }
261
439
 
440
+ if (req.method === 'DELETE') {
441
+ const epicDeleteMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
442
+ if (epicDeleteMatch) {
443
+ const result = await kanban.deleteEpic(epicDeleteMatch[1]);
444
+ sendJson(res, 200, result);
445
+ return;
446
+ }
447
+
448
+ const taskDeleteMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
449
+ if (taskDeleteMatch) {
450
+ const result = await kanban.deleteTask(taskDeleteMatch[1]);
451
+ sendJson(res, 200, result);
452
+ return;
453
+ }
454
+ }
455
+
456
+ if (req.method === 'POST') {
457
+ const epicArchiveMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)\/(archive|unarchive)$/);
458
+ if (epicArchiveMatch) {
459
+ const epic = epicArchiveMatch[2] === 'archive'
460
+ ? await kanban.archiveEpic(epicArchiveMatch[1])
461
+ : await kanban.unarchiveEpic(epicArchiveMatch[1]);
462
+ sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
463
+ return;
464
+ }
465
+ }
466
+
262
467
  if (req.method === 'PATCH') {
263
- const moveMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/move$/);
468
+ const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
469
+ if (epicUpdateMatch) {
470
+ const body = await readBody(req);
471
+ const patch = body.patch ? { ...body.patch } : { ...body };
472
+ delete patch.patch;
473
+ const epic = await kanban.updateEpicEntity(epicUpdateMatch[1], patch);
474
+ sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
475
+ return;
476
+ }
477
+
478
+ const moveMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/move$/);
264
479
  if (moveMatch) {
265
480
  const taskId = moveMatch[1];
266
481
  const body = await readBody(req);
@@ -269,7 +484,7 @@ async function serveWeb(port) {
269
484
  return;
270
485
  }
271
486
 
272
- const toggleMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/tasks\/(\d+)$/);
487
+ const toggleMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/tasks\/(\d+)$/);
273
488
  if (toggleMatch) {
274
489
  const taskId = toggleMatch[1];
275
490
  const idx = parseInt(toggleMatch[2], 10);
@@ -294,7 +509,7 @@ async function serveWeb(port) {
294
509
  return;
295
510
  }
296
511
 
297
- const updateMatch = requestPath.match(/^\/api\/epics\/([^/]+)$/);
512
+ const updateMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
298
513
  if (updateMatch) {
299
514
  const taskId = updateMatch[1];
300
515
  const body = await readBody(req);
@@ -309,7 +524,9 @@ async function serveWeb(port) {
309
524
  if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
310
525
  if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
311
526
  if (body.notes !== undefined) patch.notes = body.notes;
312
- if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
527
+ if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
528
+ else if (body.epic !== undefined) patch.epic = body.epic;
529
+ else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
313
530
 
314
531
  const task = await kanban.updateTask(taskId, patch);
315
532
  sendJson(res, 200, task);
@@ -381,6 +598,7 @@ async function serveWeb(port) {
381
598
  });
382
599
 
383
600
  console.log(`\x1b[1;32m→ Kanban GUI: ${portInfo.url}\x1b[0m`);
601
+ console.log(` Project: ${portInfo.project || project}`);
384
602
  console.log(` Backlog: ${BACKLOG}`);
385
603
  console.log(' Ctrl+C żeby zamknąć');
386
604
  }
@@ -494,6 +712,7 @@ async function main() {
494
712
  let colFilter = null;
495
713
  let epicFilter = null;
496
714
  let asJson = false;
715
+ let includeArchived = false;
497
716
 
498
717
  for (let i = 1; i < args.length; i++) {
499
718
  if (args[i] === '--col' && args[i + 1]) {
@@ -502,12 +721,57 @@ async function main() {
502
721
  epicFilter = args[++i];
503
722
  } else if (args[i] === '--json') {
504
723
  asJson = true;
724
+ } else if (args[i] === '--include-archived') {
725
+ includeArchived = true;
505
726
  }
506
727
  }
507
728
 
508
- await cliList(colFilter, epicFilter, asJson);
729
+ await cliList(colFilter, epicFilter, asJson, { include_archived: includeArchived });
730
+ } else if (cmd === 'epic') {
731
+ const sub = args[1];
732
+ if (sub === 'list') {
733
+ let includeArchived = false;
734
+ let includeDone = false;
735
+ let status;
736
+ for (let i = 2; i < args.length; i++) {
737
+ if (args[i] === '--include-archived') includeArchived = true;
738
+ else if (args[i] === '--include-done') includeDone = true;
739
+ else if (args[i] === '--all') {
740
+ includeArchived = true;
741
+ includeDone = true;
742
+ } else if (args[i] === '--status' && args[i + 1]) status = args[++i];
743
+ }
744
+ await cliEpicList(args.includes('--json'), {
745
+ include_archived: includeArchived,
746
+ include_done: includeDone,
747
+ status
748
+ });
749
+ } else if (sub === 'show' && args[2]) {
750
+ await cliEpicShow(args[2]);
751
+ } else if (sub === 'add' && args[2]) {
752
+ let description = '';
753
+ let goals = '';
754
+ for (let i = 3; i < args.length; i++) {
755
+ if (args[i] === '--description' && args[i + 1]) description = args[++i];
756
+ else if (args[i] === '--goals' && args[i + 1]) goals = args[++i];
757
+ }
758
+ await cliEpicAdd(args[2], { description, goals });
759
+ } else if (sub === 'update' && args[2] && args[3]) {
760
+ await cliEpicUpdate(args[2], parseJsonPayload(args[3]));
761
+ } else if (sub === 'archive' && args[2]) {
762
+ await cliEpicArchive(args[2], true);
763
+ } else if (sub === 'unarchive' && args[2]) {
764
+ await cliEpicArchive(args[2], false);
765
+ } else if ((sub === 'rm' || sub === 'delete') && args[2]) {
766
+ await cliEpicDelete(args[2]);
767
+ } else {
768
+ console.error('Usage: kanban epic list|show|add|update|archive|unarchive|rm ...');
769
+ process.exit(1);
770
+ }
509
771
  } else if (cmd === 'show' && args[1]) {
510
772
  await cliShow(args[1]);
773
+ } else if ((cmd === 'rm' || cmd === 'delete') && args[1]) {
774
+ await cliDelete(args[1]);
511
775
  } else if (cmd === 'move' && args[1] && args[2]) {
512
776
  await cliMove(args[1], args[2]);
513
777
  } else if (cmd === 'add' && args[1]) {
package/gui-registry.js CHANGED
@@ -21,6 +21,15 @@ function guiPortFilePath() {
21
21
  return path.join(BACKLOG, GUI_PORT_FILE);
22
22
  }
23
23
 
24
+ function projectLabel(cwd = process.cwd()) {
25
+ const fromEnv = process.env.KANBANGO_PROJECT_NAME;
26
+ if (fromEnv !== undefined && fromEnv !== null && String(fromEnv).trim()) {
27
+ return String(fromEnv).trim();
28
+ }
29
+ const base = path.basename(String(cwd || '').replace(/[/\\]+$/, ''));
30
+ return base || 'kanban';
31
+ }
32
+
24
33
  function hashCwdToPort(cwd = process.cwd()) {
25
34
  let hash = 0;
26
35
  const input = String(cwd);
@@ -79,11 +88,13 @@ async function writeGuiPortFile({ port, pid = process.pid } = {}) {
79
88
  }
80
89
 
81
90
  await ensureBacklogDir();
91
+ const cwd = process.cwd();
82
92
  const data = {
83
93
  port: normalizedPort,
84
94
  pid,
85
95
  url: `http://localhost:${normalizedPort}`,
86
- cwd: process.cwd(),
96
+ cwd,
97
+ project: projectLabel(cwd),
87
98
  started_at: new Date().toISOString()
88
99
  };
89
100
  await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
@@ -129,11 +140,13 @@ async function discoverRunningGui() {
129
140
  pid: info.pid,
130
141
  url: info.url || `http://localhost:${info.port}`,
131
142
  cwd: info.cwd,
143
+ project: info.project || projectLabel(info.cwd),
132
144
  started_at: info.started_at
133
145
  };
134
146
  }
135
147
 
136
148
  module.exports = {
149
+ projectLabel,
137
150
  hashCwdToPort,
138
151
  normalizeGuiPort,
139
152
  resolvePreferredGuiPort,