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/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 (field === 'description' || field === 'specs' || field === 'notes') {
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
- epic_group: normalizeString(task.epic_group, '—') || '—',
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', epicGroup = '—', extra = {}) {
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
- epic_group: epicGroup || '—',
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.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
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
- RECOMMENDED_CREATE_FIELDS
1450
+ EPIC_VIEW_FIELDS,
1451
+ LIVE_EPIC_STATUSES,
1452
+ RECOMMENDED_CREATE_FIELDS,
1453
+ RECOMMENDED_EPIC_CREATE_FIELDS,
1454
+ EPICS_DIR
785
1455
  };