local-kanban 1.12.0 → 1.14.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/CHANGELOG.md CHANGED
@@ -3,6 +3,73 @@
3
3
  Notable changes, newest first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
4
4
  and the project uses [semantic versioning](https://semver.org/).
5
5
 
6
+ Anything not shipped yet is collected under **Unreleased**; releasing renames that section to the
7
+ version it goes out as and opens an empty one again. Released sections are history and stay as they are.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.14.0]
12
+
13
+ ### Added
14
+
15
+ - **`kb` shows how tasks are linked, and can make a sub-task.** The board keeps parents, sub-tasks and
16
+ related tasks, but `kb show` and `kb take` printed none of them, so Claude could not know that two
17
+ tasks were meant to go together. Both commands now print one line per link: `↑ parent`,
18
+ `↓ subtask`, `↔ related`, with the key, title and status. `kb link A B child` makes B a sub-task
19
+ of A, `kb link A B parent` makes it A's parent; without a third word the link is related, as before.
20
+ - **`kb info` prints the project's description and its documents.** The description is where owners
21
+ keep the context for work, and it never reached Claude. A `docs:` line now lists the documents to
22
+ read before a task (`CLAUDE.md`, `README.md`, `ARCHITECTURE.md` …) and says so plainly when there is
23
+ no `CLAUDE.md` or the project folder is not on this machine.
24
+ - **`kb check <key> <n>` / `kb uncheck` tick checklist items from the CLI.** Claude could read the
25
+ checklist but not tick it, so at acceptance every box was empty and the human could not tell what
26
+ of the list was done. The item is addressed by its number in the order `kb show` prints it.
27
+ - **`KB_GH_BIN` points the board at `gh` when it lives somewhere unusual.** The board looks for `gh`
28
+ in the usual install locations (Homebrew, `/usr/bin`, `~/.local/bin`) and then on `PATH`; a copy
29
+ anywhere else can now be named explicitly.
30
+
31
+ ### Fixed
32
+
33
+ - **`kb` no longer loses what the human wrote in a card.** `kb take` printed only the latest note
34
+ from the human, so a send-back followed by a second note lost the first; `kb show` printed the last
35
+ five comments of any author, and Claude's own notes plus the commit stats pushed the human's note
36
+ out without a word. Both now print every note the human left since Claude's last report, each
37
+ comment carries its date and time, and anything left out is counted ("… 3 more comments not
38
+ printed") instead of dropped silently.
39
+ - **A commit stat is no longer printed as the human speaking.** `kb show` marked everything that was
40
+ not Claude's with 👤, including the commit stat the board attaches on its own. It is marked ⎇ now.
41
+ - **`kb take` tells a blocked To do from an empty one.** When every card in To do was blocked it
42
+ answered a bare `empty`, and Claude reported an empty column while the human could see cards in
43
+ it. It now says `empty (2 blocked)`; `kb next` does the same.
44
+ - **`kb take` and `kb show` print the issue the same way** — `issue: #42 <link>`. One printed the
45
+ number and the other the link.
46
+ - **The checklist now reaches Claude.** A card's checklist is shown on the board right under the
47
+ description, but `kb show` and `kb take` printed everything about the task except it — the CLI is
48
+ the only channel Claude has, so the items simply did not exist for it: a card with three checklist
49
+ items and an empty description arrived as a bare title. Both commands now print the items as
50
+ `- [ ]` / `- [x]` lines, in the board's order, right after the description.
51
+
52
+ ## [1.13.0]
53
+
54
+ ### Added
55
+
56
+ - **The board tells you what's new.** The changelog was written for every release and shown nowhere:
57
+ the only place to read it was the package page on npm. The About screen now has a "What's new"
58
+ block. For the version you are running it reads the `CHANGELOG.md` that ships next to the code, so
59
+ it needs no network at all. When an update is waiting, it shows the notes of the version you do
60
+ *not* have yet, read from that release's page on GitHub — the decision to update is made on what
61
+ actually changed, not on a version number. Releases now get such a page: publishing puts the
62
+ changelog section for that version into it, which also gives the npm page real releases to link to.
63
+
64
+ ### Fixed
65
+
66
+ - **A folder the board is not allowed to read is no longer reported as missing.** The Check button
67
+ next to the project folder answered every refusal the same way — "no such folder on this computer" —
68
+ whether the folder was absent, was a file, or simply could not be read. For a folder without
69
+ permissions that is plainly untrue: it is there, the board was not let in, and the answer sent you
70
+ off to fix a path that was already correct. Each case now says what actually happened, and a check
71
+ that could not reach the server says so instead of blaming the path.
72
+
6
73
  ## [1.12.0]
7
74
 
8
75
  ### Added
package/README.md CHANGED
@@ -142,6 +142,7 @@ Releases live in `main` (stable, tagged); day-to-day development happens in `dev
142
142
  | `KB_PANEL_INFO` | — | optional path to an external panel's info.json for category sync |
143
143
  | `KB_SSE_MAX` | `20` | cap on concurrent SSE connections |
144
144
  | `KB_SKILLS_EXTRA` | — | extra skill root directories, `:`-separated |
145
+ | `KB_GH_BIN` | — | path to the `gh` binary when it is not in a usual place (Homebrew, `/usr/bin`, `~/.local/bin`) |
145
146
 
146
147
  The legacy names `PANEL_URL` and `PANEL_INFO` (without the prefix) still work as a fallback.
147
148
 
package/README.ru.md CHANGED
@@ -141,6 +141,7 @@ npm run update # git pull + npm install + pm2 restart (если примени
141
141
  | `KB_PANEL_INFO` | — | опциональный info.json внешней панели для синка категорий |
142
142
  | `KB_SSE_MAX` | `20` | потолок одновременных SSE-соединений |
143
143
  | `KB_SKILLS_EXTRA` | — | дополнительные корни скиллов через `:` |
144
+ | `KB_GH_BIN` | — | путь к `gh`, если он лежит не в обычном месте (Homebrew, `/usr/bin`, `~/.local/bin`) |
144
145
 
145
146
  Старые имена `PANEL_URL` и `PANEL_INFO` (без префикса) продолжают работать как запасные.
146
147
 
package/bin/kb CHANGED
@@ -63,8 +63,96 @@ function taskLine(t) {
63
63
  return `${t.key} ${t.status}${flags ? ' ' + flags : ''} ${t.title}${labels}`;
64
64
  }
65
65
 
66
+ // The checklist is part of the task's spec: the board shows it right under the description, and
67
+ // this CLI is the only channel Claude has — an item that is not printed here does not exist for
68
+ // Claude. Markdown task-list lines, `- [ ]` open and `- [x]` done, in the board's order.
69
+ function printChecklist(t) {
70
+ for (const c of (t.checklist || [])) console.log(`- [${c.done ? 'x' : ' '}] ${c.text}`);
71
+ }
72
+
73
+ // here, and the skill's rule "related tasks solved together are taken together" cannot be followed
74
+ // without knowing they are related. `rel` is relative to this task: 'parent' — the other task is
75
+ // this one's parent, 'child' — the other task is a sub-task of this one, 'related' — symmetric.
76
+ const REL = { parent: ['↑', 'parent'], child: ['↓', 'subtask'], related: ['↔', 'related'] };
77
+ function printLinks(t) {
78
+ for (const l of (t.links || [])) {
79
+ const [arrow, word] = REL[l.rel] || REL.related;
80
+ console.log(`${arrow} ${word} ${l.key} ${l.title} (${l.status})`);
81
+ }
82
+ }
83
+ const linksLine = (id, links) => `${id} ${links.map((l) => (REL[l.rel] || REL.related)[0] + l.key).join(' ') || '—'}`;
84
+
85
+ // the skill had to explain two formats. The number goes into commit messages, the link is for eyes.
86
+ function printIssue(t) {
87
+ if (t.gh_issue_number) console.log(`issue: #${t.gh_issue_number}${t.gh_issue_url ? ' ' + t.gh_issue_url : ''}`);
88
+ else if (t.gh_issue_url) console.log(`issue: ${t.gh_issue_url}`);
89
+ }
90
+
91
+ // sees cards in the column, and a bare "empty" made Claude report that there was nothing there.
92
+ function emptyLine(todo) {
93
+ const blocked = todo.filter((t) => t.blocked).length;
94
+ return blocked ? `empty (${blocked} blocked)` : 'empty';
95
+ }
96
+
97
+ function printAttachments(t) {
98
+ for (const a of (t.attachments || [])) console.log(`📎 screenshot (open it with Read): ${a.path}`);
99
+ }
100
+
101
+ // Comments. Three authors: the human ('me'), Claude, and 'git' — the commit stat the board attaches
102
+ // by itself on the move to review. Each gets its own mark: a commit printed as 👤 read as the human
103
+ const WHO = { claude: '🤖', git: '⎇' };
104
+
105
+ // The database keeps UTC ('YYYY-MM-DD HH:MM:SS'); print local time, to the minute — enough to tell
106
+ function when(c) {
107
+ const d = new Date(String(c.created_at || '').replace(' ', 'T') + 'Z');
108
+ if (Number.isNaN(d.getTime())) return '';
109
+ const p = (n) => String(n).padStart(2, '0');
110
+ return `[${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}] `;
111
+ }
112
+
113
+ function printComment(c) {
114
+ const at = when(c);
115
+ if (c.body) console.log(`> ${WHO[c.author] || '👤'} ${at}${c.author === 'git' ? 'commit ' : ''}${c.body}`);
116
+ if (c.image_path) console.log(`📎 image from a comment ${at}(open it with Read): ${c.image_path}`);
117
+ }
118
+
119
+ // Claude's last report — not just the latest one (a send-back plus a follow-up note lost the first)
120
+ // and not whatever happens to fit a window of the last few (Claude's own notes and commit stats
121
+ // pushed the human out of it). Those are always printed; `window` adds the last N comments of any
122
+ // author on top, and the latest images stay too — they are usually the correction itself. When the
123
+ // human has said nothing since the last report, the last thing they did say is printed, dated, so
124
+ // it can be told apart from a fresh one. Whatever is left out is counted, never dropped silently.
125
+ function printComments(cs, window) {
126
+ const keep = new Set();
127
+ let lastReport = -1;
128
+ cs.forEach((c, i) => { if (c.author === 'claude') lastReport = i; });
129
+ const human = (c) => c.author !== 'claude' && c.author !== 'git';
130
+ cs.forEach((c, i) => { if (i > lastReport && human(c)) keep.add(i); });
131
+ if (!keep.size) {
132
+ const i = cs.findLastIndex((c) => human(c) && c.body);
133
+ if (i >= 0) keep.add(i);
134
+ }
135
+ for (let i = Math.max(0, cs.length - window); i < cs.length; i++) keep.add(i);
136
+ cs.map((c, i) => (c.image_path ? i : -1)).filter((i) => i >= 0).slice(-3).forEach((i) => keep.add(i));
137
+ const hidden = cs.length - keep.size;
138
+ if (hidden > 0) console.log(`… ${hidden} more comment${hidden === 1 ? '' : 's'} not printed — the board has them all`);
139
+ cs.forEach((c, i) => { if (keep.has(i)) printComment(c); });
140
+ }
141
+
66
142
  const [cmd, ...args] = process.argv.slice(2);
67
143
 
144
+ async function tick(done) {
145
+ const [id, n] = args;
146
+ const no = Number(n);
147
+ if (!id || !Number.isInteger(no) || no < 1) die(`kb ${done ? 'check' : 'uncheck'} <key> <n> (n = the item number, from 1, in the order kb show prints them)`);
148
+ const t = await api('GET', `/api/tasks/${id}`);
149
+ const items = t.checklist || [];
150
+ if (no > items.length) die(`kb: ${t.key} has ${items.length ? `only ${items.length} checklist item${items.length === 1 ? '' : 's'}` : 'no checklist'}`);
151
+ const item = await api('PATCH', `/api/tasks/${id}/checklist/${items[no - 1].id}`, { done });
152
+ const open = items.filter((c, i) => (i === no - 1 ? !item.done : !c.done)).length;
153
+ console.log(`${t.key} - [${item.done ? 'x' : ' '}] ${item.text} (${open} open)`);
154
+ }
155
+
68
156
  const commands = {
69
157
  async p() {
70
158
  const ps = await api('GET', '/api/projects');
@@ -91,28 +179,27 @@ const commands = {
91
179
  if (!proj) die('kb next <project>');
92
180
  const ts = await api('GET', `/api/tasks?${new URLSearchParams({ project: proj, status: 'todo' })}`);
93
181
  const free = ts.filter((t) => !t.blocked);
94
- console.log(free.length ? taskLine(free[0]) : 'empty');
182
+ console.log(free.length ? taskLine(free[0]) : emptyLine(ts));
95
183
  },
96
184
 
97
- // take the next task and put it straight into prep (study before code); prints the last
98
- // comment from the human (notes from a send-back). Writing code → kb mv <key> doing.
185
+ // take the next task and put it straight into prep (study before code); prints what the human
186
+ // wrote since Claude's last report (notes from a send-back). Writing code → kb mv <key> doing.
99
187
  async take() {
100
188
  const [proj] = args;
101
189
  if (!proj) die('kb take <project>');
102
190
  const ts = await api('GET', `/api/tasks?${new URLSearchParams({ project: proj, status: 'todo' })}`);
103
191
  const free = ts.filter((t) => !t.blocked);
104
- if (!free.length) return console.log('empty');
192
+ if (!free.length) return console.log(emptyLine(ts));
105
193
  const t = await api('PATCH', `/api/tasks/${free[0].id}`, { status: 'prep' });
106
194
  console.log(taskLine({ ...t, labels: t.labels }));
107
195
  if (t.description) console.log(t.description);
108
- if (t.gh_issue_number) console.log(`issue: ${t.gh_issue_number}`);
109
- const atts = await api('GET', `/api/tasks/${t.id}/attachments`);
110
- for (const a of atts) console.log(`📎 screenshot (open it with Read): ${a.path}`);
111
- const cs = await api('GET', `/api/tasks/${t.id}/comments`);
112
- const lastHuman = cs.filter((c) => c.author === 'me' && c.body).pop();
113
- if (lastHuman) console.log(`> 👤 ${lastHuman.body}`);
114
- // comments often carry corrections — print the paths of the latest images so they get opened
115
- for (const c of cs.filter((c) => c.image_path).slice(-3)) console.log(`📎 image from a comment (open it with Read): ${c.image_path}`);
196
+ // PATCH answers with the bare row; the checklist and the attachments live on the full card
197
+ const full = await api('GET', `/api/tasks/${t.id}`);
198
+ printChecklist(full);
199
+ printLinks(full);
200
+ printIssue(full);
201
+ printAttachments(full);
202
+ printComments(await api('GET', `/api/tasks/${t.id}/comments`), 0);
116
203
  },
117
204
 
118
205
  // leave a note and move to review in one command
@@ -131,13 +218,11 @@ const commands = {
131
218
  console.log(`${t.key} ${t.project} ${t.status}${t.blocked ? ` blocked: ${t.blocked_reason || ''}` : ''}${PRI[t.priority] ? ' ' + PRI[t.priority] : ''}${t.labels.length ? ' [' + t.labels.join(',') + ']' : ''}`);
132
219
  console.log(t.title);
133
220
  if (t.description) console.log(t.description);
134
- if (t.gh_issue_url) console.log(`issue: ${t.gh_issue_url}`);
135
- for (const a of (t.attachments || [])) console.log(`📎 screenshot (open it with Read): ${a.path}`);
136
- const cs = await api('GET', `/api/tasks/${id}/comments`);
137
- for (const c of cs.slice(-5)) {
138
- if (c.body) console.log(`> ${c.author === 'claude' ? '🤖' : '👤'} ${c.body}`);
139
- if (c.image_path) console.log(`📎 image from a comment (open it with Read): ${c.image_path}`);
140
- }
221
+ printChecklist(t);
222
+ printLinks(t);
223
+ printIssue(t);
224
+ printAttachments(t);
225
+ printComments(await api('GET', `/api/tasks/${id}/comments`), 5);
141
226
  },
142
227
 
143
228
  async add() {
@@ -222,20 +307,28 @@ const commands = {
222
307
  console.log(`${t.key} 📝`);
223
308
  },
224
309
 
310
+ // kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
311
+ // A's parent. Without a third word the link is plain `related`, as it always was. A pair holds
312
+ // one link, so linking again with another word re-types it.
225
313
  async link() {
226
- const [id, other] = args;
227
- if (!id || !other) die('kb link <key> <key> (link two tasks)');
228
- const links = await api('POST', `/api/tasks/${id}/links`, { key: other });
229
- console.log(`${id} ↔ ${links.map((l) => l.key).join(', ') || '—'}`);
314
+ const [id, other, rel = 'related'] = args;
315
+ if (!id || !other || !REL[rel]) die('kb link <key> <key> [child|parent|related] (child: the second task becomes a sub-task of the first)');
316
+ const links = await api('POST', `/api/tasks/${id}/links`, { key: other, rel });
317
+ console.log(linksLine(id, links));
230
318
  },
231
319
 
232
320
  async unlink() {
233
321
  const [id, other] = args;
234
322
  if (!id || !other) die('kb unlink <key> <key>');
235
323
  const links = await api('DELETE', `/api/tasks/${id}/links/${other}`);
236
- console.log(`${id} ↔ ${links.map((l) => l.key).join(', ') || '—'}`);
324
+ console.log(linksLine(id, links));
237
325
  },
238
326
 
327
+ // empty and could not tell what of the list was done. The item is addressed by its number — the
328
+ // position in which show/take print it (the board's order), counting from 1.
329
+ async check() { await tick(true); },
330
+ async uncheck() { await tick(false); },
331
+
239
332
  async block() {
240
333
  const [id, reason] = args;
241
334
  if (!id) die('kb block <key> ["reason"]');
@@ -268,6 +361,17 @@ const commands = {
268
361
  const services = parsePm2Services(p.pm2_services);
269
362
  if (services.length) out.push(`pm2: ${services.join(', ')}`);
270
363
  if (p.domain) out.push(`domain: ${p.domain}`);
364
+ // CLAUDE.md", "deployed by skill X", DNS facts) — info printed everything but it
365
+ if (p.description) out.push(`description: ${p.description}`);
366
+ // page; without this line Claude went looking by itself and sometimes missed. A missing
367
+ // CLAUDE.md is said out loud — it is a signal (the task starts without architecture notes).
368
+ if (p.path) {
369
+ const d = await api('GET', `/api/projects/${encodeURIComponent(p.slug)}/docs`);
370
+ const found = (d.docs || []).filter((x) => x.exists).map((x) => x.name);
371
+ if (!d.path_exists) out.push('docs: — the project folder does not exist on this machine');
372
+ else if (!found.includes('CLAUDE.md')) out.push(`docs: no CLAUDE.md${found.length ? ` — read ${found.join(', ')} instead` : ', and no README.md / ARCHITECTURE.md either'}`);
373
+ else out.push(`docs: ${found.join(', ')}`);
374
+ }
271
375
  console.log(out.join('\n'));
272
376
  },
273
377
 
@@ -297,7 +401,7 @@ const commands = {
297
401
  };
298
402
 
299
403
  if (!cmd || !commands[cmd]) {
300
- die('kb p|ls|next|take|show|add|mv|review|done|cancel|pri|lbl|desc|note|link|unlink|block|unblock|rm|info|svc|sync|retry');
404
+ die('kb p|ls|next|take|show|add|mv|review|done|cancel|pri|lbl|desc|note|link|unlink|check|uncheck|block|unblock|rm|info|svc|sync|retry');
301
405
  }
302
406
  commands[cmd]().catch((e) => {
303
407
  // die() inside a command already exited the process — only real exceptions reach here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
package/public/js/core.js CHANGED
@@ -375,9 +375,11 @@ const I18N_RU = {
375
375
  'Claude moves these columns itself': 'эти колонки двигает сам Claude',
376
376
  'no file': 'файла нет',
377
377
  'no folders inside': 'внутри нет папок',
378
+ 'no notes for this version': 'для этой версии заметок нет',
378
379
  'no such folder on this computer': 'такой папки на этом компьютере нет',
379
380
  'not deleted': 'не удалено',
380
381
  'not installed': 'не установлен',
382
+ 'not installed yet': 'ещё не установлена',
381
383
  'not set': 'не задан',
382
384
  'not set — no deploy': 'не задан — деплоя нет',
383
385
  'not there': 'нет файла',
@@ -425,11 +427,13 @@ const I18N_RU = {
425
427
  'subfolders': 'подпапок',
426
428
  'key is free': 'ключ свободен',
427
429
  'task prefix': 'префикс задач',
430
+ 'the folder is there, but the board cannot read it — check its permissions': 'папка есть, но доска не может её прочитать — посмотри права доступа',
428
431
  'this key is already taken by': 'этот ключ уже занят проектом',
429
432
  'no such host — check the name, or add it to your ~/.ssh/config': 'такого хоста нет — проверь имя или добавь его в свой ~/.ssh/config',
430
433
  'the server refused the key — a password will not help here, the key has to be authorized': 'сервер не принял ключ — пароль тут не поможет, ключ надо разрешить на сервере',
431
434
  'the host key is unknown — connect once from your terminal and confirm the fingerprint': 'ключ хоста неизвестен — подключись разок из терминала и подтверди отпечаток',
432
435
  'the server did not answer — wrong address, wrong port, or it is down': 'сервер не ответил — не тот адрес, не тот порт или он лежит',
436
+ 'could not check the folder': 'не удалось проверить папку',
433
437
  'could not connect': 'подключиться не вышло',
434
438
  'on create, git clone puts it on THIS computer at': 'при создании git clone положит его на ЭТОТ компьютер в',
435
439
  'An existing folder is refused, not overwritten. Git credentials are not asked here — access comes from your own git and ssh setup.': 'Если папка уже есть — откажет, а не перезапишет. Логин git тут не спрашивается: доступ берётся из твоих git-настроек и ssh-ключей.',
@@ -535,6 +539,7 @@ const I18N_RU = {
535
539
  'recovered on retry': 'починилось повторной попыткой',
536
540
  'related': 'связана',
537
541
  'Read': 'Открыть',
542
+ 'release page': 'страница релиза',
538
543
  'Reload now': 'Обновить сейчас',
539
544
  'Reload the page': 'Обновить страницу',
540
545
  'remove': 'убрать',
@@ -636,6 +641,7 @@ const I18N_RU = {
636
641
  'The board could not reach the server — the change was not saved': 'доска не достучалась до сервера — правка не сохранена',
637
642
  'capped: the task sat in a working column too long': 'урезано: задача слишком долго лежала в рабочей колонке',
638
643
  'To do': 'Сделать',
644
+ 'this path is not a folder': 'по этому пути не папка',
639
645
  'Today': 'Сегодня',
640
646
  'Top projects': 'Топ проектов',
641
647
  'Tue': 'Вт',
@@ -657,6 +663,7 @@ const I18N_RU = {
657
663
  'waiting for the board to come back…': 'жду, пока доска поднимется…',
658
664
  'Waiting for you': 'Ждёт тебя',
659
665
  'Wed': 'Ср',
666
+ "What's new": 'Что нового',
660
667
  'Week': 'Неделя',
661
668
  'Weeks': 'Недели',
662
669
  'Welcome!': 'Добро пожаловать!',
@@ -6,6 +6,35 @@ import { checkVersion, pendingReload } from './init.js';
6
6
  import { loadProjects, popLayer, pushLayer, setupPromptBlockHTML, styledAlert, styledConfirm, styledPrompt, wireSetupPrompt } from './sidebar.js';
7
7
  import { SHORT_MONTHS, SOUND_LIB, STATUS_LABELS, audioCtx, lastMissedAt, lastSseAt, missedSounds, pageLoadedAt, playNamedSound, refresh, relTime, titleOr, updateSoundBadge } from './sse.js';
8
8
 
9
+ function mdInline(text) {
10
+ return esc(text)
11
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
12
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
13
+ .replace(/\*([^*\n]+)\*/g, '<em>$1</em>')
14
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
15
+ }
16
+ export function mdLite(md) {
17
+ const out = [];
18
+ let list = false;
19
+ const closeList = () => { if (list) { out.push('</ul>'); list = false; } };
20
+ for (const block of String(md || '').split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean)) {
21
+ const flat = block.split('\n').map((l) => l.trim()).join(' ');
22
+ const head = flat.match(/^#{1,6}\s+(.*)$/);
23
+ if (head) { closeList(); out.push(`<h4>${mdInline(head[1])}</h4>`); continue; }
24
+ if (/^[-*]\s+/.test(block)) {
25
+ if (!list) { out.push('<ul>'); list = true; }
26
+ for (const item of block.split(/\n(?=[-*]\s)/)) {
27
+ out.push(`<li>${mdInline(item.replace(/^[-*]\s+/, '').split('\n').map((l) => l.trim()).join(' '))}</li>`);
28
+ }
29
+ continue;
30
+ }
31
+ closeList();
32
+ out.push(`<p>${mdInline(flat)}</p>`);
33
+ }
34
+ closeList();
35
+ return out.join('');
36
+ }
37
+
9
38
  const SETTINGS_DEFAULTS = { sound: true, soundReview: 'ding', soundDone: 'click', startScreen: 'dashboard', dashRange: 'week', theme: 'system', keepDrawer: false, notifyReview: false, compactCards: false, linkLines: true, keymap: {} };
10
39
  export function getSetting(k) {
11
40
  const v = localStorage.getItem('kb.set.' + k);
@@ -424,6 +453,7 @@ async function renderSection(sec) {
424
453
  + `<div class="set-col hidden" id="about-upd-fail"><span class="set-lab">${tr('Run it yourself')}<small>${tr('the board is not allowed to do this — the command is the same one it just tried')}</small></span>`
425
454
  + '<pre class="onb-code" id="about-upd-cmd"></pre></div>'
426
455
  + `<div class="set-row hidden" id="about-dev-row"><span class="set-lab">${tr('dev branch')}<small id="about-dev-sub">…</small></span><span class="about-fresh" id="about-dev"></span></div>`
456
+ + `<div class="set-col"><span class="set-lab">${tr("What's new")}<small id="about-news-sub">…</small></span><div class="about-news" id="about-news">…</div></div>`
427
457
  + `<div class="set-row"><span class="set-lab">${tr('Check for updates')}<small id="about-recheck-sub">${tr('re-read branch state from GitHub')}</small></span><button class="btn-ghost" id="about-recheck">${tr('Check')}</button></div>`
428
458
  + `<div class="set-row"><span class="set-lab">${tr('Reload the page')}<small>${tr('re-read the board code right now')}</small></span><button class="btn-ghost" id="about-reload">${tr('Reload now')}</button></div>`
429
459
  + `<div class="set-row"><span class="set-lab">${tr('Using the board at work')}<small>${tr('free for everyone, nothing switched off — if it earns you money, $12 a year per person keeps it maintained')}</small></span>`
@@ -475,8 +505,21 @@ async function renderSection(sec) {
475
505
  ? `${u.dev.sha}${u.dev.date ? ` ${tr('at')} ${fmtDbTime(u.dev.date.slice(0, 19).replace('T', ' '))}` : ''}${u.dev.message ? ` · ${u.dev.message}` : ''}`
476
506
  : tr('dev has no commits beyond main');
477
507
  };
508
+ const loadNews = async (upd, forced) => {
509
+ const box = $('about-news'); const sub = $('about-news-sub');
510
+ if (!box || !sub) return;
511
+ try {
512
+ const remote = upd?.update_available === true ? `?remote=1${forced ? '&refresh=1' : ''}` : '';
513
+ const n = await api('GET', `/api/whats-new${remote}`, null, { quiet: true });
514
+ box.innerHTML = n?.notes ? mdLite(n.notes) : `<p class="muted">${esc(tr('no notes for this version'))}</p>`;
515
+ const ver = n?.version ? `${tr('version')} ${n.version}${n.source === 'github' ? ` · ${tr('not installed yet')}` : ''}` : '';
516
+ const link = /^https:\/\//.test(n?.url || '')
517
+ ? ` · <a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer">${esc(tr('release page'))}</a>` : '';
518
+ sub.innerHTML = esc(ver) + link;
519
+ } catch { box.textContent = '—'; sub.textContent = ''; }
520
+ };
478
521
  api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
479
- .then(() => api('GET', '/api/update-check')).then(renderUpd)
522
+ .then(() => api('GET', '/api/update-check')).then((u) => { renderUpd(u); return loadNews(u); })
480
523
  .catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
481
524
  const showFailCmd = (cmd) => {
482
525
  const wrap = $('about-upd-fail');
@@ -531,7 +574,7 @@ async function renderSection(sec) {
531
574
  $('about-recheck').onclick = async () => {
532
575
  const btn = $('about-recheck');
533
576
  btn.disabled = true; $('about-upd-sub').textContent = tr('checking…');
534
- try { renderUpd(await api('GET', '/api/update-check?refresh=1')); }
577
+ try { const u = await api('GET', '/api/update-check?refresh=1'); renderUpd(u); await loadNews(u, true); }
535
578
  catch { $('about-upd-sub').textContent = '—'; }
536
579
  btn.disabled = false;
537
580
  };
@@ -374,7 +374,10 @@ export async function checkPath(path) {
374
374
  setState('wiz-path-state', `${tr('the folder is there')} · ${r.dirs.length} ${tr('subfolders')}`, 'ok');
375
375
  return true;
376
376
  } catch (e) {
377
- setState('wiz-path-state', `${tr('no such folder on this computer')}: ${e.message || path}`, 'bad');
377
+ const said = { 404: tr('no such folder on this computer'),
378
+ 400: tr('this path is not a folder'),
379
+ 403: tr('the folder is there, but the board cannot read it — check its permissions') }[e.status];
380
+ setState('wiz-path-state', said || `${tr('could not check the folder')}: ${e.message || path}`, 'bad');
378
381
  return false;
379
382
  }
380
383
  }
package/public/style.css CHANGED
@@ -995,6 +995,19 @@ body.dragging-board .col-add-ghost { display: none; }
995
995
  .theme-opt { border: none; background: var(--panel); color: var(--muted); font: inherit; font-size: 12.5px; padding: 6px 12px; cursor: pointer; }
996
996
  .theme-opt + .theme-opt { border-left: 1px solid var(--border); }
997
997
  .theme-opt.active { background: var(--accent); color: #fff; }
998
+ .about-news {
999
+ max-height: 260px; overflow-y: auto; font-size: 12.5px; line-height: 1.5;
1000
+ border: 1px solid var(--border-soft); border-radius: 8px; padding: 10px 12px; background: var(--panel);
1001
+ }
1002
+ .about-news h4 { font-size: 12.5px; font-weight: 600; margin: 12px 0 6px; color: var(--muted); }
1003
+ .about-news h4:first-child { margin-top: 0; }
1004
+ .about-news p { margin: 0 0 8px; }
1005
+ .about-news ul { margin: 0 0 8px; padding-left: 18px; }
1006
+ .about-news li { margin-bottom: 7px; }
1007
+ .about-news li:last-child, .about-news p:last-child { margin-bottom: 0; }
1008
+ .about-news code { font-size: 11.5px; background: var(--border-soft); border-radius: 4px; padding: 1px 4px; }
1009
+ .about-news a { color: var(--accent); }
1010
+
998
1011
  .bk-list { display: flex; flex-direction: column; gap: 0; margin-top: 6px; }
999
1012
  .bk-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding: 7px 0; border-top: 1px solid var(--border-soft); }
1000
1013
  .bk-row:first-child { border-top: none; }
@@ -15,10 +15,14 @@ Statuses: `backlog` → `todo` → `prep` → `doing` → `deploy` → `review`
15
15
 
16
16
  ```
17
17
  kb take <proj> # take the next task from todo → prep; prints the description,
18
- # the issue number (for commit messages), the last note from the
19
- # human (if the task was sent back, act on it!) and the paths of
20
- # attached screenshots as "📎 <path>" — OPEN those with Read,
21
- # they are context for the task.
18
+ # the checklist as "- [ ] item" / "- [x] item" lines (that IS the
19
+ # spec — do every open item, and verify them one by one before
20
+ # review), the issue as "issue: #N <link>" (#N goes into commit
21
+ # messages; kb show prints the same line), EVERY note the
22
+ # human left since your last report, dated — "> 👤 [2026-09-02 11:30]
23
+ # …" (if the task was sent back, act on them!) and the paths of
24
+ # attached screenshots as "📎 <path>" — OPEN those with Read, they
25
+ # are context for the task.
22
26
  # started writing code → kb mv <key> doing.
23
27
  kb review <key> "note" # leave a note and move to review. NOT atomic: the note is stored
24
28
  # EVEN IF the move is rejected (backlog and todo cannot go straight
@@ -27,7 +31,11 @@ kb review <key> "note" # leave a note and move to review. NOT atomic: the
27
31
  # never went through doing.
28
32
  kb p # projects: slug (PREFIX) b0 t2 pp0 d1 dp0 r1 ✓5 !1 (pp = prep)
29
33
  kb ls <proj> [-s st] [-a] # tasks (without -a, backlog/done/cancelled are hidden)
30
- kb show <key> # details + issue + latest comments
34
+ kb show <key> # details + checklist + issue + comments: the last five plus every
35
+ # human note since your last report. Marks: 🤖 yours, 👤 the
36
+ # human's, ⎇ a commit stat the board attached by itself (not the
37
+ # human speaking). "… N more comments not printed" = the rest are
38
+ # on the board — nothing is dropped silently.
31
39
  kb add <proj> "title" [-d "desc"] [-p high|med|low] [-s todo] # goes to backlog by default!
32
40
  kb mv <key> todo|prep|doing|deploy|review|done|cancelled
33
41
  kb done <key> · kb cancel <key> · kb pri <key> high|med|low|none
@@ -40,9 +48,16 @@ kb lbl <key> +bug -ui # labels: bug docs duplicate enhancement ui featur
40
48
  # label goes into the LABELS palette first, and into this file.
41
49
  kb desc <key> "description" # set or overwrite a task description (used when grooming the backlog)
42
50
  kb note <key> "text" # a standalone comment (rarely needed — kb review covers it)
43
- kb link <key> <key> # link two tasks (the board draws a line) · kb unlink to unlink
51
+ kb check <key> <n> # tick checklist item n (from 1, in the order show/take print them)
52
+ # · kb uncheck <key> <n> clears it. Answers "- [x] item (2 open)"
53
+ kb link <key> <key> [child|parent] # link two tasks (the board draws a line); `child` makes the
54
+ # second task a sub-task of the first, `parent` its parent, no word
55
+ # = plain related · kb unlink to unlink
44
56
  kb block <key> "why" · kb unblock <key>
45
- kb info <proj> # path, deploy_skill, server, pm2, domain
57
+ kb info <proj> # path, deploy_skill, server, pm2, domain, the project's description
58
+ # (the owner's context for work — read it) and "docs: CLAUDE.md,
59
+ # README.md" — the documents to read before the task; "docs: no
60
+ # CLAUDE.md — read README.md instead" is a signal, not an empty line
46
61
  kb svc <proj> # live status of the project's services (local pm2 or your own PANEL_URL)
47
62
  kb sync # state of the sync queue (or "sync is off" in local-only mode)
48
63
  ```
@@ -92,15 +107,18 @@ Claude takes a task as far as *review* and stops. Only the user moves anything t
92
107
  **When the prompt lists specific keys** (the 📋 button on the "To do" column produces them) those
93
108
  are SPECIFIC tasks, not a queue. Take each one with `kb show <key>` (NOT `kb take`, which pulls
94
109
  ONE next task from the project's `todo`) and **move it to prep right away: `kb mv <key> prep`** —
95
- `kb show` does not change the status. If `kb show` printed `> 👤 Send back: …`, the task was
96
- returned: open the `📎` screenshots with Read and act on that note FIRST. Then follow steps 2–6
110
+ `kb show` does not change the status. If `kb show` printed `> 👤 [date] Send back: …`, the task was
111
+ returned — the date tells a fresh send-back from an old one you already answered: open the `📎` screenshots with Read and act on that note FIRST. Then follow steps 2–6
97
112
  for each. Use `kb take X` only when asked to "work through project X" without specific keys.
98
113
 
99
114
  1. `kb take X` — take a task (lands in prep; description and issue are already in the output).
100
- "empty" → report it and stop. `> 👤 …` is a note from a send-back, act on it first.
115
+ "empty" → report it and stop. "empty (N blocked)" is NOT an empty column: every card in To do
116
+ is blocked — list them (`kb ls X -s todo`) and report that, not "nothing to do". `> 👤 …` lines are what the human wrote since your last report
117
+ (a send-back and its follow-ups) — act on all of them first.
101
118
  `📎 <path>` — open the screenshot with Read.
102
119
  2. **HARD RULE — starting a task (never skip, even for a one-line change).** `kb info X` → go to
103
- `path`. Before any code you MUST read: (a) the project's `CLAUDE.md` if it has one — **and if
120
+ `path`; its `description:` and `docs:` lines say what the owner wants you to know and which
121
+ documents the project has. Before any code you MUST read: (a) the project's `CLAUDE.md` if it has one — **and if
104
122
  it does not, find where the project keeps its architecture or research document (`README.md`,
105
123
  `EXPERIMENTS.md`, `docs/`) and read that; apply the "doc in the same commit" rule to it**;
106
124
  (b) the deploy skill at `deploy_skill_path` from `kb info`, if one is set. The matching commit
@@ -126,7 +144,9 @@ for each. Use `kb take X` only when asked to "work through project X" without sp
126
144
  The generic `deploy` skill ships with the board (`skills/deploy/`) and works purely off
127
145
  `kb info` data. Production commands (ssh/rsync) will ask for confirmation — that is expected.
128
146
  5. `kb review <key> "<two plain sentences: 1) what the problem was 2) how you solved it>"` → take
129
- the next one.
147
+ the next one. **If the task has a checklist, tick each item as you finish it (`kb check <key>
148
+ <n>`).** Before review every item is either ticked or named in the note as not done — the human
149
+ accepts the task by those boxes, and an unticked done item reads as undone.
130
150
  6. Stuck, or the decision is the human's → `kb block <key> "reason"` and move on.
131
151
  7. At the end, a one-line summary: how many in review, how many blocked.
132
152
 
@@ -161,7 +181,9 @@ One line of summary at the end.
161
181
  - **You found an unrelated problem along the way** → do not fix it silently. Create a task
162
182
  (`kb add <proj> "..." -d "..."`) and ask in chat: do it now, defer it, or is it for the human.
163
183
  - **Related tasks solved together** → take both at once (both in doing), fix them in one pass and
164
- link them with `kb link`.
184
+ link them with `kb link`. `kb show`/`kb take` print a task's links as `↑ parent KEY title
185
+ (status)`, `↓ subtask …`, `↔ related …` — look at them before you start: a linked task in To do
186
+ is usually meant to go together with this one, and a parent explains why the task exists.
165
187
  - **A discussion or question task** (no code): study it in `prep`, put the full answer in
166
188
  `kb note <key> "..."`, then `kb mv <key> review`. You write no code and do not move it to done.
167
189
  - A "check whether the service is alive" task → `kb svc <proj>` (one line per service, no ssh).
package/src/config.js CHANGED
@@ -38,3 +38,4 @@ export const allowedHosts = () => env('KB_ALLOWED_HOSTS').split(',').map((s) =>
38
38
  export const langOverride = () => env('KB_LANG');
39
39
  export const ghOwnerEnv = () => env('KB_GH_OWNER');
40
40
  export const ghRepoEnv = () => env('KB_GH_REPO');
41
+ export const ghBinEnv = () => expandPath(env('KB_GH_BIN'));
@@ -20,8 +20,8 @@ let selfPkgCache;
20
20
  function selfPkg() {
21
21
  if (selfPkgCache === undefined) {
22
22
  try {
23
- const { name, version } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
24
- selfPkgCache = name && version ? { name, version } : null;
23
+ const { name, version, repository } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
24
+ selfPkgCache = name && version ? { name, version, repository: repository?.url || repository || null } : null;
25
25
  } catch { selfPkgCache = null; }
26
26
  }
27
27
  return selfPkgCache;
@@ -82,6 +82,32 @@ export async function registryCheck() {
82
82
  } catch { return null; }
83
83
  }
84
84
 
85
+ export async function ghApi(path) {
86
+ try {
87
+ const res = await fetch(`https://api.github.com/${path}`, {
88
+ headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
89
+ signal: AbortSignal.timeout(8000),
90
+ });
91
+ if (res.ok) return await res.json();
92
+ } catch { }
93
+ try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
94
+ catch { return null; }
95
+ }
96
+
97
+ export function changelogSection(text, version) {
98
+ const head = new RegExp(`^##\\s*\\[?${String(version).replace(/\./g, '\\.')}\\]?`, 'm');
99
+ const m = head.exec(String(text || ''));
100
+ if (!m) return null;
101
+ const rest = text.slice(m.index + m[0].length);
102
+ const end = rest.search(/^## /m);
103
+ return (end === -1 ? rest : rest.slice(0, end)).trim() || null;
104
+ }
105
+
106
+ export function repoSlug(url) {
107
+ const m = String(url || '').match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#.*)?$/);
108
+ return m ? `${m[1]}/${m[2]}` : null;
109
+ }
110
+
85
111
  const UPLOAD_FLOOR = 64 * 1024 * 1024;
86
112
  function uploadLimit() {
87
113
  try { return Math.max(UPLOAD_FLOOR, statSync(join(DATA_DIR, 'kanban.db')).size * 4); } catch { return UPLOAD_FLOOR; }
@@ -255,17 +281,6 @@ export default async function systemRoutes(app) {
255
281
  const git = (args) => new Promise((resolve) => {
256
282
  execFile('git', args, { cwd: ROOT, timeout: 8000 }, (err, out) => resolve(err ? null : out.trim()));
257
283
  });
258
- const ghApi = async (path) => {
259
- try {
260
- const res = await fetch(`https://api.github.com/${path}`, {
261
- headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
262
- signal: AbortSignal.timeout(8000),
263
- });
264
- if (res.ok) return await res.json();
265
- } catch { }
266
- try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
267
- catch { return null; }
268
- };
269
284
  let data = { update_available: null, local: null, remote: null, branch: null, tag: null, dev: null };
270
285
  try {
271
286
  const [local, branch, origin, tag] = await Promise.all([
@@ -300,6 +315,30 @@ export default async function systemRoutes(app) {
300
315
  return data;
301
316
  });
302
317
 
318
+ const NEWS_OK_MS = 3600000;
319
+ const NEWS_FAIL_MS = 60000;
320
+ let newsCache = { at: 0, ttl: 0, data: null };
321
+ app.get('/api/whats-new', async (req) => {
322
+ const version = selfPkg()?.version || null;
323
+ const own = () => {
324
+ let text = null;
325
+ try { text = readFileSync(join(ROOT_DIR, 'CHANGELOG.md'), 'utf8'); } catch { }
326
+ return { version, notes: (text && version) ? changelogSection(text, version) : null, source: 'changelog', url: null };
327
+ };
328
+ if (!req.query?.remote) return own();
329
+ if (!req.query?.refresh && newsCache.data && Date.now() - newsCache.at < newsCache.ttl) return newsCache.data;
330
+ const repo = repoSlug(selfPkg()?.repository);
331
+ const rel = repo ? await ghApi(`repos/${repo}/releases/latest`) : null;
332
+ const tag = String(rel?.tag_name || '').replace(/^v/, '');
333
+ const body = String(rel?.body || '').trim();
334
+ const got = Boolean(tag && body);
335
+ const data = got
336
+ ? { version: tag, notes: body, source: 'github', url: rel.html_url || null }
337
+ : own();
338
+ newsCache = { at: Date.now(), ttl: got ? NEWS_OK_MS : NEWS_FAIL_MS, data };
339
+ return data;
340
+ });
341
+
303
342
  let updateRunning = null;
304
343
  app.post('/api/update', async (req, reply) => {
305
344
  if (updateRunning) return reply.code(409).send({ error: 'an update is already running' });
@@ -1,11 +1,13 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { db, ghOwner, ghRepo, uiLang } from '../db.js';
4
+ import { ghBinEnv } from '../config.js';
4
5
  const GH_CANDIDATES = [
5
6
  '/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh',
6
7
  '/home/linuxbrew/.linuxbrew/bin/gh', `${process.env.HOME}/.local/bin/gh`,
7
8
  ];
8
- const GH = GH_CANDIDATES.find((p) => existsSync(p)) || 'gh';
9
+ const GH_FOUND = GH_CANDIDATES.find((p) => existsSync(p)) || 'gh';
10
+ const ghBin = () => ghBinEnv() || GH_FOUND;
9
11
 
10
12
  const PERMANENT = [
11
13
  /validation failed/i,
@@ -19,7 +21,7 @@ export function isPermanent(stderr) {
19
21
 
20
22
  export function gh(args, { timeout = 60000 } = {}) {
21
23
  return new Promise((resolve, reject) => {
22
- execFile(GH, args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
24
+ execFile(ghBin(), args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
23
25
  if (err) {
24
26
  const e = new Error((stderr || err.message || '').trim().slice(0, 500));
25
27
  e.permanent = isPermanent(String(stderr));