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/kanban.js
CHANGED
|
@@ -2,6 +2,7 @@ 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
7
|
const STATUS_MAP = {
|
|
7
8
|
active: 'in_progress',
|
|
@@ -10,11 +11,12 @@ const STATUS_MAP = {
|
|
|
10
11
|
done: 'done'
|
|
11
12
|
};
|
|
12
13
|
const VIEW_FIELDS = {
|
|
13
|
-
summary: ['task_number', 'title', 'column', 'epic_group', 'created', 'progress'],
|
|
14
|
+
summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
|
|
14
15
|
planning: [
|
|
15
16
|
'task_number',
|
|
16
17
|
'title',
|
|
17
18
|
'column',
|
|
19
|
+
'epic_id',
|
|
18
20
|
'epic_group',
|
|
19
21
|
'created',
|
|
20
22
|
'progress',
|
|
@@ -29,6 +31,7 @@ const VIEW_FIELDS = {
|
|
|
29
31
|
'task_number',
|
|
30
32
|
'title',
|
|
31
33
|
'column',
|
|
34
|
+
'epic_id',
|
|
32
35
|
'epic_group',
|
|
33
36
|
'created',
|
|
34
37
|
'progress',
|
|
@@ -44,6 +47,7 @@ const VIEW_FIELDS = {
|
|
|
44
47
|
'task_number',
|
|
45
48
|
'title',
|
|
46
49
|
'column',
|
|
50
|
+
'epic_id',
|
|
47
51
|
'epic_group',
|
|
48
52
|
'created',
|
|
49
53
|
'progress',
|
|
@@ -58,6 +62,38 @@ const VIEW_FIELDS = {
|
|
|
58
62
|
]
|
|
59
63
|
};
|
|
60
64
|
|
|
65
|
+
const EPIC_VIEW_FIELDS = {
|
|
66
|
+
summary: ['id', 'title', 'created', 'status', 'archived', 'progress'],
|
|
67
|
+
planning: [
|
|
68
|
+
'id',
|
|
69
|
+
'title',
|
|
70
|
+
'created',
|
|
71
|
+
'status',
|
|
72
|
+
'archived',
|
|
73
|
+
'progress',
|
|
74
|
+
'description',
|
|
75
|
+
'goals',
|
|
76
|
+
'in_scope',
|
|
77
|
+
'out_of_scope'
|
|
78
|
+
],
|
|
79
|
+
full: [
|
|
80
|
+
'id',
|
|
81
|
+
'title',
|
|
82
|
+
'created',
|
|
83
|
+
'status',
|
|
84
|
+
'archived',
|
|
85
|
+
'progress',
|
|
86
|
+
'description',
|
|
87
|
+
'goals',
|
|
88
|
+
'in_scope',
|
|
89
|
+
'out_of_scope',
|
|
90
|
+
'notes',
|
|
91
|
+
'tasks'
|
|
92
|
+
]
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const LIVE_EPIC_STATUSES = ['empty', 'planned', 'active'];
|
|
96
|
+
|
|
61
97
|
// Hard-required on create: title only (keeps GUI/CLI quick-add usable).
|
|
62
98
|
// Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
|
|
63
99
|
const RECOMMENDED_CREATE_FIELDS = [
|
|
@@ -68,6 +104,13 @@ const RECOMMENDED_CREATE_FIELDS = [
|
|
|
68
104
|
'acceptance_criteria'
|
|
69
105
|
];
|
|
70
106
|
|
|
107
|
+
const RECOMMENDED_EPIC_CREATE_FIELDS = [
|
|
108
|
+
'description',
|
|
109
|
+
'goals',
|
|
110
|
+
'in_scope',
|
|
111
|
+
'out_of_scope'
|
|
112
|
+
];
|
|
113
|
+
|
|
71
114
|
function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
|
|
72
115
|
const error = new Error(message);
|
|
73
116
|
error.code = code;
|
|
@@ -103,7 +146,12 @@ function normalizeStringArray(value) {
|
|
|
103
146
|
}
|
|
104
147
|
|
|
105
148
|
function isPresentCreateField(field, value) {
|
|
106
|
-
if (
|
|
149
|
+
if (
|
|
150
|
+
field === 'description'
|
|
151
|
+
|| field === 'specs'
|
|
152
|
+
|| field === 'notes'
|
|
153
|
+
|| field === 'goals'
|
|
154
|
+
) {
|
|
107
155
|
return Boolean(normalizeString(value));
|
|
108
156
|
}
|
|
109
157
|
if (
|
|
@@ -123,6 +171,10 @@ function missingRecommendedCreateFields(payload = {}) {
|
|
|
123
171
|
return RECOMMENDED_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
124
172
|
}
|
|
125
173
|
|
|
174
|
+
function missingRecommendedEpicCreateFields(payload = {}) {
|
|
175
|
+
return RECOMMENDED_EPIC_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
function createFieldWarnings(payload = {}) {
|
|
127
179
|
const missing = missingRecommendedCreateFields(payload);
|
|
128
180
|
if (missing.length === 0) return [];
|
|
@@ -132,6 +184,28 @@ function createFieldWarnings(payload = {}) {
|
|
|
132
184
|
];
|
|
133
185
|
}
|
|
134
186
|
|
|
187
|
+
function createEpicFieldWarnings(payload = {}) {
|
|
188
|
+
const missing = missingRecommendedEpicCreateFields(payload);
|
|
189
|
+
if (missing.length === 0) return [];
|
|
190
|
+
return [
|
|
191
|
+
`Strongly recommended epic fields missing: ${missing.join(', ')}. ` +
|
|
192
|
+
'Fill them so agents get initiative context and boundaries.'
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeEpicId(value) {
|
|
197
|
+
const raw = normalizeString(value);
|
|
198
|
+
if (!raw || raw === '—') return null;
|
|
199
|
+
const match = raw.match(/^E0*(\d+)$/i);
|
|
200
|
+
if (match) return `E${String(parseInt(match[1], 10)).padStart(3, '0')}`;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isBlankEpicRef(value) {
|
|
205
|
+
const raw = normalizeString(value);
|
|
206
|
+
return !raw || raw === '—';
|
|
207
|
+
}
|
|
208
|
+
|
|
135
209
|
function normalizeSubtasks(value) {
|
|
136
210
|
if (!Array.isArray(value)) return [];
|
|
137
211
|
return value.map((subtask, idx) => ({
|
|
@@ -164,11 +238,14 @@ function normalizePlan(value) {
|
|
|
164
238
|
|
|
165
239
|
function normalizeTask(task) {
|
|
166
240
|
const id = normalizeString(task.id);
|
|
241
|
+
const epicId = normalizeEpicId(task.epic_id);
|
|
242
|
+
const epicGroup = normalizeString(task.epic_group, '—') || '—';
|
|
167
243
|
const normalized = {
|
|
168
244
|
id,
|
|
169
245
|
title: stripTitlePrefix(task.title || id),
|
|
170
246
|
column: COLS.includes(task.column) ? task.column : 'planned',
|
|
171
|
-
|
|
247
|
+
epic_id: epicId,
|
|
248
|
+
epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
|
|
172
249
|
created: normalizeString(task.created) || todayIso(),
|
|
173
250
|
description: normalizeString(task.description),
|
|
174
251
|
specs: normalizeString(task.specs),
|
|
@@ -192,6 +269,7 @@ function serializeTask(task) {
|
|
|
192
269
|
id: normalized.id,
|
|
193
270
|
title: normalized.title,
|
|
194
271
|
column: normalized.column,
|
|
272
|
+
epic_id: normalized.epic_id,
|
|
195
273
|
epic_group: normalized.epic_group,
|
|
196
274
|
created: normalized.created,
|
|
197
275
|
description: normalized.description,
|
|
@@ -208,6 +286,156 @@ function serializeTask(task) {
|
|
|
208
286
|
};
|
|
209
287
|
}
|
|
210
288
|
|
|
289
|
+
function normalizeEpic(epic) {
|
|
290
|
+
const id = normalizeEpicId(epic && epic.id) || normalizeString(epic && epic.id);
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
title: stripTitlePrefix((epic && epic.title) || id),
|
|
294
|
+
created: normalizeString(epic && epic.created) || todayIso(),
|
|
295
|
+
description: normalizeString(epic && epic.description),
|
|
296
|
+
goals: normalizeString(epic && epic.goals),
|
|
297
|
+
in_scope: normalizeStringArray(epic && epic.in_scope),
|
|
298
|
+
out_of_scope: normalizeStringArray(epic && epic.out_of_scope),
|
|
299
|
+
notes: normalizeString(epic && epic.notes),
|
|
300
|
+
archived: Boolean(epic && epic.archived)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function serializeEpic(epic) {
|
|
305
|
+
const normalized = normalizeEpic(epic);
|
|
306
|
+
return {
|
|
307
|
+
id: normalized.id,
|
|
308
|
+
title: normalized.title,
|
|
309
|
+
created: normalized.created,
|
|
310
|
+
description: normalized.description,
|
|
311
|
+
goals: normalized.goals,
|
|
312
|
+
in_scope: normalized.in_scope,
|
|
313
|
+
out_of_scope: normalized.out_of_scope,
|
|
314
|
+
notes: normalized.notes,
|
|
315
|
+
archived: normalized.archived
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function deriveEpicStatus(tasks, epic) {
|
|
320
|
+
if (epic && epic.archived) return 'archived';
|
|
321
|
+
if (!tasks || tasks.length === 0) return 'empty';
|
|
322
|
+
if (tasks.some((task) => task.column === 'active')) return 'active';
|
|
323
|
+
if (tasks.every((task) => task.column === 'done')) return 'done';
|
|
324
|
+
return 'planned';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isLiveEpic(epicOrShaped) {
|
|
328
|
+
if (!epicOrShaped) return false;
|
|
329
|
+
if (epicOrShaped.archived) return false;
|
|
330
|
+
if (epicOrShaped.status) return LIVE_EPIC_STATUSES.includes(epicOrShaped.status);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function getEpicProgress(tasks) {
|
|
335
|
+
const progress = {
|
|
336
|
+
tasks_total: tasks.length,
|
|
337
|
+
tasks_done: 0,
|
|
338
|
+
tasks_active: 0,
|
|
339
|
+
tasks_planned: 0,
|
|
340
|
+
tasks_icebox: 0
|
|
341
|
+
};
|
|
342
|
+
for (const task of tasks) {
|
|
343
|
+
if (task.column === 'done') progress.tasks_done += 1;
|
|
344
|
+
else if (task.column === 'active') progress.tasks_active += 1;
|
|
345
|
+
else if (task.column === 'planned') progress.tasks_planned += 1;
|
|
346
|
+
else if (task.column === 'icebox') progress.tasks_icebox += 1;
|
|
347
|
+
}
|
|
348
|
+
return progress;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function pickEpicFields(epicPayload, fieldNames) {
|
|
352
|
+
const picked = {};
|
|
353
|
+
for (const field of fieldNames) {
|
|
354
|
+
if (field in epicPayload) {
|
|
355
|
+
picked[field] = epicPayload[field];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return picked;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function shapeEpic(epic, tasks = [], options = {}) {
|
|
362
|
+
const normalized = normalizeEpic(epic);
|
|
363
|
+
const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
|
|
364
|
+
const payload = {
|
|
365
|
+
...normalized,
|
|
366
|
+
status: deriveEpicStatus(childTasks, normalized),
|
|
367
|
+
progress: getEpicProgress(childTasks),
|
|
368
|
+
tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
|
|
369
|
+
};
|
|
370
|
+
const fields = Array.isArray(options.fields) && options.fields.length > 0
|
|
371
|
+
? options.fields
|
|
372
|
+
: (EPIC_VIEW_FIELDS[options.view || 'full'] || EPIC_VIEW_FIELDS.full);
|
|
373
|
+
return pickEpicFields(payload, fields);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function filterShapedEpics(shapedEpics, options = {}) {
|
|
377
|
+
const statusFilter = normalizeString(options.status).toLowerCase() || null;
|
|
378
|
+
if (statusFilter) {
|
|
379
|
+
return shapedEpics.filter((epic) => epic.status === statusFilter);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// live_only=false: human/GUI board — everything except archived unless include_archived
|
|
383
|
+
if (options.live_only === false) {
|
|
384
|
+
if (options.include_archived) return shapedEpics;
|
|
385
|
+
return shapedEpics.filter((epic) => !epic.archived);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Default (agents): only empty|planned|active
|
|
389
|
+
const includeArchived = Boolean(options.include_archived);
|
|
390
|
+
const includeDone = Boolean(options.include_done);
|
|
391
|
+
|
|
392
|
+
return shapedEpics.filter((epic) => {
|
|
393
|
+
if (epic.archived) return includeArchived;
|
|
394
|
+
if (epic.status === 'done') return includeDone || includeArchived;
|
|
395
|
+
return isLiveEpic(epic);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function archivedEpicIdSet(epics) {
|
|
400
|
+
const set = new Set();
|
|
401
|
+
for (const epic of epics) {
|
|
402
|
+
if (epic.archived) set.add(epic.id);
|
|
403
|
+
}
|
|
404
|
+
return set;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function nonLiveEpicIdSet(epics, tasks, options = {}) {
|
|
408
|
+
const includeArchived = Boolean(options.include_archived);
|
|
409
|
+
const includeDone = Boolean(options.include_done);
|
|
410
|
+
if (includeArchived && includeDone) return new Set();
|
|
411
|
+
|
|
412
|
+
const hidden = new Set();
|
|
413
|
+
for (const epic of epics) {
|
|
414
|
+
const childTasks = tasks.filter((task) => task.epic_id === epic.id);
|
|
415
|
+
const status = deriveEpicStatus(childTasks, epic);
|
|
416
|
+
if (status === 'archived' && !includeArchived) hidden.add(epic.id);
|
|
417
|
+
else if (status === 'done' && !includeDone && !includeArchived) hidden.add(epic.id);
|
|
418
|
+
}
|
|
419
|
+
return hidden;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function filterTasksForList(tasks, epics, options = {}) {
|
|
423
|
+
// Explicit epic filter (show that epic's tasks) is applied by caller after this.
|
|
424
|
+
// Default agent list: hide tasks under done/archived epics.
|
|
425
|
+
if (options.include_archived && options.include_done) return tasks;
|
|
426
|
+
// GUI path: hide only archived-epic tasks (done epics still show done cards)
|
|
427
|
+
if (options.live_only === false) {
|
|
428
|
+
if (options.include_archived) return tasks;
|
|
429
|
+
const archivedIds = archivedEpicIdSet(epics);
|
|
430
|
+
if (archivedIds.size === 0) return tasks;
|
|
431
|
+
return tasks.filter((task) => !task.epic_id || !archivedIds.has(task.epic_id));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const hiddenIds = nonLiveEpicIdSet(epics, tasks, options);
|
|
435
|
+
if (hiddenIds.size === 0) return tasks;
|
|
436
|
+
return tasks.filter((task) => !task.epic_id || !hiddenIds.has(task.epic_id));
|
|
437
|
+
}
|
|
438
|
+
|
|
211
439
|
function getProgress(task) {
|
|
212
440
|
const total = task.subtasks.length;
|
|
213
441
|
const done = task.subtasks.filter((subtask) => subtask.done).length;
|
|
@@ -269,6 +497,7 @@ async function ensureBacklogDir() {
|
|
|
269
497
|
const colDir = path.join(BACKLOG, col);
|
|
270
498
|
await fs.mkdir(colDir, { recursive: true });
|
|
271
499
|
}
|
|
500
|
+
await fs.mkdir(EPICS_DIR, { recursive: true });
|
|
272
501
|
}
|
|
273
502
|
|
|
274
503
|
async function parseMarkdownTask(filePath, column) {
|
|
@@ -371,6 +600,398 @@ async function allEpics() {
|
|
|
371
600
|
return epics;
|
|
372
601
|
}
|
|
373
602
|
|
|
603
|
+
async function allTasks() {
|
|
604
|
+
return allEpics();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function epicFilePath(epicId) {
|
|
608
|
+
return path.join(EPICS_DIR, `${epicId}.json`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function nextEpicNumber() {
|
|
612
|
+
await ensureBacklogDir();
|
|
613
|
+
const ids = [];
|
|
614
|
+
try {
|
|
615
|
+
const files = await fs.readdir(EPICS_DIR);
|
|
616
|
+
for (const file of files) {
|
|
617
|
+
const match = file.match(/^E0*(\d+)\.json$/i);
|
|
618
|
+
if (match) ids.push(parseInt(match[1], 10));
|
|
619
|
+
}
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (error.code !== 'ENOENT') throw error;
|
|
622
|
+
}
|
|
623
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function parseJsonEpic(filePath) {
|
|
627
|
+
let raw;
|
|
628
|
+
try {
|
|
629
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (error.code === 'ENOENT') throw error;
|
|
632
|
+
throw createKanbanError(
|
|
633
|
+
'PARSE_ERROR',
|
|
634
|
+
`Epic file ${path.basename(filePath)} could not be read`,
|
|
635
|
+
'Fix permissions or restore the file from version control',
|
|
636
|
+
{ file: filePath, reason: error.message },
|
|
637
|
+
false,
|
|
638
|
+
500
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
let data;
|
|
642
|
+
try {
|
|
643
|
+
data = JSON.parse(raw);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
throw createKanbanError(
|
|
646
|
+
'PARSE_ERROR',
|
|
647
|
+
`Epic file ${path.basename(filePath)} could not be parsed`,
|
|
648
|
+
'Fix the JSON syntax or restore the file from version control',
|
|
649
|
+
{ file: filePath, reason: error.message },
|
|
650
|
+
false,
|
|
651
|
+
500
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return normalizeEpic({
|
|
655
|
+
...data,
|
|
656
|
+
id: data.id || path.basename(filePath, '.json')
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function listEpicEntities() {
|
|
661
|
+
await ensureBacklogDir();
|
|
662
|
+
const epics = [];
|
|
663
|
+
try {
|
|
664
|
+
const files = (await fs.readdir(EPICS_DIR))
|
|
665
|
+
.filter((file) => file.endsWith('.json'))
|
|
666
|
+
.sort((left, right) => left.localeCompare(right));
|
|
667
|
+
for (const file of files) {
|
|
668
|
+
try {
|
|
669
|
+
epics.push(await parseJsonEpic(path.join(EPICS_DIR, file)));
|
|
670
|
+
} catch (error) {
|
|
671
|
+
console.error(` parse error ${file}: ${error.message}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error.code !== 'ENOENT') throw error;
|
|
676
|
+
}
|
|
677
|
+
return epics;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function writeEpic(epic) {
|
|
681
|
+
const normalized = normalizeEpic(epic);
|
|
682
|
+
if (!normalizeEpicId(normalized.id)) {
|
|
683
|
+
throw createKanbanError(
|
|
684
|
+
'VALIDATION_ERROR',
|
|
685
|
+
'epic id must look like E001',
|
|
686
|
+
'Use an epic id such as E001',
|
|
687
|
+
{ epic_id: normalized.id },
|
|
688
|
+
false,
|
|
689
|
+
400
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
await ensureBacklogDir();
|
|
693
|
+
const filePath = epicFilePath(normalized.id);
|
|
694
|
+
await fs.writeFile(filePath, JSON.stringify(serializeEpic(normalized), null, 2) + '\n', 'utf-8');
|
|
695
|
+
return parseJsonEpic(filePath);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function getEpicEntity(epicId) {
|
|
699
|
+
const id = normalizeEpicId(epicId) || normalizeString(epicId);
|
|
700
|
+
if (!id) {
|
|
701
|
+
throw createKanbanError(
|
|
702
|
+
'EPIC_NOT_FOUND',
|
|
703
|
+
`Epic ${epicId} was not found`,
|
|
704
|
+
'Call kanban_read with operation=list_epics to discover valid epic ids',
|
|
705
|
+
{ epic_id: epicId },
|
|
706
|
+
false,
|
|
707
|
+
404
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const filePath = epicFilePath(normalizeEpicId(id) || id);
|
|
711
|
+
try {
|
|
712
|
+
return await parseJsonEpic(filePath);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
if (error.code === 'ENOENT' || error.code === 'PARSE_ERROR') {
|
|
715
|
+
if (error.code === 'PARSE_ERROR') throw error;
|
|
716
|
+
throw createKanbanError(
|
|
717
|
+
'EPIC_NOT_FOUND',
|
|
718
|
+
`Epic ${epicId} was not found`,
|
|
719
|
+
'Call kanban_read with operation=list_epics to discover valid epic ids',
|
|
720
|
+
{ epic_id: epicId },
|
|
721
|
+
false,
|
|
722
|
+
404
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function findEpicsByTitle(title) {
|
|
730
|
+
const needle = normalizeString(title).toLowerCase();
|
|
731
|
+
if (!needle) return [];
|
|
732
|
+
const epics = await listEpicEntities();
|
|
733
|
+
return epics.filter((epic) => epic.title.toLowerCase() === needle);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function resolveEpicRef(ref, options = {}) {
|
|
737
|
+
if (isBlankEpicRef(ref)) {
|
|
738
|
+
return { epic_id: null, epic_group: '—' };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const asId = normalizeEpicId(ref);
|
|
742
|
+
if (asId) {
|
|
743
|
+
const epic = await getEpicEntity(asId);
|
|
744
|
+
return { epic_id: epic.id, epic_group: epic.title };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const matches = await findEpicsByTitle(ref);
|
|
748
|
+
if (matches.length === 1) {
|
|
749
|
+
return { epic_id: matches[0].id, epic_group: matches[0].title };
|
|
750
|
+
}
|
|
751
|
+
if (matches.length > 1) {
|
|
752
|
+
throw createKanbanError(
|
|
753
|
+
'AMBIGUOUS_EPIC',
|
|
754
|
+
`Multiple epics titled "${normalizeString(ref)}"`,
|
|
755
|
+
'Use an epic id (E001) instead of the title',
|
|
756
|
+
{ title: normalizeString(ref), epic_ids: matches.map((epic) => epic.id) },
|
|
757
|
+
false,
|
|
758
|
+
400
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (options.createIfMissing) {
|
|
763
|
+
const created = await doCreateEpic(normalizeString(ref), {});
|
|
764
|
+
return { epic_id: created.id, epic_group: created.title };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
throw createKanbanError(
|
|
768
|
+
'EPIC_NOT_FOUND',
|
|
769
|
+
`Epic "${normalizeString(ref)}" was not found`,
|
|
770
|
+
'Create it with epic_create or pass an existing epic id/title',
|
|
771
|
+
{ epic: normalizeString(ref) },
|
|
772
|
+
false,
|
|
773
|
+
404
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function doCreateEpic(title, extra = {}) {
|
|
778
|
+
if (!normalizeString(title)) {
|
|
779
|
+
throw createKanbanError(
|
|
780
|
+
'MISSING_REQUIRED_FIELD',
|
|
781
|
+
'title is required',
|
|
782
|
+
'Provide a non-empty title when creating an epic',
|
|
783
|
+
{ field: 'title' },
|
|
784
|
+
false,
|
|
785
|
+
400
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const nextId = await nextEpicNumber();
|
|
790
|
+
const epic = normalizeEpic({
|
|
791
|
+
id: `E${String(nextId).padStart(3, '0')}`,
|
|
792
|
+
title,
|
|
793
|
+
created: todayIso(),
|
|
794
|
+
description: extra.description,
|
|
795
|
+
goals: extra.goals,
|
|
796
|
+
in_scope: extra.in_scope,
|
|
797
|
+
out_of_scope: extra.out_of_scope,
|
|
798
|
+
notes: extra.notes
|
|
799
|
+
});
|
|
800
|
+
return writeEpic(epic);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function updateEpicEntity(epicId, patch) {
|
|
804
|
+
validatePatch(patch);
|
|
805
|
+
const current = await getEpicEntity(epicId);
|
|
806
|
+
const next = { ...current };
|
|
807
|
+
|
|
808
|
+
if (patch.title !== undefined) {
|
|
809
|
+
const title = normalizeString(patch.title);
|
|
810
|
+
if (!title) {
|
|
811
|
+
throw createKanbanError(
|
|
812
|
+
'VALIDATION_ERROR',
|
|
813
|
+
'title must be a non-empty string',
|
|
814
|
+
'Send a non-empty title or omit the field',
|
|
815
|
+
{ field: 'title' },
|
|
816
|
+
false,
|
|
817
|
+
400
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
next.title = title;
|
|
821
|
+
}
|
|
822
|
+
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
823
|
+
if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
|
|
824
|
+
if (patch.in_scope !== undefined) {
|
|
825
|
+
if (!Array.isArray(patch.in_scope)) {
|
|
826
|
+
throw createKanbanError(
|
|
827
|
+
'VALIDATION_ERROR',
|
|
828
|
+
'in_scope must be an array of strings',
|
|
829
|
+
'Send in_scope as an array',
|
|
830
|
+
{ field: 'in_scope' },
|
|
831
|
+
false,
|
|
832
|
+
400
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
next.in_scope = normalizeStringArray(patch.in_scope);
|
|
836
|
+
}
|
|
837
|
+
if (patch.out_of_scope !== undefined) {
|
|
838
|
+
if (!Array.isArray(patch.out_of_scope)) {
|
|
839
|
+
throw createKanbanError(
|
|
840
|
+
'VALIDATION_ERROR',
|
|
841
|
+
'out_of_scope must be an array of strings',
|
|
842
|
+
'Send out_of_scope as an array',
|
|
843
|
+
{ field: 'out_of_scope' },
|
|
844
|
+
false,
|
|
845
|
+
400
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
next.out_of_scope = normalizeStringArray(patch.out_of_scope);
|
|
849
|
+
}
|
|
850
|
+
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
851
|
+
if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
|
|
852
|
+
|
|
853
|
+
const saved = await writeEpic(next);
|
|
854
|
+
|
|
855
|
+
if (patch.title !== undefined && saved.title !== current.title) {
|
|
856
|
+
const tasks = await allTasks();
|
|
857
|
+
for (const task of tasks) {
|
|
858
|
+
if (task.epic_id === saved.id && task.epic_group !== saved.title) {
|
|
859
|
+
await updateTask(task.id, { epic_group: saved.title, _skipEpicResolve: true });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return saved;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function archiveEpic(epicId) {
|
|
868
|
+
return updateEpicEntity(epicId, { archived: true });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function unarchiveEpic(epicId) {
|
|
872
|
+
return updateEpicEntity(epicId, { archived: false });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function deleteTask(taskId) {
|
|
876
|
+
const resolvedId = await resolveTaskId(taskId);
|
|
877
|
+
const filePath = await findFile(resolvedId);
|
|
878
|
+
if (!filePath) {
|
|
879
|
+
throw createKanbanError(
|
|
880
|
+
'TASK_NOT_FOUND',
|
|
881
|
+
`Task ${taskId} was not found`,
|
|
882
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
883
|
+
{ task_id: taskId },
|
|
884
|
+
false,
|
|
885
|
+
404
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const column = path.basename(path.dirname(filePath));
|
|
890
|
+
const task = await parseEpic(filePath, column);
|
|
891
|
+
await fs.unlink(filePath).catch((error) => {
|
|
892
|
+
if (error.code !== 'ENOENT') throw error;
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
return {
|
|
896
|
+
ok: true,
|
|
897
|
+
task_id: task.id,
|
|
898
|
+
task_number: task.task_number,
|
|
899
|
+
title: task.title,
|
|
900
|
+
column: task.column
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async function deleteEpic(epicId) {
|
|
905
|
+
const epic = await getEpicEntity(epicId);
|
|
906
|
+
const tasks = await allTasks();
|
|
907
|
+
const children = tasks.filter((task) => task.epic_id === epic.id);
|
|
908
|
+
const deletedTasks = [];
|
|
909
|
+
|
|
910
|
+
for (const child of children) {
|
|
911
|
+
const result = await deleteTask(child.id);
|
|
912
|
+
deletedTasks.push({
|
|
913
|
+
task_id: result.task_id,
|
|
914
|
+
title: result.title,
|
|
915
|
+
column: result.column
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const filePath = epicFilePath(epic.id);
|
|
920
|
+
await fs.unlink(filePath).catch((error) => {
|
|
921
|
+
if (error.code !== 'ENOENT') throw error;
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
ok: true,
|
|
926
|
+
epic_id: epic.id,
|
|
927
|
+
title: epic.title,
|
|
928
|
+
deleted_tasks: deletedTasks,
|
|
929
|
+
deleted_task_count: deletedTasks.length
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function migrateEpicGroups(options = {}) {
|
|
934
|
+
await ensureBacklogDir();
|
|
935
|
+
const tasks = await allTasks();
|
|
936
|
+
const existing = await listEpicEntities();
|
|
937
|
+
const byTitle = new Map(existing.map((epic) => [epic.title.toLowerCase(), epic]));
|
|
938
|
+
const created = [];
|
|
939
|
+
const linked = [];
|
|
940
|
+
const dryRun = Boolean(options.dryRun);
|
|
941
|
+
|
|
942
|
+
const labels = new Set();
|
|
943
|
+
for (const task of tasks) {
|
|
944
|
+
if (!task.epic_id && task.epic_group && task.epic_group !== '—') {
|
|
945
|
+
labels.add(task.epic_group);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
for (const label of labels) {
|
|
950
|
+
const key = label.toLowerCase();
|
|
951
|
+
let epic = byTitle.get(key);
|
|
952
|
+
if (!epic) {
|
|
953
|
+
if (dryRun) {
|
|
954
|
+
created.push({ title: label });
|
|
955
|
+
epic = { id: `(new)`, title: label };
|
|
956
|
+
} else {
|
|
957
|
+
epic = await doCreateEpic(label, {
|
|
958
|
+
description: `Migrated from epic_group label "${label}".`
|
|
959
|
+
});
|
|
960
|
+
created.push({ id: epic.id, title: epic.title });
|
|
961
|
+
}
|
|
962
|
+
byTitle.set(key, epic);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
for (const task of tasks) {
|
|
967
|
+
if (task.epic_id || !task.epic_group || task.epic_group === '—') continue;
|
|
968
|
+
const epic = byTitle.get(task.epic_group.toLowerCase());
|
|
969
|
+
if (!epic || !epic.id || epic.id === '(new)') {
|
|
970
|
+
linked.push({ task_id: task.id, epic_group: task.epic_group, dry_run: true });
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (!dryRun) {
|
|
974
|
+
await updateTask(task.id, {
|
|
975
|
+
epic_id: epic.id,
|
|
976
|
+
epic_group: epic.title,
|
|
977
|
+
_skipEpicResolve: true
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
linked.push({ task_id: task.id, epic_id: epic.id, epic_group: epic.title });
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return { created, linked, dry_run: dryRun };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function taskMatchesEpicFilter(task, epicFilter) {
|
|
987
|
+
if (isBlankEpicRef(epicFilter)) return true;
|
|
988
|
+
const asId = normalizeEpicId(epicFilter);
|
|
989
|
+
if (asId) return task.epic_id === asId;
|
|
990
|
+
const needle = normalizeString(epicFilter).toLowerCase();
|
|
991
|
+
return task.epic_group.toLowerCase() === needle
|
|
992
|
+
|| (task.epic_id && task.epic_id.toLowerCase() === needle);
|
|
993
|
+
}
|
|
994
|
+
|
|
374
995
|
async function findFile(epicId) {
|
|
375
996
|
for (const col of COLS) {
|
|
376
997
|
const colDir = path.join(BACKLOG, col);
|
|
@@ -545,7 +1166,7 @@ function validatePatch(patch) {
|
|
|
545
1166
|
}
|
|
546
1167
|
}
|
|
547
1168
|
|
|
548
|
-
async function doCreate(title, column = 'planned',
|
|
1169
|
+
async function doCreate(title, column = 'planned', epicRef = '—', extra = {}) {
|
|
549
1170
|
if (!normalizeString(title)) {
|
|
550
1171
|
throw createKanbanError(
|
|
551
1172
|
'MISSING_REQUIRED_FIELD',
|
|
@@ -559,12 +1180,20 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
|
|
|
559
1180
|
|
|
560
1181
|
validateColumn(column, 'col');
|
|
561
1182
|
|
|
1183
|
+
let epicLink = { epic_id: null, epic_group: '—' };
|
|
1184
|
+
if (!isBlankEpicRef(epicRef)) {
|
|
1185
|
+
epicLink = await resolveEpicRef(epicRef, { createIfMissing: true });
|
|
1186
|
+
} else if (extra.epic_id) {
|
|
1187
|
+
epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
562
1190
|
const nextId = await nextTaskNumber();
|
|
563
1191
|
const task = normalizeTask({
|
|
564
1192
|
id: String(nextId).padStart(3, '0'),
|
|
565
1193
|
title,
|
|
566
1194
|
column,
|
|
567
|
-
|
|
1195
|
+
epic_id: epicLink.epic_id,
|
|
1196
|
+
epic_group: epicLink.epic_group,
|
|
568
1197
|
created: todayIso(),
|
|
569
1198
|
description: extra.description,
|
|
570
1199
|
specs: extra.specs,
|
|
@@ -618,7 +1247,24 @@ async function updateTask(taskId, patch) {
|
|
|
618
1247
|
}
|
|
619
1248
|
next.title = title;
|
|
620
1249
|
}
|
|
621
|
-
if (patch.
|
|
1250
|
+
if (!patch._skipEpicResolve) {
|
|
1251
|
+
if (patch.epic_id !== undefined || patch.epic !== undefined || patch.epic_group !== undefined) {
|
|
1252
|
+
const ref = patch.epic_id !== undefined
|
|
1253
|
+
? patch.epic_id
|
|
1254
|
+
: (patch.epic !== undefined ? patch.epic : patch.epic_group);
|
|
1255
|
+
if (isBlankEpicRef(ref)) {
|
|
1256
|
+
next.epic_id = null;
|
|
1257
|
+
next.epic_group = '—';
|
|
1258
|
+
} else {
|
|
1259
|
+
const link = await resolveEpicRef(ref, { createIfMissing: Boolean(patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined) });
|
|
1260
|
+
next.epic_id = link.epic_id;
|
|
1261
|
+
next.epic_group = link.epic_group;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
} else {
|
|
1265
|
+
if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
|
|
1266
|
+
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
1267
|
+
}
|
|
622
1268
|
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
623
1269
|
if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
|
|
624
1270
|
if (patch.in_scope !== undefined) {
|
|
@@ -764,22 +1410,46 @@ module.exports = {
|
|
|
764
1410
|
ensureBacklogDir,
|
|
765
1411
|
parseEpic,
|
|
766
1412
|
allEpics,
|
|
1413
|
+
allTasks,
|
|
767
1414
|
findFile,
|
|
768
1415
|
getTask,
|
|
769
1416
|
shapeTask,
|
|
1417
|
+
shapeEpic,
|
|
770
1418
|
updateTask,
|
|
771
1419
|
migrateAll,
|
|
1420
|
+
migrateEpicGroups,
|
|
772
1421
|
doMove,
|
|
773
1422
|
doToggle,
|
|
774
1423
|
doUpdate,
|
|
775
1424
|
doCreate,
|
|
1425
|
+
doCreateEpic,
|
|
1426
|
+
updateEpicEntity,
|
|
1427
|
+
archiveEpic,
|
|
1428
|
+
unarchiveEpic,
|
|
1429
|
+
deleteTask,
|
|
1430
|
+
deleteEpic,
|
|
1431
|
+
getEpicEntity,
|
|
1432
|
+
listEpicEntities,
|
|
1433
|
+
resolveEpicRef,
|
|
1434
|
+
taskMatchesEpicFilter,
|
|
1435
|
+
filterShapedEpics,
|
|
1436
|
+
filterTasksForList,
|
|
1437
|
+
isLiveEpic,
|
|
776
1438
|
createKanbanError,
|
|
777
1439
|
createFieldWarnings,
|
|
1440
|
+
createEpicFieldWarnings,
|
|
778
1441
|
missingRecommendedCreateFields,
|
|
1442
|
+
missingRecommendedEpicCreateFields,
|
|
779
1443
|
getProgress,
|
|
1444
|
+
getEpicProgress,
|
|
1445
|
+
deriveEpicStatus,
|
|
780
1446
|
resolveTaskId,
|
|
781
1447
|
COLS,
|
|
782
1448
|
STATUS_MAP,
|
|
783
1449
|
VIEW_FIELDS,
|
|
784
|
-
|
|
1450
|
+
EPIC_VIEW_FIELDS,
|
|
1451
|
+
LIVE_EPIC_STATUSES,
|
|
1452
|
+
RECOMMENDED_CREATE_FIELDS,
|
|
1453
|
+
RECOMMENDED_EPIC_CREATE_FIELDS,
|
|
1454
|
+
EPICS_DIR
|
|
785
1455
|
};
|