kanbango 3.0.2 → 3.1.0

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