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/.ai/lessons.jsonl +4 -0
- package/.ai/retro/last-run.json +1 -1
- package/AGENTS.md +14 -0
- package/CHANGELOG.md +54 -0
- package/LLM_AGENTS.md +73 -18
- package/README.md +24 -6
- package/agent-playbook.js +79 -0
- package/bin/kanban-cmd.js +1 -1
- package/bin/kanban.js +169 -26
- package/gui-registry.js +148 -0
- package/index.html +61 -31
- package/index.js +4 -0
- package/kanban.js +525 -139
- package/mcp-server.js +215 -87
- package/package.json +1 -1
- package/plan.js +1 -1
- package/tests/run.js +3 -0
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
|
-
|
|
149
|
+
await kanban.migrateEpicGroups();
|
|
150
|
+
let tasks = await kanban.allTasks();
|
|
149
151
|
|
|
150
152
|
if (colFilter) {
|
|
151
153
|
tasks = tasks.filter((task) => task.column === colFilter);
|
|
152
154
|
}
|
|
153
155
|
if (epicFilter) {
|
|
154
|
-
tasks = tasks.filter((task) => task
|
|
156
|
+
tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, epicFilter));
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
if (asJson) {
|
|
@@ -168,8 +170,11 @@ async function cliList(colFilter, epicFilter, asJson) {
|
|
|
168
170
|
const progress = kanban.getProgress(task);
|
|
169
171
|
const prog = progress.total ? `${progress.done}/${progress.total}` : '—';
|
|
170
172
|
const title = displayTitle(task).substring(0, 42);
|
|
173
|
+
const epicLabel = task.epic_id
|
|
174
|
+
? `${task.epic_id}:${task.epic_group}`
|
|
175
|
+
: task.epic_group;
|
|
171
176
|
console.log(
|
|
172
|
-
` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${
|
|
177
|
+
` ${task.column.padEnd(8)} ${shortId(task.id).padEnd(8)} ${title.padEnd(43)} ${prog.padStart(5)} [${epicLabel}]`
|
|
173
178
|
);
|
|
174
179
|
}
|
|
175
180
|
}
|
|
@@ -181,7 +186,7 @@ async function cliShow(taskId) {
|
|
|
181
186
|
console.log(`Plik: ${taskFilePath(task)}`);
|
|
182
187
|
console.log(`Tytuł: ${displayTitle(task)}`);
|
|
183
188
|
console.log(`Kolumna: ${task.column}`);
|
|
184
|
-
console.log(`Epik: ${task.epic_group}`);
|
|
189
|
+
console.log(`Epik: ${task.epic_id ? `${task.epic_id} (${task.epic_group})` : task.epic_group}`);
|
|
185
190
|
console.log(`Worzono: ${task.created || '—'}`);
|
|
186
191
|
|
|
187
192
|
if (task.description) {
|
|
@@ -202,6 +207,64 @@ async function cliShow(taskId) {
|
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
async function cliEpicList(asJson) {
|
|
211
|
+
await kanban.migrateEpicGroups();
|
|
212
|
+
const tasks = await kanban.allTasks();
|
|
213
|
+
const epics = await kanban.listEpicEntities();
|
|
214
|
+
const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'summary' }));
|
|
215
|
+
|
|
216
|
+
if (asJson) {
|
|
217
|
+
console.log(JSON.stringify(shaped, null, 2));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (shaped.length === 0) {
|
|
222
|
+
console.log('(brak epików)');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const epic of shaped) {
|
|
227
|
+
const p = epic.progress || {};
|
|
228
|
+
console.log(
|
|
229
|
+
` ${epic.id.padEnd(6)} ${epic.status.padEnd(8)} ${String(epic.title).substring(0, 40).padEnd(40)} ${p.tasks_done || 0}/${p.tasks_total || 0} tasks`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function cliEpicShow(epicId) {
|
|
235
|
+
try {
|
|
236
|
+
await kanban.migrateEpicGroups();
|
|
237
|
+
const link = await kanban.resolveEpicRef(epicId, { createIfMissing: false });
|
|
238
|
+
const epic = await kanban.getEpicEntity(link.epic_id);
|
|
239
|
+
const tasks = await kanban.allTasks();
|
|
240
|
+
const shaped = kanban.shapeEpic(epic, tasks, { view: 'full' });
|
|
241
|
+
console.log(JSON.stringify(shaped, null, 2));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error(`✗ ${error.message}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function cliEpicAdd(title, options = {}) {
|
|
249
|
+
try {
|
|
250
|
+
const epic = await kanban.doCreateEpic(title, options);
|
|
251
|
+
console.log(`✓ Epic ${epic.id}: ${epic.title}`);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error(`✗ ${error.message}`);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function cliEpicUpdate(epicId, patch) {
|
|
259
|
+
try {
|
|
260
|
+
const epic = await kanban.updateEpicEntity(epicId, patch);
|
|
261
|
+
console.log(`✓ Epic ${epic.id} updated`);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error(`✗ ${error.message}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
205
268
|
async function cliMove(taskId, column) {
|
|
206
269
|
const success = await kanban.doMove(taskId, column);
|
|
207
270
|
if (success) {
|
|
@@ -238,29 +301,87 @@ async function serveWeb(port) {
|
|
|
238
301
|
}
|
|
239
302
|
|
|
240
303
|
if (requestPath === '/api/board') {
|
|
241
|
-
|
|
304
|
+
await kanban.migrateEpicGroups();
|
|
305
|
+
const tasks = await kanban.allTasks();
|
|
242
306
|
sendJson(res, 200, tasks);
|
|
243
307
|
return;
|
|
244
308
|
}
|
|
245
309
|
|
|
310
|
+
if (requestPath === '/api/epics' && req.method === 'GET') {
|
|
311
|
+
await kanban.migrateEpicGroups();
|
|
312
|
+
const tasks = await kanban.allTasks();
|
|
313
|
+
const epics = await kanban.listEpicEntities();
|
|
314
|
+
sendJson(res, 200, epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' })));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
246
318
|
if (requestPath === '/api/epics' && req.method === 'POST') {
|
|
247
319
|
const body = await readBody(req);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
// Real epic container when no task column is provided.
|
|
321
|
+
if (body.column === undefined && body.as_task !== true) {
|
|
322
|
+
const epic = await kanban.doCreateEpic(body.title || '', {
|
|
323
|
+
description: body.description,
|
|
324
|
+
goals: body.goals,
|
|
325
|
+
in_scope: body.in_scope,
|
|
326
|
+
out_of_scope: body.out_of_scope,
|
|
327
|
+
notes: body.notes
|
|
328
|
+
});
|
|
329
|
+
sendJson(res, 201, kanban.shapeEpic(epic, [], { view: 'full' }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const task = await kanban.doCreate(
|
|
334
|
+
body.title || '',
|
|
335
|
+
body.column || 'planned',
|
|
336
|
+
body.epic_id || body.epic || body.epic_group || '—',
|
|
337
|
+
{
|
|
338
|
+
description: body.description,
|
|
339
|
+
specs: body.specs,
|
|
340
|
+
in_scope: body.in_scope,
|
|
341
|
+
out_of_scope: body.out_of_scope,
|
|
342
|
+
acceptance_criteria: body.acceptance_criteria,
|
|
343
|
+
test_cases: body.test_cases,
|
|
344
|
+
subtasks: body.subtasks,
|
|
345
|
+
notes: body.notes
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
sendJson(res, 201, task);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (requestPath === '/api/tasks' && req.method === 'POST') {
|
|
353
|
+
const body = await readBody(req);
|
|
354
|
+
const task = await kanban.doCreate(
|
|
355
|
+
body.title || '',
|
|
356
|
+
body.column || 'planned',
|
|
357
|
+
body.epic_id || body.epic || body.epic_group || '—',
|
|
358
|
+
{
|
|
359
|
+
description: body.description,
|
|
360
|
+
specs: body.specs,
|
|
361
|
+
in_scope: body.in_scope,
|
|
362
|
+
out_of_scope: body.out_of_scope,
|
|
363
|
+
acceptance_criteria: body.acceptance_criteria,
|
|
364
|
+
test_cases: body.test_cases,
|
|
365
|
+
subtasks: body.subtasks,
|
|
366
|
+
notes: body.notes
|
|
367
|
+
}
|
|
368
|
+
);
|
|
258
369
|
sendJson(res, 201, task);
|
|
259
370
|
return;
|
|
260
371
|
}
|
|
261
372
|
|
|
262
373
|
if (req.method === 'PATCH') {
|
|
263
|
-
const
|
|
374
|
+
const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
|
|
375
|
+
if (epicUpdateMatch) {
|
|
376
|
+
const body = await readBody(req);
|
|
377
|
+
const patch = body.patch ? { ...body.patch } : { ...body };
|
|
378
|
+
delete patch.patch;
|
|
379
|
+
const epic = await kanban.updateEpicEntity(epicUpdateMatch[1], patch);
|
|
380
|
+
sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const moveMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/move$/);
|
|
264
385
|
if (moveMatch) {
|
|
265
386
|
const taskId = moveMatch[1];
|
|
266
387
|
const body = await readBody(req);
|
|
@@ -269,7 +390,7 @@ async function serveWeb(port) {
|
|
|
269
390
|
return;
|
|
270
391
|
}
|
|
271
392
|
|
|
272
|
-
const toggleMatch = requestPath.match(/^\/api\/epics\/([^/]+)\/tasks\/(\d+)$/);
|
|
393
|
+
const toggleMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/tasks\/(\d+)$/);
|
|
273
394
|
if (toggleMatch) {
|
|
274
395
|
const taskId = toggleMatch[1];
|
|
275
396
|
const idx = parseInt(toggleMatch[2], 10);
|
|
@@ -294,7 +415,7 @@ async function serveWeb(port) {
|
|
|
294
415
|
return;
|
|
295
416
|
}
|
|
296
417
|
|
|
297
|
-
const updateMatch = requestPath.match(/^\/api\/epics\/([^/]+)$/);
|
|
418
|
+
const updateMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
|
|
298
419
|
if (updateMatch) {
|
|
299
420
|
const taskId = updateMatch[1];
|
|
300
421
|
const body = await readBody(req);
|
|
@@ -309,7 +430,9 @@ async function serveWeb(port) {
|
|
|
309
430
|
if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
|
|
310
431
|
if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
|
|
311
432
|
if (body.notes !== undefined) patch.notes = body.notes;
|
|
312
|
-
if (body.
|
|
433
|
+
if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
|
|
434
|
+
else if (body.epic !== undefined) patch.epic = body.epic;
|
|
435
|
+
else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
|
|
313
436
|
|
|
314
437
|
const task = await kanban.updateTask(taskId, patch);
|
|
315
438
|
sendJson(res, 200, task);
|
|
@@ -357,11 +480,11 @@ async function serveWeb(port) {
|
|
|
357
480
|
}
|
|
358
481
|
|
|
359
482
|
const actualPort = server.address().port;
|
|
360
|
-
const portInfo = await
|
|
483
|
+
const portInfo = await guiRegistry.writeGuiPortFile({ port: actualPort, pid: process.pid });
|
|
361
484
|
|
|
362
485
|
async function cleanupGuiPortFile() {
|
|
363
486
|
try {
|
|
364
|
-
await
|
|
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
|
-
|
|
463
|
-
|
|
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]) {
|
package/gui-registry.js
ADDED
|
@@ -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
|
|
532
|
+
let allTasks = [];
|
|
533
|
+
let allEpicEntities = [];
|
|
533
534
|
let colCounts = {};
|
|
534
535
|
let collapsed = new Set();
|
|
535
536
|
let expanded = new Set();
|
|
536
537
|
let editingId = null;
|
|
537
|
-
let dirtyEdits = {}; //
|
|
538
|
+
let dirtyEdits = {}; // taskId -> editable task draft
|
|
538
539
|
|
|
539
540
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
540
541
|
|
|
541
542
|
async function load() {
|
|
542
|
-
const
|
|
543
|
-
|
|
543
|
+
const [boardRes, epicsRes] = await Promise.all([
|
|
544
|
+
fetch("/api/board"),
|
|
545
|
+
fetch("/api/epics")
|
|
546
|
+
]);
|
|
547
|
+
allTasks = await boardRes.json();
|
|
548
|
+
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
544
549
|
render();
|
|
545
550
|
}
|
|
546
551
|
|
|
547
552
|
function render() {
|
|
548
553
|
colCounts = {};
|
|
549
554
|
COL_DEFS.forEach(c => (colCounts[c.id] = 0));
|
|
550
|
-
|
|
555
|
+
allTasks.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
|
|
551
556
|
|
|
552
557
|
// Header stats
|
|
553
|
-
const total =
|
|
554
|
-
const done =
|
|
558
|
+
const total = allTasks.length;
|
|
559
|
+
const done = allTasks.filter(e => e.column === "done").length;
|
|
555
560
|
document.getElementById("hstats").textContent = `${done}/${total} done`;
|
|
556
561
|
|
|
557
562
|
// Col headers
|
|
@@ -567,59 +572,82 @@ function render() {
|
|
|
567
572
|
// Swimlanes
|
|
568
573
|
const sl = document.getElementById("swimlanes");
|
|
569
574
|
sl.innerHTML = "";
|
|
570
|
-
|
|
575
|
+
groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
|
|
571
576
|
}
|
|
572
577
|
|
|
573
|
-
function
|
|
578
|
+
function groupTasks(tasks) {
|
|
574
579
|
const map = new Map();
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
if (!map.has(
|
|
578
|
-
map.get(
|
|
580
|
+
tasks.forEach(t => {
|
|
581
|
+
const key = t.epic_id || "—";
|
|
582
|
+
if (!map.has(key)) map.set(key, []);
|
|
583
|
+
map.get(key).push(t);
|
|
579
584
|
});
|
|
580
|
-
// Sort: known phases first, "—" last
|
|
581
585
|
return [...map.entries()]
|
|
582
586
|
.sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
|
|
583
|
-
.map(([
|
|
587
|
+
.map(([key, laneTasks]) => {
|
|
588
|
+
const entity = allEpicEntities.find(e => e.id === key);
|
|
589
|
+
const name = key === "—"
|
|
590
|
+
? "—"
|
|
591
|
+
: (entity ? entity.title : (laneTasks[0] && laneTasks[0].epic_group) || key);
|
|
592
|
+
return {
|
|
593
|
+
key,
|
|
594
|
+
name,
|
|
595
|
+
epicId: key === "—" ? null : key,
|
|
596
|
+
status: entity ? entity.status : null,
|
|
597
|
+
description: entity ? entity.description : "",
|
|
598
|
+
goals: entity ? entity.goals : "",
|
|
599
|
+
tasks: laneTasks
|
|
600
|
+
};
|
|
601
|
+
});
|
|
584
602
|
}
|
|
585
603
|
|
|
586
604
|
// ── Swimlane ──────────────────────────────────────────────────────────────────
|
|
587
605
|
|
|
588
|
-
function renderSwimlane({ name,
|
|
589
|
-
const isCollapsed = collapsed.has(
|
|
606
|
+
function renderSwimlane({ key, name, epicId, status, description, goals, tasks }) {
|
|
607
|
+
const isCollapsed = collapsed.has(key);
|
|
590
608
|
|
|
591
609
|
const wrap = el("div", "swimlane");
|
|
592
610
|
|
|
593
611
|
// Header
|
|
594
612
|
const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
|
|
595
|
-
const totalTasks =
|
|
596
|
-
const doneTasks =
|
|
613
|
+
const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
|
|
614
|
+
const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
|
|
597
615
|
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
616
|
+
const cardsDone = tasks.filter(t => t.column === "done").length;
|
|
617
|
+
const statusBadge = status ? `<span class="sl-cnt" title="derived status">${escHtml(status)}</span>` : "";
|
|
598
618
|
|
|
599
619
|
hdr.innerHTML = `
|
|
600
620
|
<span class="sl-arrow">▼</span>
|
|
601
|
-
<span class="sl-name">${escHtml(name === "—" ? "Uncategorized (no epic)" : name)}</span>
|
|
602
|
-
<span class="sl-cnt">${
|
|
603
|
-
${
|
|
621
|
+
<span class="sl-name">${escHtml(name === "—" ? "Uncategorized (no epic)" : name)}${epicId ? ` <small style="opacity:.6">${escHtml(epicId)}</small>` : ""}</span>
|
|
622
|
+
<span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
|
|
623
|
+
${statusBadge}
|
|
624
|
+
${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% subtasks</span>` : ""}
|
|
604
625
|
`;
|
|
605
626
|
hdr.onclick = () => {
|
|
606
|
-
if (collapsed.has(
|
|
607
|
-
else collapsed.add(
|
|
627
|
+
if (collapsed.has(key)) collapsed.delete(key);
|
|
628
|
+
else collapsed.add(key);
|
|
608
629
|
render();
|
|
609
630
|
};
|
|
610
631
|
wrap.appendChild(hdr);
|
|
611
632
|
|
|
633
|
+
if (!isCollapsed && (description || goals)) {
|
|
634
|
+
const meta = el("div", "sl-body");
|
|
635
|
+
meta.style.cssText = "display:block;padding:6px 12px 0;font-size:12px;opacity:.85";
|
|
636
|
+
meta.innerHTML = `${goals ? `<div><strong>Goals:</strong> ${escHtml(goals)}</div>` : ""}${description ? `<div>${escHtml(description)}</div>` : ""}`;
|
|
637
|
+
wrap.appendChild(meta);
|
|
638
|
+
}
|
|
639
|
+
|
|
612
640
|
// Body
|
|
613
641
|
const body = el("div", `sl-body${isCollapsed ? " hidden" : ""}`);
|
|
614
642
|
COL_DEFS.forEach(col => {
|
|
615
643
|
const lane = el("div", `lane col-${col.id}`);
|
|
616
|
-
|
|
644
|
+
tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
|
|
617
645
|
// Add button only in non-done columns
|
|
618
646
|
if (col.id !== "done") {
|
|
619
647
|
const addBtn = document.createElement("button");
|
|
620
648
|
addBtn.className = "lane-add";
|
|
621
649
|
addBtn.textContent = "+ Add here";
|
|
622
|
-
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, name, addBtn); };
|
|
650
|
+
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, epicId || name, addBtn); };
|
|
623
651
|
lane.appendChild(addBtn);
|
|
624
652
|
}
|
|
625
653
|
body.appendChild(lane);
|
|
@@ -931,7 +959,7 @@ async function saveEdit(epicId) {
|
|
|
931
959
|
if (!dirty) { editingId = null; render(); return; }
|
|
932
960
|
|
|
933
961
|
const body = {};
|
|
934
|
-
const epic =
|
|
962
|
+
const epic = allTasks.find(e => e.id === epicId);
|
|
935
963
|
if (epic) {
|
|
936
964
|
const origTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
937
965
|
if (dirty.title !== origTitle) body.title = dirty.title;
|
|
@@ -1007,13 +1035,15 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1007
1035
|
if (!title) return;
|
|
1008
1036
|
inp.disabled = true;
|
|
1009
1037
|
description.disabled = true;
|
|
1010
|
-
const
|
|
1038
|
+
const epicRef = groupName === "—" ? "" : groupName;
|
|
1039
|
+
const res = await fetch("/api/tasks", {
|
|
1011
1040
|
method: "POST",
|
|
1012
1041
|
headers: { "Content-Type": "application/json" },
|
|
1013
1042
|
body: JSON.stringify({
|
|
1014
1043
|
title,
|
|
1015
1044
|
column: colId,
|
|
1016
|
-
|
|
1045
|
+
epic: epicRef,
|
|
1046
|
+
epic_id: epicRef && /^E\d+/i.test(epicRef) ? epicRef : undefined,
|
|
1017
1047
|
description: description.value.trim()
|
|
1018
1048
|
}),
|
|
1019
1049
|
});
|
|
@@ -1058,10 +1088,10 @@ async function submitNew() {
|
|
|
1058
1088
|
const group = document.getElementById("ne-group").value.trim();
|
|
1059
1089
|
if (!title) { document.getElementById("ne-title").focus(); return; }
|
|
1060
1090
|
|
|
1061
|
-
const res = await fetch("/api/
|
|
1091
|
+
const res = await fetch("/api/tasks", {
|
|
1062
1092
|
method: "POST",
|
|
1063
1093
|
headers: { "Content-Type": "application/json" },
|
|
1064
|
-
body: JSON.stringify({ title, column: col,
|
|
1094
|
+
body: JSON.stringify({ title, column: col, epic: group || "—" }),
|
|
1065
1095
|
});
|
|
1066
1096
|
if (res.ok) {
|
|
1067
1097
|
toast("Task added ✓");
|
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
|
};
|