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 +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/AGENTS.md +6 -1
- package/CHANGELOG.md +36 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +26 -4
- package/agent-playbook.js +22 -6
- package/bin/kanban.js +287 -23
- package/gui-registry.js +14 -1
- package/index.html +687 -51
- package/kanban.js +677 -7
- package/mcp-server.js +239 -32
- package/package.json +1 -1
- package/tests/run.js +2 -0
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}
|
package/.ai/retro/last-run.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"ts":"2026-07-
|
|
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 "
|
|
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.
|
|
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
|
|
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);
|
|
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","
|
|
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
|
-
|
|
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
|
|
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)} [${
|
|
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, '<')
|
|
332
|
+
.replace(/>/g, '>')
|
|
333
|
+
.replace(/"/g, '"');
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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.
|
|
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
|
|
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,
|