local-kanban 2.0.0-beta.1 → 2.0.0-beta.2
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 +56 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/bin/kb +28 -18
- package/package.json +1 -1
- package/public/js/automations.js +143 -36
- package/public/js/away-rules.js +5 -0
- package/public/js/away.js +55 -0
- package/public/js/board.js +49 -11
- package/public/js/chat.js +62 -18
- package/public/js/core.js +104 -5
- package/public/js/drawer.js +71 -23
- package/public/js/glyphs.js +28 -7
- package/public/js/init.js +14 -4
- package/public/js/link-lines.js +48 -0
- package/public/js/live-link.js +1 -1
- package/public/js/now-terms.js +10 -2
- package/public/js/project.js +12 -2
- package/public/js/review-rules.js +5 -2
- package/public/js/settings.js +23 -4
- package/public/js/share.js +18 -3
- package/public/js/shipped-rules.js +11 -0
- package/public/js/sidebar.js +14 -1
- package/public/js/sse.js +3 -3
- package/public/js/term-delta.js +52 -0
- package/public/js/term-input-rules.js +2 -1
- package/public/js/unsaved.js +7 -2
- package/public/style.css +46 -4
- package/skills/kanban/SKILL.md +6 -2
- package/src/auto-health.js +36 -0
- package/src/auto-resume.js +183 -0
- package/src/automations.js +293 -26
- package/src/chat-feed.js +73 -25
- package/src/claude-asks.js +2 -2
- package/src/claude-now.js +6 -6
- package/src/claude-sessions.js +10 -6
- package/src/db.js +44 -1
- package/src/own-wait.js +14 -6
- package/src/phantom.js +8 -4
- package/src/ready-gate.js +11 -0
- package/src/routes/automations.js +19 -1
- package/src/routes/chat.js +17 -2
- package/src/routes/dashboard.js +4 -4
- package/src/routes/projects.js +20 -5
- package/src/routes/share.js +6 -1
- package/src/routes/system.js +30 -4
- package/src/routes/tasks.js +20 -9
- package/src/routes/terminal.js +16 -2
- package/src/schema.sql +13 -4
- package/src/share.js +123 -10
- package/src/shipped.js +117 -0
- package/src/sync/github.js +1 -0
- package/src/work-feed.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,62 @@ 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.2]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- A status «Ready to ship» between Doing and Deploying, off by default: with the switch in Settings → Statuses a finished task waits for your word, and the board itself refuses Deploying past it, and Doing straight to Review. A project can say otherwise in its settings.
|
|
16
|
+
- A mark on the cards of Ready to ship and Review: on main, only in a branch or no commits found, and a filter of Review for what is not on main.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- The chat shows helpers and workflows at work: those started without the background flag, those brought back after a stop, and those that woke after finishing.
|
|
21
|
+
- The terminal sends what changed on the screen instead of the whole screen every second: far less traffic on a phone.
|
|
22
|
+
- The symbol background holds no video memory while it is off, and resizing the window no longer piles up canvases.
|
|
23
|
+
- Scrolling a column with many link lines no longer rewrites all of them on every frame.
|
|
24
|
+
- A comment whose answer got lost on the way is no longer written twice when the page sends it again.
|
|
25
|
+
- Automations: a restart of the board no longer loses the words that tell an interrupted Claude to go on, and a limit line left on the screen past its moment does not hold them.
|
|
26
|
+
- Automations: after a refused start, «Next batch in» counts down to the next try instead of standing at 0:00 for five minutes.
|
|
27
|
+
- `kb show A B` prints every card, a line of `---` between them; it used to print the first one and drop the rest with code 0.
|
|
28
|
+
- **Automations.** The fix of a returned task no longer waits hours for the terminals at their prompts; the grooming's Claude is
|
|
29
|
+
not closed while it asks you; one held task does not stop a batch; a blocked or `ask` task is not resumed.
|
|
30
|
+
- **Words kept.** A last word in a checklist item is not overwritten by a late resend, a failed save of the description no longer
|
|
31
|
+
claims someone else changed it, and words typed into a question that went away come back by themselves.
|
|
32
|
+
- **Phones and failures.** A phone on its side keeps the back gesture on the page, a phone says when the projects did not load,
|
|
33
|
+
a stream that never opened is announced, and a device stopped at the key page is listed in Turned away.
|
|
34
|
+
- **Sessions.** Only a move of the status takes a task over: a note or a field edited from another session does not.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Automations watch the size of CLAUDE.md.** A switch opens a task in a project's backlog when its CLAUDE.md is past the
|
|
39
|
+
norm of Health; you start it yourself, and a task you cancelled does not come back.
|
|
40
|
+
- **A summary when you come back.** With automations on, the first look after eight hours of silence shows what was handed
|
|
41
|
+
in, accepted, returned or got stuck, each line opening its card.
|
|
42
|
+
- **Automations take the queue and a schedule.** Once a marked project is finished its next batch of To do starts, at most 5
|
|
43
|
+
tasks and not over 80% of the limit; a check of Review or the grooming of the backlog can run at a set time. A run the
|
|
44
|
+
sleeping computer missed is skipped.
|
|
45
|
+
- **A repeating automation can fix what its check returns.** A returned task goes back to work in a new session and the
|
|
46
|
+
check comes after it, at most 2 rounds; then the task waits for you.
|
|
47
|
+
- **The board can go on with interrupted work by itself.** A switch in Automations: once the limit has reset or the Mac is
|
|
48
|
+
up, a session cut off mid-work goes on, one a minute, at most 3 times in a row, and never one that waits for you.
|
|
49
|
+
- **An automation can repeat, and one form covers several projects.** A repeating rule stays after its check and waits for
|
|
50
|
+
the next batch, at most 10 checks a day; tick any projects, or all of them, and each gets its rule. At most two checks
|
|
51
|
+
run on the board at once, the others wait for a place.
|
|
52
|
+
- **The board over Wi-Fi opens by a key again.** The QR code and the link carry it, and a browser without them is asked for
|
|
53
|
+
it, so a home-screen app or a private tab is never locked out: the 16 characters can be typed by hand, and the device
|
|
54
|
+
remembers. Settings → Wi-Fi shows the key and changes it. Everyone connected by the plain address needs the new link.
|
|
55
|
+
- **Markdown files open in a window.** A `.md` file Claude wrote or edited opens from the list of changed files in the
|
|
56
|
+
chat, rendered and readable at the width of a page, on a phone too.
|
|
57
|
+
- **A card opens with its chat closed.** It used to open on the chat and scroll to its end, taking the title and the
|
|
58
|
+
description out of sight; now the chat opens from its tab, from ▶ or from «Open in the card».
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **Words typed in a chat survive Claude moving to its next task.** In a column of Terminals the field emptied and the
|
|
63
|
+
chat blinked «Loading…» whenever the session took the next task of its batch; the words now stay and the chat stands still.
|
|
64
|
+
- **Claude's last words stay in sight when its turn ends on a step.** A turn that ended by sending helpers or a command to
|
|
65
|
+
the background folded all its text behind the «Worked for» bar.
|
|
66
|
+
|
|
11
67
|
## [2.0.0-beta.1]
|
|
12
68
|
|
|
13
69
|
**2.0 beta.** The board stopped being a list of tasks next to Claude and became the place you work with it: Claude starts
|
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ Claude on it — the report stays on the card of the check.
|
|
|
134
134
|
|
|
135
135
|
The server listens on **127.0.0.1 only** and has **no authentication**. The board is strictly local: your machine is the trust boundary. **Never expose the port to the network** (no reverse proxies, no `0.0.0.0`, no port forwarding). Third-party integrations should run on the same machine and talk to `127.0.0.1` (see [docs/API.md](docs/API.md)).
|
|
136
136
|
|
|
137
|
-
The one exception is your own switch: **Settings → Wi-Fi** opens a separate door on the local network (port + 10, `KB_SHARE_PORT`) for people you show its QR code to. Only the link
|
|
137
|
+
The one exception is your own switch: **Settings → Wi-Fi** opens a separate door on the local network (port + 10, `KB_SHARE_PORT`) for people you show its QR code to. Only its key lets anyone in: in the link of the QR code, or typed by hand when a browser has no link. With it a guest can do on the board everything you can — including talking to Claude on your computer. The connection is plain HTTP, so the key keeps people out but does not encrypt the traffic: switch the door off on a network of strangers. It is off by default; switching it off closes the door, a new key drops every guest.
|
|
138
138
|
|
|
139
139
|
**The board holds no credentials.** A project can name an SSH host to deploy to, but neither a key
|
|
140
140
|
nor a password is ever entered here: the board simply runs `ssh <host>`, and who you are, on which
|
package/README.ru.md
CHANGED
|
@@ -133,7 +133,7 @@ local-kanban start # доска на http://localhost:3100
|
|
|
133
133
|
|
|
134
134
|
Сервер слушает **только 127.0.0.1**, авторизации **нет**. Доска строго локальная: граница доверия — твоя машина. **Никогда не выставляй порт наружу** (никаких reverse proxy, `0.0.0.0`, проброса портов). Сторонние интеграции запускаются на той же машине и ходят на `127.0.0.1` (см. [docs/API.md](docs/API.md)).
|
|
135
135
|
|
|
136
|
-
Исключение одно — твой выключатель: **«Настройки → Wi-Fi»** открывает отдельный вход в локальной сети (порт + 10, `KB_SHARE_PORT`) для тех, кому ты покажешь QR-код. Пускает только
|
|
136
|
+
Исключение одно — твой выключатель: **«Настройки → Wi-Fi»** открывает отдельный вход в локальной сети (порт + 10, `KB_SHARE_PORT`) для тех, кому ты покажешь QR-код. Пускает только ключ: он в ссылке QR-кода, а в браузере без ссылки его можно ввести руками. С ним гость может на доске всё то же, что и ты, — в том числе говорить с Claude на твоём компьютере. Соединение идёт простым HTTP: ключ не пускает посторонних, но не шифрует трафик, поэтому в сети с чужими людьми вход выключай. По умолчанию он закрыт; выключатель закрывает вход, а новый ключ выкидывает всех гостей.
|
|
137
137
|
|
|
138
138
|
**Доска не хранит доступов.** У проекта можно указать ssh-хост, куда он выкатывается, но ни ключ, ни
|
|
139
139
|
пароль здесь не вводятся: доска просто запускает `ssh <хост>`, а кто ты, на каком порту и с каким
|
package/bin/kb
CHANGED
|
@@ -14,7 +14,7 @@ const PRI_BY_NAME = { none: 0, low: 1, med: 2, medium: 2, high: 3, p0: 0, p1: 1,
|
|
|
14
14
|
const ST_ALIAS = {
|
|
15
15
|
b: 'backlog', backlog: 'backlog', t: 'todo', todo: 'todo',
|
|
16
16
|
pp: 'prep', prep: 'prep', d: 'doing', doing: 'doing',
|
|
17
|
-
dp: 'deploy', deploy: 'deploy', r: 'review', review: 'review', dn: 'done', done: 'done',
|
|
17
|
+
rd: 'ready', ready: 'ready', dp: 'deploy', deploy: 'deploy', r: 'review', review: 'review', dn: 'done', done: 'done',
|
|
18
18
|
c: 'cancelled', cancelled: 'cancelled', cancel: 'cancelled',
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -409,7 +409,7 @@ const commands = {
|
|
|
409
409
|
const ps = await api('GET', '/api/projects', null, LIST);
|
|
410
410
|
if (!ps.length) return console.log('no projects');
|
|
411
411
|
for (const p of ps) {
|
|
412
|
-
console.log(`${p.slug} (${p.prefix}) b${p.c_backlog || 0} t${p.c_todo || 0} pp${p.c_prep || 0} d${p.c_doing || 0} dp${p.c_deploy || 0} r${p.c_review || 0} ✓${p.c_done || 0}${p.c_blocked ? ` !${p.c_blocked}` : ''}`);
|
|
412
|
+
console.log(`${p.slug} (${p.prefix}) b${p.c_backlog || 0} t${p.c_todo || 0} pp${p.c_prep || 0} d${p.c_doing || 0}${p.c_ready ? ` rd${p.c_ready}` : ''} dp${p.c_deploy || 0} r${p.c_review || 0} ✓${p.c_done || 0}${p.c_blocked ? ` !${p.c_blocked}` : ''}`);
|
|
413
413
|
}
|
|
414
414
|
},
|
|
415
415
|
|
|
@@ -471,21 +471,30 @@ const commands = {
|
|
|
471
471
|
console.log(`${t.key} review 📝`);
|
|
472
472
|
},
|
|
473
473
|
|
|
474
|
+
// A batch is read in one go, so every key is printed, cards apart by a line of ---. A wrong key stops the
|
|
475
|
+
// command before the first request; a task the board does not know stops it there, and says at which key.
|
|
474
476
|
async show() {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
477
|
+
if (!args.length) die('kb show <key> [<key> …]');
|
|
478
|
+
const ids = args.map(key);
|
|
479
|
+
let at = 0;
|
|
480
|
+
process.on('exit', (code) => {
|
|
481
|
+
if (code && ids.length > 1) process.stderr.write(`kb: stopped at ${args[at]}: ${at} of ${ids.length} printed\n`);
|
|
482
|
+
});
|
|
483
|
+
for (; at < ids.length; at++) {
|
|
484
|
+
if (at) console.log('---');
|
|
485
|
+
const t = await api('GET', `/api/tasks/${ids[at]}`, null, TASK);
|
|
486
|
+
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
|
+
console.log(t.title);
|
|
488
|
+
if (t.description) console.log(t.description);
|
|
489
|
+
printChecklist(t);
|
|
490
|
+
printLinks(t);
|
|
491
|
+
printIssue(t);
|
|
492
|
+
printMoves(t);
|
|
493
|
+
printSessions(t);
|
|
494
|
+
printUsage(t);
|
|
495
|
+
printAttachments(t);
|
|
496
|
+
printComments(await api('GET', `/api/tasks/${t.id}/comments`, null, LIST), 5);
|
|
497
|
+
}
|
|
489
498
|
},
|
|
490
499
|
|
|
491
500
|
async add() {
|
|
@@ -506,7 +515,7 @@ const commands = {
|
|
|
506
515
|
}
|
|
507
516
|
if (si >= 0) {
|
|
508
517
|
body.status = ST_ALIAS[args[si + 1]];
|
|
509
|
-
if (!body.status) die('kb: status backlog|todo|prep|doing|deploy|review|done');
|
|
518
|
+
if (!body.status) die('kb: status backlog|todo|prep|doing|ready|deploy|review|done');
|
|
510
519
|
}
|
|
511
520
|
const t = await api('POST', '/api/tasks', body, TASK);
|
|
512
521
|
console.log(`${t.key} ${t.status} ${t.project}`);
|
|
@@ -515,7 +524,7 @@ const commands = {
|
|
|
515
524
|
async mv() {
|
|
516
525
|
const [id, st] = args;
|
|
517
526
|
const status = ST_ALIAS[st];
|
|
518
|
-
if (!id || !status) die('kb mv <key> backlog|todo|prep|doing|deploy|review');
|
|
527
|
+
if (!id || !status) die('kb mv <key> backlog|todo|prep|doing|ready|deploy|review');
|
|
519
528
|
const k = key(id);
|
|
520
529
|
// started by the board runs only after asking (src/phantom.js), while its `kb mv` goes without asking. Before
|
|
521
530
|
// this, `kb mv <key> done` sent the very request of `kb done` and skipped the question.
|
|
@@ -650,6 +659,7 @@ const commands = {
|
|
|
650
659
|
if (p.domain) out.push(`domain: ${p.domain}`);
|
|
651
660
|
// CLAUDE.md", "deployed by skill X", DNS facts) — info printed everything but it
|
|
652
661
|
if (p.description) out.push(`description: ${p.description}`);
|
|
662
|
+
if (p.ship_gate) out.push('ships on the word: after the commit kb mv <key> ready and wait; deploy only once the person says so');
|
|
653
663
|
// page; without this line Claude went looking by itself and sometimes missed. A missing
|
|
654
664
|
// CLAUDE.md is said out loud — it is a signal (the task starts without architecture notes).
|
|
655
665
|
if (p.path) {
|
package/package.json
CHANGED
package/public/js/automations.js
CHANGED
|
@@ -9,13 +9,14 @@ import { getSetting, openSettingsModal } from './settings.js';
|
|
|
9
9
|
import { renderSidebar, selectProject, styledAlert } from './sidebar.js';
|
|
10
10
|
import { refusalLine } from './unsaved.js';
|
|
11
11
|
|
|
12
|
+
const CHECK_KIND = 'finished-check';
|
|
13
|
+
const SCHEDULED = new Set(['schedule-check', 'schedule-groom']);
|
|
12
14
|
let auto = { on: false, rules: [] };
|
|
13
15
|
let skew = 0; // the server's clock minus ours: a guest's phone may run off the Mac's
|
|
14
16
|
/** The last view of the server: {on, rules}. */
|
|
15
17
|
export const automations = () => auto;
|
|
16
|
-
/** The armed rules (one per project at most). */
|
|
17
18
|
export const armedRules = () => (auto.on ? auto.rules.filter((r) => r.state === 'armed') : []);
|
|
18
|
-
const armedOf = (slug) => armedRules().find((r) => r.project === slug) || null;
|
|
19
|
+
const armedOf = (slug) => armedRules().find((r) => r.project === slug && r.kind === CHECK_KIND) || null; // what ▶ of Review waits for
|
|
19
20
|
|
|
20
21
|
/** Read the view from the server; quiet — the board works the same without it. */
|
|
21
22
|
export async function loadAutomations() {
|
|
@@ -26,7 +27,7 @@ export function applyAutomations(v) {
|
|
|
26
27
|
if (!v || !Array.isArray(v.rules)) return;
|
|
27
28
|
const sig = (x) => JSON.stringify([x.on, x.rules.filter((r) => r.state === 'armed').length]);
|
|
28
29
|
const sidebar = sig(v) !== sig(auto);
|
|
29
|
-
auto = { on: Boolean(v.on), rules: v.rules };
|
|
30
|
+
auto = { on: Boolean(v.on), rules: v.rules, extras: v.extras || {} };
|
|
30
31
|
if (typeof v.now === 'number') skew = v.now - Date.now();
|
|
31
32
|
if (sidebar) renderSidebar();
|
|
32
33
|
if (state.slug === AUTO && $('automations')) paint();
|
|
@@ -52,19 +53,26 @@ function tick() {
|
|
|
52
53
|
const keysOf = (r) => (r.keys || []).join(', ');
|
|
53
54
|
/** What an armed rule waits for, in the board's words. */
|
|
54
55
|
export function phaseLine(r) {
|
|
55
|
-
if (r.phase === 'ready' && r.due_at) return `${tr('Check in')} <span class="auto-due" data-due="${r.due_at}">${mmss(left(r.due_at))}</span>`;
|
|
56
|
+
if (r.phase === 'ready' && r.due_at) return `${tr(r.kind === 'todo-queue' ? 'Next batch in' : 'Check in')} <span class="auto-due" data-due="${r.due_at}">${mmss(left(r.due_at))}</span>`;
|
|
56
57
|
const say = {
|
|
57
58
|
idle: tr('Waits for Claude to take up tasks of the project'),
|
|
58
59
|
working: `${tr('Claude is at work:')} ${esc(keysOf(r))}`,
|
|
59
60
|
ready: tr('Claude has finished — the pause begins'),
|
|
61
|
+
planned: `${tr('Next run:')} ${esc(r.plan?.at || '')}`,
|
|
60
62
|
}[r.phase];
|
|
61
63
|
if (say) return say;
|
|
62
64
|
const held = {
|
|
65
|
+
'at-work': `${tr('Claude is at work:')} ${esc(keysOf(r))}`,
|
|
63
66
|
question: `${tr('Waits for you: a question of Claude in')} ${esc(keysOf(r))}`,
|
|
64
67
|
ask: `${tr('Waits for you: Claude asks in')} ${esc(keysOf(r))}`,
|
|
65
68
|
'sent-back': `${tr('Waits for you: back in To do —')} ${esc(keysOf(r))}`,
|
|
66
69
|
limit: tr('Waits for the subscription limit to reset'),
|
|
67
70
|
'check-open': `${tr('The project already has an open check')} ${esc(keysOf(r))}`,
|
|
71
|
+
'day-cap': `${tr('The checks of the day are used up:')} ${r.fires_max}`,
|
|
72
|
+
busy: tr('Waits for a place: enough checks run on the board now'),
|
|
73
|
+
'limit-high': tr('Waits: over 80% of the subscription limit is used'),
|
|
74
|
+
'queue-empty': tr('Nothing to take in To do'),
|
|
75
|
+
unfinished: `${tr('Waits: the work in the project was cut off —')} ${esc(keysOf(r))}`,
|
|
68
76
|
nothing: tr('Nothing in Review to check'),
|
|
69
77
|
off: tr('Automations are switched off in Settings'),
|
|
70
78
|
}[r.reason];
|
|
@@ -80,35 +88,106 @@ const FAIL_WORDS = {
|
|
|
80
88
|
'check-open': 'the project already has an open check',
|
|
81
89
|
manual: 'a task done by hand cannot be given to Claude',
|
|
82
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
|
+
};
|
|
83
95
|
function endedLine(r) {
|
|
84
96
|
const key = r.check_key ? `<button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
|
|
85
97
|
if (r.state === 'fired') return `${tr('Check started')} ${key} · ${hm(r.ended_at)}`;
|
|
86
98
|
if (r.state === 'covered') return `${tr('The check was started by hand:')} ${key} · ${hm(r.ended_at)}`;
|
|
87
99
|
if (r.state === 'cancelled') return `${tr('Taken off')} · ${hm(r.ended_at)}${/^archived:/.test(r.error || '') ? `: ${tr('the project was archived')}` : ''}`;
|
|
88
|
-
|
|
89
|
-
const why = reason && FAIL_WORDS[reason] ? tr(FAIL_WORDS[reason]) : esc(r.error || '');
|
|
90
|
-
return `${tr('Did not start')} · ${hm(r.ended_at)}: ${why}${key ? ` · ${tr('the check waits in To do:')} ${key}` : ''}`;
|
|
100
|
+
return `${tr('Did not start')} · ${hm(r.ended_at)}: ${failWhy(r)}${key ? ` · ${tr('the check waits in To do:')} ${key}` : ''}`;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
/* ── the section ── */
|
|
94
104
|
const projectName = (slug) => { const p = state.projects.find((x) => x.slug === slug); return p ? (p.name || p.slug) : slug; };
|
|
95
|
-
const
|
|
105
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
106
|
+
const whenWords = (plan) => `${plan?.day === null || plan?.day === undefined ? tr('Every day') : tr(DAYS[plan.day])}, ${esc(plan?.at || '')}`;
|
|
107
|
+
const ruleWhat = (r) => (r.kind === 'todo-queue' ? `${tr('When')} <b>${esc(r.project_name || projectName(r.project))}</b> ${tr('finishes, wait')} ${Math.round(r.delay_s / 60)} ${tr('min and start the next batch of To do')}`
|
|
108
|
+
: r.kind === 'schedule-check' ? `<b>${esc(r.project_name || projectName(r.project))}</b>: ${tr('a check of Review')} · ${whenWords(r.plan)}`
|
|
109
|
+
: r.kind === 'schedule-groom' ? `<b>${esc(r.project_name || projectName(r.project))}</b>: ${tr('the grooming of the backlog')} · ${whenWords(r.plan)}` : checkWhat(r));
|
|
110
|
+
const checkWhat = (r) => `${tr('When')} <b>${esc(r.project_name || projectName(r.project))}</b> ${tr('finishes, wait')} ${Math.round(r.delay_s / 60)} ${tr(r.repeat ? 'min and check Review, every time' : 'min and check Review')}${r.fix ? `, ${tr('fixing what comes back')}` : ''}`;
|
|
96
111
|
|
|
112
|
+
const KIND_NAMES = [
|
|
113
|
+
[CHECK_KIND, 'A check once Claude has finished'], ['todo-queue', 'The next batch of To do'],
|
|
114
|
+
['schedule-check', 'A check of Review on a schedule'], ['schedule-groom', 'The grooming of the backlog on a schedule'],
|
|
115
|
+
];
|
|
116
|
+
const KIND_HINTS = {
|
|
117
|
+
[CHECK_KIND]: 'Starts when every task Claude took in the project has reached Review and nothing waits for you. Like ▶ on the Review column — Claude inside the board, with the permissions and effort of Settings → Claude Code.',
|
|
118
|
+
'todo-queue': 'At most 5 tasks by priority, without ask and noclaude; it does not start while over 80% of the 5-hour or the weekly limit is used, nor over a question of Claude.',
|
|
119
|
+
'schedule-check': 'If the computer sleeps at that time the run is skipped until the next one. It does not start over work going on in the project.',
|
|
120
|
+
'schedule-groom': 'Claude prepares the backlog and moves no tasks. If the computer sleeps at that time the run is skipped until the next one.',
|
|
121
|
+
};
|
|
122
|
+
let formKind = CHECK_KIND;
|
|
123
|
+
let formDay = 'every';
|
|
97
124
|
function newRuleHTML() {
|
|
98
125
|
return '<div class="panel auto-new">'
|
|
99
126
|
+ `<div class="auto-h">${ic('bolt', 14)} ${tr('New automation')}</div>`
|
|
100
|
-
+
|
|
101
|
-
+ `<
|
|
127
|
+
+ `<div class="auto-sentence"><div class="kbsel auto-kind" id="auto-kind"></div></div>`
|
|
128
|
+
+ `<div class="auto-sentence" data-for="wait"><span>${tr('When the project finishes its work,')}</span>`
|
|
102
129
|
+ `<span>${tr('wait')}</span><input type="number" id="auto-min" class="auto-min" min="0" max="120" step="1" value="3" inputmode="numeric" aria-label="${esc(tr('minutes'))}"><span>${tr('min')}</span>`
|
|
103
|
-
+ `<span
|
|
104
|
-
+ `<div class="
|
|
130
|
+
+ `<span id="auto-then"></span></div>`
|
|
131
|
+
+ `<div class="auto-sentence" data-for="plan"><span>${tr('At')}</span><input type="time" id="auto-at" class="auto-min auto-at" value="03:00" aria-label="${esc(tr('Time'))}"><div class="kbsel auto-day" id="auto-day"></div></div>`
|
|
132
|
+
+ `<div class="auto-picks-h"><span>${tr('Projects')}</span><button type="button" class="auto-all" id="auto-all"></button></div>`
|
|
133
|
+
+ '<div class="auto-picks" id="auto-picks"></div>'
|
|
134
|
+
+ `<label class="set-row auto-repeat" data-for="check"><span class="set-lab">${tr('Repeat')}<small>${tr('after a check the rule waits for the next batch, until you take it off')}</small></span><input type="checkbox" class="set-toggle" id="auto-repeat" checked></label>`
|
|
135
|
+
+ `<label class="set-row auto-repeat" data-for="check" id="auto-fix-row"><span class="set-lab">${tr('Fix what the check returns')}<small>${tr('the board starts a returned task in a new session and the check comes after it, at most 2 rounds; then the task waits for you')}</small></span><input type="checkbox" class="set-toggle" id="auto-fix"></label>`
|
|
136
|
+
+ '<div class="muted auto-hint" id="auto-hint"></div>'
|
|
105
137
|
+ `<div class="auto-actions"><button type="button" class="btn-primary" id="auto-add">${tr('Add')}</button></div>`
|
|
106
138
|
+ '</div>';
|
|
107
139
|
}
|
|
140
|
+
/** The form for the kind of rule chosen. */
|
|
141
|
+
function paintKind() {
|
|
142
|
+
const shows = { wait: !SCHEDULED.has(formKind), plan: SCHEDULED.has(formKind), check: formKind === CHECK_KIND };
|
|
143
|
+
document.querySelectorAll('.auto-new [data-for]').forEach((el) => { el.classList.toggle('hidden', !shows[el.dataset.for]); });
|
|
144
|
+
$('auto-then').textContent = tr(formKind === CHECK_KIND ? 'and start a check of the tasks in Review' : 'and start the next batch of To do');
|
|
145
|
+
$('auto-hint').textContent = tr(KIND_HINTS[formKind]);
|
|
146
|
+
if (formKind === CHECK_KIND) $('auto-fix-row').classList.toggle('hidden', !$('auto-repeat').checked);
|
|
147
|
+
}
|
|
148
|
+
function wireForm() {
|
|
149
|
+
buildSelect($('auto-kind'), { value: formKind, options: KIND_NAMES.map(([value, label]) => ({ value, label: tr(label) })),
|
|
150
|
+
onChange: (v) => { formKind = v; freeSig = ''; paintKind(); paint(); } });
|
|
151
|
+
buildSelect($('auto-day'), { value: formDay, options: [{ value: 'every', label: tr('Every day') }, ...[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: tr(DAYS[d]) }))],
|
|
152
|
+
onChange: (v) => { formDay = v; } });
|
|
153
|
+
paintKind();
|
|
154
|
+
}
|
|
155
|
+
const EXTRAS = [
|
|
156
|
+
['resume', 'Go on with interrupted work', 'once the limit has reset or the Mac is up: all sessions, at most 3 times in a row, one a minute; never one that waits for you'],
|
|
157
|
+
['health', 'Watch the size of CLAUDE.md', 'a file past the norm of Health gets a task in the backlog of its project, once a day; you start it yourself'],
|
|
158
|
+
];
|
|
159
|
+
function extrasHTML() {
|
|
160
|
+
return `<div class="panel auto-extras"><div class="auto-h">${ic('bolt', 14)} ${tr('By itself')}</div>`
|
|
161
|
+
+ EXTRAS.map(([k, name, hint]) => `<label class="set-row auto-repeat"><span class="set-lab">${tr(name)}<small>${tr(hint)}</small></span><input type="checkbox" class="set-toggle" data-extra="${k}"></label>`).join('')
|
|
162
|
+
+ '</div>';
|
|
163
|
+
}
|
|
164
|
+
function paintExtras() {
|
|
165
|
+
document.querySelectorAll('[data-extra]').forEach((b) => { if (!b.disabled) b.checked = Boolean(auto.extras?.[b.dataset.extra]); });
|
|
166
|
+
}
|
|
167
|
+
function wireExtras() {
|
|
168
|
+
document.querySelectorAll('[data-extra]').forEach((b) => {
|
|
169
|
+
b.onchange = async () => {
|
|
170
|
+
b.disabled = true;
|
|
171
|
+
const launch = { permission: getSetting('permission'), effort: startEffort(false, getSetting('effort'), getSetting('effortCheck')) || undefined };
|
|
172
|
+
try { const v = await api('PUT', '/api/automations/extras', { [b.dataset.extra]: b.checked, launch }, { quiet: true }); b.disabled = false; applyAutomations(v); } catch (e) {
|
|
173
|
+
b.disabled = false;
|
|
174
|
+
b.checked = !b.checked;
|
|
175
|
+
styledAlert(refusalLine('Could not change the automation', e, tr));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function repeatLine(r) {
|
|
182
|
+
if (!r.repeat || !(r.fires_n || r.error)) return '';
|
|
183
|
+
const key = r.check_key ? ` · <button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
|
|
184
|
+
if (r.error) return `<div class="auto-phase">${tr('Did not start')}: ${failWhy(r)}${r.check_key && r.check_status === 'todo' ? ` · ${tr('the check waits in To do:')}${key.slice(2)}` : ''}</div>`;
|
|
185
|
+
return `<div class="auto-phase">${tr('Checks in the last day:')} ${r.fires_n} / ${r.fires_max}${key}</div>`;
|
|
186
|
+
}
|
|
108
187
|
function armedHTML(r) {
|
|
109
188
|
return `<div class="panel auto-rule" data-id="${r.id}">`
|
|
110
189
|
+ `<div class="auto-rule-main"><div class="auto-what">${ruleWhat(r)}</div>`
|
|
111
|
-
+ `<div class="auto-phase${r.phase === 'ready' ? ' auto-ready' : ''}">${phaseLine(r)}</div
|
|
190
|
+
+ `<div class="auto-phase${r.phase === 'ready' ? ' auto-ready' : ''}">${phaseLine(r)}</div>${repeatLine(r)}</div>`
|
|
112
191
|
+ `<button type="button" class="btn-ghost auto-drop" data-id="${r.id}">${tr('Take off')}</button></div>`;
|
|
113
192
|
}
|
|
114
193
|
function endedHTML(r) {
|
|
@@ -117,7 +196,7 @@ function endedHTML(r) {
|
|
|
117
196
|
+ `<button type="button" class="btn-icon auto-forget" data-id="${r.id}" title="${esc(tr('Remove from the list'))}" aria-label="${esc(tr('Remove from the list'))}">✕</button></div>`;
|
|
118
197
|
}
|
|
119
198
|
|
|
120
|
-
|
|
199
|
+
const picked = new Set();
|
|
121
200
|
let freeSig = '';
|
|
122
201
|
/* The form is drawn once per opening and kept: the section repaints on every event of the board (it is a special view,
|
|
123
202
|
and the 5 s look of the server sends its own), and a form drawn anew took the focus out of the minutes field and
|
|
@@ -134,23 +213,32 @@ function paint() {
|
|
|
134
213
|
}
|
|
135
214
|
if (box.dataset.mode !== 'on' || !$('auto-list')) {
|
|
136
215
|
box.dataset.mode = 'on';
|
|
137
|
-
box.innerHTML = `<div class="now-wrap">${newRuleHTML()}<div id="auto-list" class="auto-list"></div></div>`;
|
|
216
|
+
box.innerHTML = `<div class="now-wrap">${newRuleHTML()}${extrasHTML()}<div id="auto-list" class="auto-list"></div></div>`;
|
|
217
|
+
wireExtras();
|
|
138
218
|
freeSig = '';
|
|
139
219
|
$('auto-add').onclick = () => addRule($('auto-add'));
|
|
220
|
+
$('auto-repeat').onchange = paintKind;
|
|
221
|
+
wireForm();
|
|
222
|
+
$('auto-all').onclick = () => {
|
|
223
|
+
const boxes = [...document.querySelectorAll('#auto-picks input')];
|
|
224
|
+
const all = boxes.every((b) => b.checked);
|
|
225
|
+
for (const b of boxes) { b.checked = !all; if (all) picked.delete(b.value); else picked.add(b.value); }
|
|
226
|
+
paintPicks();
|
|
227
|
+
};
|
|
140
228
|
}
|
|
229
|
+
paintExtras();
|
|
141
230
|
const armed = auto.rules.filter((r) => r.state === 'armed');
|
|
142
231
|
const past = auto.rules.filter((r) => r.state !== 'armed');
|
|
143
|
-
const free = state.projects.filter((p) => !p.archived && !armed.some((r) => r.project === p.slug));
|
|
232
|
+
const free = state.projects.filter((p) => !p.archived && !armed.some((r) => r.project === p.slug && r.kind === formKind));
|
|
144
233
|
const sig = JSON.stringify(free.map((p) => [p.slug, p.name]));
|
|
145
|
-
if (sig !== freeSig
|
|
234
|
+
if (sig !== freeSig) {
|
|
146
235
|
freeSig = sig;
|
|
147
|
-
if (!free.some((p) => p.slug ===
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
$('auto-add').disabled = !free.length;
|
|
236
|
+
for (const slug of picked) if (!free.some((p) => p.slug === slug)) picked.delete(slug);
|
|
237
|
+
if (!picked.size) { const first = free.find((p) => p.slug === state.lastProject) || free[0]; if (first) picked.add(first.slug); }
|
|
238
|
+
$('auto-picks').innerHTML = free.map((p) => `<label class="auto-pick"><input type="checkbox" value="${esc(p.slug)}"${picked.has(p.slug) ? ' checked' : ''}><span>${esc(p.name || p.slug)}</span></label>`).join('')
|
|
239
|
+
|| `<span class="muted">${tr('no project is free')}</span>`;
|
|
240
|
+
$('auto-picks').querySelectorAll('input').forEach((b) => { b.onchange = () => { if (b.checked) picked.add(b.value); else picked.delete(b.value); paintPicks(); }; });
|
|
241
|
+
paintPicks();
|
|
154
242
|
}
|
|
155
243
|
const list = $('auto-list');
|
|
156
244
|
keepFocus(list, () => {
|
|
@@ -161,22 +249,41 @@ function paint() {
|
|
|
161
249
|
list.querySelectorAll('.auto-key').forEach((b) => { b.onclick = () => openDrawer(b.dataset.key); });
|
|
162
250
|
}
|
|
163
251
|
|
|
252
|
+
function paintPicks() {
|
|
253
|
+
const boxes = [...document.querySelectorAll('#auto-picks input')];
|
|
254
|
+
$('auto-add').disabled = !picked.size;
|
|
255
|
+
$('auto-all').textContent = tr(boxes.length && boxes.every((b) => b.checked) ? 'Clear' : 'All projects');
|
|
256
|
+
$('auto-all').hidden = boxes.length < 2;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const BACK_WORDS = 'Each task that does not pass goes back to work by itself: write what is wrong with kb note <KEY>, then kb mv <KEY> todo.';
|
|
164
260
|
async function addRule(btn) {
|
|
165
261
|
const min = Number($('auto-min').value);
|
|
166
|
-
if (!picked) return;
|
|
167
|
-
if (!Number.isInteger(min) || min < 0 || min > 120) { styledAlert(tr('The pause is whole minutes, from 0 to 120.')); return; }
|
|
262
|
+
if (!picked.size) return;
|
|
263
|
+
if (!SCHEDULED.has(formKind) && (!Number.isInteger(min) || min < 0 || min > 120)) { styledAlert(tr('The pause is whole minutes, from 0 to 120.')); return; }
|
|
168
264
|
btn.disabled = true;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
265
|
+
const check = formKind === CHECK_KIND;
|
|
266
|
+
const repeat = check && $('auto-repeat').checked;
|
|
267
|
+
const fix = repeat && $('auto-fix').checked;
|
|
268
|
+
const plan = { at: $('auto-at').value, day: formDay === 'every' ? null : Number(formDay) };
|
|
269
|
+
const launch = { permission: getSetting('permission'), effort: startEffort(formKind === 'schedule-check' || check, getSetting('effort'), getSetting('effortCheck')) || undefined };
|
|
270
|
+
const wordsFor = (project) => {
|
|
271
|
+
if (formKind === 'todo-queue') { const w = checkWords(); return { run: w.run, doTask: w.doTask }; }
|
|
272
|
+
if (formKind === 'schedule-groom') return { run: checkWords().run, groom: `${tr('Groom the backlog of')} ${project} ${tr('(grooming mode):')}` };
|
|
273
|
+
return { ...checkWords(), ...(fix ? { back: tr(BACK_WORDS) } : {}) };
|
|
274
|
+
};
|
|
275
|
+
const refused = [];
|
|
276
|
+
let last = null;
|
|
277
|
+
for (const project of [...picked]) {
|
|
278
|
+
try {
|
|
279
|
+
last = await api('POST', '/api/automations', {
|
|
280
|
+
project, kind: formKind, ...(SCHEDULED.has(formKind) ? { plan } : { delay_min: min }), ...(check ? { repeat, fix } : {}), words: wordsFor(project), launch,
|
|
281
|
+
}, { quiet: true });
|
|
282
|
+
} catch (e) { refused.push(`${projectName(project)}: ${refusalLine('Could not add the automation', e, tr)}`); }
|
|
179
283
|
}
|
|
284
|
+
btn.disabled = !picked.size;
|
|
285
|
+
if (last) applyAutomations(last);
|
|
286
|
+
if (refused.length) styledAlert(refused.join('\n'));
|
|
180
287
|
}
|
|
181
288
|
async function dropRule(id, btn) {
|
|
182
289
|
btn.disabled = true;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const AWAY_MS = 8 * 3600_000; // the owner's word: eight hours of silence
|
|
2
|
+
/** Whether a page seen last at `last` owes a summary at `now`. A first visit has nothing to tell. */
|
|
3
|
+
export const awayDue = (last, now) => Number.isFinite(last) && last > 0 && now - last >= AWAY_MS;
|
|
4
|
+
/** Whether a summary has anything to tell. */
|
|
5
|
+
export const hasNews = (s) => ['handed', 'accepted', 'returned', 'stuck'].some((k) => s?.[k]?.n > 0);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { api, esc, tr } from './core.js';
|
|
2
|
+
import { automations, loadAutomations } from './automations.js';
|
|
3
|
+
import { openDrawer } from './drawer.js';
|
|
4
|
+
import { plural } from './settings.js';
|
|
5
|
+
import { overlayLayer } from './sidebar.js';
|
|
6
|
+
import { localStore } from './store.js';
|
|
7
|
+
import { awayDue, hasNews } from './away-rules.js';
|
|
8
|
+
|
|
9
|
+
const SEEN_KEY = 'kb.ui.lastSeen';
|
|
10
|
+
const SEEN_EVERY_MS = 60_000;
|
|
11
|
+
const seenAt = () => { try { return Number(localStore.getItem(SEEN_KEY)); } catch { return 0; } };
|
|
12
|
+
const noteSeen = () => { try { localStore.setItem(SEEN_KEY, String(Date.now())); } catch { /* not kept */ } };
|
|
13
|
+
|
|
14
|
+
const SECTIONS = [['handed', 'Handed in'], ['accepted', 'Accepted'], ['returned', 'Returned'], ['stuck', 'Stuck']];
|
|
15
|
+
function sectionHTML(k, name, s) {
|
|
16
|
+
if (!s?.n) return '';
|
|
17
|
+
const rows = s.tasks.map((t) => `<button type="button" class="away-row" data-key="${esc(t.key)}"><span class="away-key">${esc(t.key)}</span><span class="away-title">${esc(t.title)}</span><span class="away-proj">${esc(t.project)}</span></button>`).join('');
|
|
18
|
+
const more = s.n - s.tasks.length;
|
|
19
|
+
return `<div class="away-sec"><div class="away-h">${esc(tr(name))}: ${s.n}</div>${rows}${more > 0 ? `<div class="muted away-more">${tr('and more:')} ${more} ${esc(plural(more, 'task', 'tasks'))}</div>` : ''}</div>`;
|
|
20
|
+
}
|
|
21
|
+
function showSummary(s) {
|
|
22
|
+
const overlay = document.createElement('div');
|
|
23
|
+
overlay.className = 'modal-overlay';
|
|
24
|
+
overlay.innerHTML = `<div class="modal away-box" role="dialog" aria-label="${esc(tr('While you were away'))}"><div class="modal-title">${esc(tr('While you were away'))}</div>`
|
|
25
|
+
+ `<div class="away-body">${SECTIONS.map(([k, name]) => sectionHTML(k, name, s[k])).join('')}</div>`
|
|
26
|
+
+ `<div class="modal-actions"><button type="button" class="btn-primary modal-ok">${esc(tr('Got it'))}</button></div></div>`;
|
|
27
|
+
const close = overlayLayer(overlay);
|
|
28
|
+
document.body.appendChild(overlay);
|
|
29
|
+
overlay.querySelector('.modal-ok').onclick = close;
|
|
30
|
+
overlay.querySelectorAll('.away-row').forEach((b) => { b.onclick = () => { close(); openDrawer(b.dataset.key); }; });
|
|
31
|
+
overlay.querySelector('.modal-ok').focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let looking = false;
|
|
35
|
+
async function look() {
|
|
36
|
+
if (looking || document.hidden) return;
|
|
37
|
+
const last = seenAt();
|
|
38
|
+
if (Date.now() - last < SEEN_EVERY_MS) return;
|
|
39
|
+
looking = true;
|
|
40
|
+
noteSeen();
|
|
41
|
+
try {
|
|
42
|
+
if (!awayDue(last, Date.now())) return;
|
|
43
|
+
await loadAutomations();
|
|
44
|
+
if (!automations().on) return;
|
|
45
|
+
const s = await api('GET', `/api/automations/summary?since=${Math.round(last)}`, undefined, { quiet: true });
|
|
46
|
+
if (hasNews(s)) showSummary(s);
|
|
47
|
+
} catch { try { localStore.setItem(SEEN_KEY, String(last)); } catch { /* not kept */ } /* asked again at the next touch */ } finally { looking = false; }
|
|
48
|
+
}
|
|
49
|
+
/** On start, on a return to the tab and on the person's first touch: a tab left on the screen overnight is no one there, so
|
|
50
|
+
the moment seen is the person's, not the page's. */
|
|
51
|
+
export function startAway() {
|
|
52
|
+
look();
|
|
53
|
+
document.addEventListener('visibilitychange', look);
|
|
54
|
+
for (const e of ['pointerdown', 'keydown', 'wheel']) document.addEventListener(e, look, { passive: true, capture: true });
|
|
55
|
+
}
|