local-kanban 2.0.0-beta.3 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,26 @@ 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
+
11
31
  ## [2.0.0-beta.3]
12
32
 
13
33
  ### Fixed
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
 
@@ -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);
@@ -470,7 +484,7 @@ const commands = {
470
484
  const t = await api('PATCH', `/api/tasks/${key(id)}`, { status: 'review' }, TASK);
471
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`); };
472
486
  process.on('exit', unsaved);
473
- await api('POST', `/api/tasks/${key(id)}/comments`, { body: note, author: 'claude' }, COMMENT);
487
+ await postNote(id, note);
474
488
  process.off('exit', unsaved);
475
489
  console.log(`${t.key} review 📝`);
476
490
  },
@@ -605,7 +619,7 @@ const commands = {
605
619
  if (!id || !body) die('kb note <key> "text"');
606
620
  // Nothing is read after the write: a read that failed reported a failure of a note already saved, and the retry
607
621
  // wrote a second one. So the key is printed as it was typed.
608
- await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude' }, COMMENT);
622
+ await postNote(id, body);
609
623
  console.log(`${id} 📝`);
610
624
  },
611
625
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0-beta.4",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -13,7 +13,7 @@ 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
18
  import { SHIP_TITLES, SHIP_WORDS, notOnMain, shipAsker, showsShip } from './shipped-rules.js';
19
19
  import { boardColumns } from './ready-rules.js';
@@ -586,10 +586,27 @@ function renderMultiPanel() {
586
586
  + `<button class="multi-clear" data-act="clear">${tr('Clear selection')}</button>`;
587
587
  box.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => multiAction(b.dataset.act, b); });
588
588
  }
589
- 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') {
590
603
  const tasks = selectedTaskList();
591
- 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
+ }
592
608
  await refresh();
609
+ sayRefusedMany(head, refused);
593
610
  }
594
611
  function renderMultiSub(title, optsHTML, onPick) {
595
612
  const box = $('drawer-multi');
@@ -608,7 +625,7 @@ export async function multiAction(act, btn) {
608
625
  }).join('');
609
626
  renderMultiSub(tr('Move to status'), opts, async (o) => {
610
627
  if (o.classList.contains('disabled')) return;
611
- await multiPatchEach(() => ({ status: o.dataset.v }));
628
+ await multiPatchEach(() => ({ status: o.dataset.v }), 'Not moved');
612
629
  renderMultiPanel();
613
630
  });
614
631
  return;
@@ -779,7 +796,7 @@ const LIVE_BADGE = {
779
796
  function liveBadge(t) {
780
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>`;
781
798
  const l = state.live[t.id];
782
- 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'
783
800
  : l.claude === 'idle' && l.broke ? (l.broke === 'retrying' ? 'brokeRetrying' : 'brokeStuck') : l.claude];
784
801
  if (!b) return '';
785
802
  const until = l.claude === 'limit' && l.resets ? ` · ${tr('until')} ${esc(l.resets)}` : '';
@@ -938,10 +955,12 @@ async function onDrop(e, status, body) {
938
955
  }
939
956
  dropPh.remove();
940
957
  try {
941
- await api('PATCH', `/api/tasks/${seg(id)}`, patch);
958
+ await api('PATCH', `/api/tasks/${seg(id)}`, patch, { quiet: true });
942
959
  } catch (e) {
943
960
  renderBoard();
944
- 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();
945
964
  return;
946
965
  }
947
966
  await refresh();
@@ -976,12 +995,15 @@ async function onDropGroup(e, status, body) {
976
995
 
977
996
  dropPh.remove();
978
997
  dropSends = true;
998
+ const failed = [];
979
999
  for (let i = 0; i < movable.length; i++) {
980
1000
  if (fixedOrder && movable[i].status === status) continue;
981
- 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 }); }
982
1003
  }
983
1004
  clearSelection();
984
1005
  await refresh();
1006
+ sayRefusedMany('Not moved', failed);
985
1007
  }
986
1008
 
987
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
@@ -1479,6 +1479,15 @@ const I18N_RU = {
1479
1479
  'Not saved: only the owner of the board can do this.': 'Не сохранено: это может только владелец доски.',
1480
1480
  'only the owner of the board can do this': 'это может только владелец доски',
1481
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': 'Не удалось завести задачу',
1482
1491
  'this board has no such label': 'на этой доске такой метки нет',
1483
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.': 'Не сохранено: у этого устройства больше нет ключа доски, возможно, его сменили. Скопируй набранное, обнови страницу и введи ключ.',
1484
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': 'у этого устройства больше нет ключа доски, возможно, его сменили — скопируй набранное, обнови страницу и введи ключ',
@@ -601,6 +601,7 @@ async function pollTerminal() {
601
601
  let r;
602
602
  try { r = await api('GET', `/api/tasks/${seg(key)}/terminal${qs ? `?${qs}` : ''}`, undefined, { quiet: true }); } catch { return; }
603
603
  if (n !== termReq || key !== termKey || key !== state.drawerKey) return;
604
+ if (r.unknown) return;
604
605
  if (!r.same) {
605
606
  r = wholeAnswer(r, termBase, termSeen);
606
607
  if (!r) { termSeen = null; return; } // not against what this card has: the next look gets it whole
@@ -2,7 +2,10 @@
2
2
  import { $, HEALTH, LANG, api, esc, ic, state, tr } from './core.js';
3
3
  import { keepFocus } from './now.js';
4
4
  import { plural } from './settings.js';
5
- import { selectProject } from './sidebar.js';
5
+ import { selectProject, styledAlert, styledConfirm } from './sidebar.js';
6
+ import { refusalLine } from './unsaved.js';
7
+ import { openDrawer } from './drawer.js';
8
+ import { launchInClaude, taskPromptText } from './sse.js';
6
9
  import { fileState, healthCounts, healthGroups, startTotal } from './health-rules.js';
7
10
 
8
11
  const FRESH_MS = 30000;
@@ -48,13 +51,16 @@ function projectHTML(p, d) {
48
51
  : `<span class="hl-size muted">${esc(tr('no CLAUDE.md of its own'))}</span>`;
49
52
  const start = `<span class="hl-start${total.over ? ' hl-heavy' : ''}" title="${esc(tr('Everything a session reads when it starts in this folder, with your own files'))}">`
50
53
  + `${esc(tr('at the start'))}: ${esc(num(total.chars))} ${esc(plural(total.chars, 'character', 'characters'))}</span>`;
54
+ const fix = st === 'heavy' || st === 'long'
55
+ ? `<button type="button" class="btn-ghost hl-fix" data-slug="${esc(p.slug)}" title="${esc(tr('Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project'))}">${esc(tr('Fix'))}</button>`
56
+ : '';
51
57
  const files = [...p.files, ...p.nested];
52
58
  const body = files.length
53
59
  ? files.map((f) => fileHTML(f, norms)).join('')
54
60
  : `<div class="hl-file muted">${esc(tr('Claude Code reads nothing of this project at the start but your own files.'))}</div>`;
55
61
  return `<details class="hl-proj" data-slug="${esc(p.slug)}"${opened.has(p.slug) ? ' open' : ''}>`
56
62
  + `<summary class="hl-row">${ic('chevron', 13)}<span class="hl-main"><a class="hl-name" href="#" data-slug="${esc(p.slug)}">${esc(p.name)}</a>`
57
- + `<span class="hl-what">${what}</span></span>${start}</summary>`
63
+ + `<span class="hl-what">${what}</span></span>${start}${fix}</summary>`
58
64
  + `<div class="hl-files">${body}</div></details>`;
59
65
  }
60
66
 
@@ -109,6 +115,39 @@ function paint(d) {
109
115
  a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); selectProject(a.dataset.slug); };
110
116
  });
111
117
  box.querySelector('.hl-again').onclick = (e) => { e.currentTarget.disabled = true; renderHealth({ force: true }); };
118
+ box.querySelectorAll('.hl-fix').forEach((b) => {
119
+ b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fixProject(b.dataset.slug, b); }; // a click in <summary> folds it
120
+ });
121
+ }
122
+
123
+ const fixing = new Set(); // a second press while the first one opens and starts the task
124
+ /** The words of a refusal «Claude works there»: what holds the folder. */
125
+ export function busyWords(b) {
126
+ const parts = [tr('Claude works in the folder of this project — fix its CLAUDE.md once that work is over.')];
127
+ if (b.keys?.length) parts.push(`${tr('Tasks in work')}: ${b.keys.join(', ')}.`);
128
+ if (b.sessions) parts.push(`${tr('Claude sessions at work there')}: ${b.sessions}.`);
129
+ return parts.join(' ');
130
+ }
131
+ async function fixProject(slug, btn) {
132
+ if (!slug || fixing.has(slug)) return;
133
+ fixing.add(slug);
134
+ btn.disabled = true;
135
+ try {
136
+ let r;
137
+ try { r = await api('POST', '/api/health/fix', { project: slug }, { quiet: true }); } catch (e) {
138
+ const b = e.body || {};
139
+ if (b.reason === 'busy') styledAlert(busyWords(b));
140
+ else if (b.reason === 'review' && b.key) {
141
+ styledConfirm(`${tr('The last fix of this CLAUDE.md waits for your review')}: ${b.key}.`, { okLabel: `${tr('Open')} ${b.key}` })
142
+ .then((ok) => { if (ok) openDrawer(b.key); });
143
+ } else styledAlert(refusalLine('Could not open the task', e, tr));
144
+ return;
145
+ }
146
+ await launchInClaude({ text: taskPromptText(r.key), project: slug, keys: [r.key], btn });
147
+ } finally {
148
+ fixing.delete(slug);
149
+ btn.disabled = false;
150
+ }
112
151
  }
113
152
 
114
153
  let seq = 0;
@@ -208,7 +208,7 @@ async function look(c) {
208
208
  let r;
209
209
  try { r = await api('GET', `/api/tasks/${seg(c.key)}/terminal?${q}`, undefined, { quiet: true }); } catch { return; }
210
210
  if (n2 !== c.req) return; // a newer look has answered already
211
- if (r.same) return;
211
+ if (r.same || r.unknown) return;
212
212
  r = wholeAnswer(r, c.base, c.seen);
213
213
  if (!r) { c.seen = null; return; } // not against what this column has: the next look gets it whole
214
214
  c.seen = r.v || null;
@@ -227,7 +227,7 @@ async function renderDocs(p) {
227
227
  const row = (opts) => `<div class="ps-doc${opts.missing ? ' ps-doc-missing' : ''}">
228
228
  <span class="ps-doc-ic">${ic(opts.icon, 13)}</span>
229
229
  <span class="ps-doc-name">${esc(opts.title)}${opts.tag ? `<span class="ps-doc-tag">${esc(opts.tag)}</span>` : ''}</span>
230
- <span class="ps-doc-path" title="${esc(opts.path || '')}">${esc(opts.path || tr('not there'))}</span>
230
+ <span class="ps-doc-path" title="${esc(opts.path || '')}">${esc(opts.path || (info.paths_hidden ? '' : tr('not there')))}</span>
231
231
  <span class="ps-doc-act">
232
232
  ${opts.missing ? '' : `<button class="btn-icon ps-doc-open" data-open="${esc(opts.open)}" title="${tr('Open')}">${ic('eye', 13)}</button>`}
233
233
  ${opts.path ? `<button class="btn-icon ps-doc-copy" data-copy="${esc(opts.path)}" title="${tr('Copy the path')}">${ic('copy', 13)}</button>` : ''}
@@ -147,7 +147,7 @@ export async function renderReview() {
147
147
  await Promise.all(groups.filter((g) => g.running).map(async (g) => {
148
148
  try {
149
149
  const t = await api('GET', `/api/tasks/${encodeURIComponent(g.running.key)}/terminal`, undefined, { quiet: true });
150
- g.terminal = { session: t.session || null, dead: Boolean(t.dead), question: Boolean(t.question) };
150
+ if (!t.unknown) g.terminal = { session: t.session || null, dead: Boolean(t.dead), question: Boolean(t.question) };
151
151
  } catch { /* no answer: the column says what it can */ }
152
152
  }));
153
153
  if (state.slug !== REVIEW || my !== seq) return;
package/public/js/sse.js CHANGED
@@ -257,7 +257,7 @@ function openTaskFromNotification(key, project) {
257
257
  function maybeWaitingAlert(changes) {
258
258
  // it tells its Claude to go on (`retrying`): that rang for a blink of the network the person need not see
259
259
  const waiting = (changes || []).filter((c) => (c.claude === 'idle' && c.broke === 'stuck' && c.fromBroke !== 'stuck')
260
- || (!c.broke && (c.from === 'busy' || c.from === 'background') && (c.claude === 'waiting' || c.claude === 'idle'))
260
+ || (!c.broke && c.status !== 'ready' && (c.from === 'busy' || c.from === 'background') && (c.claude === 'waiting' || c.claude === 'idle'))
261
261
  || (c.claude === 'limit' && c.from !== 'limit'));
262
262
  if (!waiting.length) return new Set();
263
263
  playReviewSound();
@@ -234,6 +234,8 @@ const KNOWN = [
234
234
  [/^the project has no folder on this computer — the command would run/, 'the project has no folder on this computer — set it in the project, or copy the command'],
235
235
  [/^the terminal did not open/, 'the terminal did not open — copy the command instead; the details are in Settings → Errors'],
236
236
  [/^a guest starts Claude inside the board only$/, 'a guest starts Claude only inside the board'],
237
+ [/^the project has no CLAUDE\.md of its own$/, 'the project has no CLAUDE.md of its own any more'],
238
+ [/^tmux did not answer in time — try again$/, 'tmux did not answer in time — try again in a few seconds'],
237
239
  [/^unknown labels: /, 'this board has no such label'],
238
240
  ];
239
241
  export function refusalText(e) {
package/public/style.css CHANGED
@@ -1081,7 +1081,7 @@ html[data-design="v3"] .drawer.drawer-full { padding-inline: max(18px, calc((100
1081
1081
  box-shadow: 0 16px 48px rgba(0,0,0,.18); padding: 16px; width: 320px;
1082
1082
  display: flex; flex-direction: column; gap: 10px;
1083
1083
  }
1084
- .modal-title { font-weight: 600; font-size: 14px; text-wrap: pretty; }
1084
+ .modal-title { font-weight: 600; font-size: 14px; text-wrap: pretty; white-space: pre-line; }
1085
1085
  .modal-input {
1086
1086
  padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px;
1087
1087
  font: inherit; font-size: 13px; outline: none; background: var(--input-bg); color: var(--text);
@@ -2782,6 +2782,7 @@ a.hl-name:hover { color: var(--accent); }
2782
2782
  .hl-heavy, .hl-state.hl-heavy { color: var(--accent); }
2783
2783
  .hl-start { flex: none; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
2784
2784
  .hl-start.hl-heavy { color: var(--accent); }
2785
+ .hl-fix { flex: none; font-size: 12px; padding: 3px 10px; }
2785
2786
  .hl-files { display: flex; flex-direction: column; gap: 6px; padding: 0 0 12px 23px; }
2786
2787
  .hl-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1px 12px; font-size: 12px; line-height: 1.4; }
2787
2788
  .hl-file-path { font-family: ui-monospace, monospace; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1,16 +1,22 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { relative, isAbsolute } from 'node:path';
1
3
  import { PORT } from './config.js';
2
4
  import { db, kvGet, kvSet, logError, uiLang } from './db.js';
3
5
  import { NORMS, healthView } from './health.js';
6
+ import { readRegistry, stateOf } from './claude-live.js';
7
+ import { noclaudeFilter } from './task-shape.js';
4
8
 
5
9
  export const HEALTH_SWITCH = 'automations.health';
6
10
  const LAST_KEY = 'automations.health.last';
7
11
  export const HEALTH_EVERY_MS = 24 * 3600_000;
8
12
  const own = { host: `127.0.0.1:${PORT}` };
9
13
  const TITLES = { ru: 'Ужать CLAUDE.md', en: 'Shrink CLAUDE.md' };
14
+ export const HEALTH_DOCS = 'https://code.claude.com/docs/en/memory';
10
15
  const fmt = (n) => n.toLocaleString('ru-RU');
11
16
  const WORDS = {
12
- ru: (file, chars) => `${file}: ${fmt(chars)} знаков при норме ${fmt(NORMS.file_chars)}. Claude Code предупреждает об этом на старте и хуже следует таким инструкциям. Подробности разнести по правилам .claude/rules/<тема>.md с paths:, в файле оставить общее.\n\nЗадачу завела доска: раздел «Здоровье», автоматизация.`,
13
- en: (file, chars) => `${file}: ${fmt(chars)} characters, the norm is ${fmt(NORMS.file_chars)}. Claude Code warns about it at the start and follows such instructions worse. Move the details to rules .claude/rules/<topic>.md with paths:, and keep what is common in the file.\n\nThe board opened this task: the Health section, an automation.`,
17
+ ru: (file, chars, lines, by = 'автоматизация') => `${file}: ${fmt(chars)} знаков и ${fmt(lines)} строк при норме ${fmt(NORMS.file_chars)} знаков и ${fmt(NORMS.lines)} строк. Claude Code предупреждает о большом файле на старте и хуже следует длинным инструкциям.\n\nОптимизировать по документации Claude Code: ${HEALTH_DOCS}\n- в файле оставить общее для любой работы в проекте;\n- подробности по темам — в правила .claude/rules/<тема>.md с paths: (грузятся, только когда Claude работает с этими файлами) или в CLAUDE.md подпапки;\n- @-импорты место не экономят: файл грузится вместе с CLAUDE.md;\n- устаревшее переписать, историю починок не копить — она в git log.\n\nЗадачу завела доска: раздел «Здоровье», ${by}.`,
18
+ en: (file, chars, lines, by = 'an automation') => `${file}: ${fmt(chars)} characters and ${fmt(lines)} lines, the norm is ${fmt(NORMS.file_chars)} characters and ${fmt(NORMS.lines)} lines. Claude Code warns about a large file at the start and follows long instructions worse.\n\nOptimize it by the docs of Claude Code: ${HEALTH_DOCS}\n- keep in the file what any work in the project needs;\n- the details of a topic go to rules .claude/rules/<topic>.md with paths: (they load only when Claude works with those files) or to the CLAUDE.md of a subfolder;\n- @-imports save no room: the file loads together with the CLAUDE.md;\n- rewrite what is out of date, keep no history of fixes — it is in git log.\n\nThe board opened this task: the Health section, ${by}.`,
19
+ button: { ru: 'кнопка «Починить»', en: 'the button «Fix»' }, // who opened it, for the words above
14
20
  };
15
21
 
16
22
  /** The projects whose own CLAUDE.md is past the norm. Pure. */
@@ -30,7 +36,59 @@ export async function tickHealth(app, now) {
30
36
  const id = projects.find((x) => x.slug === p.slug)?.id;
31
37
  if (!id || seen.get(id, TITLES.ru, TITLES.en)) continue;
32
38
  const res = await app.inject({ method: 'POST', url: '/api/tasks', headers: own,
33
- payload: { project: p.slug, title: TITLES[lang], description: WORDS[lang](p.main.path, p.main.chars), status: 'backlog' } });
39
+ payload: { project: p.slug, title: TITLES[lang], description: WORDS[lang](p.main.path, p.main.chars, p.main.lines), status: 'backlog' } });
34
40
  if (res.statusCode >= 300) logError('server', 'automations', `health task of ${p.slug}: ${res.statusCode} ${res.body.slice(0, 200)}`);
35
41
  }
36
42
  }
43
+
44
+ const WORK = ['prep', 'doing', 'ready', 'deploy'];
45
+ const within = (folder, cwd) => {
46
+ const rel = relative(folder, cwd);
47
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
48
+ };
49
+ /** The sessions at work in `folder` among the registry's entries ({cwd, alive, status}): their count. Pure. */
50
+ export function claudesAt(folder, entries) {
51
+ let n = 0;
52
+ for (const e of entries) if (e.cwd && within(folder, e.cwd) && !['gone', 'idle'].includes(stateOf(e))) n += 1;
53
+ return n;
54
+ }
55
+ const real = async (p) => { try { return await realpath(p); } catch { return p; } };
56
+
57
+ /** What holds a fix of the project's CLAUDE.md now: {sessions, keys} — nothing held when both are empty. */
58
+ export async function fixHolds(project, registry = readRegistry) {
59
+ const folder = await real(project.path);
60
+ const entries = await Promise.all([...(await registry()).values()].map(async (e) => ({ ...e, cwd: e.cwd ? await real(e.cwd) : null })));
61
+ const keys = db.prepare(`SELECT p.prefix || '-' || t.task_no AS key FROM tasks t JOIN projects p ON p.id = t.project_id
62
+ WHERE t.project_id = ? AND t.status IN (${WORK.map(() => '?').join(', ')}) AND ${noclaudeFilter('t.')} ORDER BY t.task_no`)
63
+ .all(project.id, ...WORK).map((r) => r.key);
64
+ return { sessions: claudesAt(folder, entries), keys };
65
+ }
66
+
67
+ /** The task of «Fix» for a project: one still open of the same title (backlog → To do), else a new one in To do.
68
+ {key, created} or {code, error, reason, …} of a refusal. */
69
+ export async function healthFixTask(app, slug, registry) {
70
+ const p = db.prepare('SELECT id, slug, name, path, category, archived FROM projects WHERE slug = ?').get(String(slug || ''));
71
+ if (!p) return { code: 404, error: 'project not found' };
72
+ if (p.archived) return { code: 400, error: 'the project is archived' };
73
+ const view = await healthView([p]);
74
+ const h = view.projects[0];
75
+ if (!p.path || h.folder === null || h.missing) return { code: 409, reason: 'no-folder', error: 'the project has no folder on this computer — set it in the project' };
76
+ if (!h.main) return { code: 409, reason: 'no-claude-md', error: 'the project has no CLAUDE.md of its own' };
77
+ const held = await fixHolds(p, registry);
78
+ if (held.sessions || held.keys.length) return { code: 409, reason: 'busy', error: 'Claude works in the folder of this project — fix it once the work there is over', ...held };
79
+ const open = db.prepare(`SELECT p.prefix || '-' || t.task_no AS key, t.status FROM tasks t JOIN projects p ON p.id = t.project_id
80
+ WHERE t.project_id = ? AND t.title IN (?, ?) AND t.status NOT IN ('done', 'cancelled') ORDER BY t.id DESC LIMIT 1`).get(p.id, TITLES.ru, TITLES.en);
81
+ if (open?.status === 'review') return { code: 409, reason: 'review', key: open.key, error: 'the last fix of this CLAUDE.md waits for your review' };
82
+ if (open) {
83
+ if (open.status === 'backlog') {
84
+ const res = await app.inject({ method: 'PATCH', url: `/api/tasks/${encodeURIComponent(open.key)}`, headers: own, payload: { status: 'todo' } });
85
+ if (res.statusCode >= 300) return { code: res.statusCode, error: res.json?.().error || 'could not move the task to To do' };
86
+ }
87
+ return { key: open.key, created: false };
88
+ }
89
+ const lang = uiLang() === 'en' ? 'en' : 'ru';
90
+ const res = await app.inject({ method: 'POST', url: '/api/tasks', headers: own,
91
+ payload: { project: p.slug, title: TITLES[lang], description: WORDS[lang](h.main.path, h.main.chars, h.main.lines, WORDS.button[lang]), status: 'todo' } });
92
+ if (res.statusCode >= 300) return { code: res.statusCode, error: res.json().error || 'could not open the task' };
93
+ return { key: res.json().key, created: true };
94
+ }