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/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`