kanbango 2.1.0 → 2.5.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/index.js CHANGED
@@ -9,7 +9,9 @@
9
9
  */
10
10
 
11
11
  const kanban = require('./kanban.js');
12
+ const plan = require('./plan.js');
12
13
 
13
14
  module.exports = {
14
15
  kanban,
16
+ plan,
15
17
  };
package/kan2.md ADDED
@@ -0,0 +1,76 @@
1
+ # Plan kan2: Kanbango Plan Workflow
2
+
3
+ ## Cel
4
+
5
+ Rozszerzyć Kanbango o niezależny workflow planów, który może być używany przez
6
+ OpenCode, inne agenty i CLI. Kanbango pozostaje osobnym repozytorium.
7
+
8
+ ## Zasada architektoniczna
9
+
10
+ Kanbango nie zna API OpenCode ani hooków pluginu. Udostępnia stabilne operacje
11
+ na planie i evidence. Plugin OpenCode jest tylko klientem oraz strażnikiem sesji.
12
+
13
+ ## Funkcjonalność
14
+
15
+ Dodać bibliotekę domenową oraz odpowiednie operacje CLI/MCP:
16
+
17
+ - utworzenie taska z zaakceptowanym planem,
18
+ - zapis kroków planu jako subtasks,
19
+ - oznaczenie bieżącego kroku jako zakończonego,
20
+ - zapis evidence: diff, komenda testowa, stdout, stderr i exit code,
21
+ - zakończenie workflow i przeniesienie taska do `done`,
22
+ - odczyt pełnego statusu planu w stabilnym formacie JSON.
23
+
24
+ ## Domyślne kroki
25
+
26
+ Każdy zaakceptowany plan dostaje kolejno:
27
+
28
+ 1. `Write tests`
29
+ 2. `Run tests and confirm red`
30
+ 3. Kroki implementacyjne dostarczone przez advisora
31
+ 4. `Run tests and confirm green`
32
+
33
+ ## Test runner
34
+
35
+ Autodetekcja musi działać per projekt, bez założenia Node:
36
+
37
+ - Rust: `cargo test` przy `Cargo.toml`,
38
+ - Go: `go test ./...` przy `go.mod`,
39
+ - Python: `python -m pytest` przy `pyproject.toml` lub `pytest.ini`,
40
+ - JavaScript/TypeScript: `npm test`, `pnpm test`, `yarn test` albo `bun test`
41
+ zgodnie z lockfilem i skryptem `test` w `package.json`,
42
+ - jawny override przez `OPENCODE_TEST_COMMAND`.
43
+
44
+ Autodetekcja ma zwracać także powód wyboru komendy i czytelny błąd, gdy nie
45
+ znaleziono testów.
46
+
47
+ ## Interfejs
48
+
49
+ Preferowany jest wspólny moduł JS oraz JSON CLI/MCP, zamiast parsowania tekstu:
50
+
51
+ - `plan create --json <payload>`,
52
+ - `plan advance --json <payload>`,
53
+ - `plan evidence --json <payload>`,
54
+ - `plan done --json <payload>`,
55
+ - odpowiadające operacje w `kanban_manage` lub osobnym narzędziu MCP.
56
+
57
+ CLI musi zwracać stabilny JSON z `ok`, `task_id`, `subtasks` i `error`.
58
+
59
+ ## Testy TDD
60
+
61
+ - autodetekcja runnera dla Rust, Go, Python i Node,
62
+ - utworzenie planu z czterema grupami kroków,
63
+ - aktualizacja kroku bez kasowania pozostałych subtasks,
64
+ - zapis evidence bez silent fail,
65
+ - zakończenie planu i przejście do `done`,
66
+ - błędna komenda testowa i brak testów,
67
+ - wywołanie przez CLI i MCP z tym samym rezultatem.
68
+
69
+ ## Kolejność implementacji
70
+
71
+ 1. Model planu i runner detection.
72
+ 2. Operacje biblioteki Kanbango.
73
+ 3. CLI i JSON output.
74
+ 4. MCP adapter.
75
+ 5. Integracja pluginu OpenCode z tym kontraktem.
76
+ 6. Test end-to-end w przykładowym projekcie.
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',
@@ -52,6 +62,16 @@ const VIEW_FIELDS = {
52
62
  ]
53
63
  };
54
64
 
65
+ // Hard-required on create: title only (keeps GUI/CLI quick-add usable).
66
+ // Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
67
+ const RECOMMENDED_CREATE_FIELDS = [
68
+ 'description',
69
+ 'specs',
70
+ 'in_scope',
71
+ 'out_of_scope',
72
+ 'acceptance_criteria'
73
+ ];
74
+
55
75
  function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
56
76
  const error = new Error(message);
57
77
  error.code = code;
@@ -74,6 +94,11 @@ function normalizeString(value, fallback = '') {
74
94
  return typeof value === 'string' ? value.trim() : fallback;
75
95
  }
76
96
 
97
+ function extractTaskNumber(taskId) {
98
+ const match = normalizeString(taskId).match(/^(?:[A-Z]+-)?(\d+)/);
99
+ return match ? parseInt(match[1], 10) : null;
100
+ }
101
+
77
102
  function normalizeStringArray(value) {
78
103
  if (!Array.isArray(value)) return [];
79
104
  return value
@@ -81,6 +106,36 @@ function normalizeStringArray(value) {
81
106
  .filter(Boolean);
82
107
  }
83
108
 
109
+ function isPresentCreateField(field, value) {
110
+ if (field === 'description' || field === 'specs' || field === 'notes') {
111
+ return Boolean(normalizeString(value));
112
+ }
113
+ if (
114
+ field === 'in_scope' ||
115
+ field === 'out_of_scope' ||
116
+ field === 'acceptance_criteria' ||
117
+ field === 'test_cases' ||
118
+ field === 'subtasks'
119
+ ) {
120
+ if (field === 'subtasks') return normalizeSubtasks(value).length > 0;
121
+ return normalizeStringArray(value).length > 0;
122
+ }
123
+ return value !== undefined && value !== null && value !== '';
124
+ }
125
+
126
+ function missingRecommendedCreateFields(payload = {}) {
127
+ return RECOMMENDED_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
128
+ }
129
+
130
+ function createFieldWarnings(payload = {}) {
131
+ const missing = missingRecommendedCreateFields(payload);
132
+ if (missing.length === 0) return [];
133
+ return [
134
+ `Strongly recommended fields missing: ${missing.join(', ')}. ` +
135
+ 'Fill them on create (or via update) so scope and done-criteria are explicit.'
136
+ ];
137
+ }
138
+
84
139
  function normalizeSubtasks(value) {
85
140
  if (!Array.isArray(value)) return [];
86
141
  return value.map((subtask, idx) => ({
@@ -91,34 +146,48 @@ function normalizeSubtasks(value) {
91
146
  })).filter((subtask) => subtask.text);
92
147
  }
93
148
 
94
- function withLegacyTaskAlias(task) {
149
+ function normalizeEvidence(value) {
150
+ if (!Array.isArray(value)) return [];
151
+ return value.map((item) => ({
152
+ diff: normalizeString(item && item.diff),
153
+ test_command: normalizeString(item && item.test_command),
154
+ stdout: normalizeString(item && item.stdout),
155
+ stderr: normalizeString(item && item.stderr),
156
+ exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
157
+ created: normalizeString(item && item.created) || todayIso()
158
+ }));
159
+ }
160
+
161
+ function normalizePlan(value) {
162
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
95
163
  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
- }))
164
+ runner: value.runner && typeof value.runner === 'object' ? value.runner : null,
165
+ status: ['active', 'done'].includes(value.status) ? value.status : 'active'
103
166
  };
104
167
  }
105
168
 
106
169
  function normalizeTask(task) {
170
+ const id = normalizeString(task.id);
107
171
  const normalized = {
108
- id: normalizeString(task.id),
109
- title: stripTitlePrefix(task.title || task.id),
172
+ id,
173
+ title: stripTitlePrefix(task.title || id),
110
174
  column: COLS.includes(task.column) ? task.column : 'planned',
111
175
  epic_group: normalizeString(task.epic_group, '—') || '—',
112
176
  created: normalizeString(task.created) || todayIso(),
113
177
  description: normalizeString(task.description),
114
178
  specs: normalizeString(task.specs),
179
+ in_scope: normalizeStringArray(task.in_scope),
180
+ out_of_scope: normalizeStringArray(task.out_of_scope),
115
181
  acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
116
182
  test_cases: normalizeStringArray(task.test_cases),
117
- subtasks: normalizeSubtasks(task.subtasks || task.tasks),
118
- notes: normalizeString(task.notes)
183
+ subtasks: normalizeSubtasks(task.subtasks),
184
+ notes: normalizeString(task.notes),
185
+ plan: normalizePlan(task.plan),
186
+ evidence: normalizeEvidence(task.evidence),
187
+ task_number: extractTaskNumber(id)
119
188
  };
120
189
 
121
- return withLegacyTaskAlias(normalized);
190
+ return normalized;
122
191
  }
123
192
 
124
193
  function serializeTask(task) {
@@ -131,10 +200,15 @@ function serializeTask(task) {
131
200
  created: normalized.created,
132
201
  description: normalized.description,
133
202
  specs: normalized.specs,
203
+ in_scope: normalized.in_scope,
204
+ out_of_scope: normalized.out_of_scope,
134
205
  acceptance_criteria: normalized.acceptance_criteria,
135
206
  test_cases: normalized.test_cases,
136
207
  subtasks: normalized.subtasks,
137
- notes: normalized.notes
208
+ notes: normalized.notes,
209
+ plan: normalized.plan,
210
+ evidence: normalized.evidence,
211
+ task_number: normalized.task_number
138
212
  };
139
213
  }
140
214
 
@@ -152,10 +226,6 @@ function pickFields(task, fieldNames) {
152
226
  picked.progress = getProgress(task);
153
227
  continue;
154
228
  }
155
- if (field === 'tasks') {
156
- picked.tasks = task.tasks;
157
- continue;
158
- }
159
229
  if (field in task) {
160
230
  picked[field] = task[field];
161
231
  }
@@ -231,6 +301,8 @@ async function parseMarkdownTask(filePath, column) {
231
301
  created: createdMatch ? createdMatch[1].trim() : todayIso(),
232
302
  description: extractSection(text, ['Opis', 'Description']),
233
303
  specs: extractSection(text, ['Specs', 'Specyfikacja']),
304
+ in_scope: parseListSection(extractSection(text, ['In Scope', 'W Zakresie'])),
305
+ out_of_scope: parseListSection(extractSection(text, ['Out of Scope', 'Poza Zakresem'])),
234
306
  acceptance_criteria: parseListSection(extractSection(text, ['Acceptance Criteria', 'Kryteria Akceptacji'])),
235
307
  test_cases: parseListSection(extractSection(text, ['Test Cases', 'Przypadki Testowe'])),
236
308
  subtasks,
@@ -323,7 +395,8 @@ async function findFile(epicId) {
323
395
  }
324
396
 
325
397
  async function getTask(taskId) {
326
- const filePath = await findFile(taskId);
398
+ const resolvedId = await resolveTaskId(taskId);
399
+ const filePath = await findFile(resolvedId);
327
400
  if (!filePath) {
328
401
  throw createKanbanError(
329
402
  'TASK_NOT_FOUND',
@@ -339,6 +412,34 @@ async function getTask(taskId) {
339
412
  return parseEpic(filePath, column);
340
413
  }
341
414
 
415
+ async function resolveTaskId(input) {
416
+ if (!input) return null;
417
+
418
+ const exact = await findFile(String(input));
419
+ if (exact) return String(input);
420
+
421
+ const num = parseInt(String(input), 10);
422
+ if (!Number.isFinite(num)) return null;
423
+
424
+ for (const col of COLS) {
425
+ const colDir = path.join(BACKLOG, col);
426
+ try {
427
+ const files = await fs.readdir(colDir);
428
+ const rawPattern = new RegExp(`^(?:[A-Z]+-)?${String(num)}(?:-|$)`);
429
+ const paddedPattern = new RegExp(`^(?:[A-Z]+-)?${String(num).padStart(3, '0')}(?:-|$)`);
430
+ const match = files.find((file) => {
431
+ const base = path.basename(file, path.extname(file));
432
+ return rawPattern.test(base) || paddedPattern.test(base + '-');
433
+ });
434
+ if (match) return path.basename(match, path.extname(match));
435
+ } catch (error) {
436
+ if (error.code !== 'ENOENT') throw error;
437
+ }
438
+ }
439
+
440
+ return String(input);
441
+ }
442
+
342
443
  async function writeTask(task, previousFilePath = null) {
343
444
  const normalized = normalizeTask(task);
344
445
  await ensureBacklogDir();
@@ -403,7 +504,7 @@ async function migrateAll(options = {}) {
403
504
  return { migrated, errors };
404
505
  }
405
506
 
406
- async function nextPiNumber() {
507
+ async function nextTaskNumber() {
407
508
  const ids = [];
408
509
 
409
510
  for (const col of COLS) {
@@ -411,7 +512,7 @@ async function nextPiNumber() {
411
512
  try {
412
513
  const files = await fs.readdir(colDir);
413
514
  for (const file of files) {
414
- const match = file.match(/^PI-(\d+)/);
515
+ const match = file.match(/^(?:[A-Z]+-)?(\d+)/);
415
516
  if (match) ids.push(parseInt(match[1], 10));
416
517
  }
417
518
  } catch (error) {
@@ -462,24 +563,28 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
462
563
 
463
564
  validateColumn(column, 'col');
464
565
 
465
- const nextId = await nextPiNumber();
566
+ const nextId = await nextTaskNumber();
466
567
  const slug = title
467
568
  .toLowerCase()
468
569
  .replace(/[^a-z0-9]+/g, '-')
469
570
  .replace(/^-+|-+$/g, '')
470
571
  .substring(0, 25);
471
572
  const task = normalizeTask({
472
- id: `PI-${String(nextId).padStart(3, '0')}-${slug || 'task'}`,
573
+ id: String(nextId).padStart(3, '0'),
473
574
  title,
474
575
  column,
475
576
  epic_group: epicGroup || '—',
476
577
  created: todayIso(),
477
578
  description: extra.description,
478
579
  specs: extra.specs,
580
+ in_scope: extra.in_scope,
581
+ out_of_scope: extra.out_of_scope,
479
582
  acceptance_criteria: extra.acceptance_criteria,
480
583
  test_cases: extra.test_cases,
481
584
  subtasks: extra.subtasks,
482
- notes: extra.notes
585
+ notes: extra.notes,
586
+ plan: extra.plan,
587
+ evidence: extra.evidence
483
588
  });
484
589
 
485
590
  return writeTask(task);
@@ -488,7 +593,8 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
488
593
  async function updateTask(taskId, patch) {
489
594
  validatePatch(patch);
490
595
 
491
- const previousFilePath = await findFile(taskId);
596
+ const resolvedId = await resolveTaskId(taskId);
597
+ const previousFilePath = await findFile(resolvedId);
492
598
  if (!previousFilePath) {
493
599
  throw createKanbanError(
494
600
  'TASK_NOT_FOUND',
@@ -524,6 +630,32 @@ async function updateTask(taskId, patch) {
524
630
  if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
525
631
  if (patch.description !== undefined) next.description = normalizeString(patch.description);
526
632
  if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
633
+ if (patch.in_scope !== undefined) {
634
+ if (!Array.isArray(patch.in_scope)) {
635
+ throw createKanbanError(
636
+ 'VALIDATION_ERROR',
637
+ 'in_scope must be an array of strings',
638
+ 'Send in_scope as an array',
639
+ { field: 'in_scope' },
640
+ false,
641
+ 400
642
+ );
643
+ }
644
+ next.in_scope = patch.in_scope;
645
+ }
646
+ if (patch.out_of_scope !== undefined) {
647
+ if (!Array.isArray(patch.out_of_scope)) {
648
+ throw createKanbanError(
649
+ 'VALIDATION_ERROR',
650
+ 'out_of_scope must be an array of strings',
651
+ 'Send out_of_scope as an array',
652
+ { field: 'out_of_scope' },
653
+ false,
654
+ 400
655
+ );
656
+ }
657
+ next.out_of_scope = patch.out_of_scope;
658
+ }
527
659
  if (patch.acceptance_criteria !== undefined) {
528
660
  if (!Array.isArray(patch.acceptance_criteria)) {
529
661
  throw createKanbanError(
@@ -550,9 +682,8 @@ async function updateTask(taskId, patch) {
550
682
  }
551
683
  next.test_cases = patch.test_cases;
552
684
  }
553
- if (patch.subtasks !== undefined || patch.tasks !== undefined) {
554
- const subtasks = patch.subtasks !== undefined ? patch.subtasks : patch.tasks;
555
- if (!Array.isArray(subtasks)) {
685
+ if (patch.subtasks !== undefined) {
686
+ if (!Array.isArray(patch.subtasks)) {
556
687
  throw createKanbanError(
557
688
  'VALIDATION_ERROR',
558
689
  'subtasks must be an array',
@@ -562,9 +693,23 @@ async function updateTask(taskId, patch) {
562
693
  400
563
694
  );
564
695
  }
565
- next.subtasks = subtasks;
696
+ next.subtasks = patch.subtasks;
566
697
  }
567
698
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
699
+ if (patch.plan !== undefined) next.plan = patch.plan;
700
+ if (patch.evidence !== undefined) {
701
+ if (!Array.isArray(patch.evidence)) {
702
+ throw createKanbanError(
703
+ 'VALIDATION_ERROR',
704
+ 'evidence must be an array',
705
+ 'Send evidence as an array of evidence objects',
706
+ { field: 'evidence' },
707
+ false,
708
+ 400
709
+ );
710
+ }
711
+ next.evidence = patch.evidence;
712
+ }
568
713
 
569
714
  return writeTask(next, previousFilePath);
570
715
  }
@@ -624,6 +769,118 @@ async function doUpdate(epicId, newTitle, newTasks) {
624
769
  }
625
770
  }
626
771
 
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
+
627
884
  module.exports = {
628
885
  ensureBacklogDir,
629
886
  parseEpic,
@@ -638,8 +895,23 @@ module.exports = {
638
895
  doUpdate,
639
896
  doCreate,
640
897
  createKanbanError,
898
+ createFieldWarnings,
899
+ missingRecommendedCreateFields,
641
900
  getProgress,
901
+ resolveTaskId,
902
+ hashCwdToPort,
903
+ normalizeGuiPort,
904
+ resolvePreferredGuiPort,
905
+ isPidAlive,
906
+ writeGuiPortFile,
907
+ readGuiPortFile,
908
+ clearGuiPortFile,
909
+ discoverRunningGui,
910
+ guiPortFilePath,
642
911
  COLS,
643
912
  STATUS_MAP,
644
- VIEW_FIELDS
913
+ VIEW_FIELDS,
914
+ RECOMMENDED_CREATE_FIELDS,
915
+ GUI_PORT_MIN,
916
+ GUI_PORT_MAX
645
917
  };