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 +20 -0
- package/bin/kb +16 -2
- package/package.json +1 -1
- package/public/js/board.js +30 -8
- package/public/js/chat.js +23 -22
- package/public/js/core.js +9 -0
- package/public/js/drawer.js +1 -0
- package/public/js/health.js +41 -2
- package/public/js/now-terms.js +1 -1
- package/public/js/project.js +1 -1
- package/public/js/review.js +1 -1
- package/public/js/sse.js +1 -1
- package/public/js/unsaved.js +2 -0
- package/public/style.css +2 -1
- package/src/auto-health.js +61 -3
- package/src/auto-resume.js +39 -9
- package/src/automations.js +13 -1
- package/src/bin-path.js +26 -0
- package/src/chat-feed.js +26 -17
- package/src/claude-live.js +9 -5
- package/src/launch.js +2 -8
- package/src/own-wait.js +5 -6
- package/src/phantom.js +13 -10
- package/src/repo-base.js +2 -1
- package/src/routes/chat.js +11 -13
- package/src/routes/dashboard.js +1 -1
- package/src/routes/health.js +8 -0
- package/src/routes/projects.js +18 -6
- package/src/routes/system.js +7 -6
- package/src/routes/tasks.js +36 -31
- package/src/routes/terminal.js +4 -1
- package/src/shipped.js +12 -1
- package/src/tree-print.js +2 -1
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
|
|
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
|
|
622
|
+
await postNote(id, body);
|
|
609
623
|
console.log(`${id} 📝`);
|
|
610
624
|
},
|
|
611
625
|
|
package/package.json
CHANGED
package/public/js/board.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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' ?
|
|
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': 'у этого устройства больше нет ключа доски, возможно, его сменили — скопируй набранное, обнови страницу и введи ключ',
|
package/public/js/drawer.js
CHANGED
|
@@ -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
|
package/public/js/health.js
CHANGED
|
@@ -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;
|
package/public/js/now-terms.js
CHANGED
|
@@ -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;
|
package/public/js/project.js
CHANGED
|
@@ -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>` : ''}
|
package/public/js/review.js
CHANGED
|
@@ -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();
|
package/public/js/unsaved.js
CHANGED
|
@@ -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; }
|
package/src/auto-health.js
CHANGED
|
@@ -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 предупреждает
|
|
13
|
-
en: (file, chars) => `${file}: ${fmt(chars)} characters, the norm is ${fmt(NORMS.file_chars)}. Claude Code warns about
|
|
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
|
+
}
|