kanbango 2.0.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/AGENTS.md +606 -0
- package/API.md +177 -0
- package/CHANGELOG.md +122 -0
- package/LICENSE +21 -0
- package/LLM_AGENTS.md +484 -0
- package/README.md +360 -0
- package/bin/kanban-cmd.js +40 -0
- package/bin/kanban.js +462 -0
- package/index.html +1095 -0
- package/index.js +15 -0
- package/kanban.js +625 -0
- package/kanbango.md +48 -0
- package/mcp-server.js +489 -0
- package/package.json +41 -0
- package/planv2.md +307 -0
- package/tests/run.js +19 -0
package/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kanbango - JSON-first Kanban board
|
|
3
|
+
*
|
|
4
|
+
* This package provides a local Kanban board with web GUI, CLI, and MCP server.
|
|
5
|
+
* Tasks are stored as JSON files in a `backlog/` directory, with Markdown read
|
|
6
|
+
* compatibility during migration.
|
|
7
|
+
*
|
|
8
|
+
* @module kanbango
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const kanban = require('./kanban.js');
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
kanban,
|
|
15
|
+
};
|
package/kanban.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
5
|
+
const COLS = ['active', 'planned', 'icebox', 'done'];
|
|
6
|
+
const STATUS_MAP = {
|
|
7
|
+
active: 'in_progress',
|
|
8
|
+
planned: 'planned',
|
|
9
|
+
icebox: 'icebox',
|
|
10
|
+
done: 'done'
|
|
11
|
+
};
|
|
12
|
+
const VIEW_FIELDS = {
|
|
13
|
+
summary: ['id', 'title', 'column', 'epic_group', 'created', 'progress'],
|
|
14
|
+
planning: [
|
|
15
|
+
'id',
|
|
16
|
+
'title',
|
|
17
|
+
'column',
|
|
18
|
+
'epic_group',
|
|
19
|
+
'created',
|
|
20
|
+
'progress',
|
|
21
|
+
'description',
|
|
22
|
+
'specs',
|
|
23
|
+
'acceptance_criteria'
|
|
24
|
+
],
|
|
25
|
+
execution: [
|
|
26
|
+
'id',
|
|
27
|
+
'title',
|
|
28
|
+
'column',
|
|
29
|
+
'epic_group',
|
|
30
|
+
'created',
|
|
31
|
+
'progress',
|
|
32
|
+
'description',
|
|
33
|
+
'specs',
|
|
34
|
+
'acceptance_criteria',
|
|
35
|
+
'subtasks'
|
|
36
|
+
],
|
|
37
|
+
full: [
|
|
38
|
+
'id',
|
|
39
|
+
'title',
|
|
40
|
+
'column',
|
|
41
|
+
'epic_group',
|
|
42
|
+
'created',
|
|
43
|
+
'progress',
|
|
44
|
+
'description',
|
|
45
|
+
'specs',
|
|
46
|
+
'acceptance_criteria',
|
|
47
|
+
'subtasks',
|
|
48
|
+
'notes'
|
|
49
|
+
]
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
|
|
53
|
+
const error = new Error(message);
|
|
54
|
+
error.code = code;
|
|
55
|
+
error.hint = hint;
|
|
56
|
+
error.details = details;
|
|
57
|
+
error.retryable = retryable;
|
|
58
|
+
error.status = status;
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function todayIso() {
|
|
63
|
+
return new Date().toISOString().split('T')[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stripTitlePrefix(title) {
|
|
67
|
+
return String(title || '').replace(/^[\w.-]+:\s*/, '').trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeString(value, fallback = '') {
|
|
71
|
+
return typeof value === 'string' ? value.trim() : fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeStringArray(value) {
|
|
75
|
+
if (!Array.isArray(value)) return [];
|
|
76
|
+
return value
|
|
77
|
+
.map((item) => String(item || '').trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeSubtasks(value) {
|
|
82
|
+
if (!Array.isArray(value)) return [];
|
|
83
|
+
return value.map((subtask, idx) => ({
|
|
84
|
+
id: normalizeString(subtask && subtask.id, `st-${idx + 1}`),
|
|
85
|
+
text: normalizeString(subtask && subtask.text),
|
|
86
|
+
done: Boolean(subtask && subtask.done),
|
|
87
|
+
description: normalizeString(subtask && subtask.description)
|
|
88
|
+
})).filter((subtask) => subtask.text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function withLegacyTaskAlias(task) {
|
|
92
|
+
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
|
+
}))
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeTask(task) {
|
|
104
|
+
const normalized = {
|
|
105
|
+
id: normalizeString(task.id),
|
|
106
|
+
title: stripTitlePrefix(task.title || task.id),
|
|
107
|
+
column: COLS.includes(task.column) ? task.column : 'planned',
|
|
108
|
+
epic_group: normalizeString(task.epic_group, '—') || '—',
|
|
109
|
+
created: normalizeString(task.created) || todayIso(),
|
|
110
|
+
description: normalizeString(task.description),
|
|
111
|
+
specs: normalizeString(task.specs),
|
|
112
|
+
acceptance_criteria: normalizeStringArray(task.acceptance_criteria),
|
|
113
|
+
subtasks: normalizeSubtasks(task.subtasks || task.tasks),
|
|
114
|
+
notes: normalizeString(task.notes)
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return withLegacyTaskAlias(normalized);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function serializeTask(task) {
|
|
121
|
+
const normalized = normalizeTask(task);
|
|
122
|
+
return {
|
|
123
|
+
id: normalized.id,
|
|
124
|
+
title: normalized.title,
|
|
125
|
+
column: normalized.column,
|
|
126
|
+
epic_group: normalized.epic_group,
|
|
127
|
+
created: normalized.created,
|
|
128
|
+
description: normalized.description,
|
|
129
|
+
specs: normalized.specs,
|
|
130
|
+
acceptance_criteria: normalized.acceptance_criteria,
|
|
131
|
+
subtasks: normalized.subtasks,
|
|
132
|
+
notes: normalized.notes
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getProgress(task) {
|
|
137
|
+
const total = task.subtasks.length;
|
|
138
|
+
const done = task.subtasks.filter((subtask) => subtask.done).length;
|
|
139
|
+
return { done, total };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pickFields(task, fieldNames) {
|
|
143
|
+
const picked = {};
|
|
144
|
+
|
|
145
|
+
for (const field of fieldNames) {
|
|
146
|
+
if (field === 'progress') {
|
|
147
|
+
picked.progress = getProgress(task);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (field === 'tasks') {
|
|
151
|
+
picked.tasks = task.tasks;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (field in task) {
|
|
155
|
+
picked[field] = task[field];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return picked;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function shapeTask(task, options = {}) {
|
|
163
|
+
const normalized = normalizeTask(task);
|
|
164
|
+
const fields = Array.isArray(options.fields) && options.fields.length > 0
|
|
165
|
+
? options.fields
|
|
166
|
+
: (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
|
|
167
|
+
|
|
168
|
+
return pickFields(normalized, fields);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeRegex(value) {
|
|
172
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractSection(text, headings) {
|
|
176
|
+
for (const heading of headings) {
|
|
177
|
+
const regex = new RegExp(`^## ${escapeRegex(heading)}\\s*\\n([\\s\\S]*?)(?=^## |\\s*$)`, 'm');
|
|
178
|
+
const match = text.match(regex);
|
|
179
|
+
if (match) {
|
|
180
|
+
return match[1].trim();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseListSection(sectionText) {
|
|
187
|
+
if (!sectionText) return [];
|
|
188
|
+
return sectionText
|
|
189
|
+
.split('\n')
|
|
190
|
+
.map((line) => line.match(/^[-*]\s+(.+)$/))
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.map((match) => match[1].trim())
|
|
193
|
+
.filter(Boolean);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function ensureBacklogDir() {
|
|
197
|
+
for (const col of COLS) {
|
|
198
|
+
const colDir = path.join(BACKLOG, col);
|
|
199
|
+
await fs.mkdir(colDir, { recursive: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function parseMarkdownTask(filePath, column) {
|
|
204
|
+
const text = await fs.readFile(filePath, 'utf-8');
|
|
205
|
+
const titleMatch = text.match(/^# (.+)$/m);
|
|
206
|
+
const epicMatch = text.match(/^\*\*Epic:\*\*\s*(.+)$/m);
|
|
207
|
+
const createdMatch = text.match(/^\*\*Created:\*\*\s*(.+)$/m);
|
|
208
|
+
|
|
209
|
+
const subtasks = [];
|
|
210
|
+
const taskRegex = /^- \[([ x])\] (.+)$/gm;
|
|
211
|
+
let taskMatch;
|
|
212
|
+
while ((taskMatch = taskRegex.exec(text)) !== null) {
|
|
213
|
+
subtasks.push({
|
|
214
|
+
id: `st-${subtasks.length + 1}`,
|
|
215
|
+
done: taskMatch[1] === 'x',
|
|
216
|
+
text: taskMatch[2].trim(),
|
|
217
|
+
description: ''
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return normalizeTask({
|
|
222
|
+
id: path.basename(filePath, '.md'),
|
|
223
|
+
title: titleMatch ? titleMatch[1] : path.basename(filePath, '.md'),
|
|
224
|
+
column,
|
|
225
|
+
epic_group: epicMatch ? epicMatch[1].trim() : '—',
|
|
226
|
+
created: createdMatch ? createdMatch[1].trim() : todayIso(),
|
|
227
|
+
description: extractSection(text, ['Opis', 'Description']),
|
|
228
|
+
specs: extractSection(text, ['Specs', 'Specyfikacja']),
|
|
229
|
+
acceptance_criteria: parseListSection(extractSection(text, ['Acceptance Criteria', 'Kryteria Akceptacji'])),
|
|
230
|
+
subtasks,
|
|
231
|
+
notes: extractSection(text, ['Notes'])
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function parseJsonTask(filePath, column) {
|
|
236
|
+
let data;
|
|
237
|
+
try {
|
|
238
|
+
data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw createKanbanError(
|
|
241
|
+
'PARSE_ERROR',
|
|
242
|
+
`Task file ${path.basename(filePath)} could not be parsed`,
|
|
243
|
+
'Fix the JSON syntax or restore the file from version control',
|
|
244
|
+
{ file: filePath, reason: error.message },
|
|
245
|
+
false,
|
|
246
|
+
500
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return normalizeTask({
|
|
251
|
+
...data,
|
|
252
|
+
id: data.id || path.basename(filePath, '.json'),
|
|
253
|
+
column: column || data.column
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function parseEpic(filePath, column) {
|
|
258
|
+
if (filePath.endsWith('.json')) {
|
|
259
|
+
return parseJsonTask(filePath, column);
|
|
260
|
+
}
|
|
261
|
+
return parseMarkdownTask(filePath, column);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function allEpics() {
|
|
265
|
+
const epics = [];
|
|
266
|
+
|
|
267
|
+
for (const col of COLS) {
|
|
268
|
+
const colDir = path.join(BACKLOG, col);
|
|
269
|
+
try {
|
|
270
|
+
const files = await fs.readdir(colDir);
|
|
271
|
+
const taskFiles = files
|
|
272
|
+
.filter((file) => file.endsWith('.json') || file.endsWith('.md'))
|
|
273
|
+
.sort((left, right) => {
|
|
274
|
+
const leftBase = path.basename(left, path.extname(left));
|
|
275
|
+
const rightBase = path.basename(right, path.extname(right));
|
|
276
|
+
if (leftBase !== rightBase) return leftBase.localeCompare(rightBase);
|
|
277
|
+
return left.endsWith('.json') ? -1 : 1;
|
|
278
|
+
});
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
|
|
281
|
+
for (const file of taskFiles) {
|
|
282
|
+
const taskId = path.basename(file, path.extname(file));
|
|
283
|
+
if (seen.has(taskId)) continue;
|
|
284
|
+
seen.add(taskId);
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
epics.push(await parseEpic(path.join(colDir, file), col));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error(` parse error ${file}: ${error.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error.code !== 'ENOENT') throw error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return epics;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function findFile(epicId) {
|
|
301
|
+
for (const col of COLS) {
|
|
302
|
+
const colDir = path.join(BACKLOG, col);
|
|
303
|
+
try {
|
|
304
|
+
const files = await fs.readdir(colDir);
|
|
305
|
+
const candidates = files
|
|
306
|
+
.filter((file) => (file.endsWith('.json') || file.endsWith('.md'))
|
|
307
|
+
&& path.basename(file, path.extname(file)) === epicId)
|
|
308
|
+
.sort((left, right) => (left.endsWith('.json') ? -1 : 1));
|
|
309
|
+
if (candidates[0]) {
|
|
310
|
+
return path.join(colDir, candidates[0]);
|
|
311
|
+
}
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error.code !== 'ENOENT') throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function getTask(taskId) {
|
|
320
|
+
const filePath = await findFile(taskId);
|
|
321
|
+
if (!filePath) {
|
|
322
|
+
throw createKanbanError(
|
|
323
|
+
'TASK_NOT_FOUND',
|
|
324
|
+
`Task ${taskId} was not found`,
|
|
325
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
326
|
+
{ task_id: taskId },
|
|
327
|
+
false,
|
|
328
|
+
404
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const column = path.basename(path.dirname(filePath));
|
|
333
|
+
return parseEpic(filePath, column);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function writeTask(task, previousFilePath = null) {
|
|
337
|
+
const normalized = normalizeTask(task);
|
|
338
|
+
await ensureBacklogDir();
|
|
339
|
+
|
|
340
|
+
const nextFilePath = path.join(BACKLOG, normalized.column, `${normalized.id}.json`);
|
|
341
|
+
await fs.writeFile(nextFilePath, JSON.stringify(serializeTask(normalized), null, 2) + '\n', 'utf-8');
|
|
342
|
+
|
|
343
|
+
if (previousFilePath && path.resolve(previousFilePath) !== path.resolve(nextFilePath)) {
|
|
344
|
+
await fs.unlink(previousFilePath).catch((error) => {
|
|
345
|
+
if (error.code !== 'ENOENT') throw error;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return parseJsonTask(nextFilePath, normalized.column);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function migrateAll(options = {}) {
|
|
353
|
+
await ensureBacklogDir();
|
|
354
|
+
const migrated = [];
|
|
355
|
+
const errors = [];
|
|
356
|
+
|
|
357
|
+
for (const col of COLS) {
|
|
358
|
+
const colDir = path.join(BACKLOG, col);
|
|
359
|
+
let files;
|
|
360
|
+
try {
|
|
361
|
+
files = await fs.readdir(colDir);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error.code !== 'ENOENT') throw error;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const mdFiles = files
|
|
368
|
+
.filter((file) => file.endsWith('.md'))
|
|
369
|
+
.map((file) => ({
|
|
370
|
+
name: file,
|
|
371
|
+
taskId: path.basename(file, '.md')
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
for (const { name, taskId } of mdFiles) {
|
|
375
|
+
const mdPath = path.join(colDir, name);
|
|
376
|
+
const jsonPath = path.join(colDir, `${taskId}.json`);
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const task = await parseMarkdownTask(mdPath, col);
|
|
380
|
+
|
|
381
|
+
if (options.dryRun) {
|
|
382
|
+
migrated.push({ id: taskId, from: mdPath, to: jsonPath });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await writeTask(task);
|
|
387
|
+
await fs.unlink(mdPath).catch((error) => {
|
|
388
|
+
if (error.code !== 'ENOENT') throw error;
|
|
389
|
+
});
|
|
390
|
+
migrated.push({ id: taskId, from: mdPath, to: jsonPath });
|
|
391
|
+
} catch (error) {
|
|
392
|
+
errors.push({ file: mdPath, reason: error.message });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { migrated, errors };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function nextPiNumber() {
|
|
401
|
+
const ids = [];
|
|
402
|
+
|
|
403
|
+
for (const col of COLS) {
|
|
404
|
+
const colDir = path.join(BACKLOG, col);
|
|
405
|
+
try {
|
|
406
|
+
const files = await fs.readdir(colDir);
|
|
407
|
+
for (const file of files) {
|
|
408
|
+
const match = file.match(/^PI-(\d+)/);
|
|
409
|
+
if (match) ids.push(parseInt(match[1], 10));
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error.code !== 'ENOENT') throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function validateColumn(column, fieldName = 'column') {
|
|
420
|
+
if (!COLS.includes(column)) {
|
|
421
|
+
throw createKanbanError(
|
|
422
|
+
'INVALID_COLUMN',
|
|
423
|
+
`Column ${column} is not valid`,
|
|
424
|
+
`Use one of: ${COLS.join(', ')}`,
|
|
425
|
+
{ [fieldName]: column, valid_columns: COLS },
|
|
426
|
+
false,
|
|
427
|
+
400
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validatePatch(patch) {
|
|
433
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
434
|
+
throw createKanbanError(
|
|
435
|
+
'VALIDATION_ERROR',
|
|
436
|
+
'patch must be an object',
|
|
437
|
+
'Send a JSON object with only the fields you want to update',
|
|
438
|
+
{ patch },
|
|
439
|
+
false,
|
|
440
|
+
400
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}) {
|
|
446
|
+
if (!normalizeString(title)) {
|
|
447
|
+
throw createKanbanError(
|
|
448
|
+
'MISSING_REQUIRED_FIELD',
|
|
449
|
+
'title is required',
|
|
450
|
+
'Provide a non-empty title when creating a task',
|
|
451
|
+
{ field: 'title' },
|
|
452
|
+
false,
|
|
453
|
+
400
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
validateColumn(column, 'col');
|
|
458
|
+
|
|
459
|
+
const nextId = await nextPiNumber();
|
|
460
|
+
const slug = title
|
|
461
|
+
.toLowerCase()
|
|
462
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
463
|
+
.replace(/^-+|-+$/g, '')
|
|
464
|
+
.substring(0, 25);
|
|
465
|
+
const task = normalizeTask({
|
|
466
|
+
id: `PI-${String(nextId).padStart(3, '0')}-${slug || 'task'}`,
|
|
467
|
+
title,
|
|
468
|
+
column,
|
|
469
|
+
epic_group: epicGroup || '—',
|
|
470
|
+
created: todayIso(),
|
|
471
|
+
description: extra.description,
|
|
472
|
+
specs: extra.specs,
|
|
473
|
+
acceptance_criteria: extra.acceptance_criteria,
|
|
474
|
+
subtasks: extra.subtasks,
|
|
475
|
+
notes: extra.notes
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return writeTask(task);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function updateTask(taskId, patch) {
|
|
482
|
+
validatePatch(patch);
|
|
483
|
+
|
|
484
|
+
const previousFilePath = await findFile(taskId);
|
|
485
|
+
if (!previousFilePath) {
|
|
486
|
+
throw createKanbanError(
|
|
487
|
+
'TASK_NOT_FOUND',
|
|
488
|
+
`Task ${taskId} was not found`,
|
|
489
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
490
|
+
{ task_id: taskId },
|
|
491
|
+
false,
|
|
492
|
+
404
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
|
|
497
|
+
const next = { ...current };
|
|
498
|
+
|
|
499
|
+
if (patch.column !== undefined) {
|
|
500
|
+
validateColumn(patch.column);
|
|
501
|
+
next.column = patch.column;
|
|
502
|
+
}
|
|
503
|
+
if (patch.title !== undefined) {
|
|
504
|
+
const title = normalizeString(patch.title);
|
|
505
|
+
if (!title) {
|
|
506
|
+
throw createKanbanError(
|
|
507
|
+
'VALIDATION_ERROR',
|
|
508
|
+
'title must be a non-empty string',
|
|
509
|
+
'Send a non-empty title or omit the field',
|
|
510
|
+
{ field: 'title' },
|
|
511
|
+
false,
|
|
512
|
+
400
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
next.title = title;
|
|
516
|
+
}
|
|
517
|
+
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
518
|
+
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
519
|
+
if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
|
|
520
|
+
if (patch.acceptance_criteria !== undefined) {
|
|
521
|
+
if (!Array.isArray(patch.acceptance_criteria)) {
|
|
522
|
+
throw createKanbanError(
|
|
523
|
+
'VALIDATION_ERROR',
|
|
524
|
+
'acceptance_criteria must be an array of strings',
|
|
525
|
+
'Send acceptance_criteria as an array',
|
|
526
|
+
{ field: 'acceptance_criteria' },
|
|
527
|
+
false,
|
|
528
|
+
400
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
next.acceptance_criteria = patch.acceptance_criteria;
|
|
532
|
+
}
|
|
533
|
+
if (patch.subtasks !== undefined || patch.tasks !== undefined) {
|
|
534
|
+
const subtasks = patch.subtasks !== undefined ? patch.subtasks : patch.tasks;
|
|
535
|
+
if (!Array.isArray(subtasks)) {
|
|
536
|
+
throw createKanbanError(
|
|
537
|
+
'VALIDATION_ERROR',
|
|
538
|
+
'subtasks must be an array',
|
|
539
|
+
'Send subtasks as an array of objects',
|
|
540
|
+
{ field: 'subtasks' },
|
|
541
|
+
false,
|
|
542
|
+
400
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
next.subtasks = subtasks;
|
|
546
|
+
}
|
|
547
|
+
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
548
|
+
|
|
549
|
+
return writeTask(next, previousFilePath);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function doMove(epicId, target) {
|
|
553
|
+
try {
|
|
554
|
+
await updateTask(epicId, { column: target });
|
|
555
|
+
return true;
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN') {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function doToggle(epicId, idx) {
|
|
565
|
+
try {
|
|
566
|
+
const task = await getTask(epicId);
|
|
567
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= task.subtasks.length) {
|
|
568
|
+
throw createKanbanError(
|
|
569
|
+
'INVALID_SUBTASK_INDEX',
|
|
570
|
+
`Subtask index ${idx} is not valid for task ${epicId}`,
|
|
571
|
+
'Read the task first and use an index between 0 and subtasks.length - 1',
|
|
572
|
+
{ task_id: epicId, idx, total_subtasks: task.subtasks.length },
|
|
573
|
+
false,
|
|
574
|
+
400
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const subtasks = task.subtasks.map((subtask, subtaskIdx) => ({
|
|
579
|
+
...subtask,
|
|
580
|
+
done: subtaskIdx === idx ? !subtask.done : subtask.done
|
|
581
|
+
}));
|
|
582
|
+
await updateTask(epicId, { subtasks });
|
|
583
|
+
return true;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_SUBTASK_INDEX') {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function doUpdate(epicId, newTitle, newTasks) {
|
|
593
|
+
try {
|
|
594
|
+
const patch = {};
|
|
595
|
+
if (newTitle !== null) patch.title = newTitle;
|
|
596
|
+
if (newTasks !== null) patch.subtasks = newTasks;
|
|
597
|
+
await updateTask(epicId, patch);
|
|
598
|
+
return true;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (error.code === 'TASK_NOT_FOUND' || error.code === 'VALIDATION_ERROR') {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = {
|
|
608
|
+
ensureBacklogDir,
|
|
609
|
+
parseEpic,
|
|
610
|
+
allEpics,
|
|
611
|
+
findFile,
|
|
612
|
+
getTask,
|
|
613
|
+
shapeTask,
|
|
614
|
+
updateTask,
|
|
615
|
+
migrateAll,
|
|
616
|
+
doMove,
|
|
617
|
+
doToggle,
|
|
618
|
+
doUpdate,
|
|
619
|
+
doCreate,
|
|
620
|
+
createKanbanError,
|
|
621
|
+
getProgress,
|
|
622
|
+
COLS,
|
|
623
|
+
STATUS_MAP,
|
|
624
|
+
VIEW_FIELDS
|
|
625
|
+
};
|
package/kanbango.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# kanbango — Rename + Migrate Tool Plan
|
|
2
|
+
|
|
3
|
+
## Phase 1 — Add `migrate` command to `kanban.js`
|
|
4
|
+
|
|
5
|
+
**New exported function `migrateAll(options)`** in `kanban.js`:
|
|
6
|
+
- Walks all 4 columns
|
|
7
|
+
- Finds `.md` files, reads via existing `parseMarkdownTask`, writes `.json` via `writeTask`
|
|
8
|
+
- `writeTask` already deletes the old `.md` file automatically
|
|
9
|
+
- `options.dryRun` — preview only, returns what would migrate
|
|
10
|
+
- Returns `{ migrated: [{id, from, to}], errors: [{file, reason}] }`
|
|
11
|
+
|
|
12
|
+
## Phase 2 — Add `kanban migrate` CLI subcommand
|
|
13
|
+
|
|
14
|
+
In `bin/kanban.js`:
|
|
15
|
+
- `kanban migrate` — runs migration, reports each file converted
|
|
16
|
+
- `kanban migrate --dry-run` — preview only
|
|
17
|
+
|
|
18
|
+
## Phase 3 — Rename everything from `markdown-kanban` → `kanbango`
|
|
19
|
+
|
|
20
|
+
**Files to update:**
|
|
21
|
+
|
|
22
|
+
| File | Changes |
|
|
23
|
+
|------|---------|
|
|
24
|
+
| `package.json` | name → `kanbango`, description → "JSON-first local Kanban board with web GUI, CLI, and MCP server", repo/homepage/bugs URLs → `k0r81/kanbango` |
|
|
25
|
+
| `CHANGELOG.md` | Add `[2.0.0]` entry, rename references |
|
|
26
|
+
| `bin/kanban.js` | `claudeMcpConfig` and `openCodeMcpConfig` — change `npx markdown-kanban` → `npx kanbango` (lines 26, 42) |
|
|
27
|
+
| `README.md` | All `markdown-kanban` → `kanbango`, update description |
|
|
28
|
+
| `LLM_AGENTS.md` | Same renames |
|
|
29
|
+
| `AGENTS.md` | Same renames |
|
|
30
|
+
| `.npmignore` | Check if any path references need updating |
|
|
31
|
+
|
|
32
|
+
**What stays the same:**
|
|
33
|
+
- CLI binary names: `kanban` and `kanban-cmd` — no change
|
|
34
|
+
- `backlog/` directory structure
|
|
35
|
+
- All internal logic in `kanban.js`, `mcp-server.js`, `index.js`
|
|
36
|
+
|
|
37
|
+
## Phase 4 — Tag & Publish
|
|
38
|
+
|
|
39
|
+
1. Bump version to `2.0.0` in `package.json`
|
|
40
|
+
2. `git tag v2.0.0 -m "kanbango: rename + migrate tool"`
|
|
41
|
+
3. Push tag to GitHub
|
|
42
|
+
4. Rename GitHub repo `k0r81/markdown-kanban` → `k0r81/kanbango` (manual, via GitHub UI)
|
|
43
|
+
5. `npm publish` — first publish as `kanbango`
|
|
44
|
+
|
|
45
|
+
## Phase 5 — Verify
|
|
46
|
+
|
|
47
|
+
- `npm search kanbango` to confirm it's live
|
|
48
|
+
- Check download page at `https://www.npmjs.com/package/kanbango`
|