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/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(await fs.readFile(filePath, 'utf-8'));
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) => file.endsWith('.json') || file.endsWith('.md'))
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
- await fs.writeFile(filePath, JSON.stringify(serializeEpic(normalized), null, 2) + '\n', 'utf-8');
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 = await doCreateEpic(normalizeString(ref), {});
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 doCreateEpic(title, extra = {}) {
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
- 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);
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
- 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
- );
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.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
- );
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
- 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
- );
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.out_of_scope = normalizeStringArray(patch.out_of_scope);
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
- const saved = await writeEpic(next);
956
+ const saved = await writeEpic(next);
776
957
 
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 });
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
- return saved;
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.endsWith('.json') || file.endsWith('.md'))
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 writeTask(task, previousFilePath = null) {
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
- await fs.writeFile(nextFilePath, JSON.stringify(serializeTask(normalized), null, 2) + '\n', 'utf-8');
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
- 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
- }
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
- const nextId = await nextTaskNumber();
1047
- const task = normalizeTask({
1048
- id: String(nextId).padStart(3, '0'),
1049
- title,
1050
- column,
1051
- epic_id: epicLink.epic_id,
1052
- epic_group: epicLink.epic_group,
1053
- created: todayIso(),
1054
- description: extra.description,
1055
- specs: extra.specs,
1056
- in_scope: extra.in_scope,
1057
- out_of_scope: extra.out_of_scope,
1058
- acceptance_criteria: extra.acceptance_criteria,
1059
- test_cases: extra.test_cases,
1060
- subtasks: extra.subtasks,
1061
- notes: extra.notes,
1062
- plan: extra.plan,
1063
- evidence: extra.evidence
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 updateTask(taskId, patch) {
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, { createIfMissing: Boolean(patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined) });
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