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.
Files changed (53) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +1 -1
  3. package/README.ru.md +1 -1
  4. package/bin/kb +28 -18
  5. package/package.json +1 -1
  6. package/public/js/automations.js +143 -36
  7. package/public/js/away-rules.js +5 -0
  8. package/public/js/away.js +55 -0
  9. package/public/js/board.js +49 -11
  10. package/public/js/chat.js +62 -18
  11. package/public/js/core.js +104 -5
  12. package/public/js/drawer.js +71 -23
  13. package/public/js/glyphs.js +28 -7
  14. package/public/js/init.js +14 -4
  15. package/public/js/link-lines.js +48 -0
  16. package/public/js/live-link.js +1 -1
  17. package/public/js/now-terms.js +10 -2
  18. package/public/js/project.js +12 -2
  19. package/public/js/review-rules.js +5 -2
  20. package/public/js/settings.js +23 -4
  21. package/public/js/share.js +18 -3
  22. package/public/js/shipped-rules.js +11 -0
  23. package/public/js/sidebar.js +14 -1
  24. package/public/js/sse.js +3 -3
  25. package/public/js/term-delta.js +52 -0
  26. package/public/js/term-input-rules.js +2 -1
  27. package/public/js/unsaved.js +7 -2
  28. package/public/style.css +46 -4
  29. package/skills/kanban/SKILL.md +6 -2
  30. package/src/auto-health.js +36 -0
  31. package/src/auto-resume.js +183 -0
  32. package/src/automations.js +293 -26
  33. package/src/chat-feed.js +73 -25
  34. package/src/claude-asks.js +2 -2
  35. package/src/claude-now.js +6 -6
  36. package/src/claude-sessions.js +10 -6
  37. package/src/db.js +44 -1
  38. package/src/own-wait.js +14 -6
  39. package/src/phantom.js +8 -4
  40. package/src/ready-gate.js +11 -0
  41. package/src/routes/automations.js +19 -1
  42. package/src/routes/chat.js +17 -2
  43. package/src/routes/dashboard.js +4 -4
  44. package/src/routes/projects.js +20 -5
  45. package/src/routes/share.js +6 -1
  46. package/src/routes/system.js +30 -4
  47. package/src/routes/tasks.js +20 -9
  48. package/src/routes/terminal.js +16 -2
  49. package/src/schema.sql +13 -4
  50. package/src/share.js +123 -10
  51. package/src/shipped.js +117 -0
  52. package/src/sync/github.js +1 -0
  53. 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's secret lets anyone in, and with it a guest can do on the board everything you can — including talking to Claude on your computer. It is off by default; switching it off closes the door, a new link drops every guest.
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-код. Пускает только секрет ссылки, а с ним гость может на доске всё то же, что и ты, — в том числе писать Claude на твоём компьютере. По умолчанию выключено; выключатель закрывает вход, новая ссылка отключает всех гостей.
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
- const [id] = args;
476
- if (!id) die('kb show <key>');
477
- const t = await api('GET', `/api/tasks/${key(id)}`, null, TASK);
478
- 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(',') + ']' : ''}`);
479
- console.log(t.title);
480
- if (t.description) console.log(t.description);
481
- printChecklist(t);
482
- printLinks(t);
483
- printIssue(t);
484
- printMoves(t);
485
- printSessions(t);
486
- printUsage(t);
487
- printAttachments(t);
488
- printComments(await api('GET', `/api/tasks/${t.id}/comments`, null, LIST), 5);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.2",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -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
- const reason = String(r.error || '').match(/^([a-z-]+): /)?.[1];
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 ruleWhat = (r) => `${tr('When')} <b>${esc(r.project_name || projectName(r.project))}</b> ${tr('finishes, wait')} ${Math.round(r.delay_s / 60)} ${tr('min and check Review')}`;
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
- + '<div class="auto-sentence">'
101
- + `<span>${tr('When')}</span><div class="kbsel auto-proj" id="auto-proj"></div><span>${tr('finishes its work,')}</span>`
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>${tr('and start a check of the tasks in Review')}</span></div>`
104
- + `<div class="muted auto-hint">${tr('Runs once: 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.')}</div>`
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></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
- let picked = '';
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 && !$('auto-proj').classList.contains('open')) {
234
+ if (sig !== freeSig) {
146
235
  freeSig = sig;
147
- if (!free.some((p) => p.slug === picked)) picked = free.find((p) => p.slug === state.lastProject)?.slug || free[0]?.slug || '';
148
- buildSelect($('auto-proj'), {
149
- value: picked, placeholder: tr('no project is free'), search: free.length > 8,
150
- options: free.map((p) => ({ value: p.slug, label: p.name || p.slug })),
151
- onChange: (v) => { picked = v; },
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
- try {
170
- const v = await api('POST', '/api/automations', {
171
- project: picked, delay_min: min, words: checkWords(),
172
- launch: { permission: getSetting('permission'), effort: startEffort(true, getSetting('effort'), getSetting('effortCheck')) || undefined },
173
- }, { quiet: true });
174
- btn.disabled = false;
175
- applyAutomations(v);
176
- } catch (e) {
177
- btn.disabled = false;
178
- styledAlert(refusalLine('Could not add the automation', e, tr));
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
+ }