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/.ai/lessons.jsonl +0 -0
- package/.ai/retro/close.json +1 -0
- package/.ai/retro/last-run.json +1 -0
- package/AGENTS.md +7 -7
- package/API.md +20 -12
- package/CHANGELOG.md +39 -0
- package/LLM_AGENTS.md +170 -177
- package/README.md +31 -14
- package/bin/kanban.js +101 -26
- package/index.html +162 -85
- package/index.js +2 -0
- package/kan2.md +76 -0
- package/kanban.js +306 -34
- package/mcp-server.js +403 -185
- package/package.json +1 -1
- package/plan.js +159 -0
- package/planv2.md +17 -7
- package/tests/run.js +1 -0
package/index.js
CHANGED
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: ['
|
|
17
|
+
summary: ['task_number', 'title', 'column', 'epic_group', 'created', 'progress'],
|
|
14
18
|
planning: [
|
|
15
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
109
|
-
title: stripTitlePrefix(task.title ||
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(/^
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
554
|
-
|
|
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
|
};
|