kanbango 2.0.0 → 2.4.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
@@ -3,6 +3,10 @@ const path = require('path');
3
3
 
4
4
  const BACKLOG = path.join(process.cwd(), 'backlog');
5
5
  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;
6
10
  const STATUS_MAP = {
7
11
  active: 'in_progress',
8
12
  planned: 'planned',
@@ -10,9 +14,9 @@ const STATUS_MAP = {
10
14
  done: 'done'
11
15
  };
12
16
  const VIEW_FIELDS = {
13
- summary: ['id', 'title', 'column', 'epic_group', 'created', 'progress'],
17
+ summary: ['task_number', 'title', 'column', 'epic_group', 'created', 'progress'],
14
18
  planning: [
15
- 'id',
19
+ 'task_number',
16
20
  'title',
17
21
  'column',
18
22
  'epic_group',
@@ -20,10 +24,13 @@ const VIEW_FIELDS = {
20
24
  'progress',
21
25
  'description',
22
26
  'specs',
23
- 'acceptance_criteria'
27
+ 'in_scope',
28
+ 'out_of_scope',
29
+ 'acceptance_criteria',
30
+ 'test_cases'
24
31
  ],
25
32
  execution: [
26
- 'id',
33
+ 'task_number',
27
34
  'title',
28
35
  'column',
29
36
  'epic_group',
@@ -31,11 +38,14 @@ const VIEW_FIELDS = {
31
38
  'progress',
32
39
  'description',
33
40
  'specs',
41
+ 'in_scope',
42
+ 'out_of_scope',
34
43
  'acceptance_criteria',
44
+ 'test_cases',
35
45
  'subtasks'
36
46
  ],
37
47
  full: [
38
- 'id',
48
+ 'task_number',
39
49
  'title',
40
50
  'column',
41
51
  'epic_group',
@@ -43,7 +53,10 @@ const VIEW_FIELDS = {
43
53
  'progress',
44
54
  'description',
45
55
  'specs',
56
+ 'in_scope',
57
+ 'out_of_scope',
46
58
  'acceptance_criteria',
59
+ 'test_cases',
47
60
  'subtasks',
48
61
  'notes'
49
62
  ]
@@ -71,6 +84,11 @@ function normalizeString(value, fallback = '') {
71
84
  return typeof value === 'string' ? value.trim() : fallback;
72
85
  }
73
86
 
87
+ function extractTaskNumber(taskId) {
88
+ const match = normalizeString(taskId).match(/^(?:[A-Z]+-)?(\d+)/);
89
+ return match ? parseInt(match[1], 10) : null;
90
+ }
91
+
74
92
  function normalizeStringArray(value) {
75
93
  if (!Array.isArray(value)) return [];
76
94
  return value
@@ -88,33 +106,48 @@ function normalizeSubtasks(value) {
88
106
  })).filter((subtask) => subtask.text);
89
107
  }
90
108
 
91
- function withLegacyTaskAlias(task) {
109
+ function normalizeEvidence(value) {
110
+ if (!Array.isArray(value)) return [];
111
+ return value.map((item) => ({
112
+ diff: normalizeString(item && item.diff),
113
+ test_command: normalizeString(item && item.test_command),
114
+ stdout: normalizeString(item && item.stdout),
115
+ stderr: normalizeString(item && item.stderr),
116
+ exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
117
+ created: normalizeString(item && item.created) || todayIso()
118
+ }));
119
+ }
120
+
121
+ function normalizePlan(value) {
122
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
92
123
  return {
93
- ...task,
94
- tasks: task.subtasks.map((subtask) => ({
95
- id: subtask.id,
96
- text: subtask.text,
97
- done: subtask.done,
98
- description: subtask.description
99
- }))
124
+ runner: value.runner && typeof value.runner === 'object' ? value.runner : null,
125
+ status: ['active', 'done'].includes(value.status) ? value.status : 'active'
100
126
  };
101
127
  }
102
128
 
103
129
  function normalizeTask(task) {
130
+ const id = normalizeString(task.id);
104
131
  const normalized = {
105
- id: normalizeString(task.id),
106
- title: stripTitlePrefix(task.title || task.id),
132
+ id,
133
+ title: stripTitlePrefix(task.title || id),
107
134
  column: COLS.includes(task.column) ? task.column : 'planned',
108
135
  epic_group: normalizeString(task.epic_group, '—') || '—',
109
136
  created: normalizeString(task.created) || todayIso(),
110
137
  description: normalizeString(task.description),
111
138
  specs: normalizeString(task.specs),
139
+ in_scope: normalizeStringArray(task.in_scope),
140
+ out_of_scope: normalizeStringArray(task.out_of_scope),
112
141
  acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
113
- subtasks: normalizeSubtasks(task.subtasks || task.tasks),
114
- notes: normalizeString(task.notes)
142
+ test_cases: normalizeStringArray(task.test_cases),
143
+ subtasks: normalizeSubtasks(task.subtasks),
144
+ notes: normalizeString(task.notes),
145
+ plan: normalizePlan(task.plan),
146
+ evidence: normalizeEvidence(task.evidence),
147
+ task_number: extractTaskNumber(id)
115
148
  };
116
149
 
117
- return withLegacyTaskAlias(normalized);
150
+ return normalized;
118
151
  }
119
152
 
120
153
  function serializeTask(task) {
@@ -127,9 +160,15 @@ function serializeTask(task) {
127
160
  created: normalized.created,
128
161
  description: normalized.description,
129
162
  specs: normalized.specs,
163
+ in_scope: normalized.in_scope,
164
+ out_of_scope: normalized.out_of_scope,
130
165
  acceptance_criteria: normalized.acceptance_criteria,
166
+ test_cases: normalized.test_cases,
131
167
  subtasks: normalized.subtasks,
132
- notes: normalized.notes
168
+ notes: normalized.notes,
169
+ plan: normalized.plan,
170
+ evidence: normalized.evidence,
171
+ task_number: normalized.task_number
133
172
  };
134
173
  }
135
174
 
@@ -147,10 +186,6 @@ function pickFields(task, fieldNames) {
147
186
  picked.progress = getProgress(task);
148
187
  continue;
149
188
  }
150
- if (field === 'tasks') {
151
- picked.tasks = task.tasks;
152
- continue;
153
- }
154
189
  if (field in task) {
155
190
  picked[field] = task[field];
156
191
  }
@@ -226,7 +261,10 @@ async function parseMarkdownTask(filePath, column) {
226
261
  created: createdMatch ? createdMatch[1].trim() : todayIso(),
227
262
  description: extractSection(text, ['Opis', 'Description']),
228
263
  specs: extractSection(text, ['Specs', 'Specyfikacja']),
264
+ in_scope: parseListSection(extractSection(text, ['In Scope', 'W Zakresie'])),
265
+ out_of_scope: parseListSection(extractSection(text, ['Out of Scope', 'Poza Zakresem'])),
229
266
  acceptance_criteria: parseListSection(extractSection(text, ['Acceptance Criteria', 'Kryteria Akceptacji'])),
267
+ test_cases: parseListSection(extractSection(text, ['Test Cases', 'Przypadki Testowe'])),
230
268
  subtasks,
231
269
  notes: extractSection(text, ['Notes'])
232
270
  });
@@ -317,7 +355,8 @@ async function findFile(epicId) {
317
355
  }
318
356
 
319
357
  async function getTask(taskId) {
320
- const filePath = await findFile(taskId);
358
+ const resolvedId = await resolveTaskId(taskId);
359
+ const filePath = await findFile(resolvedId);
321
360
  if (!filePath) {
322
361
  throw createKanbanError(
323
362
  'TASK_NOT_FOUND',
@@ -333,6 +372,34 @@ async function getTask(taskId) {
333
372
  return parseEpic(filePath, column);
334
373
  }
335
374
 
375
+ async function resolveTaskId(input) {
376
+ if (!input) return null;
377
+
378
+ const exact = await findFile(String(input));
379
+ if (exact) return String(input);
380
+
381
+ const num = parseInt(String(input), 10);
382
+ if (!Number.isFinite(num)) return null;
383
+
384
+ for (const col of COLS) {
385
+ const colDir = path.join(BACKLOG, col);
386
+ try {
387
+ const files = await fs.readdir(colDir);
388
+ const rawPattern = new RegExp(`^(?:[A-Z]+-)?${String(num)}(?:-|$)`);
389
+ const paddedPattern = new RegExp(`^(?:[A-Z]+-)?${String(num).padStart(3, '0')}(?:-|$)`);
390
+ const match = files.find((file) => {
391
+ const base = path.basename(file, path.extname(file));
392
+ return rawPattern.test(base) || paddedPattern.test(base + '-');
393
+ });
394
+ if (match) return path.basename(match, path.extname(match));
395
+ } catch (error) {
396
+ if (error.code !== 'ENOENT') throw error;
397
+ }
398
+ }
399
+
400
+ return String(input);
401
+ }
402
+
336
403
  async function writeTask(task, previousFilePath = null) {
337
404
  const normalized = normalizeTask(task);
338
405
  await ensureBacklogDir();
@@ -397,7 +464,7 @@ async function migrateAll(options = {}) {
397
464
  return { migrated, errors };
398
465
  }
399
466
 
400
- async function nextPiNumber() {
467
+ async function nextTaskNumber() {
401
468
  const ids = [];
402
469
 
403
470
  for (const col of COLS) {
@@ -405,7 +472,7 @@ async function nextPiNumber() {
405
472
  try {
406
473
  const files = await fs.readdir(colDir);
407
474
  for (const file of files) {
408
- const match = file.match(/^PI-(\d+)/);
475
+ const match = file.match(/^(?:[A-Z]+-)?(\d+)/);
409
476
  if (match) ids.push(parseInt(match[1], 10));
410
477
  }
411
478
  } catch (error) {
@@ -456,23 +523,28 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
456
523
 
457
524
  validateColumn(column, 'col');
458
525
 
459
- const nextId = await nextPiNumber();
526
+ const nextId = await nextTaskNumber();
460
527
  const slug = title
461
528
  .toLowerCase()
462
529
  .replace(/[^a-z0-9]+/g, '-')
463
530
  .replace(/^-+|-+$/g, '')
464
531
  .substring(0, 25);
465
532
  const task = normalizeTask({
466
- id: `PI-${String(nextId).padStart(3, '0')}-${slug || 'task'}`,
533
+ id: String(nextId).padStart(3, '0'),
467
534
  title,
468
535
  column,
469
536
  epic_group: epicGroup || '—',
470
537
  created: todayIso(),
471
538
  description: extra.description,
472
539
  specs: extra.specs,
540
+ in_scope: extra.in_scope,
541
+ out_of_scope: extra.out_of_scope,
473
542
  acceptance_criteria: extra.acceptance_criteria,
543
+ test_cases: extra.test_cases,
474
544
  subtasks: extra.subtasks,
475
- notes: extra.notes
545
+ notes: extra.notes,
546
+ plan: extra.plan,
547
+ evidence: extra.evidence
476
548
  });
477
549
 
478
550
  return writeTask(task);
@@ -481,7 +553,8 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
481
553
  async function updateTask(taskId, patch) {
482
554
  validatePatch(patch);
483
555
 
484
- const previousFilePath = await findFile(taskId);
556
+ const resolvedId = await resolveTaskId(taskId);
557
+ const previousFilePath = await findFile(resolvedId);
485
558
  if (!previousFilePath) {
486
559
  throw createKanbanError(
487
560
  'TASK_NOT_FOUND',
@@ -517,6 +590,32 @@ async function updateTask(taskId, patch) {
517
590
  if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
518
591
  if (patch.description !== undefined) next.description = normalizeString(patch.description);
519
592
  if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
593
+ if (patch.in_scope !== undefined) {
594
+ if (!Array.isArray(patch.in_scope)) {
595
+ throw createKanbanError(
596
+ 'VALIDATION_ERROR',
597
+ 'in_scope must be an array of strings',
598
+ 'Send in_scope as an array',
599
+ { field: 'in_scope' },
600
+ false,
601
+ 400
602
+ );
603
+ }
604
+ next.in_scope = patch.in_scope;
605
+ }
606
+ if (patch.out_of_scope !== undefined) {
607
+ if (!Array.isArray(patch.out_of_scope)) {
608
+ throw createKanbanError(
609
+ 'VALIDATION_ERROR',
610
+ 'out_of_scope must be an array of strings',
611
+ 'Send out_of_scope as an array',
612
+ { field: 'out_of_scope' },
613
+ false,
614
+ 400
615
+ );
616
+ }
617
+ next.out_of_scope = patch.out_of_scope;
618
+ }
520
619
  if (patch.acceptance_criteria !== undefined) {
521
620
  if (!Array.isArray(patch.acceptance_criteria)) {
522
621
  throw createKanbanError(
@@ -530,9 +629,21 @@ async function updateTask(taskId, patch) {
530
629
  }
531
630
  next.acceptance_criteria = patch.acceptance_criteria;
532
631
  }
533
- if (patch.subtasks !== undefined || patch.tasks !== undefined) {
534
- const subtasks = patch.subtasks !== undefined ? patch.subtasks : patch.tasks;
535
- if (!Array.isArray(subtasks)) {
632
+ if (patch.test_cases !== undefined) {
633
+ if (!Array.isArray(patch.test_cases)) {
634
+ throw createKanbanError(
635
+ 'VALIDATION_ERROR',
636
+ 'test_cases must be an array of strings',
637
+ 'Send test_cases as an array',
638
+ { field: 'test_cases' },
639
+ false,
640
+ 400
641
+ );
642
+ }
643
+ next.test_cases = patch.test_cases;
644
+ }
645
+ if (patch.subtasks !== undefined) {
646
+ if (!Array.isArray(patch.subtasks)) {
536
647
  throw createKanbanError(
537
648
  'VALIDATION_ERROR',
538
649
  'subtasks must be an array',
@@ -542,9 +653,23 @@ async function updateTask(taskId, patch) {
542
653
  400
543
654
  );
544
655
  }
545
- next.subtasks = subtasks;
656
+ next.subtasks = patch.subtasks;
546
657
  }
547
658
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
659
+ if (patch.plan !== undefined) next.plan = patch.plan;
660
+ if (patch.evidence !== undefined) {
661
+ if (!Array.isArray(patch.evidence)) {
662
+ throw createKanbanError(
663
+ 'VALIDATION_ERROR',
664
+ 'evidence must be an array',
665
+ 'Send evidence as an array of evidence objects',
666
+ { field: 'evidence' },
667
+ false,
668
+ 400
669
+ );
670
+ }
671
+ next.evidence = patch.evidence;
672
+ }
548
673
 
549
674
  return writeTask(next, previousFilePath);
550
675
  }
@@ -604,6 +729,118 @@ async function doUpdate(epicId, newTitle, newTasks) {
604
729
  }
605
730
  }
606
731
 
732
+ function guiPortFilePath() {
733
+ return path.join(BACKLOG, GUI_PORT_FILE);
734
+ }
735
+
736
+ function hashCwdToPort(cwd = process.cwd()) {
737
+ let hash = 0;
738
+ const input = String(cwd);
739
+ for (let i = 0; i < input.length; i++) {
740
+ hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
741
+ }
742
+ return GUI_PORT_MIN + (Math.abs(hash) % GUI_PORT_SPAN);
743
+ }
744
+
745
+ function normalizeGuiPort(value) {
746
+ const parsed = Number.parseInt(value, 10);
747
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
748
+ return null;
749
+ }
750
+ return parsed;
751
+ }
752
+
753
+ function resolvePreferredGuiPort(explicitPort) {
754
+ if (explicitPort !== undefined && explicitPort !== null && explicitPort !== '') {
755
+ const fromArg = normalizeGuiPort(explicitPort);
756
+ if (fromArg) return fromArg;
757
+ }
758
+
759
+ const fromEnv = normalizeGuiPort(process.env.KANBANGO_GUI_PORT);
760
+ if (fromEnv) return fromEnv;
761
+
762
+ return hashCwdToPort(process.cwd());
763
+ }
764
+
765
+ function isPidAlive(pid) {
766
+ const n = Number.parseInt(pid, 10);
767
+ if (!Number.isFinite(n) || n <= 0) return false;
768
+ try {
769
+ process.kill(n, 0);
770
+ return true;
771
+ } catch {
772
+ return false;
773
+ }
774
+ }
775
+
776
+ async function writeGuiPortFile({ port, pid = process.pid } = {}) {
777
+ const normalizedPort = normalizeGuiPort(port);
778
+ if (!normalizedPort) {
779
+ throw createKanbanError(
780
+ 'VALIDATION_ERROR',
781
+ 'Invalid GUI port',
782
+ 'Use an integer between 1 and 65535',
783
+ { port },
784
+ false,
785
+ 400
786
+ );
787
+ }
788
+
789
+ await ensureBacklogDir();
790
+ const data = {
791
+ port: normalizedPort,
792
+ pid,
793
+ url: `http://localhost:${normalizedPort}`,
794
+ cwd: process.cwd(),
795
+ started_at: new Date().toISOString()
796
+ };
797
+ await fs.writeFile(guiPortFilePath(), JSON.stringify(data, null, 2), 'utf-8');
798
+ return data;
799
+ }
800
+
801
+ async function readGuiPortFile() {
802
+ try {
803
+ const raw = await fs.readFile(guiPortFilePath(), 'utf-8');
804
+ const data = JSON.parse(raw);
805
+ if (!data || !normalizeGuiPort(data.port)) return null;
806
+ return data;
807
+ } catch (error) {
808
+ if (error.code === 'ENOENT') return null;
809
+ return null;
810
+ }
811
+ }
812
+
813
+ async function clearGuiPortFile({ pid, force = false } = {}) {
814
+ const info = await readGuiPortFile();
815
+ if (!info) return false;
816
+ if (!force && pid !== undefined && info.pid !== pid) return false;
817
+ if (!force && pid === undefined && info.pid !== process.pid) return false;
818
+
819
+ try {
820
+ await fs.unlink(guiPortFilePath());
821
+ return true;
822
+ } catch (error) {
823
+ if (error.code === 'ENOENT') return false;
824
+ throw error;
825
+ }
826
+ }
827
+
828
+ async function discoverRunningGui() {
829
+ const info = await readGuiPortFile();
830
+ if (!info || !isPidAlive(info.pid)) {
831
+ if (info) await clearGuiPortFile({ force: true });
832
+ return null;
833
+ }
834
+ return {
835
+ status: 'running',
836
+ port: info.port,
837
+ pid: info.pid,
838
+ url: info.url || `http://localhost:${info.port}`,
839
+ cwd: info.cwd,
840
+ started_at: info.started_at
841
+ };
842
+ }
843
+
607
844
  module.exports = {
608
845
  ensureBacklogDir,
609
846
  parseEpic,
@@ -619,7 +856,19 @@ module.exports = {
619
856
  doCreate,
620
857
  createKanbanError,
621
858
  getProgress,
859
+ resolveTaskId,
860
+ hashCwdToPort,
861
+ normalizeGuiPort,
862
+ resolvePreferredGuiPort,
863
+ isPidAlive,
864
+ writeGuiPortFile,
865
+ readGuiPortFile,
866
+ clearGuiPortFile,
867
+ discoverRunningGui,
868
+ guiPortFilePath,
622
869
  COLS,
623
870
  STATUS_MAP,
624
- VIEW_FIELDS
871
+ VIEW_FIELDS,
872
+ GUI_PORT_MIN,
873
+ GUI_PORT_MAX
625
874
  };