@lovek4d/kickflip 0.3.1 → 0.4.0

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 (104) hide show
  1. package/CONTEXT.md +151 -115
  2. package/README.md +23 -14
  3. package/SKILL.md +37 -37
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +186 -69
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +3 -0
  13. package/board/headless.mjs +3 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -0
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-asks.js +57 -38
  18. package/board/public/app-banner.js +2 -2
  19. package/board/public/app-cards.js +156 -72
  20. package/board/public/app-core.js +2 -4
  21. package/board/public/app-header.js +12 -5
  22. package/board/public/app-lanes.js +201 -47
  23. package/board/public/app-needs.js +2 -1
  24. package/board/public/app-snapshot.js +2 -2
  25. package/board/public/app-threads.js +11 -14
  26. package/board/public/app.html +2 -1
  27. package/board/public/app.js +53 -83
  28. package/board/public/attach.js +39 -3
  29. package/board/public/dialogs.js +59 -14
  30. package/board/public/hub-kit.js +9 -6
  31. package/board/public/hub.css +5 -18
  32. package/board/public/linear-key.js +6 -4
  33. package/board/public/log.html +48 -0
  34. package/board/public/prompts.js +9 -8
  35. package/board/public/prototype-kit.js +6 -6
  36. package/board/public/prototype-switcher.js +13 -5
  37. package/board/public/prototype.html +1 -0
  38. package/board/public/router.js +8 -7
  39. package/board/public/rules-editor.js +3 -4
  40. package/board/public/shell.js +20 -6
  41. package/board/public/sidebar.css +21 -1
  42. package/board/public/sidebar.js +64 -31
  43. package/board/public/style.css +155 -62
  44. package/board/public/toasts.js +2 -2
  45. package/board/public/ui-cards.js +176 -71
  46. package/board/public/ui-enums.js +5 -4
  47. package/board/public/ui-header.js +10 -10
  48. package/board/public/ui-hub.js +4 -4
  49. package/board/public/ui-markdown.js +1 -1
  50. package/board/public/ui-order.js +9 -6
  51. package/board/public/ui-page-header.js +10 -7
  52. package/board/public/ui-patch.js +53 -0
  53. package/board/public/ui-phase-bars.js +17 -0
  54. package/board/public/ui-prompts.js +8 -8
  55. package/board/public/ui-questions.js +26 -23
  56. package/board/public/ui-sidebar.js +46 -22
  57. package/board/public/ui-snapshot.js +20 -9
  58. package/board/public/ui-threads.js +17 -11
  59. package/board/public/ui.js +80 -12
  60. package/board/public/view-frame.js +1 -1
  61. package/board/public/view-hub-settings.js +25 -18
  62. package/board/public/view-hub.js +53 -12
  63. package/board/public/view-notes.js +1 -1
  64. package/board/public/view-questions.js +101 -50
  65. package/board/public/view-settings.js +167 -49
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +18 -12
  68. package/board/store.mjs +13 -12
  69. package/board/tickets.mjs +3 -3
  70. package/board/worktree.mjs +20 -14
  71. package/docs/hub-session.md +4 -4
  72. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  73. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  74. package/hub/board-repo.mjs +1 -1
  75. package/hub/boards.mjs +4 -4
  76. package/hub/close-board.mjs +1 -1
  77. package/hub/github.mjs +4 -4
  78. package/hub/home.mjs +1 -1
  79. package/hub/hub.mjs +12 -12
  80. package/hub/incoming.mjs +2 -0
  81. package/hub/linear.mjs +2 -2
  82. package/hub/live.mjs +2 -2
  83. package/hub/new-board.mjs +2 -2
  84. package/hub/permission-denied-hook.mjs +2 -0
  85. package/hub/pr-report.mjs +3 -1
  86. package/hub/presence.mjs +1 -1
  87. package/hub/prompts.mjs +1 -1
  88. package/hub/push.mjs +2 -2
  89. package/hub/readiness.mjs +2 -2
  90. package/hub/repos.mjs +3 -3
  91. package/hub/restart-board.mjs +1 -1
  92. package/hub/schedule.mjs +1 -1
  93. package/hub/sessions.mjs +2 -2
  94. package/hub/sw.js +1 -1
  95. package/hub/try.mjs +8 -4
  96. package/hub/usage.mjs +3 -3
  97. package/hub/views.mjs +1 -1
  98. package/package.json +5 -1
  99. package/shared/card-prs.mjs +1 -0
  100. package/shared/colors.css +5 -4
  101. package/shared/fake-gh.mjs +59 -8
  102. package/shared/repos.mjs +1 -1
  103. package/shared/usage-gate.mjs +1 -1
  104. package/board/public/view-polling.js +0 -87
@@ -1,6 +1,7 @@
1
1
  // Board Settings, a modal the app shell (shell.js) mounts over its board for /<route>/settings.html.
2
- // Saving reviewers sends a `reviewers` event {add: [name], remove: [name]}; Claude finds each new person's Slack and
3
- // GitHub accounts, writes the list with board.mjs reviewers, and replies. Tickets save on each change, and Done, ✕,
2
+ // Saving reviewers sends a `reviewers` event {add: [name], remove: [name]}; the server removes names itself and the list
3
+ // redraws from its answer, while Claude finds each new person's Slack and GitHub accounts, adds them with board.mjs
4
+ // reviewers, and replies. Tickets save on each change, and Done, ✕,
4
5
  // Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
5
6
  // asks before closing the modal then.
6
7
  (globalThis.AppViews ||= {}).settings = {
@@ -14,29 +15,49 @@
14
15
  <h2>Tickets</h2>
15
16
  <p class="muted small">Every card on this board has a ticket from here. A new card gets a new ticket unless you name one.</p>
16
17
  <form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
18
+ <div class="tk-inline" id="ticketsKey" hidden></div>
17
19
  <p class="muted small" id="ticketsStatus" role="status"></p>
18
- <h2>Merging</h2>
19
- <label class="who" for="autoMerge">
20
- <span><span>Auto-merge approved PRs</span>
21
- <span class="muted small sub">On: Claude merges a PR once it is approved, CI is green and it merges cleanly. Off: its card shows you a Merge button.</span></span>
22
- <input type="checkbox" class="toggle" id="autoMerge" disabled>
23
- </label>
24
- <p class="muted small" id="autoMergeStatus"></p>
25
- <h2>Builds</h2>
20
+ <h2>Auto</h2>
21
+ <label class="who" for="autoPull"><span><span>Auto implement Todo cards</span>
22
+ <span class="muted small sub">Claude starts a Todo card as soon as it’s ready and nothing blocks or holds it, while an implementer slot is free.</span></span>
23
+ <input type="checkbox" class="toggle" id="autoPull" disabled></label>
24
+ <p class="muted small" id="autoPullStatus" role="status"></p>
25
+ <label class="who" for="maxImplementers"><span><span>Maximum parallel implementers</span>
26
+ <span class="muted small sub">Cards building or waiting in Orchestrator review count. Grills and cards waiting on you don’t.</span></span>
27
+ <input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
28
+ <p class="muted small" id="maxImplementersStatus" role="status"></p>
29
+ <label class="who" for="headlessBuilders"><span><span>Builders launch headless</span>
30
+ <span class="muted small sub">The board launches each ready card’s builder from its notes, and the builder lands its own work. Ready needs a size and notes.</span></span>
31
+ <input type="checkbox" class="toggle" id="headlessBuilders" disabled></label>
32
+ <p class="muted small" id="headlessBuildersStatus" role="status"></p>
26
33
  <label class="who" for="autoBuild"><span><span>Auto-build cards</span>
27
34
  <span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
28
35
  <input type="checkbox" class="toggle" id="autoBuild" disabled></label>
29
36
  <p class="muted small" id="autoBuildStatus" role="status"></p>
30
- <p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
31
- <h2>Todo</h2>
32
- <label class="who" for="autoPull"><span><span>Pull in Todo cards when idle</span>
33
- <span class="muted small sub">On: when no implementer is running on this board, Claude takes the top Todo card that nothing blocks or holds, and grills or implements it. Off: a Todo card waits until it is unblocked, answered or bumped.</span></span>
34
- <input type="checkbox" class="toggle" id="autoPull" disabled></label>
35
- <p class="muted small" id="autoPullStatus" role="status"></p>
36
- <label class="who" for="autoTriage"><span><span>Triage Todo when idle</span>
37
- <span class="muted small sub">On: once a day, when no implementer is running and Todo changed, Claude looks for duplicate cards, cards another covers, cards ready to close and stale deps. It links cards and merges clear duplicates itself, and asks you about the rest under one topic. Off: Todo is left as it is.</span></span>
37
+ <label class="who" for="autoReview"><span><span>Auto review</span>
38
+ <span class="muted small sub">Claude reviews teammates’ PRs that touch this board’s work. May use a lot of usage.</span></span>
39
+ <input type="checkbox" class="toggle" id="autoReview" disabled></label>
40
+ <p class="muted small" id="autoReviewStatus" role="status"></p>
41
+ <label class="who" for="autoMerge"><span><span>Auto-merge</span>
42
+ <span class="muted small sub">Claude merges a PR once it is approved, green and merges cleanly.</span></span>
43
+ <input type="checkbox" class="toggle" id="autoMerge" disabled></label>
44
+ <p class="muted small" id="autoMergeStatus" role="status"></p>
45
+ <div id="skipDraftRow" hidden><label class="who" for="skipDraft"><span><span>Skip the draft</span>
46
+ <span class="muted small sub">A builder’s PR opens as a draft and is marked ready for review once CI passes, so it goes to reviewers without waiting for you.</span></span>
47
+ <input type="checkbox" class="toggle" id="skipDraft" disabled></label>
48
+ <p class="muted small">With Auto-merge on too, a builder’s PR can merge once reviewers approve it, without you seeing it.</p>
49
+ <p class="muted small" id="skipDraftStatus" role="status"></p></div>
50
+ <label class="who" for="autoTriage"><span><span>Triage overnight</span>
51
+ <span class="muted small sub">Once a night, when two or more cards wait on your answers, Claude merges their overlapping questions and duplicate cards, and tidies Todo.</span></span>
38
52
  <input type="checkbox" class="toggle" id="autoTriage" disabled></label>
39
53
  <p class="muted small" id="autoTriageStatus" role="status"></p>
54
+ <label class="who" for="idleNotification"><span><span>Idle notification</span>
55
+ <span class="muted small sub">Pushes once when the board has been idle for 15 minutes, with what closed since the last one.</span></span>
56
+ <input type="checkbox" class="toggle" id="idleNotification" disabled></label>
57
+ <p class="muted small" id="idleNotificationStatus" role="status"></p>
58
+ <p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
59
+ <h2>Permissions</h2>
60
+ <div id="permissions"><p class="muted small">Loading…</p></div>
40
61
  <h2>Reviewers</h2>
41
62
  <p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
42
63
  <div id="list"><p class="muted small">Loading…</p></div>
@@ -139,11 +160,15 @@
139
160
  e.preventDefault();
140
161
  if (closing || typed.value !== closure?.title) return;
141
162
  closing = true;
142
- closeConfirm.disabled = true;
143
- try {
163
+ // Close board waits until the hub has stopped the board's session and server.
164
+ const post = (async () => {
144
165
  const r = await fetch('/_hub/close-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route, name: typed.value }) });
145
166
  const out = await r.json();
146
167
  if (!r.ok) throw new Error(out.error || 'Could not close board.');
168
+ return out;
169
+ })();
170
+ try {
171
+ const out = await BoardUI.waitOn(closeConfirm, post);
147
172
  if (!B.alive()) return;
148
173
  closeSheet.close();
149
174
  ctx.sidebarChanged?.();
@@ -159,7 +184,11 @@
159
184
  const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
160
185
  let repo = {}, closeRepo = () => {};
161
186
  let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
162
- const showRepo = () => { $('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo'; };
187
+ // Skip the draft is only for a board with pull requests, on GitHub (docs/adr/2026-11-04-1913-a-board-can-skip-the-draft.md).
188
+ const showRepo = () => {
189
+ $('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
190
+ $('#skipDraftRow').hidden = !repo.github;
191
+ };
163
192
  get(repoURL).then(({ body }) => {
164
193
  if (!B.alive()) return;
165
194
  repo = body;
@@ -202,11 +231,21 @@
202
231
  return false;
203
232
  }
204
233
  }
234
+ // A save waits on the control last tapped or changed, found by selector since ticketsControl redraws it, and every
235
+ // other tickets control is disabled meanwhile (ui.js waitOn).
236
+ let ticketsTapped = '[data-tk-source][aria-pressed="true"]';
237
+ const tapped = (e) => {
238
+ const source = e.target.closest?.('[data-tk-source]');
239
+ if (source) ticketsTapped = `[data-tk-source="${source.dataset.tkSource}"]`;
240
+ else if (e.target.dataset?.tk) ticketsTapped = `[data-tk="${e.target.dataset.tk}"]`;
241
+ };
242
+ B.on(ticketsForm, 'click', tapped, true);
243
+ B.on(ticketsForm, 'change', tapped, true);
205
244
  // One save at a time: a change made while one is out saves once it lands.
206
245
  function saveTickets() {
207
246
  syncTickets();
208
247
  if (savingTickets || !unsaved() || !tickets.valid()) return;
209
- ticketsStatus.innerHTML = '<span class="tk-saving"><span class="try-spin" aria-hidden="true"></span>Saving…</span>';
248
+ ticketsStatus.textContent = '';
210
249
  LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), true);
211
250
  savingTickets = (async () => {
212
251
  let saved, value;
@@ -220,6 +259,8 @@
220
259
  LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), false);
221
260
  syncTickets();
222
261
  })();
262
+ BoardUI.waitOn(ticketsForm.querySelector(ticketsTapped) || ticketsForm.querySelector('[data-tk-source][aria-pressed="true"]'), savingTickets,
263
+ { group: ticketsForm.querySelectorAll('button, select, input') });
223
264
  }
224
265
  B.on(ticketsForm, 'submit', (e) => { e.preventDefault(); saveTickets(); });
225
266
  let buildKind = 'web';
@@ -263,24 +304,28 @@
263
304
  B.on($('#save'), 'click', async () => {
264
305
  const status = $('#saveStatus');
265
306
  try {
266
- await site.send('reviewers', { add: [...add], remove: [...remove] });
307
+ const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add], remove: [...remove] }));
308
+ status.textContent = add.size ? 'Sent. Claude updates the list and replies below.' : 'Saved';
309
+ current = out.board.reviewers;
267
310
  add.clear(); remove.clear(); render();
268
- status.textContent = 'Sent. Claude updates the list and replies below.';
269
- } catch { status.textContent = 'Could not send. Is the host awake?'; }
311
+ } catch (e) { status.textContent = e?.reason || 'Could not send. Is the host awake?'; }
270
312
  });
271
313
  B.watch((events) => {
272
314
  $('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
273
315
  }, { kind: 'reviewers' });
274
316
 
275
317
  // The toggle saves at once through POST api/settings, which writes board.json itself, so it takes effect with no
276
- // session awake (docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md).
318
+ // session awake (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
277
319
  const autoMerge = $('#autoMerge');
278
320
  const autoMergeStatus = $('#autoMergeStatus');
321
+ let savedMax = '';
279
322
  get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
280
323
  if (!B.alive()) return;
281
324
  savedTickets = d.settings?.tickets || null;
282
325
  tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
283
326
  loadLinear: async () => (await get('/_hub/api/linear')).body,
327
+ keyState: async () => (await get('/_hub/api/settings')).body.linear, keySlot: $('#ticketsKey'),
328
+ keyBusy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on),
284
329
  change(_value, _valid, { loaded, typing } = {}) {
285
330
  if (!loaded) ticketsTouched = true;
286
331
  if (typing) syncTickets(); else saveTickets();
@@ -297,9 +342,27 @@
297
342
  $('#autoBuild').disabled = false;
298
343
  $('#autoPull').checked = d.settings?.auto_pull === true;
299
344
  $('#autoPull').disabled = false;
345
+ $('#maxImplementers').value = savedMax = String(d.settings?.max_implementers);
346
+ $('#maxImplementers').disabled = false;
300
347
  $('#autoTriage').checked = d.settings?.auto_triage === true;
301
348
  $('#autoTriage').disabled = false;
349
+ $('#autoReview').checked = d.settings?.auto_review === true;
350
+ $('#autoReview').disabled = false;
351
+ $('#headlessBuilders').checked = d.settings?.headless_builders === true;
352
+ $('#headlessBuilders').disabled = false;
353
+ $('#idleNotification').checked = d.settings?.idle_notification === true;
354
+ $('#idleNotification').disabled = false;
355
+ $('#skipDraft').checked = d.settings?.skip_draft === true;
356
+ $('#skipDraft').disabled = false;
302
357
  }).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
358
+ // An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
359
+ // → the saved settings
360
+ const saveToggle = (toggle, body) => BoardUI.waitOn(toggle, (async () => {
361
+ const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
362
+ const out = await r.json().catch(() => ({}));
363
+ if (!r.ok) throw new Error(out.error);
364
+ return out.settings;
365
+ })());
303
366
  B.on(autoMerge, 'change', async () => {
304
367
  const want = autoMerge.checked;
305
368
  if (want && !(await BoardDialogs.confirm('Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'))) {
@@ -307,59 +370,114 @@
307
370
  return;
308
371
  }
309
372
  if (!B.alive()) return;
310
- autoMerge.disabled = true;
311
373
  try {
312
- const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ auto_merge: want }) });
313
- const out = await r.json().catch(() => ({}));
314
- if (!r.ok) throw new Error(out.error);
315
- autoMerge.checked = out.settings.auto_merge === true;
374
+ autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
316
375
  autoMergeStatus.textContent = autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.';
317
376
  } catch (e) {
318
377
  autoMerge.checked = !want;
319
378
  autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
320
379
  }
321
- autoMerge.disabled = false;
322
380
  });
323
381
 
324
382
  B.on($('#autoBuild'), 'change', async () => {
325
383
  const toggle = $('#autoBuild');
326
384
  const want = toggle.checked;
327
- toggle.disabled = true;
328
385
  try {
329
- const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ auto_build: want }) });
330
- const out = await r.json();
331
- if (!r.ok) throw new Error(out.error);
386
+ const settings = await saveToggle(toggle, { auto_build: want });
332
387
  if (!B.alive()) return;
333
- toggle.checked = out.settings.auto_build === true;
388
+ toggle.checked = settings.auto_build === true;
334
389
  $('#autoBuildStatus').textContent = BoardUI.tryWords(buildKind).saved(toggle.checked);
335
390
  } catch (e) {
336
391
  if (!B.alive()) return;
337
392
  toggle.checked = !want;
338
393
  $('#autoBuildStatus').textContent = e.message || 'Could not save. Is the host awake?';
339
- } finally { if (B.alive()) toggle.disabled = false; }
394
+ }
340
395
  });
341
396
 
342
- // The Todo section's toggles save with no confirmation: docs/adr/0123-an-idle-board-pulls-in-its-top-todo-card.md
343
- // and docs/adr/0165-an-idle-board-triages-its-todo-once-a-day.md.
344
- const todoToggle = (id, key, saved) => B.on($(`#${id}`), 'change', async () => {
397
+ // These Auto toggles save with no confirmation: docs/adr/2026-11-02-1754-an-idle-board-pulls-in-its-top-todo-card.md,
398
+ // docs/adr/2026-11-04-1900-triage-runs-once-overnight-on-cards-waiting-on-your-answers.md and
399
+ // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md; Idle notification too:
400
+ // docs/adr/2026-11-04-0257-an-idle-board-pushes-once-when-it-has-gone-idle.md.
401
+ const autoToggle = (id, key, saved) => B.on($(`#${id}`), 'change', async () => {
345
402
  const toggle = $(`#${id}`);
346
403
  const want = toggle.checked;
347
- toggle.disabled = true;
348
404
  try {
349
- const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ [key]: want }) });
350
- const out = await r.json().catch(() => ({}));
351
- if (!r.ok) throw new Error(out.error);
405
+ const settings = await saveToggle(toggle, { [key]: want });
352
406
  if (!B.alive()) return;
353
- toggle.checked = out.settings[key] === true;
407
+ toggle.checked = settings[key] === true;
354
408
  $(`#${id}Status`).textContent = saved(toggle.checked);
355
409
  } catch (e) {
356
410
  if (!B.alive()) return;
357
411
  toggle.checked = !want;
358
412
  $(`#${id}Status`).textContent = e.message || 'Could not save. Is the host awake?';
359
- } finally { if (B.alive()) toggle.disabled = false; }
413
+ }
414
+ });
415
+ autoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. Claude starts ready Todo cards while an implementer slot is free.' : 'Saved. Todo cards wait for you.'));
416
+
417
+ // docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers saves on
418
+ // change, waiting on the server like a toggle; a number outside 1 to 99 or a refusal puts the saved one back.
419
+ B.on($('#maxImplementers'), 'change', async () => {
420
+ const input = $('#maxImplementers');
421
+ const status = $('#maxImplementersStatus');
422
+ const want = /^\d+$/.test(input.value) ? Number(input.value) : 0;
423
+ if (want < 1 || want > 99) { input.value = savedMax; status.textContent = 'Pick a whole number from 1 to 99.'; return; }
424
+ try {
425
+ const settings = await saveToggle(input, { max_implementers: want });
426
+ if (!B.alive()) return;
427
+ input.value = savedMax = String(settings.max_implementers);
428
+ status.textContent = `Saved. Up to ${settings.max_implementers === 1 ? '1 card builds or waits' : `${settings.max_implementers} cards build or wait`} in Orchestrator review at once.`;
429
+ } catch (e) {
430
+ if (!B.alive()) return;
431
+ input.value = savedMax;
432
+ status.textContent = e.message || 'Could not save. Is the host awake?';
433
+ }
434
+ });
435
+ autoToggle('autoReview', 'auto_review', (on) => (on ? 'Saved. When idle, Claude reviews incoming PRs that touch this board’s work.' : 'Saved. Claude leaves incoming PRs alone.'));
436
+ autoToggle('headlessBuilders', 'headless_builders', (on) => (on ? 'Saved. The board launches each ready card’s builder itself, and the builder lands its own work.' : 'Saved. Claude briefs and launches each builder.'));
437
+ autoToggle('idleNotification', 'idle_notification', (on) => (on ? 'Saved. One push once the board has been idle for 15 minutes.' : 'Saved. No push when the board goes idle.'));
438
+ autoToggle('autoTriage', 'auto_triage', (on) => (on ? 'Saved. Once a night, when cards wait on your answers, Claude triages them.' : 'Saved. Questions and Todo are left as they are.'));
439
+ autoToggle('skipDraft', 'skip_draft', (on) => (on ? 'Saved. A builder’s draft PR is marked ready for review once CI passes.' : 'Saved. A builder’s draft PR waits for your review.'));
440
+
441
+ // Each switch writes or removes one allow rule in the board folder's .claude/settings.local.json through POST
442
+ // api/permissions, which only the host's own browser may send; elsewhere the switches are read-only
443
+ // (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md). A tap waits on the server with the
444
+ // switch as it was and a spinner beside it, then draws the server's answer.
445
+ const permissions = $('#permissions');
446
+ const REFUSED = {
447
+ denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
448
+ asked: (command) => `The session still asked before running ${command}, so it hasn’t picked the rule up yet. It applies once the session restarts.`,
449
+ };
450
+ const permRow = (p, owner) => `<label class="who" for="perm-${esc(p.id)}"><span><span>${esc(p.name)}</span>
451
+ <span class="muted small sub">${esc(p.allows)} Writes <code>${esc(p.rule)}</code></span></span>
452
+ <span class="toggle-slot"><span class="try-spin toggle-spin" aria-hidden="true"></span><input type="checkbox" class="toggle" data-perm="${esc(p.id)}" id="perm-${esc(p.id)}"${p.on ? ' checked' : ''}${owner ? '' : ' disabled'}></span></label>
453
+ <p class="muted small" id="perm-${esc(p.id)}Status" role="status"></p>
454
+ ${p.refused ? `<p class="muted small refused-note">${esc(REFUSED[p.refused.how](p.refused.command))}</p>` : ''}`;
455
+ const drawPermissions = (d) => {
456
+ permissions.innerHTML = d.permissions.map((p) => permRow(p, d.owner)).join('')
457
+ + (d.owner ? '' : '<p class="muted small" id="permissionsNotOwner">Only the signed-in owner can change these.</p>');
458
+ };
459
+ get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
460
+ if (!B.alive()) return;
461
+ if (!ok) { permissions.innerHTML = `<p class="muted small">${esc(body.error || 'Could not load permissions.')}</p>`; return; }
462
+ drawPermissions(body);
463
+ }).catch(() => { if (B.alive()) permissions.innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
464
+ B.on(permissions, 'change', async (e) => {
465
+ const toggle = e.target.closest?.('input[data-perm]');
466
+ if (!toggle) return;
467
+ const id = toggle.dataset.perm;
468
+ const on = toggle.checked;
469
+ toggle.checked = !on;
470
+ try {
471
+ const r = await BoardUI.waitOn(toggle, fetch(site.url('api/permissions'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, on }) }));
472
+ const out = await r.json().catch(() => ({}));
473
+ if (!r.ok) throw new Error(out.error);
474
+ if (!B.alive()) return;
475
+ drawPermissions(out);
476
+ $(`#perm-${id}Status`).textContent = on ? 'Saved. The session runs it without asking.' : 'Saved. The session asks first again.';
477
+ } catch (error) {
478
+ if (B.alive()) $(`#perm-${id}Status`).textContent = error.message || 'Could not save. Is the host awake?';
479
+ }
360
480
  });
361
- todoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. When no implementer runs, Claude pulls in the top Todo card.' : 'Saved. Todo cards wait until they are unblocked, answered or bumped.'));
362
- todoToggle('autoTriage', 'auto_triage', (on) => (on ? 'Saved. Once a day, when the board is idle and Todo changed, Claude triages it.' : 'Saved. Todo is left as it is.'));
363
481
 
364
482
  // Saving writes board.json through POST api/rules, whose `rules {scope: "board"}` event wakes Claude to acknowledge
365
483
  // the change; replies show below, newest first.
package/board/review.mjs CHANGED
@@ -1 +1 @@
1
- var w=Object.defineProperty;var l=(t,e)=>w(t,"name",{value:e,configurable:!0});import m from"node:fs";import p from"node:path";import d from"node:os";import{routeRecord as f}from"../hub/routes.mjs";function _(){const t=p.join(d.homedir(),".claude","slack-bridge","topics"),e=Object.create(null);let r=[];try{r=m.readdirSync(t).filter(i=>i.endsWith(".json")).sort()}catch{}for(const i of r)try{const o=JSON.parse(m.readFileSync(p.join(t,i),"utf8"));for(const[a,s]of Object.entries(o.items||{}))e[a]={...e[a],...s}}catch{}return e}l(_,"topicItems");function j(t,e,r,i={}){const o=Object.create(null),a=[e?.review_link_ts,e?.review_sent,f(t?.review_link_ts,e?.pr),f(t?.review_link_ts,r),f(i,r)?.review_link_ts];for(const s of a)for(const[u,n]of Array.isArray(s)?s.map(c=>[c,null]):Object.entries(s||{})){if(Object.hasOwn(o,u))continue;const c=typeof n=="string"?n:n?.ts;n!==null&&(typeof c!="string"||!/^\d+(?:\.\d+)?$/.test(c))||(o[u]={ts:c||null,...typeof n?.channel=="string"?{channel:n.channel}:{}})}return o}l(j,"reviewLinks");const N=l(t=>{const e=t?Number(t)*1e3:NaN;return Number.isFinite(e)&&!Number.isNaN(new Date(e).getTime())?new Date(e).toISOString():null},"reviewTime");function k(t,e=[]){return[...new Map([...e,...t.reviewers||[]].map(r=>[r.name,r])).values()]}l(k,"reviewersOf");export{j as reviewLinks,N as reviewTime,k as reviewersOf,_ as topicItems};
1
+ var d=Object.defineProperty;var l=(t,e)=>d(t,"name",{value:e,configurable:!0});import m from"node:fs";import p from"node:path";import w from"node:os";import{routeRecord as a}from"../hub/routes.mjs";import{storedPRs as b}from"../shared/card-prs.mjs";function N(){const t=p.join(w.homedir(),".claude","slack-bridge","topics"),e=Object.create(null);let r=[];try{r=m.readdirSync(t).filter(o=>o.endsWith(".json")).sort()}catch{}for(const o of r)try{const i=JSON.parse(m.readFileSync(p.join(t,o),"utf8"));for(const[f,s]of Object.entries(i.items||{}))e[f]={...e[f],...s}}catch{}return e}l(N,"topicItems");function k(t,e,r,o={}){const i=Object.create(null),f=[e?.review_link_ts,e?.review_sent,a(t?.review_link_ts,b(e)[0]?.number),a(t?.review_link_ts,r),a(o,r)?.review_link_ts];for(const s of f)for(const[u,n]of Array.isArray(s)?s.map(c=>[c,null]):Object.entries(s||{})){if(Object.hasOwn(i,u))continue;const c=typeof n=="string"?n:n?.ts;n!==null&&(typeof c!="string"||!/^\d+(?:\.\d+)?$/.test(c))||(i[u]={ts:c||null,...typeof n?.channel=="string"?{channel:n.channel}:{}})}return i}l(k,"reviewLinks");const g=l(t=>{const e=t?Number(t)*1e3:NaN;return Number.isFinite(e)&&!Number.isNaN(new Date(e).getTime())?new Date(e).toISOString():null},"reviewTime");function S(t,e=[]){return[...new Map([...e,...t.reviewers||[]].map(r=>[r.name,r])).values()]}l(S,"reviewersOf");export{k as reviewLinks,g as reviewTime,S as reviewersOf,N as topicItems};