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/kanban.js
CHANGED
|
@@ -2,11 +2,8 @@ const fs = require('fs').promises;
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
4
|
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
5
|
+
const EPICS_DIR = path.join(BACKLOG, 'epics');
|
|
5
6
|
const COLS = ['active', 'planned', 'icebox', 'done'];
|
|
6
|
-
const GUI_PORT_FILE = '.kanbango-gui.json';
|
|
7
|
-
const GUI_PORT_MIN = 5510;
|
|
8
|
-
const GUI_PORT_MAX = 5999;
|
|
9
|
-
const GUI_PORT_SPAN = GUI_PORT_MAX - GUI_PORT_MIN + 1;
|
|
10
7
|
const STATUS_MAP = {
|
|
11
8
|
active: 'in_progress',
|
|
12
9
|
planned: 'planned',
|
|
@@ -14,11 +11,12 @@ const STATUS_MAP = {
|
|
|
14
11
|
done: 'done'
|
|
15
12
|
};
|
|
16
13
|
const VIEW_FIELDS = {
|
|
17
|
-
summary: ['task_number', 'title', 'column', 'epic_group', 'created', 'progress'],
|
|
14
|
+
summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
|
|
18
15
|
planning: [
|
|
19
16
|
'task_number',
|
|
20
17
|
'title',
|
|
21
18
|
'column',
|
|
19
|
+
'epic_id',
|
|
22
20
|
'epic_group',
|
|
23
21
|
'created',
|
|
24
22
|
'progress',
|
|
@@ -33,6 +31,7 @@ const VIEW_FIELDS = {
|
|
|
33
31
|
'task_number',
|
|
34
32
|
'title',
|
|
35
33
|
'column',
|
|
34
|
+
'epic_id',
|
|
36
35
|
'epic_group',
|
|
37
36
|
'created',
|
|
38
37
|
'progress',
|
|
@@ -48,6 +47,7 @@ const VIEW_FIELDS = {
|
|
|
48
47
|
'task_number',
|
|
49
48
|
'title',
|
|
50
49
|
'column',
|
|
50
|
+
'epic_id',
|
|
51
51
|
'epic_group',
|
|
52
52
|
'created',
|
|
53
53
|
'progress',
|
|
@@ -62,6 +62,34 @@ const VIEW_FIELDS = {
|
|
|
62
62
|
]
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
const EPIC_VIEW_FIELDS = {
|
|
66
|
+
summary: ['id', 'title', 'created', 'status', 'progress'],
|
|
67
|
+
planning: [
|
|
68
|
+
'id',
|
|
69
|
+
'title',
|
|
70
|
+
'created',
|
|
71
|
+
'status',
|
|
72
|
+
'progress',
|
|
73
|
+
'description',
|
|
74
|
+
'goals',
|
|
75
|
+
'in_scope',
|
|
76
|
+
'out_of_scope'
|
|
77
|
+
],
|
|
78
|
+
full: [
|
|
79
|
+
'id',
|
|
80
|
+
'title',
|
|
81
|
+
'created',
|
|
82
|
+
'status',
|
|
83
|
+
'progress',
|
|
84
|
+
'description',
|
|
85
|
+
'goals',
|
|
86
|
+
'in_scope',
|
|
87
|
+
'out_of_scope',
|
|
88
|
+
'notes',
|
|
89
|
+
'tasks'
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
|
|
65
93
|
// Hard-required on create: title only (keeps GUI/CLI quick-add usable).
|
|
66
94
|
// Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
|
|
67
95
|
const RECOMMENDED_CREATE_FIELDS = [
|
|
@@ -72,6 +100,13 @@ const RECOMMENDED_CREATE_FIELDS = [
|
|
|
72
100
|
'acceptance_criteria'
|
|
73
101
|
];
|
|
74
102
|
|
|
103
|
+
const RECOMMENDED_EPIC_CREATE_FIELDS = [
|
|
104
|
+
'description',
|
|
105
|
+
'goals',
|
|
106
|
+
'in_scope',
|
|
107
|
+
'out_of_scope'
|
|
108
|
+
];
|
|
109
|
+
|
|
75
110
|
function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
|
|
76
111
|
const error = new Error(message);
|
|
77
112
|
error.code = code;
|
|
@@ -107,7 +142,12 @@ function normalizeStringArray(value) {
|
|
|
107
142
|
}
|
|
108
143
|
|
|
109
144
|
function isPresentCreateField(field, value) {
|
|
110
|
-
if (
|
|
145
|
+
if (
|
|
146
|
+
field === 'description'
|
|
147
|
+
|| field === 'specs'
|
|
148
|
+
|| field === 'notes'
|
|
149
|
+
|| field === 'goals'
|
|
150
|
+
) {
|
|
111
151
|
return Boolean(normalizeString(value));
|
|
112
152
|
}
|
|
113
153
|
if (
|
|
@@ -127,6 +167,10 @@ function missingRecommendedCreateFields(payload = {}) {
|
|
|
127
167
|
return RECOMMENDED_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
128
168
|
}
|
|
129
169
|
|
|
170
|
+
function missingRecommendedEpicCreateFields(payload = {}) {
|
|
171
|
+
return RECOMMENDED_EPIC_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
172
|
+
}
|
|
173
|
+
|
|
130
174
|
function createFieldWarnings(payload = {}) {
|
|
131
175
|
const missing = missingRecommendedCreateFields(payload);
|
|
132
176
|
if (missing.length === 0) return [];
|
|
@@ -136,6 +180,28 @@ function createFieldWarnings(payload = {}) {
|
|
|
136
180
|
];
|
|
137
181
|
}
|
|
138
182
|
|
|
183
|
+
function createEpicFieldWarnings(payload = {}) {
|
|
184
|
+
const missing = missingRecommendedEpicCreateFields(payload);
|
|
185
|
+
if (missing.length === 0) return [];
|
|
186
|
+
return [
|
|
187
|
+
`Strongly recommended epic fields missing: ${missing.join(', ')}. ` +
|
|
188
|
+
'Fill them so agents get initiative context and boundaries.'
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeEpicId(value) {
|
|
193
|
+
const raw = normalizeString(value);
|
|
194
|
+
if (!raw || raw === '—') return null;
|
|
195
|
+
const match = raw.match(/^E0*(\d+)$/i);
|
|
196
|
+
if (match) return `E${String(parseInt(match[1], 10)).padStart(3, '0')}`;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isBlankEpicRef(value) {
|
|
201
|
+
const raw = normalizeString(value);
|
|
202
|
+
return !raw || raw === '—';
|
|
203
|
+
}
|
|
204
|
+
|
|
139
205
|
function normalizeSubtasks(value) {
|
|
140
206
|
if (!Array.isArray(value)) return [];
|
|
141
207
|
return value.map((subtask, idx) => ({
|
|
@@ -168,11 +234,14 @@ function normalizePlan(value) {
|
|
|
168
234
|
|
|
169
235
|
function normalizeTask(task) {
|
|
170
236
|
const id = normalizeString(task.id);
|
|
237
|
+
const epicId = normalizeEpicId(task.epic_id);
|
|
238
|
+
const epicGroup = normalizeString(task.epic_group, '—') || '—';
|
|
171
239
|
const normalized = {
|
|
172
240
|
id,
|
|
173
241
|
title: stripTitlePrefix(task.title || id),
|
|
174
242
|
column: COLS.includes(task.column) ? task.column : 'planned',
|
|
175
|
-
|
|
243
|
+
epic_id: epicId,
|
|
244
|
+
epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
|
|
176
245
|
created: normalizeString(task.created) || todayIso(),
|
|
177
246
|
description: normalizeString(task.description),
|
|
178
247
|
specs: normalizeString(task.specs),
|
|
@@ -196,6 +265,7 @@ function serializeTask(task) {
|
|
|
196
265
|
id: normalized.id,
|
|
197
266
|
title: normalized.title,
|
|
198
267
|
column: normalized.column,
|
|
268
|
+
epic_id: normalized.epic_id,
|
|
199
269
|
epic_group: normalized.epic_group,
|
|
200
270
|
created: normalized.created,
|
|
201
271
|
description: normalized.description,
|
|
@@ -212,6 +282,83 @@ function serializeTask(task) {
|
|
|
212
282
|
};
|
|
213
283
|
}
|
|
214
284
|
|
|
285
|
+
function normalizeEpic(epic) {
|
|
286
|
+
const id = normalizeEpicId(epic && epic.id) || normalizeString(epic && epic.id);
|
|
287
|
+
return {
|
|
288
|
+
id,
|
|
289
|
+
title: stripTitlePrefix((epic && epic.title) || id),
|
|
290
|
+
created: normalizeString(epic && epic.created) || todayIso(),
|
|
291
|
+
description: normalizeString(epic && epic.description),
|
|
292
|
+
goals: normalizeString(epic && epic.goals),
|
|
293
|
+
in_scope: normalizeStringArray(epic && epic.in_scope),
|
|
294
|
+
out_of_scope: normalizeStringArray(epic && epic.out_of_scope),
|
|
295
|
+
notes: normalizeString(epic && epic.notes)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function serializeEpic(epic) {
|
|
300
|
+
const normalized = normalizeEpic(epic);
|
|
301
|
+
return {
|
|
302
|
+
id: normalized.id,
|
|
303
|
+
title: normalized.title,
|
|
304
|
+
created: normalized.created,
|
|
305
|
+
description: normalized.description,
|
|
306
|
+
goals: normalized.goals,
|
|
307
|
+
in_scope: normalized.in_scope,
|
|
308
|
+
out_of_scope: normalized.out_of_scope,
|
|
309
|
+
notes: normalized.notes
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function deriveEpicStatus(tasks) {
|
|
314
|
+
if (!tasks || tasks.length === 0) return 'empty';
|
|
315
|
+
if (tasks.some((task) => task.column === 'active')) return 'active';
|
|
316
|
+
if (tasks.every((task) => task.column === 'done')) return 'done';
|
|
317
|
+
return 'planned';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getEpicProgress(tasks) {
|
|
321
|
+
const progress = {
|
|
322
|
+
tasks_total: tasks.length,
|
|
323
|
+
tasks_done: 0,
|
|
324
|
+
tasks_active: 0,
|
|
325
|
+
tasks_planned: 0,
|
|
326
|
+
tasks_icebox: 0
|
|
327
|
+
};
|
|
328
|
+
for (const task of tasks) {
|
|
329
|
+
if (task.column === 'done') progress.tasks_done += 1;
|
|
330
|
+
else if (task.column === 'active') progress.tasks_active += 1;
|
|
331
|
+
else if (task.column === 'planned') progress.tasks_planned += 1;
|
|
332
|
+
else if (task.column === 'icebox') progress.tasks_icebox += 1;
|
|
333
|
+
}
|
|
334
|
+
return progress;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function pickEpicFields(epicPayload, fieldNames) {
|
|
338
|
+
const picked = {};
|
|
339
|
+
for (const field of fieldNames) {
|
|
340
|
+
if (field in epicPayload) {
|
|
341
|
+
picked[field] = epicPayload[field];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return picked;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function shapeEpic(epic, tasks = [], options = {}) {
|
|
348
|
+
const normalized = normalizeEpic(epic);
|
|
349
|
+
const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
|
|
350
|
+
const payload = {
|
|
351
|
+
...normalized,
|
|
352
|
+
status: deriveEpicStatus(childTasks),
|
|
353
|
+
progress: getEpicProgress(childTasks),
|
|
354
|
+
tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
|
|
355
|
+
};
|
|
356
|
+
const fields = Array.isArray(options.fields) && options.fields.length > 0
|
|
357
|
+
? options.fields
|
|
358
|
+
: (EPIC_VIEW_FIELDS[options.view || 'full'] || EPIC_VIEW_FIELDS.full);
|
|
359
|
+
return pickEpicFields(payload, fields);
|
|
360
|
+
}
|
|
361
|
+
|
|
215
362
|
function getProgress(task) {
|
|
216
363
|
const total = task.subtasks.length;
|
|
217
364
|
const done = task.subtasks.filter((subtask) => subtask.done).length;
|
|
@@ -273,6 +420,7 @@ async function ensureBacklogDir() {
|
|
|
273
420
|
const colDir = path.join(BACKLOG, col);
|
|
274
421
|
await fs.mkdir(colDir, { recursive: true });
|
|
275
422
|
}
|
|
423
|
+
await fs.mkdir(EPICS_DIR, { recursive: true });
|
|
276
424
|
}
|
|
277
425
|
|
|
278
426
|
async function parseMarkdownTask(filePath, column) {
|
|
@@ -375,6 +523,331 @@ async function allEpics() {
|
|
|
375
523
|
return epics;
|
|
376
524
|
}
|
|
377
525
|
|
|
526
|
+
async function allTasks() {
|
|
527
|
+
return allEpics();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function epicFilePath(epicId) {
|
|
531
|
+
return path.join(EPICS_DIR, `${epicId}.json`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function nextEpicNumber() {
|
|
535
|
+
await ensureBacklogDir();
|
|
536
|
+
const ids = [];
|
|
537
|
+
try {
|
|
538
|
+
const files = await fs.readdir(EPICS_DIR);
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
const match = file.match(/^E0*(\d+)\.json$/i);
|
|
541
|
+
if (match) ids.push(parseInt(match[1], 10));
|
|
542
|
+
}
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error.code !== 'ENOENT') throw error;
|
|
545
|
+
}
|
|
546
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function parseJsonEpic(filePath) {
|
|
550
|
+
let raw;
|
|
551
|
+
try {
|
|
552
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (error.code === 'ENOENT') throw error;
|
|
555
|
+
throw createKanbanError(
|
|
556
|
+
'PARSE_ERROR',
|
|
557
|
+
`Epic file ${path.basename(filePath)} could not be read`,
|
|
558
|
+
'Fix permissions or restore the file from version control',
|
|
559
|
+
{ file: filePath, reason: error.message },
|
|
560
|
+
false,
|
|
561
|
+
500
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
let data;
|
|
565
|
+
try {
|
|
566
|
+
data = JSON.parse(raw);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
throw createKanbanError(
|
|
569
|
+
'PARSE_ERROR',
|
|
570
|
+
`Epic file ${path.basename(filePath)} could not be parsed`,
|
|
571
|
+
'Fix the JSON syntax or restore the file from version control',
|
|
572
|
+
{ file: filePath, reason: error.message },
|
|
573
|
+
false,
|
|
574
|
+
500
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
return normalizeEpic({
|
|
578
|
+
...data,
|
|
579
|
+
id: data.id || path.basename(filePath, '.json')
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function listEpicEntities() {
|
|
584
|
+
await ensureBacklogDir();
|
|
585
|
+
const epics = [];
|
|
586
|
+
try {
|
|
587
|
+
const files = (await fs.readdir(EPICS_DIR))
|
|
588
|
+
.filter((file) => file.endsWith('.json'))
|
|
589
|
+
.sort((left, right) => left.localeCompare(right));
|
|
590
|
+
for (const file of files) {
|
|
591
|
+
try {
|
|
592
|
+
epics.push(await parseJsonEpic(path.join(EPICS_DIR, file)));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
console.error(` parse error ${file}: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (error.code !== 'ENOENT') throw error;
|
|
599
|
+
}
|
|
600
|
+
return epics;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function writeEpic(epic) {
|
|
604
|
+
const normalized = normalizeEpic(epic);
|
|
605
|
+
if (!normalizeEpicId(normalized.id)) {
|
|
606
|
+
throw createKanbanError(
|
|
607
|
+
'VALIDATION_ERROR',
|
|
608
|
+
'epic id must look like E001',
|
|
609
|
+
'Use an epic id such as E001',
|
|
610
|
+
{ epic_id: normalized.id },
|
|
611
|
+
false,
|
|
612
|
+
400
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
await ensureBacklogDir();
|
|
616
|
+
const filePath = epicFilePath(normalized.id);
|
|
617
|
+
await fs.writeFile(filePath, JSON.stringify(serializeEpic(normalized), null, 2) + '\n', 'utf-8');
|
|
618
|
+
return parseJsonEpic(filePath);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function getEpicEntity(epicId) {
|
|
622
|
+
const id = normalizeEpicId(epicId) || normalizeString(epicId);
|
|
623
|
+
if (!id) {
|
|
624
|
+
throw createKanbanError(
|
|
625
|
+
'EPIC_NOT_FOUND',
|
|
626
|
+
`Epic ${epicId} was not found`,
|
|
627
|
+
'Call kanban_read with operation=list_epics to discover valid epic ids',
|
|
628
|
+
{ epic_id: epicId },
|
|
629
|
+
false,
|
|
630
|
+
404
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const filePath = epicFilePath(normalizeEpicId(id) || id);
|
|
634
|
+
try {
|
|
635
|
+
return await parseJsonEpic(filePath);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.code === 'ENOENT' || error.code === 'PARSE_ERROR') {
|
|
638
|
+
if (error.code === 'PARSE_ERROR') throw error;
|
|
639
|
+
throw createKanbanError(
|
|
640
|
+
'EPIC_NOT_FOUND',
|
|
641
|
+
`Epic ${epicId} was not found`,
|
|
642
|
+
'Call kanban_read with operation=list_epics to discover valid epic ids',
|
|
643
|
+
{ epic_id: epicId },
|
|
644
|
+
false,
|
|
645
|
+
404
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function findEpicsByTitle(title) {
|
|
653
|
+
const needle = normalizeString(title).toLowerCase();
|
|
654
|
+
if (!needle) return [];
|
|
655
|
+
const epics = await listEpicEntities();
|
|
656
|
+
return epics.filter((epic) => epic.title.toLowerCase() === needle);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function resolveEpicRef(ref, options = {}) {
|
|
660
|
+
if (isBlankEpicRef(ref)) {
|
|
661
|
+
return { epic_id: null, epic_group: '—' };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const asId = normalizeEpicId(ref);
|
|
665
|
+
if (asId) {
|
|
666
|
+
const epic = await getEpicEntity(asId);
|
|
667
|
+
return { epic_id: epic.id, epic_group: epic.title };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const matches = await findEpicsByTitle(ref);
|
|
671
|
+
if (matches.length === 1) {
|
|
672
|
+
return { epic_id: matches[0].id, epic_group: matches[0].title };
|
|
673
|
+
}
|
|
674
|
+
if (matches.length > 1) {
|
|
675
|
+
throw createKanbanError(
|
|
676
|
+
'AMBIGUOUS_EPIC',
|
|
677
|
+
`Multiple epics titled "${normalizeString(ref)}"`,
|
|
678
|
+
'Use an epic id (E001) instead of the title',
|
|
679
|
+
{ title: normalizeString(ref), epic_ids: matches.map((epic) => epic.id) },
|
|
680
|
+
false,
|
|
681
|
+
400
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (options.createIfMissing) {
|
|
686
|
+
const created = await doCreateEpic(normalizeString(ref), {});
|
|
687
|
+
return { epic_id: created.id, epic_group: created.title };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
throw createKanbanError(
|
|
691
|
+
'EPIC_NOT_FOUND',
|
|
692
|
+
`Epic "${normalizeString(ref)}" was not found`,
|
|
693
|
+
'Create it with epic_create or pass an existing epic id/title',
|
|
694
|
+
{ epic: normalizeString(ref) },
|
|
695
|
+
false,
|
|
696
|
+
404
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function doCreateEpic(title, extra = {}) {
|
|
701
|
+
if (!normalizeString(title)) {
|
|
702
|
+
throw createKanbanError(
|
|
703
|
+
'MISSING_REQUIRED_FIELD',
|
|
704
|
+
'title is required',
|
|
705
|
+
'Provide a non-empty title when creating an epic',
|
|
706
|
+
{ field: 'title' },
|
|
707
|
+
false,
|
|
708
|
+
400
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const nextId = await nextEpicNumber();
|
|
713
|
+
const epic = normalizeEpic({
|
|
714
|
+
id: `E${String(nextId).padStart(3, '0')}`,
|
|
715
|
+
title,
|
|
716
|
+
created: todayIso(),
|
|
717
|
+
description: extra.description,
|
|
718
|
+
goals: extra.goals,
|
|
719
|
+
in_scope: extra.in_scope,
|
|
720
|
+
out_of_scope: extra.out_of_scope,
|
|
721
|
+
notes: extra.notes
|
|
722
|
+
});
|
|
723
|
+
return writeEpic(epic);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function updateEpicEntity(epicId, patch) {
|
|
727
|
+
validatePatch(patch);
|
|
728
|
+
const current = await getEpicEntity(epicId);
|
|
729
|
+
const next = { ...current };
|
|
730
|
+
|
|
731
|
+
if (patch.title !== undefined) {
|
|
732
|
+
const title = normalizeString(patch.title);
|
|
733
|
+
if (!title) {
|
|
734
|
+
throw createKanbanError(
|
|
735
|
+
'VALIDATION_ERROR',
|
|
736
|
+
'title must be a non-empty string',
|
|
737
|
+
'Send a non-empty title or omit the field',
|
|
738
|
+
{ field: 'title' },
|
|
739
|
+
false,
|
|
740
|
+
400
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
next.title = title;
|
|
744
|
+
}
|
|
745
|
+
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
746
|
+
if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
|
|
747
|
+
if (patch.in_scope !== undefined) {
|
|
748
|
+
if (!Array.isArray(patch.in_scope)) {
|
|
749
|
+
throw createKanbanError(
|
|
750
|
+
'VALIDATION_ERROR',
|
|
751
|
+
'in_scope must be an array of strings',
|
|
752
|
+
'Send in_scope as an array',
|
|
753
|
+
{ field: 'in_scope' },
|
|
754
|
+
false,
|
|
755
|
+
400
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
next.in_scope = normalizeStringArray(patch.in_scope);
|
|
759
|
+
}
|
|
760
|
+
if (patch.out_of_scope !== undefined) {
|
|
761
|
+
if (!Array.isArray(patch.out_of_scope)) {
|
|
762
|
+
throw createKanbanError(
|
|
763
|
+
'VALIDATION_ERROR',
|
|
764
|
+
'out_of_scope must be an array of strings',
|
|
765
|
+
'Send out_of_scope as an array',
|
|
766
|
+
{ field: 'out_of_scope' },
|
|
767
|
+
false,
|
|
768
|
+
400
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
next.out_of_scope = normalizeStringArray(patch.out_of_scope);
|
|
772
|
+
}
|
|
773
|
+
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
774
|
+
|
|
775
|
+
const saved = await writeEpic(next);
|
|
776
|
+
|
|
777
|
+
if (patch.title !== undefined && saved.title !== current.title) {
|
|
778
|
+
const tasks = await allTasks();
|
|
779
|
+
for (const task of tasks) {
|
|
780
|
+
if (task.epic_id === saved.id && task.epic_group !== saved.title) {
|
|
781
|
+
await updateTask(task.id, { epic_group: saved.title, _skipEpicResolve: true });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return saved;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function migrateEpicGroups(options = {}) {
|
|
790
|
+
await ensureBacklogDir();
|
|
791
|
+
const tasks = await allTasks();
|
|
792
|
+
const existing = await listEpicEntities();
|
|
793
|
+
const byTitle = new Map(existing.map((epic) => [epic.title.toLowerCase(), epic]));
|
|
794
|
+
const created = [];
|
|
795
|
+
const linked = [];
|
|
796
|
+
const dryRun = Boolean(options.dryRun);
|
|
797
|
+
|
|
798
|
+
const labels = new Set();
|
|
799
|
+
for (const task of tasks) {
|
|
800
|
+
if (!task.epic_id && task.epic_group && task.epic_group !== '—') {
|
|
801
|
+
labels.add(task.epic_group);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (const label of labels) {
|
|
806
|
+
const key = label.toLowerCase();
|
|
807
|
+
let epic = byTitle.get(key);
|
|
808
|
+
if (!epic) {
|
|
809
|
+
if (dryRun) {
|
|
810
|
+
created.push({ title: label });
|
|
811
|
+
epic = { id: `(new)`, title: label };
|
|
812
|
+
} else {
|
|
813
|
+
epic = await doCreateEpic(label, {
|
|
814
|
+
description: `Migrated from epic_group label "${label}".`
|
|
815
|
+
});
|
|
816
|
+
created.push({ id: epic.id, title: epic.title });
|
|
817
|
+
}
|
|
818
|
+
byTitle.set(key, epic);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
for (const task of tasks) {
|
|
823
|
+
if (task.epic_id || !task.epic_group || task.epic_group === '—') continue;
|
|
824
|
+
const epic = byTitle.get(task.epic_group.toLowerCase());
|
|
825
|
+
if (!epic || !epic.id || epic.id === '(new)') {
|
|
826
|
+
linked.push({ task_id: task.id, epic_group: task.epic_group, dry_run: true });
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (!dryRun) {
|
|
830
|
+
await updateTask(task.id, {
|
|
831
|
+
epic_id: epic.id,
|
|
832
|
+
epic_group: epic.title,
|
|
833
|
+
_skipEpicResolve: true
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
linked.push({ task_id: task.id, epic_id: epic.id, epic_group: epic.title });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return { created, linked, dry_run: dryRun };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function taskMatchesEpicFilter(task, epicFilter) {
|
|
843
|
+
if (isBlankEpicRef(epicFilter)) return true;
|
|
844
|
+
const asId = normalizeEpicId(epicFilter);
|
|
845
|
+
if (asId) return task.epic_id === asId;
|
|
846
|
+
const needle = normalizeString(epicFilter).toLowerCase();
|
|
847
|
+
return task.epic_group.toLowerCase() === needle
|
|
848
|
+
|| (task.epic_id && task.epic_id.toLowerCase() === needle);
|
|
849
|
+
}
|
|
850
|
+
|
|
378
851
|
async function findFile(epicId) {
|
|
379
852
|
for (const col of COLS) {
|
|
380
853
|
const colDir = path.join(BACKLOG, col);
|
|
@@ -383,7 +856,7 @@ async function findFile(epicId) {
|
|
|
383
856
|
const candidates = files
|
|
384
857
|
.filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
|
|
385
858
|
&& path.basename(file, path.extname(file)) === epicId)
|
|
386
|
-
.sort((left,
|
|
859
|
+
.sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
|
|
387
860
|
if (candidates[0]) {
|
|
388
861
|
return path.join(colDir, candidates[0]);
|
|
389
862
|
}
|
|
@@ -549,7 +1022,7 @@ function validatePatch(patch) {
|
|
|
549
1022
|
}
|
|
550
1023
|
}
|
|
551
1024
|
|
|
552
|
-
async function doCreate(title, column = 'planned',
|
|
1025
|
+
async function doCreate(title, column = 'planned', epicRef = '—', extra = {}) {
|
|
553
1026
|
if (!normalizeString(title)) {
|
|
554
1027
|
throw createKanbanError(
|
|
555
1028
|
'MISSING_REQUIRED_FIELD',
|
|
@@ -563,17 +1036,20 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
|
|
|
563
1036
|
|
|
564
1037
|
validateColumn(column, 'col');
|
|
565
1038
|
|
|
1039
|
+
let epicLink = { epic_id: null, epic_group: '—' };
|
|
1040
|
+
if (!isBlankEpicRef(epicRef)) {
|
|
1041
|
+
epicLink = await resolveEpicRef(epicRef, { createIfMissing: true });
|
|
1042
|
+
} else if (extra.epic_id) {
|
|
1043
|
+
epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
566
1046
|
const nextId = await nextTaskNumber();
|
|
567
|
-
const slug = title
|
|
568
|
-
.toLowerCase()
|
|
569
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
570
|
-
.replace(/^-+|-+$/g, '')
|
|
571
|
-
.substring(0, 25);
|
|
572
1047
|
const task = normalizeTask({
|
|
573
1048
|
id: String(nextId).padStart(3, '0'),
|
|
574
1049
|
title,
|
|
575
1050
|
column,
|
|
576
|
-
|
|
1051
|
+
epic_id: epicLink.epic_id,
|
|
1052
|
+
epic_group: epicLink.epic_group,
|
|
577
1053
|
created: todayIso(),
|
|
578
1054
|
description: extra.description,
|
|
579
1055
|
specs: extra.specs,
|
|
@@ -627,7 +1103,24 @@ async function updateTask(taskId, patch) {
|
|
|
627
1103
|
}
|
|
628
1104
|
next.title = title;
|
|
629
1105
|
}
|
|
630
|
-
if (patch.
|
|
1106
|
+
if (!patch._skipEpicResolve) {
|
|
1107
|
+
if (patch.epic_id !== undefined || patch.epic !== undefined || patch.epic_group !== undefined) {
|
|
1108
|
+
const ref = patch.epic_id !== undefined
|
|
1109
|
+
? patch.epic_id
|
|
1110
|
+
: (patch.epic !== undefined ? patch.epic : patch.epic_group);
|
|
1111
|
+
if (isBlankEpicRef(ref)) {
|
|
1112
|
+
next.epic_id = null;
|
|
1113
|
+
next.epic_group = '—';
|
|
1114
|
+
} else {
|
|
1115
|
+
const link = await resolveEpicRef(ref, { createIfMissing: Boolean(patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined) });
|
|
1116
|
+
next.epic_id = link.epic_id;
|
|
1117
|
+
next.epic_group = link.epic_group;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
} else {
|
|
1121
|
+
if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
|
|
1122
|
+
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
1123
|
+
}
|
|
631
1124
|
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
632
1125
|
if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
|
|
633
1126
|
if (patch.in_scope !== undefined) {
|
|
@@ -769,149 +1262,42 @@ async function doUpdate(epicId, newTitle, newTasks) {
|
|
|
769
1262
|
}
|
|
770
1263
|
}
|
|
771
1264
|
|
|
772
|
-
function guiPortFilePath() {
|
|
773
|
-
return path.join(BACKLOG, GUI_PORT_FILE);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function hashCwdToPort(cwd = process.cwd()) {
|
|
777
|
-
let hash = 0;
|
|
778
|
-
const input = String(cwd);
|
|
779
|
-
for (let i = 0; i < input.length; i++) {
|
|
780
|
-
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
|
|
781
|
-
}
|
|
782
|
-
return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function normalizeGuiPort(value) {
|
|
786
|
-
const parsed = Number.parseInt(value, 10);
|
|
787
|
-
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
|
788
|
-
return null;
|
|
789
|
-
}
|
|
790
|
-
return parsed;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
function resolvePreferredGuiPort(explicitPort) {
|
|
794
|
-
if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
|
|
795
|
-
const fromArg = normalizeGuiPort(explicitPort);
|
|
796
|
-
if (fromArg) return fromArg;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
|
|
800
|
-
if (fromEnv) return fromEnv;
|
|
801
|
-
|
|
802
|
-
return hashCwdToPort(process.cwd());
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
function isPidAlive(pid) {
|
|
806
|
-
const n = Number.parseInt(pid, 10);
|
|
807
|
-
if (!Number.isFinite(n) || n <= 0) return false;
|
|
808
|
-
try {
|
|
809
|
-
process.kill(n, 0);
|
|
810
|
-
return true;
|
|
811
|
-
} catch {
|
|
812
|
-
return false;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
async function writeGuiPortFile({ port, pid = process.pid } = {}) {
|
|
817
|
-
const normalizedPort = normalizeGuiPort(port);
|
|
818
|
-
if (!normalizedPort) {
|
|
819
|
-
throw createKanbanError(
|
|
820
|
-
'VALIDATION_ERROR',
|
|
821
|
-
'Invalid GUI port',
|
|
822
|
-
'Use an integer between 1 and 65535',
|
|
823
|
-
{ port },
|
|
824
|
-
false,
|
|
825
|
-
400
|
|
826
|
-
);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
await ensureBacklogDir();
|
|
830
|
-
const data = {
|
|
831
|
-
port: normalizedPort,
|
|
832
|
-
pid,
|
|
833
|
-
url: `http://localhost:${normalizedPort}`,
|
|
834
|
-
cwd: process.cwd(),
|
|
835
|
-
started_at: new Date().toISOString()
|
|
836
|
-
};
|
|
837
|
-
await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
|
|
838
|
-
return data;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async function readGuiPortFile() {
|
|
842
|
-
try {
|
|
843
|
-
const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
|
|
844
|
-
const data = JSON.parse(raw);
|
|
845
|
-
if (!data || !normalizeGuiPort(data.port)) return null;
|
|
846
|
-
return data;
|
|
847
|
-
} catch (error) {
|
|
848
|
-
if (error.code === 'ENOENT') return null;
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async function clearGuiPortFile({ pid, force = false } = {}) {
|
|
854
|
-
const info = await readGuiPortFile();
|
|
855
|
-
if (!info) return false;
|
|
856
|
-
if (!force && pid !== undefined && info.pid !== pid) return false;
|
|
857
|
-
if (!force && pid === undefined && info.pid !== process.pid) return false;
|
|
858
|
-
|
|
859
|
-
try {
|
|
860
|
-
await fs.unlink(guiPortFilePath());
|
|
861
|
-
return true;
|
|
862
|
-
} catch (error) {
|
|
863
|
-
if (error.code === 'ENOENT') return false;
|
|
864
|
-
throw error;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
async function discoverRunningGui() {
|
|
869
|
-
const info = await readGuiPortFile();
|
|
870
|
-
if (!info || !isPidAlive(info.pid)) {
|
|
871
|
-
if (info) await clearGuiPortFile({ force: true });
|
|
872
|
-
return null;
|
|
873
|
-
}
|
|
874
|
-
return {
|
|
875
|
-
status: 'running',
|
|
876
|
-
port: info.port,
|
|
877
|
-
pid: info.pid,
|
|
878
|
-
url: info.url || `http://localhost:${info.port}`,
|
|
879
|
-
cwd: info.cwd,
|
|
880
|
-
started_at: info.started_at
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
|
|
884
1265
|
module.exports = {
|
|
885
1266
|
ensureBacklogDir,
|
|
886
1267
|
parseEpic,
|
|
887
1268
|
allEpics,
|
|
1269
|
+
allTasks,
|
|
888
1270
|
findFile,
|
|
889
1271
|
getTask,
|
|
890
1272
|
shapeTask,
|
|
1273
|
+
shapeEpic,
|
|
891
1274
|
updateTask,
|
|
892
1275
|
migrateAll,
|
|
1276
|
+
migrateEpicGroups,
|
|
893
1277
|
doMove,
|
|
894
1278
|
doToggle,
|
|
895
1279
|
doUpdate,
|
|
896
1280
|
doCreate,
|
|
1281
|
+
doCreateEpic,
|
|
1282
|
+
updateEpicEntity,
|
|
1283
|
+
getEpicEntity,
|
|
1284
|
+
listEpicEntities,
|
|
1285
|
+
resolveEpicRef,
|
|
1286
|
+
taskMatchesEpicFilter,
|
|
897
1287
|
createKanbanError,
|
|
898
1288
|
createFieldWarnings,
|
|
1289
|
+
createEpicFieldWarnings,
|
|
899
1290
|
missingRecommendedCreateFields,
|
|
1291
|
+
missingRecommendedEpicCreateFields,
|
|
900
1292
|
getProgress,
|
|
1293
|
+
getEpicProgress,
|
|
1294
|
+
deriveEpicStatus,
|
|
901
1295
|
resolveTaskId,
|
|
902
|
-
hashCwdToPort,
|
|
903
|
-
normalizeGuiPort,
|
|
904
|
-
resolvePreferredGuiPort,
|
|
905
|
-
isPidAlive,
|
|
906
|
-
writeGuiPortFile,
|
|
907
|
-
readGuiPortFile,
|
|
908
|
-
clearGuiPortFile,
|
|
909
|
-
discoverRunningGui,
|
|
910
|
-
guiPortFilePath,
|
|
911
1296
|
COLS,
|
|
912
1297
|
STATUS_MAP,
|
|
913
1298
|
VIEW_FIELDS,
|
|
1299
|
+
EPIC_VIEW_FIELDS,
|
|
914
1300
|
RECOMMENDED_CREATE_FIELDS,
|
|
915
|
-
|
|
916
|
-
|
|
1301
|
+
RECOMMENDED_EPIC_CREATE_FIELDS,
|
|
1302
|
+
EPICS_DIR
|
|
917
1303
|
};
|