local-kanban 2.0.0-beta.2 → 2.0.0-beta.4

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/kb +35 -8
  3. package/package.json +1 -1
  4. package/public/js/automations.js +2 -12
  5. package/public/js/board.js +44 -28
  6. package/public/js/chat.js +23 -22
  7. package/public/js/core.js +28 -5
  8. package/public/js/drawer.js +73 -16
  9. package/public/js/glyphs.js +1 -1
  10. package/public/js/health.js +41 -2
  11. package/public/js/init.js +12 -8
  12. package/public/js/now-terms.js +1 -1
  13. package/public/js/now.js +3 -5
  14. package/public/js/onboarding-rules.js +3 -0
  15. package/public/js/onboarding.js +2 -2
  16. package/public/js/project.js +1 -1
  17. package/public/js/ready-rules.js +25 -0
  18. package/public/js/review-rules.js +15 -1
  19. package/public/js/review.js +1 -1
  20. package/public/js/settings.js +7 -4
  21. package/public/js/share.js +4 -0
  22. package/public/js/shipped-rules.js +18 -0
  23. package/public/js/sidebar.js +4 -2
  24. package/public/js/sse.js +4 -2
  25. package/public/js/unsaved.js +52 -0
  26. package/public/style.css +6 -4
  27. package/skills/kanban/SKILL.md +19 -6
  28. package/src/auto-health.js +61 -3
  29. package/src/auto-resume.js +68 -18
  30. package/src/automations.js +30 -11
  31. package/src/bin-path.js +26 -0
  32. package/src/chat-feed.js +26 -17
  33. package/src/claude-live.js +13 -5
  34. package/src/db.js +1 -0
  35. package/src/launch.js +2 -8
  36. package/src/own-wait.js +5 -6
  37. package/src/phantom.js +13 -10
  38. package/src/ready-gate.js +11 -2
  39. package/src/repo-base.js +2 -1
  40. package/src/review-check.js +1 -1
  41. package/src/routes/chat.js +11 -13
  42. package/src/routes/dashboard.js +4 -2
  43. package/src/routes/health.js +8 -0
  44. package/src/routes/projects.js +27 -8
  45. package/src/routes/system.js +11 -9
  46. package/src/routes/tasks.js +53 -37
  47. package/src/routes/terminal.js +7 -19
  48. package/src/schema.sql +7 -1
  49. package/src/share.js +26 -17
  50. package/src/shipped.js +105 -52
  51. package/src/term-screens.js +56 -0
  52. package/src/tree-print.js +2 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ version it goes out as and opens an empty one again. Released sections are histo
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [2.0.0-beta.4]
12
+
13
+ ### Added
14
+
15
+ - Health: a CLAUDE.md past a norm has «Fix» — it opens the task to shrink the file, with the docs of Claude Code, and starts Claude on it at once; while a Claude works in the project's folder, or a task of the project is in work, the button says what holds it and starts nothing.
16
+
17
+ ### Fixed
18
+
19
+ - `kb note` and `kb review` send their note with a key, so a retry after a lost answer finds the note already written instead of adding a second one.
20
+ - **Wi-Fi: a guest no longer sees where the project's notes and deploy skill lie on the owner's disk.**
21
+ - The commit line of a card no longer shows a foreign pull request with the same number or the newest commit of another task.
22
+ - A card in Ready to ship says «waiting for you» when its Claude waits there for your word, with no second sound for it.
23
+ - A Claude that got one of its helpers back and waits for the next no longer reads as waiting for you.
24
+ - A group action or a drag the board refused says which tasks did not change and why, instead of putting cards back without a word; a refused title or description no longer holds the rest of the card's edit.
25
+ - Automations: a rule waits for the board to read the terminals after a restart before it starts a check, a pause after a refused start outlives a restart, words that cannot reach Claude stop after three tries, a card says when the board could not tell Claude to go on, and a limit line wrapped in the middle of its time keeps am/pm.
26
+ - Going on with interrupted work: tmux too slow to answer no longer uses up the tries of a session, and a card the board could not set going says what held it.
27
+ - On a Mac the board starts git and its other programs by their full path: a bare name was a short-lived process for every folder of PATH in front of it, and their stream held the start of tmux for seconds. The tests of tmux no longer fail beside the test of the mark «on main», the suite runs in 55 s instead of 92, and a release runs it at once again.
28
+ - The chat keeps words with pictures you sent while Claude worked: they vanished from it the moment Claude took them.
29
+ - A terminal tmux is slow to answer about no longer reads as «Not running», nor its chat as «does not run inside the board»: the screen and its state stay until tmux answers.
30
+
31
+ ## [2.0.0-beta.3]
32
+
33
+ ### Fixed
34
+
35
+ - The mark «on main» counts a squash and a copy under another subject as shipped, no longer reads the head of the project, a foreign pull request or a mere mention as a task's commit, does not call a branch that took its work back shipped, finds a key after a number in the subject, costs a look a few git processes instead of four a commit, and leaves no objects in the project's repository.
36
+ - Ready to ship: Prep and a label set on the way are no road past the column; an emptied column goes at once; the Dashboard's «Waiting for you» lists what waits for your word, with its number in the sidebar; Done with Claude and the first-run tour know the column; a guest of the Wi-Fi board cannot change a project's own word.
37
+ - A label or a move the board refuses no longer holds the rest of the card: the label comes off with the reason said, and what you typed is saved in any order, after a lost connection and with the card closed too.
38
+ - A guest of the Wi-Fi board is told that only the owner can do this, instead of «the board did not accept it».
39
+ - **Wi-Fi: a guest could get the key out of a backup.** Backups hold the whole database, the key of the door with it, and a guest with a pass could take one and download it. Backups now stay with the owner, the list too, and Settings → Backups tells a guest so; About no longer gives a guest the paths on disk.
40
+ - **Wi-Fi: the key no longer stays behind in the page of the board.** A link opens the board by a redirect, so a script there finds nothing of the key in `document.referrer`, and Back does not lead to its address. A right key on a long address opens the board instead of an empty page, and a guest with words typed when the key changes is told to copy them and reload.
41
+ - The screens the terminal keeps to send only what changed take less memory and go when nobody looks.
42
+ - A comment is no longer written twice when you press send again while it waits, or «Send back» again with the same reason. One written with its answer lost no longer stands under itself as «not sent», and one deleted before the resend stays deleted.
43
+ - Automations: a board that has just restarted no longer types into a question or a limit screen it has not read yet, and going on with interrupted work leaves alone what is typed in the field, a task that left work and a session a day old; a card says the board went on only when the words got there.
44
+ - Automations: «Did not start» says in the board's language that a Claude already works on the tasks, or that tmux did not answer.
45
+ - `kb show A B`: a card whose comments did not load says it is cut short, and a refusal leaves no `---` behind.
46
+ - `kb note` no longer reports a failure for a note it saved, and an answer of the board that breaks off in the middle exits with code 4, not as a failure of `kb` itself.
47
+ - A release check names the file and line of what it found, and a broken private file of patterns is said in words.
48
+ - A release runs the test of the mark «on main» after the rest of the suite, not beside it: on a Mac the two together failed tmux tests at random and stopped the release.
49
+
11
50
  ## [2.0.0-beta.2]
12
51
 
13
52
  ### Added
package/bin/kb CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { parsePm2Services } from '../src/pm2-services.js';
6
6
  import { closeSync, writeSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
7
8
  import { memoryText, moneyText, tokensText } from '../public/js/usage-rules.js';
8
9
  import { HOOK_WAIT_MS } from '../src/claude-hooks.js';
9
10
 
@@ -21,7 +22,7 @@ const ST_ALIAS = {
21
22
  // Exit codes are a contract for automation (scripts gate their moves on them):
22
23
  // 0 — success, 1 — the board is not responding, 2 — the request was rejected (4xx or an
23
24
  // unknown command), 3 — kb itself broke, 4 — the board answered, but with a failure of its
24
- // own (5xx) or with something that is not JSON, or not of the shape the board answers with.
25
+ // own (5xx) or with something that is not JSON, not of the shape the board answers with, or cut off.
25
26
  // Previously an exception exited with 1 as well, so "the board is down" was indistinguishable from
26
27
  // "kb is broken".
27
28
  // Code 4 exists because 2 was answering two different questions at once. A live board that
@@ -56,6 +57,8 @@ const TASK = ['a task', (d) => isObj(d) && typeof d.key === 'string' && typeof d
56
57
  const LIST = ['a list', Array.isArray];
57
58
  const PROJECT = ['a project', (d) => isObj(d) && typeof d.slug === 'string'];
58
59
  const COMMENT = ['a comment', (d) => isObj(d) && Number.isInteger(d.id)];
60
+ // was on the card and someone deleted it there.
61
+ const NOTE = ['a comment', (d) => COMMENT[1](d) || (isObj(d) && d.deleted === true)];
59
62
  const ITEM = ['a checklist item', (d) => isObj(d) && typeof d.text === 'string'];
60
63
  const DOCS = ['the project documents', (d) => isObj(d) && Array.isArray(d.docs)];
61
64
  const TAKEN = ['a taken task', (d) => isObj(d) && Number.isInteger(d.blocked_n) && (d.task === null || TASK[1](d.task))];
@@ -72,6 +75,17 @@ function key(id) {
72
75
  if (!KEY_RE.test(id)) die(`kb: "${id}" is not a task key (like CK-12) or a task id`);
73
76
  return encodeURIComponent(id);
74
77
  }
78
+ // after the board wrote the note (code 1), and the retry is a new kb with nothing remembered — the same key lets
79
+ // the board find what it already wrote instead of writing it twice. The same words from the same session again
80
+ // stay one note; that repeats nothing a person would want twice.
81
+ function noteKey(id, body) {
82
+ return 'kb-' + createHash('sha256').update(`${String(id).toUpperCase()}\n${SESSION}\n${body}`).digest('hex').slice(0, 40);
83
+ }
84
+ async function postNote(id, body) {
85
+ const c = await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude', client_key: noteKey(id, body) }, NOTE);
86
+ if (c.deleted) die(`kb: the same note was on ${id} and was deleted there — not written again; change the words to add it`);
87
+ return c;
88
+ }
75
89
  function seg(s) {
76
90
  if (s === '.' || s === '..') die(`kb: "${s}" is not a project`);
77
91
  return encodeURIComponent(s);
@@ -99,7 +113,11 @@ async function api(method, path, body, expect = null) {
99
113
  return data;
100
114
  };
101
115
  if (res.status === 204) return shaped(null);
102
- const text = await res.text();
116
+ // Headers came and the body did not finish: the board went down in the middle of its answer. That throws here,
117
+ // past the check above, and read as kb itself breaking.
118
+ let text;
119
+ try { text = await res.text(); }
120
+ catch { die(`kb: the board answered ${res.status} and broke off in the middle — whether it did what was asked is not known`, EXIT.BOARD_ERROR); }
103
121
  let data = null;
104
122
  let parsed = false;
105
123
  try { data = JSON.parse(text); parsed = true; } catch { /* not JSON — see below */ }
@@ -466,23 +484,29 @@ const commands = {
466
484
  const t = await api('PATCH', `/api/tasks/${key(id)}`, { status: 'review' }, TASK);
467
485
  const unsaved = (code) => { if (code) process.stderr.write(`kb: ${t.key} is in review, but the note did not save — kb note ${t.key} "…"\n`); };
468
486
  process.on('exit', unsaved);
469
- await api('POST', `/api/tasks/${key(id)}/comments`, { body: note, author: 'claude' }, COMMENT);
487
+ await postNote(id, note);
470
488
  process.off('exit', unsaved);
471
489
  console.log(`${t.key} review 📝`);
472
490
  },
473
491
 
474
492
  // A batch is read in one go, so every key is printed, cards apart by a line of ---. A wrong key stops the
475
493
  // command before the first request; a task the board does not know stops it there, and says at which key.
494
+ // The line goes out once the next card is read, so a refused one leaves none hanging, and a card whose comments
495
+ // did not come is called cut short: its head is printed, and it does not look like a whole one.
476
496
  async show() {
477
497
  if (!args.length) die('kb show <key> [<key> …]');
478
498
  const ids = args.map(key);
479
499
  let at = 0;
500
+ let cut = null;
480
501
  process.on('exit', (code) => {
481
- if (code && ids.length > 1) process.stderr.write(`kb: stopped at ${args[at]}: ${at} of ${ids.length} printed\n`);
502
+ if (!code) return;
503
+ const part = cut ? `${cut} is cut short — its comments did not load` : '';
504
+ if (ids.length > 1) process.stderr.write(`kb: stopped at ${args[at]}: ${at} of ${ids.length} printed${part ? `, ${part}` : ''}\n`);
505
+ else if (part) process.stderr.write(`kb: ${part}\n`);
482
506
  });
483
507
  for (; at < ids.length; at++) {
484
- if (at) console.log('---');
485
508
  const t = await api('GET', `/api/tasks/${ids[at]}`, null, TASK);
509
+ if (at) console.log('---');
486
510
  console.log(`${t.key} ${t.project} ${t.status}${t.pinned ? ' ' + PIN : ''}${t.blocked ? ` blocked: ${t.blocked_reason || ''}` : ''}${PRI[t.priority] ? ' ' + PRI[t.priority] : ''}${t.labels.length ? ' [' + t.labels.join(',') + ']' : ''}`);
487
511
  console.log(t.title);
488
512
  if (t.description) console.log(t.description);
@@ -493,7 +517,9 @@ const commands = {
493
517
  printSessions(t);
494
518
  printUsage(t);
495
519
  printAttachments(t);
520
+ cut = t.key;
496
521
  printComments(await api('GET', `/api/tasks/${t.id}/comments`, null, LIST), 5);
522
+ cut = null;
497
523
  }
498
524
  },
499
525
 
@@ -591,9 +617,10 @@ const commands = {
591
617
  async note() {
592
618
  const [id, body] = args;
593
619
  if (!id || !body) die('kb note <key> "text"');
594
- await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude' }, COMMENT);
595
- const t = await api('GET', `/api/tasks/${key(id)}`, null, TASK);
596
- console.log(`${t.key} 📝`);
620
+ // Nothing is read after the write: a read that failed reported a failure of a note already saved, and the retry
621
+ // wrote a second one. So the key is printed as it was typed.
622
+ await postNote(id, body);
623
+ console.log(`${id} 📝`);
597
624
  },
598
625
 
599
626
  // kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.4",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import { checkWords } from './review.js';
8
8
  import { getSetting, openSettingsModal } from './settings.js';
9
9
  import { renderSidebar, selectProject, styledAlert } from './sidebar.js';
10
10
  import { refusalLine } from './unsaved.js';
11
+ import { failWord } from './review-rules.js';
11
12
 
12
13
  const CHECK_KIND = 'finished-check';
13
14
  const SCHEDULED = new Set(['schedule-check', 'schedule-groom']);
@@ -80,18 +81,7 @@ export function phaseLine(r) {
80
81
  }
81
82
  const phaseText = (r) => { const d = document.createElement('div'); d.innerHTML = phaseLine(r); return d.textContent; };
82
83
  const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
83
- const FAIL_WORDS = {
84
- 'no-folder': 'the project has no folder on this computer',
85
- 'no-tmux': 'tmux is not installed here',
86
- 'no-claude': 'Claude Code is not installed here',
87
- 'too-long': 'the prompt is too long for the board\'s terminal',
88
- 'check-open': 'the project already has an open check',
89
- manual: 'a task done by hand cannot be given to Claude',
90
- };
91
- const failWhy = (r) => {
92
- const reason = String(r.error || '').match(/^([a-z-]+): /)?.[1];
93
- return reason && FAIL_WORDS[reason] ? tr(FAIL_WORDS[reason]) : esc(r.error || '');
94
- };
84
+ const failWhy = (r) => { const word = failWord(r.error); return word ? tr(word) : esc(r.error || ''); };
95
85
  function endedLine(r) {
96
86
  const key = r.check_key ? `<button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
97
87
  if (r.state === 'fired') return `${tr('Check started')} ${key} · ${hm(r.ended_at)}`;
@@ -13,9 +13,10 @@ import { columnCheck, reviewGroups } from './review-rules.js';
13
13
  import { heldWhy, startCheckIn } from './review.js';
14
14
  import { autoSlot } from './automations.js';
15
15
  import { paintAttention } from './attention.js';
16
- import { refusalLine } from './unsaved.js';
16
+ import { refusalLine, refusalText } from './unsaved.js';
17
17
  import { localStore } from './store.js';
18
- import { SHIP_TITLES, SHIP_WORDS, notOnMain, showsShip } from './shipped-rules.js';
18
+ import { SHIP_TITLES, SHIP_WORDS, notOnMain, shipAsker, showsShip } from './shipped-rules.js';
19
+ import { boardColumns } from './ready-rules.js';
19
20
 
20
21
  function matchesSearch(t) {
21
22
  return inSearch(t, { search: state.search, applied: state.searchApplied, hits: state.searchHits });
@@ -99,22 +100,17 @@ const SHIP_KEY = 'kb.ui.reviewNotMain';
99
100
  const SHIP_EVERY_MS = 30000;
100
101
  const shipFilter = () => localStore.getItem(SHIP_KEY) === '1';
101
102
  function setShipFilter(on) { try { localStore.setItem(SHIP_KEY, on ? '1' : '0'); } catch { /* the storage is off: the filter stays as it was */ } }
102
- let shipAsked = { slug: null, at: 0, flying: false };
103
- function askShipped() {
104
- const now = Date.now();
105
- if (shipAsked.flying || !state.tasks.some((t) => showsShip(t.status))) return;
106
- if (shipAsked.slug === state.slug && now - shipAsked.at < SHIP_EVERY_MS) return;
107
- shipAsked = { slug: state.slug, at: now, flying: true };
108
- const asked = state.slug;
109
- api('GET', `/api/shipped${asked === ALL ? '' : `?project=${encodeURIComponent(asked)}`}`, undefined, { quiet: true })
110
- .then((r) => {
111
- if (asked !== state.slug || JSON.stringify(r.tasks) === JSON.stringify(state.shipped)) return;
112
- state.shipped = r.tasks;
113
- renderBoard();
114
- })
115
- .catch(() => { /* the card says nothing: it is a hint, not a state of the task */ })
116
- .finally(() => { shipAsked.flying = false; });
117
- }
103
+ const askShipped = shipAsker({
104
+ every: SHIP_EVERY_MS,
105
+ board: () => state.slug,
106
+ wanted: () => state.tasks.some((t) => showsShip(t.status)),
107
+ ask: (slug) => api('GET', `/api/shipped${slug === ALL ? '' : `?project=${encodeURIComponent(slug)}`}`, undefined, { quiet: true }),
108
+ apply: (r) => {
109
+ if (JSON.stringify(r.tasks) === JSON.stringify(state.shipped)) return;
110
+ state.shipped = r.tasks;
111
+ renderBoard();
112
+ },
113
+ });
118
114
  const gateHere = () => (state.slug === ALL ? state.projects.some((p) => p.ship_gate) : Boolean(state.projects.find((p) => p.slug === state.slug)?.ship_gate));
119
115
  function columnHead(key, tasks, mode, labels) {
120
116
  const head = document.createElement('div');
@@ -216,9 +212,7 @@ export function renderBoard() {
216
212
  const sameBoard = prevRenderSlug === state.slug;
217
213
  const animate = prevCardIds.size > 0 && sameBoard;
218
214
  const labels = Object.fromEntries(ALL_STATUSES);
219
- const columns = ALL_STATUSES.map(([k]) => k)
220
- .filter((k) => (k !== 'cancelled' || state.tasks.some((t) => t.status === 'cancelled'))
221
- && (k !== 'ready' || readyInUse(state.slug) || state.tasks.some((t) => t.status === 'ready')));
215
+ const columns = boardColumns(ALL_STATUSES.map(([k]) => k), state.tasks, readyInUse(state.slug));
222
216
  const plan = columns.map((key) => {
223
217
  const mode = colSort(key);
224
218
  const hidden = key === 'review' && shipFilter() ? (t) => !notOnMain(t, state.shipped) : () => false;
@@ -592,10 +586,27 @@ function renderMultiPanel() {
592
586
  + `<button class="multi-clear" data-act="clear">${tr('Clear selection')}</button>`;
593
587
  box.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => multiAction(b.dataset.act, b); });
594
588
  }
595
- async function multiPatchEach(bodyFor) {
589
+ export function refusedManyText(head, refused, t = tr) {
590
+ const by = new Map();
591
+ for (const { key, e } of refused) {
592
+ const why = t(refusalText(e).reason);
593
+ by.set(why, [...(by.get(why) || []), key]);
594
+ }
595
+ const keys = (ks) => (ks.length > 6 ? `${ks.slice(0, 6).join(', ')} +${ks.length - 6}` : ks.join(', '));
596
+ const lines = [...by].map(([why, ks]) => `${keys(ks)}: ${why}`);
597
+ return lines.length === 1 ? `${t(head)} — ${lines[0]}` : `${t(head)}:\n${lines.join('\n')}`;
598
+ }
599
+ function sayRefusedMany(head, refused) {
600
+ if (refused.length) styledAlert(refusedManyText(head, refused));
601
+ }
602
+ async function multiPatchEach(bodyFor, head = 'Not saved') {
596
603
  const tasks = selectedTaskList();
597
- for (const t of tasks) { try { await api('PATCH', `/api/tasks/${seg(t.key)}`, bodyFor(t), { quiet: true }); } catch { } }
604
+ const refused = [];
605
+ for (const t of tasks) {
606
+ try { await api('PATCH', `/api/tasks/${seg(t.key)}`, bodyFor(t), { quiet: true }); } catch (e) { refused.push({ key: t.key, e }); }
607
+ }
598
608
  await refresh();
609
+ sayRefusedMany(head, refused);
599
610
  }
600
611
  function renderMultiSub(title, optsHTML, onPick) {
601
612
  const box = $('drawer-multi');
@@ -614,7 +625,7 @@ export async function multiAction(act, btn) {
614
625
  }).join('');
615
626
  renderMultiSub(tr('Move to status'), opts, async (o) => {
616
627
  if (o.classList.contains('disabled')) return;
617
- await multiPatchEach(() => ({ status: o.dataset.v }));
628
+ await multiPatchEach(() => ({ status: o.dataset.v }), 'Not moved');
618
629
  renderMultiPanel();
619
630
  });
620
631
  return;
@@ -785,7 +796,7 @@ const LIVE_BADGE = {
785
796
  function liveBadge(t) {
786
797
  if (hasAsk(t.id)) return `<span class="card-live live-wait live-ask" title="${esc(tr('Claude is waiting for your answer — open the card'))}"><span class="live-dot"></span>${tr('needs your answer')}</span>`;
787
798
  const l = state.live[t.id];
788
- const b = l && WORKING.has(t.status) && LIVE_BADGE[l.claude === 'gone' && l.interrupted ? 'interrupted'
799
+ const b = l && (WORKING.has(t.status) || t.status === 'ready') && LIVE_BADGE[l.claude === 'gone' && l.interrupted ? 'interrupted'
789
800
  : l.claude === 'idle' && l.broke ? (l.broke === 'retrying' ? 'brokeRetrying' : 'brokeStuck') : l.claude];
790
801
  if (!b) return '';
791
802
  const until = l.claude === 'limit' && l.resets ? ` · ${tr('until')} ${esc(l.resets)}` : '';
@@ -944,10 +955,12 @@ async function onDrop(e, status, body) {
944
955
  }
945
956
  dropPh.remove();
946
957
  try {
947
- await api('PATCH', `/api/tasks/${seg(id)}`, patch);
958
+ await api('PATCH', `/api/tasks/${seg(id)}`, patch, { quiet: true });
948
959
  } catch (e) {
949
960
  renderBoard();
950
- if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, patch).then(refresh));
961
+ if (e?.offline) { noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, patch).then(refresh)); return; }
962
+ sayRefusedMany('Not moved', [{ key: t.key, e }]);
963
+ await refresh();
951
964
  return;
952
965
  }
953
966
  await refresh();
@@ -982,12 +995,15 @@ async function onDropGroup(e, status, body) {
982
995
 
983
996
  dropPh.remove();
984
997
  dropSends = true;
998
+ const failed = [];
985
999
  for (let i = 0; i < movable.length; i++) {
986
1000
  if (fixedOrder && movable[i].status === status) continue;
987
- await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) });
1001
+ try { await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) }, { quiet: true }); }
1002
+ catch (e) { failed.push({ key: movable[i].key, e }); }
988
1003
  }
989
1004
  clearSelection();
990
1005
  await refresh();
1006
+ sayRefusedMany('Not moved', failed);
991
1007
  }
992
1008
 
993
1009
  export function buildSelect(host, opts) {
package/public/js/chat.js CHANGED
@@ -125,10 +125,10 @@ const codeBtn = (what, icon, label) => `<button type="button" class="chat-code-b
125
125
  // a block left open (cut off, or its fence missing) is not a command to run: its end is not known; nor is one longer than
126
126
  const RUN_MAX = 8000;
127
127
  const codeTools = (lang, closed, code = '') => `<span class="chat-code-tools">${codeBtn('copy', 'copy', 'Copy')}${closed && SHELL_LANGS.includes(lang) && code.length <= RUN_MAX ? codeBtn('run', 'terminal', 'Run in a Terminal window') : ''}</span>`;
128
- function itemHTML(it) {
128
+ function itemHTML(it, shot = null) {
129
129
  if (answered(it)) return answersHTML(it.answers);
130
130
  return it.kind === 'stop' ? `<div class="chat-sep">${esc(tr('Stopped'))}</div>`
131
- : it.kind === 'said' ? `<div class="chat-user chat-mid"><div class="chat-bubble">${esc(it.text)}</div></div>`
131
+ : it.kind === 'said' ? wordsHTML(it.text, it.images, it.shots, shot, ' chat-mid')
132
132
  : `<div class="chat-claude">${chatText(it.text, codeTools)}</div>`;
133
133
  }
134
134
  const CODE_DONE_MS = 1400; // the ✓ a button shows after it did its work
@@ -204,11 +204,11 @@ function runHTML(steps, id, open, { working, now = null, pic = null }) {
204
204
  + `${failed ? `<span class="chat-x">✗ ${failed}</span>` : ''}${ic('chevron', 11)}${thumb}</button>`
205
205
  + (open ? `<div class="chat-unfold chat-runbox" data-of="${esc(id)}">${steps.map((s) => stepRow(s, working)).join('')}</div>` : '');
206
206
  }
207
- function itemsHTML(items, at, { opened, working, fold = true, live = false, pic = null }) {
207
+ function itemsHTML(items, at, { opened, working, fold = true, live = false, pic = null, shot = null }) {
208
208
  let out = '';
209
209
  const runs = runsOf(items);
210
210
  runs.forEach((r, i) => {
211
- if (r.it) { out += itemHTML(r.it); return; }
211
+ if (r.it) { out += itemHTML(r.it, shot); return; }
212
212
  if (!fold) { out += r.steps.map((s) => stepRow(s, working)).join(''); return; }
213
213
  const id = `${at}|${i}`;
214
214
  // Claude calls tools side by side: the one still unanswered may be any of the run's, not its last
@@ -265,28 +265,29 @@ export function carryOpened(turns, opened) {
265
265
  }
266
266
  const SHOT_W = 220; // a thumbnail of the person's picture at most (.chat-shot img)
267
267
  const SHOT_H = 160;
268
+ function wordsHTML(text, images, shotList, shot, cls = '') {
269
+ const shots = shot && shotList?.length ? shotList : [];
270
+ // as many «[Image #N]» as pictures shown leave the words — the rest stay with the «📎» that counts them
271
+ let strip = shots.length;
272
+ const words = strip ? String(text || '').replace(/\[Image #\d+\]\s*/g, (x) => (strip-- > 0 ? '' : x)).trim() : text;
273
+ const unseen = (images || 0) - shots.length; // a picture the board cannot show is still counted
274
+ const pics = shots.length ? `<div class="chat-shots">${shots.map((x) => {
275
+ // the place is kept before the picture decodes: a thumbnail growing late pushed the turn being read
276
+ const k = x.w > 0 && x.h > 0 ? Math.min(1, SHOT_W / x.w, SHOT_H / x.h) : 0;
277
+ const size = k ? ` width="${Math.max(1, Math.round(x.w * k))}" height="${Math.max(1, Math.round(x.h * k))}"` : '';
278
+ return `<button type="button" class="chat-shot" data-shot="${esc(shot(x.ref))}" title="${esc(tr('Open the picture'))}"><img src="${esc(shot(x.ref))}" alt=""${size} decoding="async"></button>`;
279
+ }).join('')}</div>` : '';
280
+ const bubble = words || unseen > 0 ? `<div class="chat-bubble">${esc(words || '')}${unseen > 0 ? `<span class="chat-img">${ic('attach', 11)} ${unseen}</span>` : ''}</div>` : '';
281
+ return `<div class="chat-user${cls}${pics ? ' has-shots' : ''}">${pics}${bubble}</div>`;
282
+ }
268
283
  export function turnHTML(t, { opened = new Set(), working = false, shot = null, pic = null, asking = false } = {}) {
269
284
  let out = '';
270
285
  if (t.compact) out += `<div class="chat-sep">${esc(tr('The conversation was compacted'))}</div>`;
271
- if (t.user || t.images) {
272
- const shots = shot && t.shots?.length ? t.shots : [];
273
- // as many «[Image #N]» as pictures shown leave the words — the rest stay with the «📎» that counts them
274
- let strip = shots.length;
275
- const words = strip ? String(t.user || '').replace(/\[Image #\d+\]\s*/g, (x) => (strip-- > 0 ? '' : x)).trim() : t.user;
276
- const unseen = (t.images || 0) - shots.length; // a picture the board cannot show is still counted
277
- const pics = shots.length ? `<div class="chat-shots">${shots.map((x) => {
278
- // the place is kept before the picture decodes: a thumbnail growing late pushed the turn being read
279
- const k = x.w > 0 && x.h > 0 ? Math.min(1, SHOT_W / x.w, SHOT_H / x.h) : 0;
280
- const size = k ? ` width="${Math.max(1, Math.round(x.w * k))}" height="${Math.max(1, Math.round(x.h * k))}"` : '';
281
- return `<button type="button" class="chat-shot" data-shot="${esc(shot(x.ref))}" title="${esc(tr('Open the picture'))}"><img src="${esc(shot(x.ref))}" alt=""${size} decoding="async"></button>`;
282
- }).join('')}</div>` : '';
283
- const bubble = words || unseen > 0 ? `<div class="chat-bubble">${esc(words || '')}${unseen > 0 ? `<span class="chat-img">${ic('attach', 11)} ${unseen}</span>` : ''}</div>` : '';
284
- out += `<div class="chat-user${pics ? ' has-shots' : ''}">${pics}${bubble}</div>`;
285
- }
286
+ if (t.user || t.images) out += wordsHTML(t.user, t.images, t.shots, shot);
286
287
  const worked = t.ms ? `${esc(tr('Worked for'))} ${esc(dur(t.ms) || `${Math.round(t.ms / 1000)} ${tr('s')}`)}` : esc(tr('Done'));
287
288
  if (!barred(t)) {
288
289
  // a step waiting on a question or a permission is not run — Claude waits for the person (`asking`)
289
- out += itemsHTML(t.items, t.at, { opened, working, live: working && Boolean(t.open) && !asking, pic });
290
+ out += itemsHTML(t.items, t.at, { opened, working, live: working && Boolean(t.open) && !asking, pic, shot });
290
291
  return `<div class="chat-turn" data-at="${esc(t.at)}">${out}</div>`;
291
292
  }
292
293
  // background) folded all its text behind the bar, and the words on the screen went at the end of the turn
@@ -303,11 +304,11 @@ export function turnHTML(t, { opened = new Set(), working = false, shot = null,
303
304
  .filter(Boolean).map((x) => `<span class="chat-chip">${x}</span>`).join('');
304
305
  out += `<button type="button" class="chat-done${open ? ' open' : ''}" data-fold="${esc(id)}" aria-expanded="${open}">${ic('chevron', 11)}${ic('check', 11)}<span class="chat-done-t">${worked}</span>`
305
306
  + `<span class="chat-chips">${failed ? `<span class="chat-chip chat-x">✗ ${failed}</span>` : ''}${chips}</span></button>`;
306
- if (open) out += `<div class="chat-log-in chat-unfold" data-of="${esc(id)}">${itemsHTML(log, t.at, { opened, working, fold: false })}</div>`;
307
+ if (open) out += `<div class="chat-log-in chat-unfold" data-of="${esc(id)}">${itemsHTML(log, t.at, { opened, working, fold: false, shot })}</div>`;
307
308
  } else {
308
309
  out += `<div class="chat-worked">${ic('check', 11)} ${worked}</div>`;
309
310
  }
310
- out += t.items.filter(said).map(itemHTML).join('');
311
+ out += t.items.filter(said).map((it) => itemHTML(it, shot)).join('');
311
312
  if (files.length) out += filesHTML(files, t.at, opened);
312
313
  if (last) out += itemHTML(last);
313
314
  return `<div class="chat-turn" data-at="${esc(t.at)}">${out}</div>`;
package/public/js/core.js CHANGED
@@ -1,6 +1,7 @@
1
1
 
2
2
  import { createRetryQueue, resendDelay } from './unsaved.js';
3
3
  import { localStore, sessionStore } from './store.js';
4
+ import { readyShown, statusesShown } from './ready-rules.js';
4
5
 
5
6
  const ICONS = {
6
7
  grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
@@ -142,6 +143,7 @@ const I18N_RU = {
142
143
  'Port %s is taken by another program — the board did not open to the network. Free it or set another one in KB_SHARE_PORT.': 'Порт %s занят другой программой — доска в сеть не открылась. Освободи его или задай другой в KB_SHARE_PORT.',
143
144
  'Could not open the board to the network — the details are in Settings → Errors.': 'Не удалось открыть доску в сеть — подробности в «Настройки → Ошибки».',
144
145
  'You are a guest here': 'Ты здесь гость',
146
+ 'backups stay with the owner of the board: a backup holds its key': 'бэкапы остаются у владельца доски: в бэкапе лежит её ключ',
145
147
  'this board runs on another computer; its owner opens and closes it to the network': 'доска работает на другом компьютере; открывает и закрывает её для сети владелец',
146
148
  'The board stays on this computer: it has to be running, and switching off closes it to the network at once. After a restart of the board it is open again, with the same link.': 'Доска остаётся на этом компьютере: она должна работать, а выключатель сразу закрывает её для сети. После перезапуска доски вход снова открыт — по той же ссылке.',
147
149
  'On a Mac the system may ask whether node may accept incoming connections — allow it, or nobody gets in.': 'На маке система может спросить, разрешить ли node входящие подключения, — разреши, иначе никто не войдёт.',
@@ -349,6 +351,8 @@ const I18N_RU = {
349
351
  'The switch is off: the column goes once it is empty': 'Переключатель выключен: колонка уйдёт, когда опустеет',
350
352
  'Statuses': 'Статусы',
351
353
  'Wait for the word before shipping': 'Ждать команды перед выкаткой',
354
+ 'These projects say otherwise and do not wait:': 'Эти проекты решили иначе и не ждут:',
355
+ 'tasks waiting for your word to ship': 'задачи ждут твоей команды на выкатку',
352
356
  'a finished task stops in Ready to ship until you say to ship it; Ready to ship means it is not on main yet, Review that people have it': 'готовая задача останавливается в «Ждёт выкатки», пока ты не скажешь выкатывать; «Ждёт выкатки» — в main ещё нет, «Проверяю» — уже у людей',
353
357
  'A project can say otherwise in its settings.': 'Проект может решить иначе в своих настройках.',
354
358
  'Waits in the column:': 'В колонке ждут:',
@@ -1468,6 +1472,25 @@ const I18N_RU = {
1468
1472
  'Not saved: the board answered with an error — the details are in Settings → Errors.': 'Не сохранено: доска ответила ошибкой — подробности в «Настройках → Ошибки».',
1469
1473
  'Not saved: the task has to go through work first — the board showed it in an old column.': 'Не сохранено: задачу сначала нужно провести через работу — доска показывала её в старой колонке.',
1470
1474
  'Not saved: the board did not accept it.': 'Не сохранено: доска это не приняла.',
1475
+ 'Not saved: noclaude and check are not put on a task in work — this project ships on your word.': 'Не сохранено: noclaude и check не ставятся на задачу в работе — этот проект выкатывается по твоей команде.',
1476
+ 'noclaude and check are not put on a task in work — this project ships on your word': 'noclaude и check не ставятся на задачу в работе — этот проект выкатывается по твоей команде',
1477
+ 'Not saved: the task goes to Ready to ship first — this project ships on your word.': 'Не сохранено: задача сначала идёт в «Ждёт выкатки» — этот проект выкатывается по твоей команде.',
1478
+ 'the task goes to Ready to ship first — this project ships on your word': 'задача сначала идёт в «Ждёт выкатки» — этот проект выкатывается по твоей команде',
1479
+ 'Not saved: only the owner of the board can do this.': 'Не сохранено: это может только владелец доски.',
1480
+ 'only the owner of the board can do this': 'это может только владелец доски',
1481
+ 'a guest starts Claude only inside the board': 'гость запускает Claude только внутри доски',
1482
+ 'tmux did not answer in time — try again in a few seconds': 'tmux не ответил вовремя — повтори через несколько секунд',
1483
+ 'the project has no CLAUDE.md of its own any more': 'у проекта больше нет своего CLAUDE.md',
1484
+ 'Fix': 'Починить',
1485
+ 'Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project': 'Завести задачу «Ужать CLAUDE.md» и сразу запустить на ней Claude — только пока в папке проекта никто не работает',
1486
+ 'Claude works in the folder of this project — fix its CLAUDE.md once that work is over.': 'В папке этого проекта сейчас работает Claude — почини его CLAUDE.md, когда эта работа закончится.',
1487
+ 'Tasks in work': 'Задачи в работе',
1488
+ 'Claude sessions at work there': 'Сессий Claude работает там',
1489
+ 'The last fix of this CLAUDE.md waits for your review': 'Прошлая починка этого CLAUDE.md ждёт твоей проверки',
1490
+ 'Could not open the task': 'Не удалось завести задачу',
1491
+ 'this board has no such label': 'на этой доске такой метки нет',
1492
+ 'Not saved: this device no longer has the key of the board, it may have been changed. Copy what you typed, then reload the page and enter the key.': 'Не сохранено: у этого устройства больше нет ключа доски, возможно, его сменили. Скопируй набранное, обнови страницу и введи ключ.',
1493
+ 'this device no longer has the key of the board, it may have been changed — copy what you typed, then reload the page and enter the key': 'у этого устройства больше нет ключа доски, возможно, его сменили — скопируй набранное, обнови страницу и введи ключ',
1471
1494
  'The task is not created': 'Задача не создана',
1472
1495
  'The task is not created yet: there is no connection to the board. It is created by itself once the connection is back.': 'Задача ещё не создана: нет связи с доской. Она создастся сама, когда связь вернётся.',
1473
1496
  'The task is not created yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.': 'Задача ещё не создана: доска ответила ошибкой — подробности в «Настройках → Ошибки». Следующая правка попробует снова — или нажми «не сохранено» вверху.',
@@ -1625,6 +1648,7 @@ const I18N_RU = {
1625
1648
  'Its terminal, its steps and its questions are right in the task.': 'Его терминал, шаги и вопросы — прямо в задаче.',
1626
1649
  'Done is your word': '«Готово» — твоё слово',
1627
1650
  'Claude stops at Review: you accept the work or send it back.': 'Claude останавливается на «Проверяю»: ты принимаешь работу или возвращаешь её.',
1651
+ 'Claude stops at Ready to ship until you say to ship it, then at Review: you accept the work or send it back.': 'Claude останавливается на «Ждёт выкатки» до твоей команды, потом на «Проверяю»: ты принимаешь работу или возвращаешь её.',
1628
1652
  'Open issue': 'Открыта',
1629
1653
  'adds this': 'добавит это',
1630
1654
  'hides this': 'спрячет это',
@@ -1725,6 +1749,8 @@ const I18N_RU = {
1725
1749
  'Automations are switched off in Settings': 'Автоматизации выключены в Настройках',
1726
1750
  'the project has no folder on this computer': 'у проекта нет папки на этом компьютере',
1727
1751
  'tmux is not installed here': 'здесь не установлен tmux',
1752
+ 'a Claude of the board already works on these tasks': 'над этими задачами уже работает Claude доски',
1753
+ 'tmux did not answer in time': 'tmux не ответил вовремя',
1728
1754
  'Claude Code is not installed here': 'здесь не установлен Claude Code',
1729
1755
  "the prompt is too long for the board's terminal": 'задание слишком длинное для терминала доски',
1730
1756
  'the project already has an open check': 'в проекте уже открыта проверка',
@@ -1851,11 +1877,8 @@ export const ALL_STATUSES = [
1851
1877
  ['done', tr('Done')],
1852
1878
  ['cancelled', tr('Cancelled')],
1853
1879
  ];
1854
- export function readyInUse(slug) {
1855
- const on = (p) => Boolean(p.ship_gate || p.c_ready);
1856
- return slug === ALL ? state.projects.some(on) : on(state.projects.find((p) => p.slug === slug) || {});
1857
- }
1858
- export const statusesFor = (slug, current) => ALL_STATUSES.filter(([v]) => v !== 'ready' || v === current || readyInUse(slug));
1880
+ export const readyInUse = (slug) => readyShown(state.projects, slug, ALL);
1881
+ export const statusesFor = (slug, current) => statusesShown(ALL_STATUSES, current, readyInUse(slug));
1859
1882
  export const PRI_ICON = { 1: ['↓', 'p1', tr('low')], 2: ['=', 'p2', tr('medium')], 3: ['↑', 'p3', tr('high')] };
1860
1883
  export const PRI_LEVELS = [[3, tr('↑ High')], [2, tr('= Medium')], [1, tr('↓ Low')], [0, tr('No priority')]];
1861
1884
  export let LABEL_COLORS = {};