kanbango 2.1.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,11 +24,13 @@ const VIEW_FIELDS = {
20
24
  'progress',
21
25
  'description',
22
26
  'specs',
27
+ 'in_scope',
28
+ 'out_of_scope',
23
29
  'acceptance_criteria',
24
30
  'test_cases'
25
31
  ],
26
32
  execution: [
27
- 'id',
33
+ 'task_number',
28
34
  'title',
29
35
  'column',
30
36
  'epic_group',
@@ -32,12 +38,14 @@ const VIEW_FIELDS = {
32
38
  'progress',
33
39
  'description',
34
40
  'specs',
41
+ 'in_scope',
42
+ 'out_of_scope',
35
43
  'acceptance_criteria',
36
44
  'test_cases',
37
45
  'subtasks'
38
46
  ],
39
47
  full: [
40
- 'id',
48
+ 'task_number',
41
49
  'title',
42
50
  'column',
43
51
  'epic_group',
@@ -45,6 +53,8 @@ const VIEW_FIELDS = {
45
53
  'progress',
46
54
  'description',
47
55
  'specs',
56
+ 'in_scope',
57
+ 'out_of_scope',
48
58
  'acceptance_criteria',
49
59
  'test_cases',
50
60
  'subtasks',
@@ -74,6 +84,11 @@ function normalizeString(value, fallback = '') {
74
84
  return typeof value === 'string' ? value.trim() : fallback;
75
85
  }
76
86
 
87
+ function extractTaskNumber(taskId) {
88
+ const match = normalizeString(taskId).match(/^(?:[A-Z]+-)?(\d+)/);
89
+ return match ? parseInt(match[1], 10) : null;
90
+ }
91
+
77
92
  function normalizeStringArray(value) {
78
93
  if (!Array.isArray(value)) return [];
79
94
  return value
@@ -91,34 +106,48 @@ function normalizeSubtasks(value) {
91
106
  })).filter((subtask) => subtask.text);
92
107
  }
93
108
 
94
- 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;
95
123
  return {
96
- ...task,
97
- tasks: task.subtasks.map((subtask) => ({
98
- id: subtask.id,
99
- text: subtask.text,
100
- done: subtask.done,
101
- description: subtask.description
102
- }))
124
+ runner: value.runner && typeof value.runner === 'object' ? value.runner : null,
125
+ status: ['active', 'done'].includes(value.status) ? value.status : 'active'
103
126
  };
104
127
  }
105
128
 
106
129
  function normalizeTask(task) {
130
+ const id = normalizeString(task.id);
107
131
  const normalized = {
108
- id: normalizeString(task.id),
109
- title: stripTitlePrefix(task.title || task.id),
132
+ id,
133
+ title: stripTitlePrefix(task.title || id),
110
134
  column: COLS.includes(task.column) ? task.column : 'planned',
111
135
  epic_group: normalizeString(task.epic_group, '—') || '—',
112
136
  created: normalizeString(task.created) || todayIso(),
113
137
  description: normalizeString(task.description),
114
138
  specs: normalizeString(task.specs),
139
+ in_scope: normalizeStringArray(task.in_scope),
140
+ out_of_scope: normalizeStringArray(task.out_of_scope),
115
141
  acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
116
142
  test_cases: normalizeStringArray(task.test_cases),
117
- subtasks: normalizeSubtasks(task.subtasks || task.tasks),
118
- notes: normalizeString(task.notes)
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)
119
148
  };
120
149
 
121
- return withLegacyTaskAlias(normalized);
150
+ return normalized;
122
151
  }
123
152
 
124
153
  function serializeTask(task) {
@@ -131,10 +160,15 @@ function serializeTask(task) {
131
160
  created: normalized.created,
132
161
  description: normalized.description,
133
162
  specs: normalized.specs,
163
+ in_scope: normalized.in_scope,
164
+ out_of_scope: normalized.out_of_scope,
134
165
  acceptance_criteria: normalized.acceptance_criteria,
135
166
  test_cases: normalized.test_cases,
136
167
  subtasks: normalized.subtasks,
137
- notes: normalized.notes
168
+ notes: normalized.notes,
169
+ plan: normalized.plan,
170
+ evidence: normalized.evidence,
171
+ task_number: normalized.task_number
138
172
  };
139
173
  }
140
174
 
@@ -152,10 +186,6 @@ function pickFields(task, fieldNames) {
152
186
  picked.progress = getProgress(task);
153
187
  continue;
154
188
  }
155
- if (field === 'tasks') {
156
- picked.tasks = task.tasks;
157
- continue;
158
- }
159
189
  if (field in task) {
160
190
  picked[field] = task[field];
161
191
  }
@@ -231,6 +261,8 @@ async function parseMarkdownTask(filePath, column) {
231
261
  created: createdMatch ? createdMatch[1].trim() : todayIso(),
232
262
  description: extractSection(text, ['Opis', 'Description']),
233
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'])),
234
266
  acceptance_criteria: parseListSection(extractSection(text, ['Acceptance Criteria', 'Kryteria Akceptacji'])),
235
267
  test_cases: parseListSection(extractSection(text, ['Test Cases', 'Przypadki Testowe'])),
236
268
  subtasks,
@@ -323,7 +355,8 @@ async function findFile(epicId) {
323
355
  }
324
356
 
325
357
  async function getTask(taskId) {
326
- const filePath = await findFile(taskId);
358
+ const resolvedId = await resolveTaskId(taskId);
359
+ const filePath = await findFile(resolvedId);
327
360
  if (!filePath) {
328
361
  throw createKanbanError(
329
362
  'TASK_NOT_FOUND',
@@ -339,6 +372,34 @@ async function getTask(taskId) {
339
372
  return parseEpic(filePath, column);
340
373
  }
341
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
+
342
403
  async function writeTask(task, previousFilePath = null) {
343
404
  const normalized = normalizeTask(task);
344
405
  await ensureBacklogDir();
@@ -403,7 +464,7 @@ async function migrateAll(options = {}) {
403
464
  return { migrated, errors };
404
465
  }
405
466
 
406
- async function nextPiNumber() {
467
+ async function nextTaskNumber() {
407
468
  const ids = [];
408
469
 
409
470
  for (const col of COLS) {
@@ -411,7 +472,7 @@ async function nextPiNumber() {
411
472
  try {
412
473
  const files = await fs.readdir(colDir);
413
474
  for (const file of files) {
414
- const match = file.match(/^PI-(\d+)/);
475
+ const match = file.match(/^(?:[A-Z]+-)?(\d+)/);
415
476
  if (match) ids.push(parseInt(match[1], 10));
416
477
  }
417
478
  } catch (error) {
@@ -462,24 +523,28 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
462
523
 
463
524
  validateColumn(column, 'col');
464
525
 
465
- const nextId = await nextPiNumber();
526
+ const nextId = await nextTaskNumber();
466
527
  const slug = title
467
528
  .toLowerCase()
468
529
  .replace(/[^a-z0-9]+/g, '-')
469
530
  .replace(/^-+|-+$/g, '')
470
531
  .substring(0, 25);
471
532
  const task = normalizeTask({
472
- id: `PI-${String(nextId).padStart(3, '0')}-${slug || 'task'}`,
533
+ id: String(nextId).padStart(3, '0'),
473
534
  title,
474
535
  column,
475
536
  epic_group: epicGroup || '—',
476
537
  created: todayIso(),
477
538
  description: extra.description,
478
539
  specs: extra.specs,
540
+ in_scope: extra.in_scope,
541
+ out_of_scope: extra.out_of_scope,
479
542
  acceptance_criteria: extra.acceptance_criteria,
480
543
  test_cases: extra.test_cases,
481
544
  subtasks: extra.subtasks,
482
- notes: extra.notes
545
+ notes: extra.notes,
546
+ plan: extra.plan,
547
+ evidence: extra.evidence
483
548
  });
484
549
 
485
550
  return writeTask(task);
@@ -488,7 +553,8 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
488
553
  async function updateTask(taskId, patch) {
489
554
  validatePatch(patch);
490
555
 
491
- const previousFilePath = await findFile(taskId);
556
+ const resolvedId = await resolveTaskId(taskId);
557
+ const previousFilePath = await findFile(resolvedId);
492
558
  if (!previousFilePath) {
493
559
  throw createKanbanError(
494
560
  'TASK_NOT_FOUND',
@@ -524,6 +590,32 @@ async function updateTask(taskId, patch) {
524
590
  if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
525
591
  if (patch.description !== undefined) next.description = normalizeString(patch.description);
526
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
+ }
527
619
  if (patch.acceptance_criteria !== undefined) {
528
620
  if (!Array.isArray(patch.acceptance_criteria)) {
529
621
  throw createKanbanError(
@@ -550,9 +642,8 @@ async function updateTask(taskId, patch) {
550
642
  }
551
643
  next.test_cases = patch.test_cases;
552
644
  }
553
- if (patch.subtasks !== undefined || patch.tasks !== undefined) {
554
- const subtasks = patch.subtasks !== undefined ? patch.subtasks : patch.tasks;
555
- if (!Array.isArray(subtasks)) {
645
+ if (patch.subtasks !== undefined) {
646
+ if (!Array.isArray(patch.subtasks)) {
556
647
  throw createKanbanError(
557
648
  'VALIDATION_ERROR',
558
649
  'subtasks must be an array',
@@ -562,9 +653,23 @@ async function updateTask(taskId, patch) {
562
653
  400
563
654
  );
564
655
  }
565
- next.subtasks = subtasks;
656
+ next.subtasks = patch.subtasks;
566
657
  }
567
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
+ }
568
673
 
569
674
  return writeTask(next, previousFilePath);
570
675
  }
@@ -624,6 +729,118 @@ async function doUpdate(epicId, newTitle, newTasks) {
624
729
  }
625
730
  }
626
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
+
627
844
  module.exports = {
628
845
  ensureBacklogDir,
629
846
  parseEpic,
@@ -639,7 +856,19 @@ module.exports = {
639
856
  doCreate,
640
857
  createKanbanError,
641
858
  getProgress,
859
+ resolveTaskId,
860
+ hashCwdToPort,
861
+ normalizeGuiPort,
862
+ resolvePreferredGuiPort,
863
+ isPidAlive,
864
+ writeGuiPortFile,
865
+ readGuiPortFile,
866
+ clearGuiPortFile,
867
+ discoverRunningGui,
868
+ guiPortFilePath,
642
869
  COLS,
643
870
  STATUS_MAP,
644
- VIEW_FIELDS
871
+ VIEW_FIELDS,
872
+ GUI_PORT_MIN,
873
+ GUI_PORT_MAX
645
874
  };