kanbango 3.1.0 → 3.3.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 +3 -0
- package/.ai/retro/last-run.json +1 -1
- package/.opencode/plans/action-error-recipe.md +121 -0
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +27 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +11 -3
- package/agent-playbook.js +18 -9
- package/bin/kanban.js +143 -10
- package/gui-registry.js +14 -1
- package/index.html +645 -39
- package/kanban.js +474 -110
- package/mcp-server.js +253 -45
- package/package.json +1 -1
- package/tests/run.js +3 -0
package/kanban.js
CHANGED
|
@@ -41,7 +41,8 @@ const VIEW_FIELDS = {
|
|
|
41
41
|
'out_of_scope',
|
|
42
42
|
'acceptance_criteria',
|
|
43
43
|
'test_cases',
|
|
44
|
-
'subtasks'
|
|
44
|
+
'subtasks',
|
|
45
|
+
'comments'
|
|
45
46
|
],
|
|
46
47
|
full: [
|
|
47
48
|
'task_number',
|
|
@@ -58,17 +59,19 @@ const VIEW_FIELDS = {
|
|
|
58
59
|
'acceptance_criteria',
|
|
59
60
|
'test_cases',
|
|
60
61
|
'subtasks',
|
|
61
|
-
'notes'
|
|
62
|
+
'notes',
|
|
63
|
+
'comments'
|
|
62
64
|
]
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
const EPIC_VIEW_FIELDS = {
|
|
66
|
-
summary: ['id', 'title', 'created', 'status', 'progress'],
|
|
68
|
+
summary: ['id', 'title', 'created', 'status', 'archived', 'progress'],
|
|
67
69
|
planning: [
|
|
68
70
|
'id',
|
|
69
71
|
'title',
|
|
70
72
|
'created',
|
|
71
73
|
'status',
|
|
74
|
+
'archived',
|
|
72
75
|
'progress',
|
|
73
76
|
'description',
|
|
74
77
|
'goals',
|
|
@@ -80,6 +83,7 @@ const EPIC_VIEW_FIELDS = {
|
|
|
80
83
|
'title',
|
|
81
84
|
'created',
|
|
82
85
|
'status',
|
|
86
|
+
'archived',
|
|
83
87
|
'progress',
|
|
84
88
|
'description',
|
|
85
89
|
'goals',
|
|
@@ -90,6 +94,8 @@ const EPIC_VIEW_FIELDS = {
|
|
|
90
94
|
]
|
|
91
95
|
};
|
|
92
96
|
|
|
97
|
+
const LIVE_EPIC_STATUSES = ['empty', 'planned', 'active'];
|
|
98
|
+
|
|
93
99
|
// Hard-required on create: title only (keeps GUI/CLI quick-add usable).
|
|
94
100
|
// Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
|
|
95
101
|
const RECOMMENDED_CREATE_FIELDS = [
|
|
@@ -117,6 +123,43 @@ function createKanbanError(code, message, hint, details = {}, retryable = false,
|
|
|
117
123
|
return error;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
// Serialize board mutations so concurrent create/move/update cannot race on ids or paths.
|
|
127
|
+
let mutationTail = Promise.resolve();
|
|
128
|
+
|
|
129
|
+
function withBoardLock(fn) {
|
|
130
|
+
const run = mutationTail.then(() => fn());
|
|
131
|
+
mutationTail = run.then(() => undefined, () => undefined);
|
|
132
|
+
return run;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isTaskOrEpicDataFile(file) {
|
|
136
|
+
if (!file || file.startsWith('.')) return false;
|
|
137
|
+
return file.endsWith('.json') || file.endsWith('.md');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function writeFileAtomic(filePath, payload, { exclusive = false } = {}) {
|
|
141
|
+
const dir = path.dirname(filePath);
|
|
142
|
+
const base = path.basename(filePath);
|
|
143
|
+
const tempPath = path.join(
|
|
144
|
+
dir,
|
|
145
|
+
`.${base}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await fs.writeFile(tempPath, payload, 'utf-8');
|
|
149
|
+
try {
|
|
150
|
+
if (exclusive) {
|
|
151
|
+
// Atomic create-if-absent: never exposes an empty final path to readers.
|
|
152
|
+
await fs.link(tempPath, filePath);
|
|
153
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
154
|
+
} else {
|
|
155
|
+
await fs.rename(tempPath, filePath);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
120
163
|
function todayIso() {
|
|
121
164
|
return new Date().toISOString().split('T')[0];
|
|
122
165
|
}
|
|
@@ -212,6 +255,28 @@ function normalizeSubtasks(value) {
|
|
|
212
255
|
})).filter((subtask) => subtask.text);
|
|
213
256
|
}
|
|
214
257
|
|
|
258
|
+
function nowIso() {
|
|
259
|
+
return new Date().toISOString();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function nextCommentId(comments) {
|
|
263
|
+
const max = comments.reduce((highest, comment) => {
|
|
264
|
+
const match = String(comment && comment.id || '').match(/^c-(\d+)$/i);
|
|
265
|
+
return match ? Math.max(highest, parseInt(match[1], 10)) : highest;
|
|
266
|
+
}, 0);
|
|
267
|
+
return `c-${max + 1}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function normalizeComments(value) {
|
|
271
|
+
if (!Array.isArray(value)) return [];
|
|
272
|
+
return value.map((item, idx) => ({
|
|
273
|
+
id: normalizeString(item && item.id, `c-${idx + 1}`),
|
|
274
|
+
created: normalizeString(item && item.created) || nowIso(),
|
|
275
|
+
author: normalizeString(item && item.author, 'user') || 'user',
|
|
276
|
+
text: normalizeString(item && item.text)
|
|
277
|
+
})).filter((item) => item.text);
|
|
278
|
+
}
|
|
279
|
+
|
|
215
280
|
function normalizeEvidence(value) {
|
|
216
281
|
if (!Array.isArray(value)) return [];
|
|
217
282
|
return value.map((item) => ({
|
|
@@ -251,6 +316,7 @@ function normalizeTask(task) {
|
|
|
251
316
|
test_cases: normalizeStringArray(task.test_cases),
|
|
252
317
|
subtasks: normalizeSubtasks(task.subtasks),
|
|
253
318
|
notes: normalizeString(task.notes),
|
|
319
|
+
comments: normalizeComments(task.comments),
|
|
254
320
|
plan: normalizePlan(task.plan),
|
|
255
321
|
evidence: normalizeEvidence(task.evidence),
|
|
256
322
|
task_number: extractTaskNumber(id)
|
|
@@ -276,6 +342,7 @@ function serializeTask(task) {
|
|
|
276
342
|
test_cases: normalized.test_cases,
|
|
277
343
|
subtasks: normalized.subtasks,
|
|
278
344
|
notes: normalized.notes,
|
|
345
|
+
comments: normalized.comments,
|
|
279
346
|
plan: normalized.plan,
|
|
280
347
|
evidence: normalized.evidence,
|
|
281
348
|
task_number: normalized.task_number
|
|
@@ -292,7 +359,8 @@ function normalizeEpic(epic) {
|
|
|
292
359
|
goals: normalizeString(epic && epic.goals),
|
|
293
360
|
in_scope: normalizeStringArray(epic && epic.in_scope),
|
|
294
361
|
out_of_scope: normalizeStringArray(epic && epic.out_of_scope),
|
|
295
|
-
notes: normalizeString(epic && epic.notes)
|
|
362
|
+
notes: normalizeString(epic && epic.notes),
|
|
363
|
+
archived: Boolean(epic && epic.archived)
|
|
296
364
|
};
|
|
297
365
|
}
|
|
298
366
|
|
|
@@ -306,17 +374,26 @@ function serializeEpic(epic) {
|
|
|
306
374
|
goals: normalized.goals,
|
|
307
375
|
in_scope: normalized.in_scope,
|
|
308
376
|
out_of_scope: normalized.out_of_scope,
|
|
309
|
-
notes: normalized.notes
|
|
377
|
+
notes: normalized.notes,
|
|
378
|
+
archived: normalized.archived
|
|
310
379
|
};
|
|
311
380
|
}
|
|
312
381
|
|
|
313
|
-
function deriveEpicStatus(tasks) {
|
|
382
|
+
function deriveEpicStatus(tasks, epic) {
|
|
383
|
+
if (epic && epic.archived) return 'archived';
|
|
314
384
|
if (!tasks || tasks.length === 0) return 'empty';
|
|
315
385
|
if (tasks.some((task) => task.column === 'active')) return 'active';
|
|
316
386
|
if (tasks.every((task) => task.column === 'done')) return 'done';
|
|
317
387
|
return 'planned';
|
|
318
388
|
}
|
|
319
389
|
|
|
390
|
+
function isLiveEpic(epicOrShaped) {
|
|
391
|
+
if (!epicOrShaped) return false;
|
|
392
|
+
if (epicOrShaped.archived) return false;
|
|
393
|
+
if (epicOrShaped.status) return LIVE_EPIC_STATUSES.includes(epicOrShaped.status);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
320
397
|
function getEpicProgress(tasks) {
|
|
321
398
|
const progress = {
|
|
322
399
|
tasks_total: tasks.length,
|
|
@@ -349,7 +426,7 @@ function shapeEpic(epic, tasks = [], options = {}) {
|
|
|
349
426
|
const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
|
|
350
427
|
const payload = {
|
|
351
428
|
...normalized,
|
|
352
|
-
status: deriveEpicStatus(childTasks),
|
|
429
|
+
status: deriveEpicStatus(childTasks, normalized),
|
|
353
430
|
progress: getEpicProgress(childTasks),
|
|
354
431
|
tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
|
|
355
432
|
};
|
|
@@ -359,6 +436,69 @@ function shapeEpic(epic, tasks = [], options = {}) {
|
|
|
359
436
|
return pickEpicFields(payload, fields);
|
|
360
437
|
}
|
|
361
438
|
|
|
439
|
+
function filterShapedEpics(shapedEpics, options = {}) {
|
|
440
|
+
const statusFilter = normalizeString(options.status).toLowerCase() || null;
|
|
441
|
+
if (statusFilter) {
|
|
442
|
+
return shapedEpics.filter((epic) => epic.status === statusFilter);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// live_only=false: human/GUI board — everything except archived unless include_archived
|
|
446
|
+
if (options.live_only === false) {
|
|
447
|
+
if (options.include_archived) return shapedEpics;
|
|
448
|
+
return shapedEpics.filter((epic) => !epic.archived);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Default (agents): only empty|planned|active
|
|
452
|
+
const includeArchived = Boolean(options.include_archived);
|
|
453
|
+
const includeDone = Boolean(options.include_done);
|
|
454
|
+
|
|
455
|
+
return shapedEpics.filter((epic) => {
|
|
456
|
+
if (epic.archived) return includeArchived;
|
|
457
|
+
if (epic.status === 'done') return includeDone || includeArchived;
|
|
458
|
+
return isLiveEpic(epic);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function archivedEpicIdSet(epics) {
|
|
463
|
+
const set = new Set();
|
|
464
|
+
for (const epic of epics) {
|
|
465
|
+
if (epic.archived) set.add(epic.id);
|
|
466
|
+
}
|
|
467
|
+
return set;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function nonLiveEpicIdSet(epics, tasks, options = {}) {
|
|
471
|
+
const includeArchived = Boolean(options.include_archived);
|
|
472
|
+
const includeDone = Boolean(options.include_done);
|
|
473
|
+
if (includeArchived && includeDone) return new Set();
|
|
474
|
+
|
|
475
|
+
const hidden = new Set();
|
|
476
|
+
for (const epic of epics) {
|
|
477
|
+
const childTasks = tasks.filter((task) => task.epic_id === epic.id);
|
|
478
|
+
const status = deriveEpicStatus(childTasks, epic);
|
|
479
|
+
if (status === 'archived' && !includeArchived) hidden.add(epic.id);
|
|
480
|
+
else if (status === 'done' && !includeDone && !includeArchived) hidden.add(epic.id);
|
|
481
|
+
}
|
|
482
|
+
return hidden;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function filterTasksForList(tasks, epics, options = {}) {
|
|
486
|
+
// Explicit epic filter (show that epic's tasks) is applied by caller after this.
|
|
487
|
+
// Default agent list: hide tasks under done/archived epics.
|
|
488
|
+
if (options.include_archived && options.include_done) return tasks;
|
|
489
|
+
// GUI path: hide only archived-epic tasks (done epics still show done cards)
|
|
490
|
+
if (options.live_only === false) {
|
|
491
|
+
if (options.include_archived) return tasks;
|
|
492
|
+
const archivedIds = archivedEpicIdSet(epics);
|
|
493
|
+
if (archivedIds.size === 0) return tasks;
|
|
494
|
+
return tasks.filter((task) => !task.epic_id || !archivedIds.has(task.epic_id));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const hiddenIds = nonLiveEpicIdSet(epics, tasks, options);
|
|
498
|
+
if (hiddenIds.size === 0) return tasks;
|
|
499
|
+
return tasks.filter((task) => !task.epic_id || !hiddenIds.has(task.epic_id));
|
|
500
|
+
}
|
|
501
|
+
|
|
362
502
|
function getProgress(task) {
|
|
363
503
|
const total = task.subtasks.length;
|
|
364
504
|
const done = task.subtasks.filter((subtask) => subtask.done).length;
|
|
@@ -459,9 +599,24 @@ async function parseMarkdownTask(filePath, column) {
|
|
|
459
599
|
}
|
|
460
600
|
|
|
461
601
|
async function parseJsonTask(filePath, column) {
|
|
602
|
+
let raw;
|
|
603
|
+
try {
|
|
604
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error.code === 'ENOENT') throw error;
|
|
607
|
+
throw createKanbanError(
|
|
608
|
+
'PARSE_ERROR',
|
|
609
|
+
`Task file ${path.basename(filePath)} could not be read`,
|
|
610
|
+
'Fix permissions or restore the file from version control',
|
|
611
|
+
{ file: filePath, reason: error.message },
|
|
612
|
+
false,
|
|
613
|
+
500
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
462
617
|
let data;
|
|
463
618
|
try {
|
|
464
|
-
data = JSON.parse(
|
|
619
|
+
data = JSON.parse(raw);
|
|
465
620
|
} catch (error) {
|
|
466
621
|
throw createKanbanError(
|
|
467
622
|
'PARSE_ERROR',
|
|
@@ -495,7 +650,7 @@ async function allEpics() {
|
|
|
495
650
|
try {
|
|
496
651
|
const files = await fs.readdir(colDir);
|
|
497
652
|
const taskFiles = files
|
|
498
|
-
.filter((file) =>
|
|
653
|
+
.filter((file) => isTaskOrEpicDataFile(file))
|
|
499
654
|
.sort((left, right) => {
|
|
500
655
|
const leftBase = path.basename(left, path.extname(left));
|
|
501
656
|
const rightBase = path.basename(right, path.extname(right));
|
|
@@ -512,6 +667,8 @@ async function allEpics() {
|
|
|
512
667
|
try {
|
|
513
668
|
epics.push(await parseEpic(path.join(colDir, file), col));
|
|
514
669
|
} catch (error) {
|
|
670
|
+
// File may vanish between readdir and read under concurrent delete.
|
|
671
|
+
if (error.code === 'ENOENT') continue;
|
|
515
672
|
console.error(` parse error ${file}: ${error.message}`);
|
|
516
673
|
}
|
|
517
674
|
}
|
|
@@ -537,6 +694,7 @@ async function nextEpicNumber() {
|
|
|
537
694
|
try {
|
|
538
695
|
const files = await fs.readdir(EPICS_DIR);
|
|
539
696
|
for (const file of files) {
|
|
697
|
+
if (!isTaskOrEpicDataFile(file)) continue;
|
|
540
698
|
const match = file.match(/^E0*(\d+)\.json$/i);
|
|
541
699
|
if (match) ids.push(parseInt(match[1], 10));
|
|
542
700
|
}
|
|
@@ -585,7 +743,7 @@ async function listEpicEntities() {
|
|
|
585
743
|
const epics = [];
|
|
586
744
|
try {
|
|
587
745
|
const files = (await fs.readdir(EPICS_DIR))
|
|
588
|
-
.filter((file) => file.endsWith('.json'))
|
|
746
|
+
.filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.json'))
|
|
589
747
|
.sort((left, right) => left.localeCompare(right));
|
|
590
748
|
for (const file of files) {
|
|
591
749
|
try {
|
|
@@ -600,7 +758,7 @@ async function listEpicEntities() {
|
|
|
600
758
|
return epics;
|
|
601
759
|
}
|
|
602
760
|
|
|
603
|
-
async function writeEpic(epic) {
|
|
761
|
+
async function writeEpic(epic, { exclusive = false } = {}) {
|
|
604
762
|
const normalized = normalizeEpic(epic);
|
|
605
763
|
if (!normalizeEpicId(normalized.id)) {
|
|
606
764
|
throw createKanbanError(
|
|
@@ -614,7 +772,8 @@ async function writeEpic(epic) {
|
|
|
614
772
|
}
|
|
615
773
|
await ensureBacklogDir();
|
|
616
774
|
const filePath = epicFilePath(normalized.id);
|
|
617
|
-
|
|
775
|
+
const payload = JSON.stringify(serializeEpic(normalized), null, 2) + '\n';
|
|
776
|
+
await writeFileAtomic(filePath, payload, { exclusive });
|
|
618
777
|
return parseJsonEpic(filePath);
|
|
619
778
|
}
|
|
620
779
|
|
|
@@ -683,7 +842,9 @@ async function resolveEpicRef(ref, options = {}) {
|
|
|
683
842
|
}
|
|
684
843
|
|
|
685
844
|
if (options.createIfMissing) {
|
|
686
|
-
const created =
|
|
845
|
+
const created = options.skipLock
|
|
846
|
+
? await createEpicRecord(normalizeString(ref), {})
|
|
847
|
+
: await doCreateEpic(normalizeString(ref), {});
|
|
687
848
|
return { epic_id: created.id, epic_group: created.title };
|
|
688
849
|
}
|
|
689
850
|
|
|
@@ -697,7 +858,7 @@ async function resolveEpicRef(ref, options = {}) {
|
|
|
697
858
|
);
|
|
698
859
|
}
|
|
699
860
|
|
|
700
|
-
async function
|
|
861
|
+
async function createEpicRecord(title, extra = {}) {
|
|
701
862
|
if (!normalizeString(title)) {
|
|
702
863
|
throw createKanbanError(
|
|
703
864
|
'MISSING_REQUIRED_FIELD',
|
|
@@ -709,81 +870,175 @@ async function doCreateEpic(title, extra = {}) {
|
|
|
709
870
|
);
|
|
710
871
|
}
|
|
711
872
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
873
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
874
|
+
const nextId = await nextEpicNumber();
|
|
875
|
+
const epic = normalizeEpic({
|
|
876
|
+
id: `E${String(nextId).padStart(3, '0')}`,
|
|
877
|
+
title,
|
|
878
|
+
created: todayIso(),
|
|
879
|
+
description: extra.description,
|
|
880
|
+
goals: extra.goals,
|
|
881
|
+
in_scope: extra.in_scope,
|
|
882
|
+
out_of_scope: extra.out_of_scope,
|
|
883
|
+
notes: extra.notes
|
|
884
|
+
});
|
|
885
|
+
try {
|
|
886
|
+
return await writeEpic(epic, { exclusive: true });
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (error.code !== 'EEXIST') throw error;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
throw createKanbanError(
|
|
892
|
+
'CREATE_CONFLICT',
|
|
893
|
+
'Could not allocate a unique epic id',
|
|
894
|
+
'Retry the create operation',
|
|
895
|
+
{ title },
|
|
896
|
+
true,
|
|
897
|
+
409
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function doCreateEpic(title, extra = {}) {
|
|
902
|
+
return withBoardLock(() => createEpicRecord(title, extra));
|
|
724
903
|
}
|
|
725
904
|
|
|
726
905
|
async function updateEpicEntity(epicId, patch) {
|
|
727
906
|
validatePatch(patch);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
907
|
+
return withBoardLock(async () => {
|
|
908
|
+
const current = await getEpicEntity(epicId);
|
|
909
|
+
const next = { ...current };
|
|
910
|
+
|
|
911
|
+
if (patch.title !== undefined) {
|
|
912
|
+
const title = normalizeString(patch.title);
|
|
913
|
+
if (!title) {
|
|
914
|
+
throw createKanbanError(
|
|
915
|
+
'VALIDATION_ERROR',
|
|
916
|
+
'title must be a non-empty string',
|
|
917
|
+
'Send a non-empty title or omit the field',
|
|
918
|
+
{ field: 'title' },
|
|
919
|
+
false,
|
|
920
|
+
400
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
next.title = title;
|
|
742
924
|
}
|
|
743
|
-
next.
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
);
|
|
925
|
+
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
926
|
+
if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
|
|
927
|
+
if (patch.in_scope !== undefined) {
|
|
928
|
+
if (!Array.isArray(patch.in_scope)) {
|
|
929
|
+
throw createKanbanError(
|
|
930
|
+
'VALIDATION_ERROR',
|
|
931
|
+
'in_scope must be an array of strings',
|
|
932
|
+
'Send in_scope as an array',
|
|
933
|
+
{ field: 'in_scope' },
|
|
934
|
+
false,
|
|
935
|
+
400
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
next.in_scope = normalizeStringArray(patch.in_scope);
|
|
757
939
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
);
|
|
940
|
+
if (patch.out_of_scope !== undefined) {
|
|
941
|
+
if (!Array.isArray(patch.out_of_scope)) {
|
|
942
|
+
throw createKanbanError(
|
|
943
|
+
'VALIDATION_ERROR',
|
|
944
|
+
'out_of_scope must be an array of strings',
|
|
945
|
+
'Send out_of_scope as an array',
|
|
946
|
+
{ field: 'out_of_scope' },
|
|
947
|
+
false,
|
|
948
|
+
400
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
next.out_of_scope = normalizeStringArray(patch.out_of_scope);
|
|
770
952
|
}
|
|
771
|
-
next.
|
|
772
|
-
|
|
773
|
-
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
953
|
+
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
954
|
+
if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
|
|
774
955
|
|
|
775
|
-
|
|
956
|
+
const saved = await writeEpic(next);
|
|
776
957
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
958
|
+
if (patch.title !== undefined && saved.title !== current.title) {
|
|
959
|
+
const tasks = await allTasks();
|
|
960
|
+
for (const task of tasks) {
|
|
961
|
+
if (task.epic_id === saved.id && task.epic_group !== saved.title) {
|
|
962
|
+
await updateTaskRecord(task.id, { epic_group: saved.title, _skipEpicResolve: true });
|
|
963
|
+
}
|
|
782
964
|
}
|
|
783
965
|
}
|
|
966
|
+
|
|
967
|
+
return saved;
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function archiveEpic(epicId) {
|
|
972
|
+
return updateEpicEntity(epicId, { archived: true });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function unarchiveEpic(epicId) {
|
|
976
|
+
return updateEpicEntity(epicId, { archived: false });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function deleteTaskRecord(taskId) {
|
|
980
|
+
const resolvedId = await resolveTaskId(taskId);
|
|
981
|
+
const filePath = await findFile(resolvedId);
|
|
982
|
+
if (!filePath) {
|
|
983
|
+
throw createKanbanError(
|
|
984
|
+
'TASK_NOT_FOUND',
|
|
985
|
+
`Task ${taskId} was not found`,
|
|
986
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
987
|
+
{ task_id: taskId },
|
|
988
|
+
false,
|
|
989
|
+
404
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const column = path.basename(path.dirname(filePath));
|
|
994
|
+
const task = await parseEpic(filePath, column);
|
|
995
|
+
await fs.unlink(filePath).catch((error) => {
|
|
996
|
+
if (error.code !== 'ENOENT') throw error;
|
|
997
|
+
});
|
|
998
|
+
await removeOtherTaskCopies(task.id, path.join(BACKLOG, '__none__', `${task.id}.json`));
|
|
999
|
+
|
|
1000
|
+
return {
|
|
1001
|
+
ok: true,
|
|
1002
|
+
task_id: task.id,
|
|
1003
|
+
task_number: task.task_number,
|
|
1004
|
+
title: task.title,
|
|
1005
|
+
column: task.column
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function deleteTask(taskId) {
|
|
1010
|
+
return withBoardLock(() => deleteTaskRecord(taskId));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function deleteEpic(epicId) {
|
|
1014
|
+
return withBoardLock(async () => {
|
|
1015
|
+
const epic = await getEpicEntity(epicId);
|
|
1016
|
+
const tasks = await allTasks();
|
|
1017
|
+
const children = tasks.filter((task) => task.epic_id === epic.id);
|
|
1018
|
+
const deletedTasks = [];
|
|
1019
|
+
|
|
1020
|
+
for (const child of children) {
|
|
1021
|
+
const result = await deleteTaskRecord(child.id);
|
|
1022
|
+
deletedTasks.push({
|
|
1023
|
+
task_id: result.task_id,
|
|
1024
|
+
title: result.title,
|
|
1025
|
+
column: result.column
|
|
1026
|
+
});
|
|
784
1027
|
}
|
|
785
1028
|
|
|
786
|
-
|
|
1029
|
+
const filePath = epicFilePath(epic.id);
|
|
1030
|
+
await fs.unlink(filePath).catch((error) => {
|
|
1031
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
ok: true,
|
|
1036
|
+
epic_id: epic.id,
|
|
1037
|
+
title: epic.title,
|
|
1038
|
+
deleted_tasks: deletedTasks,
|
|
1039
|
+
deleted_task_count: deletedTasks.length
|
|
1040
|
+
};
|
|
1041
|
+
});
|
|
787
1042
|
}
|
|
788
1043
|
|
|
789
1044
|
async function migrateEpicGroups(options = {}) {
|
|
@@ -854,7 +1109,7 @@ async function findFile(epicId) {
|
|
|
854
1109
|
try {
|
|
855
1110
|
const files = await fs.readdir(colDir);
|
|
856
1111
|
const candidates = files
|
|
857
|
-
.filter((file) => (file
|
|
1112
|
+
.filter((file) => isTaskOrEpicDataFile(file)
|
|
858
1113
|
&& path.basename(file, path.extname(file)) === epicId)
|
|
859
1114
|
.sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
|
|
860
1115
|
if (candidates[0]) {
|
|
@@ -913,12 +1168,34 @@ async function resolveTaskId(input) {
|
|
|
913
1168
|
return String(input);
|
|
914
1169
|
}
|
|
915
1170
|
|
|
916
|
-
async function
|
|
1171
|
+
async function removeOtherTaskCopies(taskId, keepPath) {
|
|
1172
|
+
const keep = path.resolve(keepPath);
|
|
1173
|
+
for (const col of COLS) {
|
|
1174
|
+
const colDir = path.join(BACKLOG, col);
|
|
1175
|
+
try {
|
|
1176
|
+
const files = await fs.readdir(colDir);
|
|
1177
|
+
for (const file of files) {
|
|
1178
|
+
if (!isTaskOrEpicDataFile(file)) continue;
|
|
1179
|
+
if (path.basename(file, path.extname(file)) !== taskId) continue;
|
|
1180
|
+
const candidate = path.join(colDir, file);
|
|
1181
|
+
if (path.resolve(candidate) === keep) continue;
|
|
1182
|
+
await fs.unlink(candidate).catch((error) => {
|
|
1183
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function writeTask(task, previousFilePath = null, { exclusive = false } = {}) {
|
|
917
1193
|
const normalized = normalizeTask(task);
|
|
918
1194
|
await ensureBacklogDir();
|
|
919
1195
|
|
|
920
1196
|
const nextFilePath = path.join(BACKLOG, normalized.column, `${normalized.id}.json`);
|
|
921
|
-
|
|
1197
|
+
const payload = JSON.stringify(serializeTask(normalized), null, 2) + '\n';
|
|
1198
|
+
await writeFileAtomic(nextFilePath, payload, { exclusive });
|
|
922
1199
|
|
|
923
1200
|
if (previousFilePath && path.resolve(previousFilePath) !== path.resolve(nextFilePath)) {
|
|
924
1201
|
await fs.unlink(previousFilePath).catch((error) => {
|
|
@@ -926,6 +1203,8 @@ async function writeTask(task, previousFilePath = null) {
|
|
|
926
1203
|
});
|
|
927
1204
|
}
|
|
928
1205
|
|
|
1206
|
+
await removeOtherTaskCopies(normalized.id, nextFilePath);
|
|
1207
|
+
|
|
929
1208
|
return parseJsonTask(nextFilePath, normalized.column);
|
|
930
1209
|
}
|
|
931
1210
|
|
|
@@ -945,7 +1224,7 @@ async function migrateAll(options = {}) {
|
|
|
945
1224
|
}
|
|
946
1225
|
|
|
947
1226
|
const mdFiles = files
|
|
948
|
-
.filter((file) => file.endsWith('.md'))
|
|
1227
|
+
.filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.md'))
|
|
949
1228
|
.map((file) => ({
|
|
950
1229
|
name: file,
|
|
951
1230
|
taskId: path.basename(file, '.md')
|
|
@@ -985,6 +1264,7 @@ async function nextTaskNumber() {
|
|
|
985
1264
|
try {
|
|
986
1265
|
const files = await fs.readdir(colDir);
|
|
987
1266
|
for (const file of files) {
|
|
1267
|
+
if (!isTaskOrEpicDataFile(file)) continue;
|
|
988
1268
|
const match = file.match(/^(?:[A-Z]+-)?(\d+)/);
|
|
989
1269
|
if (match) ids.push(parseInt(match[1], 10));
|
|
990
1270
|
}
|
|
@@ -1036,39 +1316,53 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
|
|
|
1036
1316
|
|
|
1037
1317
|
validateColumn(column, 'col');
|
|
1038
1318
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1319
|
+
return withBoardLock(async () => {
|
|
1320
|
+
let epicLink = { epic_id: null, epic_group: '—' };
|
|
1321
|
+
if (!isBlankEpicRef(epicRef)) {
|
|
1322
|
+
epicLink = await resolveEpicRef(epicRef, { createIfMissing: true, skipLock: true });
|
|
1323
|
+
} else if (extra.epic_id) {
|
|
1324
|
+
epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false, skipLock: true });
|
|
1325
|
+
}
|
|
1045
1326
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1327
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
1328
|
+
const nextId = await nextTaskNumber();
|
|
1329
|
+
const task = normalizeTask({
|
|
1330
|
+
id: String(nextId).padStart(3, '0'),
|
|
1331
|
+
title,
|
|
1332
|
+
column,
|
|
1333
|
+
epic_id: epicLink.epic_id,
|
|
1334
|
+
epic_group: epicLink.epic_group,
|
|
1335
|
+
created: todayIso(),
|
|
1336
|
+
description: extra.description,
|
|
1337
|
+
specs: extra.specs,
|
|
1338
|
+
in_scope: extra.in_scope,
|
|
1339
|
+
out_of_scope: extra.out_of_scope,
|
|
1340
|
+
acceptance_criteria: extra.acceptance_criteria,
|
|
1341
|
+
test_cases: extra.test_cases,
|
|
1342
|
+
subtasks: extra.subtasks,
|
|
1343
|
+
notes: extra.notes,
|
|
1344
|
+
comments: extra.comments,
|
|
1345
|
+
plan: extra.plan,
|
|
1346
|
+
evidence: extra.evidence
|
|
1347
|
+
});
|
|
1348
|
+
try {
|
|
1349
|
+
return await writeTask(task, null, { exclusive: true });
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
if (error.code !== 'EEXIST') throw error;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
throw createKanbanError(
|
|
1355
|
+
'CREATE_CONFLICT',
|
|
1356
|
+
'Could not allocate a unique task id',
|
|
1357
|
+
'Retry the create operation',
|
|
1358
|
+
{ title },
|
|
1359
|
+
true,
|
|
1360
|
+
409
|
|
1361
|
+
);
|
|
1064
1362
|
});
|
|
1065
|
-
|
|
1066
|
-
return writeTask(task);
|
|
1067
1363
|
}
|
|
1068
1364
|
|
|
1069
|
-
async function
|
|
1070
|
-
validatePatch(patch);
|
|
1071
|
-
|
|
1365
|
+
async function updateTaskRecord(taskId, patch) {
|
|
1072
1366
|
const resolvedId = await resolveTaskId(taskId);
|
|
1073
1367
|
const previousFilePath = await findFile(resolvedId);
|
|
1074
1368
|
if (!previousFilePath) {
|
|
@@ -1112,7 +1406,12 @@ async function updateTask(taskId, patch) {
|
|
|
1112
1406
|
next.epic_id = null;
|
|
1113
1407
|
next.epic_group = '—';
|
|
1114
1408
|
} else {
|
|
1115
|
-
const link = await resolveEpicRef(ref, {
|
|
1409
|
+
const link = await resolveEpicRef(ref, {
|
|
1410
|
+
createIfMissing: Boolean(
|
|
1411
|
+
patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
|
|
1412
|
+
),
|
|
1413
|
+
skipLock: true
|
|
1414
|
+
});
|
|
1116
1415
|
next.epic_id = link.epic_id;
|
|
1117
1416
|
next.epic_group = link.epic_group;
|
|
1118
1417
|
}
|
|
@@ -1189,6 +1488,19 @@ async function updateTask(taskId, patch) {
|
|
|
1189
1488
|
next.subtasks = patch.subtasks;
|
|
1190
1489
|
}
|
|
1191
1490
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
1491
|
+
if (patch.comments !== undefined) {
|
|
1492
|
+
if (!Array.isArray(patch.comments)) {
|
|
1493
|
+
throw createKanbanError(
|
|
1494
|
+
'VALIDATION_ERROR',
|
|
1495
|
+
'comments must be an array',
|
|
1496
|
+
'Send comments as an array of comment objects',
|
|
1497
|
+
{ field: 'comments' },
|
|
1498
|
+
false,
|
|
1499
|
+
400
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
next.comments = patch.comments;
|
|
1503
|
+
}
|
|
1192
1504
|
if (patch.plan !== undefined) next.plan = patch.plan;
|
|
1193
1505
|
if (patch.evidence !== undefined) {
|
|
1194
1506
|
if (!Array.isArray(patch.evidence)) {
|
|
@@ -1207,6 +1519,49 @@ async function updateTask(taskId, patch) {
|
|
|
1207
1519
|
return writeTask(next, previousFilePath);
|
|
1208
1520
|
}
|
|
1209
1521
|
|
|
1522
|
+
async function updateTask(taskId, patch) {
|
|
1523
|
+
validatePatch(patch);
|
|
1524
|
+
return withBoardLock(() => updateTaskRecord(taskId, patch));
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
async function addComment(taskId, text, author = 'user') {
|
|
1528
|
+
const body = normalizeString(text);
|
|
1529
|
+
if (!body) {
|
|
1530
|
+
throw createKanbanError(
|
|
1531
|
+
'MISSING_REQUIRED_FIELD',
|
|
1532
|
+
'text is required',
|
|
1533
|
+
'Provide a non-empty comment text',
|
|
1534
|
+
{ field: 'text' },
|
|
1535
|
+
false,
|
|
1536
|
+
400
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
return withBoardLock(async () => {
|
|
1540
|
+
const resolvedId = await resolveTaskId(taskId);
|
|
1541
|
+
const previousFilePath = await findFile(resolvedId);
|
|
1542
|
+
if (!previousFilePath) {
|
|
1543
|
+
throw createKanbanError(
|
|
1544
|
+
'TASK_NOT_FOUND',
|
|
1545
|
+
`Task ${taskId} was not found`,
|
|
1546
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
1547
|
+
{ task_id: taskId },
|
|
1548
|
+
false,
|
|
1549
|
+
404
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
|
|
1553
|
+
const comments = normalizeComments(current.comments);
|
|
1554
|
+
const comment = {
|
|
1555
|
+
id: nextCommentId(comments),
|
|
1556
|
+
created: nowIso(),
|
|
1557
|
+
author: normalizeString(author, 'user') || 'user',
|
|
1558
|
+
text: body
|
|
1559
|
+
};
|
|
1560
|
+
const saved = await writeTask({ ...current, comments: [...comments, comment] }, previousFilePath);
|
|
1561
|
+
return { comment, comments: saved.comments, task_id: saved.id };
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1210
1565
|
async function doMove(epicId, target) {
|
|
1211
1566
|
try {
|
|
1212
1567
|
await updateTask(epicId, { column: target });
|
|
@@ -1272,6 +1627,7 @@ module.exports = {
|
|
|
1272
1627
|
shapeTask,
|
|
1273
1628
|
shapeEpic,
|
|
1274
1629
|
updateTask,
|
|
1630
|
+
addComment,
|
|
1275
1631
|
migrateAll,
|
|
1276
1632
|
migrateEpicGroups,
|
|
1277
1633
|
doMove,
|
|
@@ -1280,10 +1636,17 @@ module.exports = {
|
|
|
1280
1636
|
doCreate,
|
|
1281
1637
|
doCreateEpic,
|
|
1282
1638
|
updateEpicEntity,
|
|
1639
|
+
archiveEpic,
|
|
1640
|
+
unarchiveEpic,
|
|
1641
|
+
deleteTask,
|
|
1642
|
+
deleteEpic,
|
|
1283
1643
|
getEpicEntity,
|
|
1284
1644
|
listEpicEntities,
|
|
1285
1645
|
resolveEpicRef,
|
|
1286
1646
|
taskMatchesEpicFilter,
|
|
1647
|
+
filterShapedEpics,
|
|
1648
|
+
filterTasksForList,
|
|
1649
|
+
isLiveEpic,
|
|
1287
1650
|
createKanbanError,
|
|
1288
1651
|
createFieldWarnings,
|
|
1289
1652
|
createEpicFieldWarnings,
|
|
@@ -1297,6 +1660,7 @@ module.exports = {
|
|
|
1297
1660
|
STATUS_MAP,
|
|
1298
1661
|
VIEW_FIELDS,
|
|
1299
1662
|
EPIC_VIEW_FIELDS,
|
|
1663
|
+
LIVE_EPIC_STATUSES,
|
|
1300
1664
|
RECOMMENDED_CREATE_FIELDS,
|
|
1301
1665
|
RECOMMENDED_EPIC_CREATE_FIELDS,
|
|
1302
1666
|
EPICS_DIR
|