kanbango 2.5.0 → 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,11 +2,8 @@ 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
- const GUI_PORT_FILE = '.kanbango-gui.json';
7
- const GUI_PORT_MIN = 5510;
8
- const GUI_PORT_MAX = 5999;
9
- const GUI_PORT_SPAN = GUI_PORT_MAX - GUI_PORT_MIN + 1;
10
7
  const STATUS_MAP = {
11
8
  active: 'in_progress',
12
9
  planned: 'planned',
@@ -14,11 +11,12 @@ const STATUS_MAP = {
14
11
  done: 'done'
15
12
  };
16
13
  const VIEW_FIELDS = {
17
- summary: ['task_number', 'title', 'column', 'epic_group', 'created', 'progress'],
14
+ summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
18
15
  planning: [
19
16
  'task_number',
20
17
  'title',
21
18
  'column',
19
+ 'epic_id',
22
20
  'epic_group',
23
21
  'created',
24
22
  'progress',
@@ -33,6 +31,7 @@ const VIEW_FIELDS = {
33
31
  'task_number',
34
32
  'title',
35
33
  'column',
34
+ 'epic_id',
36
35
  'epic_group',
37
36
  'created',
38
37
  'progress',
@@ -48,6 +47,7 @@ const VIEW_FIELDS = {
48
47
  'task_number',
49
48
  'title',
50
49
  'column',
50
+ 'epic_id',
51
51
  'epic_group',
52
52
  'created',
53
53
  'progress',
@@ -62,6 +62,34 @@ const VIEW_FIELDS = {
62
62
  ]
63
63
  };
64
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
+
65
93
  // Hard-required on create: title only (keeps GUI/CLI quick-add usable).
66
94
  // Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
67
95
  const RECOMMENDED_CREATE_FIELDS = [
@@ -72,6 +100,13 @@ const RECOMMENDED_CREATE_FIELDS = [
72
100
  'acceptance_criteria'
73
101
  ];
74
102
 
103
+ const RECOMMENDED_EPIC_CREATE_FIELDS = [
104
+ 'description',
105
+ 'goals',
106
+ 'in_scope',
107
+ 'out_of_scope'
108
+ ];
109
+
75
110
  function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
76
111
  const error = new Error(message);
77
112
  error.code = code;
@@ -107,7 +142,12 @@ function normalizeStringArray(value) {
107
142
  }
108
143
 
109
144
  function isPresentCreateField(field, value) {
110
- if (field === 'description' || field === 'specs' || field === 'notes') {
145
+ if (
146
+ field === 'description'
147
+ || field === 'specs'
148
+ || field === 'notes'
149
+ || field === 'goals'
150
+ ) {
111
151
  return Boolean(normalizeString(value));
112
152
  }
113
153
  if (
@@ -127,6 +167,10 @@ function missingRecommendedCreateFields(payload = {}) {
127
167
  return RECOMMENDED_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
128
168
  }
129
169
 
170
+ function missingRecommendedEpicCreateFields(payload = {}) {
171
+ return RECOMMENDED_EPIC_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
172
+ }
173
+
130
174
  function createFieldWarnings(payload = {}) {
131
175
  const missing = missingRecommendedCreateFields(payload);
132
176
  if (missing.length === 0) return [];
@@ -136,6 +180,28 @@ function createFieldWarnings(payload = {}) {
136
180
  ];
137
181
  }
138
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
+
139
205
  function normalizeSubtasks(value) {
140
206
  if (!Array.isArray(value)) return [];
141
207
  return value.map((subtask, idx) => ({
@@ -168,11 +234,14 @@ function normalizePlan(value) {
168
234
 
169
235
  function normalizeTask(task) {
170
236
  const id = normalizeString(task.id);
237
+ const epicId = normalizeEpicId(task.epic_id);
238
+ const epicGroup = normalizeString(task.epic_group, '—') || '—';
171
239
  const normalized = {
172
240
  id,
173
241
  title: stripTitlePrefix(task.title || id),
174
242
  column: COLS.includes(task.column) ? task.column : 'planned',
175
- epic_group: normalizeString(task.epic_group, '—') || '—',
243
+ epic_id: epicId,
244
+ epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
176
245
  created: normalizeString(task.created) || todayIso(),
177
246
  description: normalizeString(task.description),
178
247
  specs: normalizeString(task.specs),
@@ -196,6 +265,7 @@ function serializeTask(task) {
196
265
  id: normalized.id,
197
266
  title: normalized.title,
198
267
  column: normalized.column,
268
+ epic_id: normalized.epic_id,
199
269
  epic_group: normalized.epic_group,
200
270
  created: normalized.created,
201
271
  description: normalized.description,
@@ -212,6 +282,83 @@ function serializeTask(task) {
212
282
  };
213
283
  }
214
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
+
215
362
  function getProgress(task) {
216
363
  const total = task.subtasks.length;
217
364
  const done = task.subtasks.filter((subtask) => subtask.done).length;
@@ -273,6 +420,7 @@ async function ensureBacklogDir() {
273
420
  const colDir = path.join(BACKLOG, col);
274
421
  await fs.mkdir(colDir, { recursive: true });
275
422
  }
423
+ await fs.mkdir(EPICS_DIR, { recursive: true });
276
424
  }
277
425
 
278
426
  async function parseMarkdownTask(filePath, column) {
@@ -375,6 +523,331 @@ async function allEpics() {
375
523
  return epics;
376
524
  }
377
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
+
378
851
  async function findFile(epicId) {
379
852
  for (const col of COLS) {
380
853
  const colDir = path.join(BACKLOG, col);
@@ -383,7 +856,7 @@ async function findFile(epicId) {
383
856
  const candidates = files
384
857
  .filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
385
858
  && path.basename(file, path.extname(file)) === epicId)
386
- .sort((left, right) => (left.endsWith('.json') ? -1 : 1));
859
+ .sort((left, _right) => (left.endsWith('.json') ? -1 : 1));
387
860
  if (candidates[0]) {
388
861
  return path.join(colDir, candidates[0]);
389
862
  }
@@ -549,7 +1022,7 @@ function validatePatch(patch) {
549
1022
  }
550
1023
  }
551
1024
 
552
- async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}) {
1025
+ async function doCreate(title, column = 'planned', epicRef = '—', extra = {}) {
553
1026
  if (!normalizeString(title)) {
554
1027
  throw createKanbanError(
555
1028
  'MISSING_REQUIRED_FIELD',
@@ -563,17 +1036,20 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
563
1036
 
564
1037
  validateColumn(column, 'col');
565
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
+
566
1046
  const nextId = await nextTaskNumber();
567
- const slug = title
568
- .toLowerCase()
569
- .replace(/[^a-z0-9]+/g, '-')
570
- .replace(/^-+|-+$/g, '')
571
- .substring(0, 25);
572
1047
  const task = normalizeTask({
573
1048
  id: String(nextId).padStart(3, '0'),
574
1049
  title,
575
1050
  column,
576
- epic_group: epicGroup || '—',
1051
+ epic_id: epicLink.epic_id,
1052
+ epic_group: epicLink.epic_group,
577
1053
  created: todayIso(),
578
1054
  description: extra.description,
579
1055
  specs: extra.specs,
@@ -627,7 +1103,24 @@ async function updateTask(taskId, patch) {
627
1103
  }
628
1104
  next.title = title;
629
1105
  }
630
- 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
+ }
631
1124
  if (patch.description !== undefined) next.description = normalizeString(patch.description);
632
1125
  if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
633
1126
  if (patch.in_scope !== undefined) {
@@ -769,149 +1262,42 @@ async function doUpdate(epicId, newTitle, newTasks) {
769
1262
  }
770
1263
  }
771
1264
 
772
- function guiPortFilePath() {
773
- return path.join(BACKLOG, GUI_PORT_FILE);
774
- }
775
-
776
- function hashCwdToPort(cwd = process.cwd()) {
777
- let hash = 0;
778
- const input = String(cwd);
779
- for (let i = 0; i < input.length; i++) {
780
- hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
781
- }
782
- return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
783
- }
784
-
785
- function normalizeGuiPort(value) {
786
- const parsed = Number.parseInt(value, 10);
787
- if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
788
- return null;
789
- }
790
- return parsed;
791
- }
792
-
793
- function resolvePreferredGuiPort(explicitPort) {
794
- if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
795
- const fromArg = normalizeGuiPort(explicitPort);
796
- if (fromArg) return fromArg;
797
- }
798
-
799
- const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
800
- if (fromEnv) return fromEnv;
801
-
802
- return hashCwdToPort(process.cwd());
803
- }
804
-
805
- function isPidAlive(pid) {
806
- const n = Number.parseInt(pid, 10);
807
- if (!Number.isFinite(n) || n <= 0) return false;
808
- try {
809
- process.kill(n, 0);
810
- return true;
811
- } catch {
812
- return false;
813
- }
814
- }
815
-
816
- async function writeGuiPortFile({ port, pid = process.pid } = {}) {
817
- const normalizedPort = normalizeGuiPort(port);
818
- if (!normalizedPort) {
819
- throw createKanbanError(
820
- 'VALIDATION_ERROR',
821
- 'Invalid GUI port',
822
- 'Use an integer between 1 and 65535',
823
- { port },
824
- false,
825
- 400
826
- );
827
- }
828
-
829
- await ensureBacklogDir();
830
- const data = {
831
- port: normalizedPort,
832
- pid,
833
- url: `http://localhost:${normalizedPort}`,
834
- cwd: process.cwd(),
835
- started_at: new Date().toISOString()
836
- };
837
- await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
838
- return data;
839
- }
840
-
841
- async function readGuiPortFile() {
842
- try {
843
- const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
844
- const data = JSON.parse(raw);
845
- if (!data || !normalizeGuiPort(data.port)) return null;
846
- return data;
847
- } catch (error) {
848
- if (error.code === 'ENOENT') return null;
849
- return null;
850
- }
851
- }
852
-
853
- async function clearGuiPortFile({ pid, force = false } = {}) {
854
- const info = await readGuiPortFile();
855
- if (!info) return false;
856
- if (!force && pid !== undefined && info.pid !== pid) return false;
857
- if (!force && pid === undefined && info.pid !== process.pid) return false;
858
-
859
- try {
860
- await fs.unlink(guiPortFilePath());
861
- return true;
862
- } catch (error) {
863
- if (error.code === 'ENOENT') return false;
864
- throw error;
865
- }
866
- }
867
-
868
- async function discoverRunningGui() {
869
- const info = await readGuiPortFile();
870
- if (!info || !isPidAlive(info.pid)) {
871
- if (info) await clearGuiPortFile({ force: true });
872
- return null;
873
- }
874
- return {
875
- status: 'running',
876
- port: info.port,
877
- pid: info.pid,
878
- url: info.url || `http://localhost:${info.port}`,
879
- cwd: info.cwd,
880
- started_at: info.started_at
881
- };
882
- }
883
-
884
1265
  module.exports = {
885
1266
  ensureBacklogDir,
886
1267
  parseEpic,
887
1268
  allEpics,
1269
+ allTasks,
888
1270
  findFile,
889
1271
  getTask,
890
1272
  shapeTask,
1273
+ shapeEpic,
891
1274
  updateTask,
892
1275
  migrateAll,
1276
+ migrateEpicGroups,
893
1277
  doMove,
894
1278
  doToggle,
895
1279
  doUpdate,
896
1280
  doCreate,
1281
+ doCreateEpic,
1282
+ updateEpicEntity,
1283
+ getEpicEntity,
1284
+ listEpicEntities,
1285
+ resolveEpicRef,
1286
+ taskMatchesEpicFilter,
897
1287
  createKanbanError,
898
1288
  createFieldWarnings,
1289
+ createEpicFieldWarnings,
899
1290
  missingRecommendedCreateFields,
1291
+ missingRecommendedEpicCreateFields,
900
1292
  getProgress,
1293
+ getEpicProgress,
1294
+ deriveEpicStatus,
901
1295
  resolveTaskId,
902
- hashCwdToPort,
903
- normalizeGuiPort,
904
- resolvePreferredGuiPort,
905
- isPidAlive,
906
- writeGuiPortFile,
907
- readGuiPortFile,
908
- clearGuiPortFile,
909
- discoverRunningGui,
910
- guiPortFilePath,
911
1296
  COLS,
912
1297
  STATUS_MAP,
913
1298
  VIEW_FIELDS,
1299
+ EPIC_VIEW_FIELDS,
914
1300
  RECOMMENDED_CREATE_FIELDS,
915
- GUI_PORT_MIN,
916
- GUI_PORT_MAX
1301
+ RECOMMENDED_EPIC_CREATE_FIELDS,
1302
+ EPICS_DIR
917
1303
  };