kandown 0.11.2 → 0.12.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/README.md CHANGED
@@ -48,16 +48,25 @@ cd my-project
48
48
  kandown init
49
49
  ```
50
50
 
51
- This creates a `.kandown/` folder with:
51
+ This creates two folders at your project root:
52
52
 
53
53
  ```
54
- .kandown/
54
+ .kandown/ # config + web UI + agent docs
55
55
  ├── kandown.html # Single-file web app
56
56
  ├── kandown.json # Project config (columns, appearance)
57
- ├── tasks/ # One .md file per task
58
- └── AGENT.md # AI-agent quick reference
57
+ ├── AGENT.md # AI-agent quick reference
58
+ └── AGENT_KANDOWN.md # Full agent reference
59
+
60
+ tasks/ # Task files (source of truth)
61
+ ├── t1.md # One .md file per task
62
+ └── archive/ # Archived tasks live here
59
63
  ```
60
64
 
65
+ > **Layout note:** tasks live at the project root in `./tasks/`, not inside
66
+ > `.kandown/`. This keeps config and data cleanly separated. If you're
67
+ > upgrading from an older version with tasks in `.kandown/tasks/`, the CLI
68
+ > will move them automatically the first time you run it — nothing to do.
69
+
61
70
  ### Launch the board
62
71
 
63
72
  ```bash
package/bin/kandown.js CHANGED
@@ -19,7 +19,10 @@
19
19
  * → resolveKandownBin — resolves the global kandown binary path for respawn
20
20
  * → semverGt — compares two semver strings
21
21
  * → checkForUpdate — non-blocking auto-updater with lock file and graceful fallback
22
- * → cmdInitinstalls `.kandown`
22
+ * → getProjectRootreturns the project root (parent of .kandown/)
23
+ * → getTasksDir — returns the project-root tasks/ path
24
+ * → migrateTasksToTopLevel — silently moves legacy .kandown/tasks/ → ./tasks/
25
+ * → cmdInit — installs `.kandown` and top-level `tasks/`
23
26
  * → cmdUpdate — refreshes installed kandown.html
24
27
  * → injectServerRoot — injects the CLI server root into single-file HTML
25
28
  * → createServeServer — creates the local zero-dependency HTTP server
@@ -57,6 +60,7 @@ import {
57
60
  statSync,
58
61
  unlinkSync,
59
62
  renameSync,
63
+ rmdirSync,
60
64
  } from 'node:fs';
61
65
  import { spawnSync, spawn, execSync } from 'node:child_process';
62
66
 
@@ -355,8 +359,8 @@ ${marker}
355
359
  **IMPORTANT:** Before touching any task files, you MUST read \`${kandownPath}/AGENT_KANDOWN.md\`.
356
360
 
357
361
  This project uses a file-based kanban:
358
- - **Tasks live in \`${kandownPath}/tasks/t-xxx.md\`** — each task file owns its status
359
- - **Columns live in \`${kandownPath}/kandown.json\`** under \`board.columns\`
362
+ - **Tasks live in \`./tasks/t-xxx.md\`** at the project root — each task file owns its status
363
+ - **Config lives in \`${kandownPath}/kandown.json\`** (columns, appearance, agent settings)
360
364
  - **Completion workflow:** set task frontmatter \`status: Done\` + write the completion report
361
365
  `;
362
366
 
@@ -376,8 +380,8 @@ function createAgentsFileIfMissing(cwd, kandownPath) {
376
380
  **IMPORTANT:** Before touching any task files, you MUST read \`AGENT_KANDOWN.md\`.
377
381
 
378
382
  This project uses a file-based kandown:
379
- - **Tasks live in \`${kandownPath}/tasks/t-xxx.md\`** — each task file owns its status
380
- - **Columns live in \`${kandownPath}/kandown.json\`** under \`board.columns\`
383
+ - **Tasks live in \`./tasks/t-xxx.md\`** at the project root — each task file owns its status
384
+ - **Config lives in \`${kandownPath}/kandown.json\`** (columns, appearance, agent settings)
381
385
  - **Completion workflow:** set task frontmatter \`status: Done\` + write the completion report
382
386
  `;
383
387
  writeFileSync(agentsPath, content, 'utf8');
@@ -396,9 +400,130 @@ function parseArgs(argv) {
396
400
  return args;
397
401
  }
398
402
 
403
+ /**
404
+ * 📖 Returns the absolute path of the project root (parent of the kandown
405
+ * config dir). Tasks live at the project root in `./tasks/`, not inside
406
+ * `.kandown/tasks/`. Used by every code path that touches task files.
407
+ * @param {string} kandownDir absolute path to the kandown config dir
408
+ * @returns {string} absolute path to the project root
409
+ */
410
+ function getProjectRoot(kandownDir) {
411
+ return dirname(kandownDir);
412
+ }
413
+
414
+ /**
415
+ * 📖 Returns the absolute path of the tasks directory at the project root.
416
+ * Mirrors the File System Access layout: `./tasks/` is a sibling of `.kandown/`.
417
+ * @param {string} kandownDir absolute path to the kandown config dir
418
+ * @returns {string} absolute path to `./tasks/`
419
+ */
420
+ function getTasksDir(kandownDir) {
421
+ return join(getProjectRoot(kandownDir), 'tasks');
422
+ }
423
+
424
+ /**
425
+ * 📖 Silently migrates task files from the legacy `.kandown/tasks/` location
426
+ * to the new top-level `./tasks/` location. Idempotent: returns
427
+ * `{ moved, cleanedUp }` describing what was done (or nothing).
428
+ *
429
+ * Rules:
430
+ * - If `./tasks/*.md` already exists, never move anything (avoid clobbering).
431
+ * - If `.kandown/tasks/` doesn't exist or has no .md files, no-op.
432
+ * - Move every `.md` file (including those in `archive/`) to `./tasks/`.
433
+ * - Delete `.kandown/tasks/` only if it's now empty (preserves any non-md
434
+ * files like `.scratch/` notes the user may have stashed there).
435
+ * - Never throw — failures are logged and skipped so a single bad file
436
+ * doesn't block the rest of the migration.
437
+ *
438
+ * @param {string} kandownDir absolute path to the kandown config dir
439
+ * @returns {{ moved: number, cleanedUp: boolean, skipped: boolean }}
440
+ */
441
+ function migrateTasksToTopLevel(kandownDir) {
442
+ const projectRoot = getProjectRoot(kandownDir);
443
+ const oldDir = join(kandownDir, 'tasks');
444
+ const newDir = getTasksDir(kandownDir);
445
+ const result = { moved: 0, cleanedUp: false, skipped: false };
446
+
447
+ if (!existsSync(oldDir)) return result;
448
+ if (!existsSync(newDir)) mkdirSync(newDir, { recursive: true });
449
+
450
+ // 📖 If the new location already has any .md file, don't touch anything.
451
+ // The user is in a hybrid state and we should not clobber their work.
452
+ const existingNew = existsSync(newDir)
453
+ ? readdirSync(newDir).filter(n => n.endsWith('.md'))
454
+ : [];
455
+ if (existingNew.length > 0) {
456
+ result.skipped = true;
457
+ return result;
458
+ }
459
+
460
+ // 📖 If the old location has no .md files either, no migration needed.
461
+ const oldMdFiles = readdirSync(oldDir, { withFileTypes: true })
462
+ .filter(e => e.isFile() && e.name.endsWith('.md'));
463
+ if (oldMdFiles.length === 0) {
464
+ // Still try the archive subfolder.
465
+ return migrateArchive(kandownDir, result);
466
+ }
467
+
468
+ for (const entry of oldMdFiles) {
469
+ try {
470
+ renameSync(join(oldDir, entry.name), join(newDir, entry.name));
471
+ result.moved += 1;
472
+ } catch (e) {
473
+ warn(`migrate: could not move ${entry.name} (${e.code ?? e.message})`);
474
+ }
475
+ }
476
+
477
+ return migrateArchive(kandownDir, result);
478
+ }
479
+
480
+ /**
481
+ * 📖 Helper that moves the legacy `archive/` subfolder to the new location.
482
+ * Called from migrateTasksToTopLevel after the active files have been moved.
483
+ */
484
+ function migrateArchive(kandownDir, result) {
485
+ const oldArchive = join(kandownDir, 'tasks', 'archive');
486
+ const newArchive = join(getTasksDir(kandownDir), 'archive');
487
+ if (!existsSync(oldArchive)) return cleanupLegacyTasksDir(kandownDir, result);
488
+
489
+ if (!existsSync(newArchive)) mkdirSync(newArchive, { recursive: true });
490
+ for (const entry of readdirSync(oldArchive, { withFileTypes: true })) {
491
+ if (entry.isFile() && entry.name.endsWith('.md')) {
492
+ try {
493
+ renameSync(join(oldArchive, entry.name), join(newArchive, entry.name));
494
+ result.moved += 1;
495
+ } catch (e) {
496
+ warn(`migrate: could not move archive/${entry.name} (${e.code ?? e.message})`);
497
+ }
498
+ }
499
+ }
500
+
501
+ return cleanupLegacyTasksDir(kandownDir, result);
502
+ }
503
+
504
+ /**
505
+ * 📖 Removes the legacy `.kandown/tasks/` directory only if it is empty.
506
+ * Preserves any leftover non-md files (e.g. `.scratch/` notes) so we never
507
+ * delete user data the migration didn't move.
508
+ */
509
+ function cleanupLegacyTasksDir(kandownDir, result) {
510
+ const oldDir = join(kandownDir, 'tasks');
511
+ if (!existsSync(oldDir)) return result;
512
+ const remaining = readdirSync(oldDir);
513
+ if (remaining.length === 0) {
514
+ try {
515
+ rmdirSync(oldDir);
516
+ result.cleanedUp = true;
517
+ } catch { /* directory not empty or platform limitation — leave it */ }
518
+ }
519
+ return result;
520
+ }
521
+
399
522
  /**
400
523
  * @returns {{ kandownDir: string, alreadyExisted: boolean }} — resolves the
401
524
  * kandown directory and auto-inits it if it doesn't exist (no prompt, silent init).
525
+ * Also performs a one-time silent migration of tasks from `.kandown/tasks/` to
526
+ * the project root `./tasks/` on first access of a legacy project.
402
527
  */
403
528
  function ensureKandownDir(rawArgs) {
404
529
  const args = parseArgs(rawArgs);
@@ -406,7 +531,18 @@ function ensureKandownDir(rawArgs) {
406
531
  const explicitPath = rawArgs.includes('--path') || rawArgs.includes('-p');
407
532
  const kandownDir = resolve(cwd, args.path);
408
533
 
409
- if (existsSync(kandownDir)) return { kandownDir, alreadyExisted: true };
534
+ if (existsSync(kandownDir)) {
535
+ // 📖 Silent one-time migration: move any legacy `.kandown/tasks/*.md` to
536
+ // the project-root `./tasks/`. Idempotent — safe to call on every startup.
537
+ const migration = migrateTasksToTopLevel(kandownDir);
538
+ if (migration.moved > 0) {
539
+ success(`Migrated ${migration.moved} task${migration.moved === 1 ? '' : 's'} from .kandown/tasks/ to ./tasks/`);
540
+ if (migration.cleanedUp) info('Removed empty .kandown/tasks/ folder');
541
+ } else if (migration.skipped) {
542
+ info('Both .kandown/tasks/ and ./tasks/ have files — leaving both in place');
543
+ }
544
+ return { kandownDir, alreadyExisted: true };
545
+ }
410
546
 
411
547
  log('');
412
548
  info(`No .kandown/ found — auto-installing...`);
@@ -440,13 +576,16 @@ function doInit(args, cwd, kandownPath, kandownDir) {
440
576
  success('README.md');
441
577
  }
442
578
 
579
+ // 📖 Tasks live at the project root in `./tasks/`, not inside `.kandown/`.
580
+ // This keeps config separate from data and follows the user-facing convention.
443
581
  const tasksSrc = join(templatesDir, 'tasks');
444
- const tasksDest = join(kandownDir, 'tasks');
582
+ const tasksDest = getTasksDir(kandownDir);
445
583
  if (!existsSync(tasksDest)) {
584
+ mkdirSync(tasksDest, { recursive: true });
446
585
  copyRecursive(tasksSrc, tasksDest);
447
- success('tasks/ (with welcome example)');
586
+ success('./tasks/ (with welcome example)');
448
587
  } else {
449
- info('tasks/ already exists (kept)');
588
+ info('./tasks/ already exists (kept)');
450
589
  }
451
590
 
452
591
  if (!existsSync(join(kandownDir, 'kandown.json'))) {
@@ -512,9 +651,13 @@ function cmdInit(rawArgs) {
512
651
  log('');
513
652
  log(`${c.green}${c.bold}Done.${c.reset}`);
514
653
  log('');
654
+ log(` ${c.dim}Layout:${c.reset}`);
655
+ log(` ${c.dim}└─${c.reset} ${c.bold}.kandown/${c.reset} — config, web UI, agent docs`);
656
+ log(` ${c.dim}└─${c.reset} ${c.bold}tasks/${c.reset} — task files (source of truth)`);
657
+ log('');
515
658
  log(` ${c.dim}Next steps:${c.reset}`);
516
659
  log(` ${c.cyan}1.${c.reset} Open ${c.bold}${kandownPath}/kandown.html${c.reset} in Chrome/Edge/Brave`);
517
- log(` ${c.cyan}2.${c.reset} Select the ${c.bold}${kandownPath}/${c.reset} folder when prompted`);
660
+ log(` ${c.cyan}2.${c.reset} Select the ${c.bold}project root${c.reset} (the parent of ${c.bold}${kandownPath}/${c.reset})`);
518
661
  log(` ${c.cyan}3.${c.reset} Start creating tasks. Press ${c.cyan}⌘K${c.reset} for the command palette`);
519
662
  log('');
520
663
  log(` ${c.dim}macOS:${c.reset} open ${kandownPath}/kandown.html`);
@@ -796,17 +939,21 @@ function putBoard(req, res, kandownDir) {
796
939
  * first, then the archive subfolder. Returns null when the id exists in
797
940
  * neither location. Used by every CRUD handler so archived tasks stay
798
941
  * reachable at their real location.
942
+ *
943
+ * Tasks live at the project root in `./tasks/` (sibling of `.kandown/`),
944
+ * not inside `.kandown/tasks/`.
799
945
  */
800
946
  function findTaskPath(kandownDir, id) {
801
- const inTasks = join(kandownDir, 'tasks', `${id}.md`);
947
+ const tasksDir = getTasksDir(kandownDir);
948
+ const inTasks = join(tasksDir, `${id}.md`);
802
949
  if (existsSync(inTasks)) return inTasks;
803
- const inArchive = join(kandownDir, 'tasks', 'archive', `${id}.md`);
950
+ const inArchive = join(tasksDir, 'archive', `${id}.md`);
804
951
  if (existsSync(inArchive)) return inArchive;
805
952
  return null;
806
953
  }
807
954
 
808
955
  function getTasks(res, kandownDir) {
809
- const tasksDir = join(kandownDir, 'tasks');
956
+ const tasksDir = getTasksDir(kandownDir);
810
957
  const archiveDir = join(tasksDir, 'archive');
811
958
  const ids = new Set();
812
959
  try {
@@ -853,7 +1000,7 @@ function putTask(req, res, kandownDir, id) {
853
1000
  return;
854
1001
  }
855
1002
  readBody(req).then(body => {
856
- const tasksDir = join(kandownDir, 'tasks');
1003
+ const tasksDir = getTasksDir(kandownDir);
857
1004
  if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
858
1005
  // 📖 Write in place: keep an archived task inside archive/ on save so the
859
1006
  // file location never drifts from its archived flag.
@@ -887,6 +1034,66 @@ function deleteTask(res, kandownDir, id) {
887
1034
  }
888
1035
  }
889
1036
 
1037
+ /**
1038
+ * 📖 Archives a task: writes the (already flag-updated) content into
1039
+ * `tasks/archive/<id>.md` and removes the active `tasks/<id>.md` copy.
1040
+ * The body comes pre-flagged from the web client (which knows frontmatter).
1041
+ */
1042
+ function archiveTask(req, res, kandownDir, id) {
1043
+ if (!isValidTaskId(id)) {
1044
+ writeText(res, 400, 'Invalid task id');
1045
+ return;
1046
+ }
1047
+ readBody(req).then(body => {
1048
+ const tasksDir = getTasksDir(kandownDir);
1049
+ const archiveDir = join(tasksDir, 'archive');
1050
+ if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1051
+ writeFileSync(join(archiveDir, `${id}.md`), body, 'utf8');
1052
+ try {
1053
+ unlinkSync(join(tasksDir, `${id}.md`));
1054
+ } catch { /* already absent */ }
1055
+ writeJson(res, 200, { ok: true });
1056
+ }).catch(e => {
1057
+ writeJson(res, 500, { error: `Failed to archive task: ${e.message}` });
1058
+ });
1059
+ }
1060
+
1061
+ /**
1062
+ * 📖 Unarchives a task: writes the content back into `tasks/<id>.md` and
1063
+ * removes the archived copy. Mirror of archiveTask.
1064
+ */
1065
+ function unarchiveTask(req, res, kandownDir, id) {
1066
+ if (!isValidTaskId(id)) {
1067
+ writeText(res, 400, 'Invalid task id');
1068
+ return;
1069
+ }
1070
+ readBody(req).then(body => {
1071
+ const tasksDir = getTasksDir(kandownDir);
1072
+ writeFileSync(join(tasksDir, `${id}.md`), body, 'utf8');
1073
+ try {
1074
+ unlinkSync(join(tasksDir, 'archive', `${id}.md`));
1075
+ } catch { /* already absent */ }
1076
+ writeJson(res, 200, { ok: true });
1077
+ }).catch(e => {
1078
+ writeJson(res, 500, { error: `Failed to unarchive task: ${e.message}` });
1079
+ });
1080
+ }
1081
+
1082
+ /**
1083
+ * 📖 REST endpoint to trigger the silent one-time task migration. The web
1084
+ * client (server mode) can call this on startup to perform the same
1085
+ * `.kandown/tasks/` → `./tasks/` move the CLI does on first access.
1086
+ * Safe to call multiple times — idempotent.
1087
+ */
1088
+ function postMigrateTasks(res, kandownDir) {
1089
+ try {
1090
+ const result = migrateTasksToTopLevel(kandownDir);
1091
+ writeJson(res, 200, { ok: true, ...result });
1092
+ } catch (e) {
1093
+ writeJson(res, 500, { error: `Migration failed: ${e.message}` });
1094
+ }
1095
+ }
1096
+
890
1097
  /**
891
1098
  * 📖 The single-file Vite bundle can contain literal strings such as
892
1099
  * `</head>` from HTML parser libraries. Use the last closing head tag so the
@@ -941,6 +1148,11 @@ function handleApi(req, res, url, kandownDir) {
941
1148
  if (req.method === 'POST' && id && parts[2] === 'unarchive') return unarchiveTask(req, res, kandownDir, id);
942
1149
  }
943
1150
 
1151
+ // 📖 Migration endpoint: `POST /api/migrate-tasks` with no id. Idempotent.
1152
+ if (resource === 'migrate-tasks' && req.method === 'POST' && !id) {
1153
+ return postMigrateTasks(res, kandownDir);
1154
+ }
1155
+
944
1156
  writeJson(res, 404, { error: 'Not found' });
945
1157
  }
946
1158
 
package/bin/tui.js CHANGED
@@ -54536,8 +54536,11 @@ function serializeTaskFile(frontmatter, body) {
54536
54536
  function getProjectRoot(kandownDir) {
54537
54537
  return dirname(kandownDir);
54538
54538
  }
54539
+ function getTasksDir(kandownDir) {
54540
+ return join2(getProjectRoot(kandownDir), "tasks");
54541
+ }
54539
54542
  function listTaskIds(kandownDir) {
54540
- const tasksDir = join2(kandownDir, "tasks");
54543
+ const tasksDir = getTasksDir(kandownDir);
54541
54544
  if (!existsSync3(tasksDir)) return [];
54542
54545
  return readdirSync(tasksDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
54543
54546
  }
@@ -54561,7 +54564,7 @@ function readBoard(kandownDir) {
54561
54564
  };
54562
54565
  }
54563
54566
  function readTask(kandownDir, taskId) {
54564
- const taskPath = join2(kandownDir, "tasks", `${taskId}.md`);
54567
+ const taskPath = join2(getTasksDir(kandownDir), `${taskId}.md`);
54565
54568
  if (!existsSync3(taskPath)) {
54566
54569
  return {
54567
54570
  frontmatter: { id: taskId, title: `Task ${taskId}`, status: "Backlog" },
@@ -54593,7 +54596,7 @@ function readAgentDoc(kandownDir) {
54593
54596
  return "";
54594
54597
  }
54595
54598
  function moveTaskToColumn(kandownDir, taskId, targetColumn) {
54596
- const taskPath = join2(kandownDir, "tasks", `${taskId}.md`);
54599
+ const taskPath = join2(getTasksDir(kandownDir), `${taskId}.md`);
54597
54600
  if (!existsSync3(taskPath)) return false;
54598
54601
  const parsed = readTask(kandownDir, taskId);
54599
54602
  writeFileSync2(taskPath, serializeTaskFile({
@@ -56454,9 +56457,12 @@ var FileWatcher = class {
56454
56457
  * 📖 Start watching task files and kandown.json.
56455
56458
  * Uses chokidar for immediate FS event detection, plus a fallback 500ms
56456
56459
  * poll to catch any races or network-mounted file changes.
56460
+ *
56461
+ * Tasks live at the project root (`./tasks/`, sibling of `.kandown/`);
56462
+ * kandown.json lives inside `.kandown/`.
56457
56463
  */
56458
56464
  start(kandownDir) {
56459
- const tasksDir = join6(kandownDir, "tasks");
56465
+ const tasksDir = getTasksDir(kandownDir);
56460
56466
  const configPath = join6(kandownDir, "kandown.json");
56461
56467
  const existingIds = listTaskIds(kandownDir);
56462
56468
  for (const id of existingIds) {
@@ -56515,7 +56521,7 @@ var FileWatcher = class {
56515
56521
  // ─── Private ───────────────────────────────────────────────────────────────
56516
56522
  handleFsEvent(event, filePath, kandownDir) {
56517
56523
  if (this.stopped) return;
56518
- const tasksDir = join6(kandownDir, "tasks");
56524
+ const tasksDir = getTasksDir(kandownDir);
56519
56525
  const configPath = join6(kandownDir, "kandown.json");
56520
56526
  if (filePath === configPath) {
56521
56527
  const key = `config:${event}`;
@@ -56568,7 +56574,7 @@ var FileWatcher = class {
56568
56574
  /** 📖 Fallback poll — catches changes that chokidar missed (network mounts, exotic FS). */
56569
56575
  async pollHashes(kandownDir) {
56570
56576
  if (this.stopped) return;
56571
- const tasksDir = join6(kandownDir, "tasks");
56577
+ const tasksDir = getTasksDir(kandownDir);
56572
56578
  const configPath = join6(kandownDir, "kandown.json");
56573
56579
  try {
56574
56580
  const newHash = hashFileSync(configPath);