@lovek4d/kickflip 0.3.1 → 0.5.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 (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  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 +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. 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,50 @@
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>
26
- <label class="who" for="autoBuild"><span><span>Auto-build cards</span>
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="autoBuild"><span><span>Auto serve</span>
27
30
  <span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
28
31
  <input type="checkbox" class="toggle" id="autoBuild" disabled></label>
29
32
  <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>
33
+ <label class="who" for="autoReview"><span><span>Auto review</span>
34
+ <span class="muted small sub">Claude reviews teammates’ PRs that touch this board’s work. May use a lot of usage.</span></span>
35
+ <input type="checkbox" class="toggle" id="autoReview" disabled></label>
36
+ <p class="muted small" id="autoReviewStatus" role="status"></p>
37
+ <div id="prsRow" hidden><label class="who" for="prs"><span><span>PRs</span>
38
+ <span class="muted small sub">Each card opens a PR. Off, no PR opens: each card lands on main and main is pushed.</span></span>
39
+ <input type="checkbox" class="toggle" id="prs" disabled></label>
40
+ <p class="muted small" id="prsStatus" role="status"></p></div>
41
+ <label class="who" for="autoMerge"><span><span>Auto-merge</span>
42
+ <span class="muted small sub" id="autoMergeSub">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>
61
+ <div id="reviewersSection" hidden>
40
62
  <h2>Reviewers</h2>
41
63
  <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
64
  <div id="list"><p class="muted small">Loading…</p></div>
@@ -46,6 +68,7 @@
46
68
  </form>
47
69
  <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
48
70
  <div class="replies" id="replies"></div>
71
+ </div>
49
72
  <h2>Rules</h2>
50
73
  <p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
51
74
  <div id="rules"><p class="muted small">Loading…</p></div>
@@ -139,11 +162,15 @@
139
162
  e.preventDefault();
140
163
  if (closing || typed.value !== closure?.title) return;
141
164
  closing = true;
142
- closeConfirm.disabled = true;
143
- try {
165
+ // Close board waits until the hub has stopped the board's session and server.
166
+ const post = (async () => {
144
167
  const r = await fetch('/_hub/close-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route, name: typed.value }) });
145
168
  const out = await r.json();
146
169
  if (!r.ok) throw new Error(out.error || 'Could not close board.');
170
+ return out;
171
+ })();
172
+ try {
173
+ const out = await BoardUI.waitOn(closeConfirm, post);
147
174
  if (!B.alive()) return;
148
175
  closeSheet.close();
149
176
  ctx.sidebarChanged?.();
@@ -159,7 +186,20 @@
159
186
  const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
160
187
  let repo = {}, closeRepo = () => {};
161
188
  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'; };
189
+ // PRs shows on a board on GitHub that builds from its shared clone; a local board never opens PRs. Skip the draft and
190
+ // Reviewers show only while cards open PRs, and Auto-merge says what it merges
191
+ // (docs/adr/2026-11-04-1913-a-board-can-skip-the-draft.md, docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
192
+ let prsSetting = true;
193
+ const opensPRs = () => Boolean(repo.github) && !repo.local && prsSetting;
194
+ const syncPRs = () => {
195
+ $('#prsRow').hidden = !repo.github || Boolean(repo.local);
196
+ $('#skipDraftRow').hidden = $('#reviewersSection').hidden = !opensPRs();
197
+ $('#autoMergeSub').textContent = opensPRs() ? 'Claude merges a PR once it is approved, green and merges cleanly.' : 'Lands each verified card on main without waiting for your Merge.';
198
+ };
199
+ const showRepo = () => {
200
+ $('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
201
+ syncPRs();
202
+ };
163
203
  get(repoURL).then(({ body }) => {
164
204
  if (!B.alive()) return;
165
205
  repo = body;
@@ -202,11 +242,21 @@
202
242
  return false;
203
243
  }
204
244
  }
245
+ // A save waits on the control last tapped or changed, found by selector since ticketsControl redraws it, and every
246
+ // other tickets control is disabled meanwhile (ui.js waitOn).
247
+ let ticketsTapped = '[data-tk-source][aria-pressed="true"]';
248
+ const tapped = (e) => {
249
+ const source = e.target.closest?.('[data-tk-source]');
250
+ if (source) ticketsTapped = `[data-tk-source="${source.dataset.tkSource}"]`;
251
+ else if (e.target.dataset?.tk) ticketsTapped = `[data-tk="${e.target.dataset.tk}"]`;
252
+ };
253
+ B.on(ticketsForm, 'click', tapped, true);
254
+ B.on(ticketsForm, 'change', tapped, true);
205
255
  // One save at a time: a change made while one is out saves once it lands.
206
256
  function saveTickets() {
207
257
  syncTickets();
208
258
  if (savingTickets || !unsaved() || !tickets.valid()) return;
209
- ticketsStatus.innerHTML = '<span class="tk-saving"><span class="try-spin" aria-hidden="true"></span>Saving…</span>';
259
+ ticketsStatus.textContent = '';
210
260
  LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), true);
211
261
  savingTickets = (async () => {
212
262
  let saved, value;
@@ -220,6 +270,8 @@
220
270
  LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), false);
221
271
  syncTickets();
222
272
  })();
273
+ BoardUI.waitOn(ticketsForm.querySelector(ticketsTapped) || ticketsForm.querySelector('[data-tk-source][aria-pressed="true"]'), savingTickets,
274
+ { group: ticketsForm.querySelectorAll('button, select, input') });
223
275
  }
224
276
  B.on(ticketsForm, 'submit', (e) => { e.preventDefault(); saveTickets(); });
225
277
  let buildKind = 'web';
@@ -263,24 +315,28 @@
263
315
  B.on($('#save'), 'click', async () => {
264
316
  const status = $('#saveStatus');
265
317
  try {
266
- await site.send('reviewers', { add: [...add], remove: [...remove] });
318
+ const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add], remove: [...remove] }));
319
+ status.textContent = add.size ? 'Sent. Claude updates the list and replies below.' : 'Saved';
320
+ current = out.board.reviewers;
267
321
  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?'; }
322
+ } catch (e) { status.textContent = e?.reason || 'Could not send. Is the host awake?'; }
270
323
  });
271
324
  B.watch((events) => {
272
325
  $('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
273
326
  }, { kind: 'reviewers' });
274
327
 
275
328
  // 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).
329
+ // session awake (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
277
330
  const autoMerge = $('#autoMerge');
278
331
  const autoMergeStatus = $('#autoMergeStatus');
332
+ let savedMax = '';
279
333
  get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
280
334
  if (!B.alive()) return;
281
335
  savedTickets = d.settings?.tickets || null;
282
336
  tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
283
337
  loadLinear: async () => (await get('/_hub/api/linear')).body,
338
+ keyState: async () => (await get('/_hub/api/settings')).body.linear, keySlot: $('#ticketsKey'),
339
+ keyBusy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on),
284
340
  change(_value, _valid, { loaded, typing } = {}) {
285
341
  if (!loaded) ticketsTouched = true;
286
342
  if (typing) syncTickets(); else saveTickets();
@@ -297,69 +353,150 @@
297
353
  $('#autoBuild').disabled = false;
298
354
  $('#autoPull').checked = d.settings?.auto_pull === true;
299
355
  $('#autoPull').disabled = false;
356
+ $('#maxImplementers').value = savedMax = String(d.settings?.max_implementers);
357
+ $('#maxImplementers').disabled = false;
300
358
  $('#autoTriage').checked = d.settings?.auto_triage === true;
301
359
  $('#autoTriage').disabled = false;
360
+ $('#autoReview').checked = d.settings?.auto_review === true;
361
+ $('#autoReview').disabled = false;
362
+ $('#idleNotification').checked = d.settings?.idle_notification === true;
363
+ $('#idleNotification').disabled = false;
364
+ $('#skipDraft').checked = d.settings?.skip_draft === true;
365
+ $('#skipDraft').disabled = false;
366
+ $('#prs').checked = prsSetting = d.settings?.prs !== false;
367
+ $('#prs').disabled = false;
368
+ syncPRs();
302
369
  }).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
370
+ // An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
371
+ // → the saved settings
372
+ const saveToggle = (toggle, body) => BoardUI.waitOn(toggle, (async () => {
373
+ const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
374
+ const out = await r.json().catch(() => ({}));
375
+ if (!r.ok) throw new Error(out.error);
376
+ return out.settings;
377
+ })());
303
378
  B.on(autoMerge, 'change', async () => {
304
379
  const want = autoMerge.checked;
305
- 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.'))) {
380
+ const ask = opensPRs() ? 'Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'
381
+ : 'Turn on auto-merge? Each verified card lands on main without asking you.';
382
+ if (want && !(await BoardDialogs.confirm(ask))) {
306
383
  autoMerge.checked = false;
307
384
  return;
308
385
  }
309
386
  if (!B.alive()) return;
310
- autoMerge.disabled = true;
311
387
  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;
316
- autoMergeStatus.textContent = autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.';
388
+ autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
389
+ autoMergeStatus.textContent = opensPRs() ? (autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.')
390
+ : autoMerge.checked ? 'Saved. Each verified card lands on main by itself.' : 'Saved. Each verified card waits for your Merge.';
317
391
  } catch (e) {
318
392
  autoMerge.checked = !want;
319
393
  autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
320
394
  }
321
- autoMerge.disabled = false;
322
395
  });
323
396
 
324
397
  B.on($('#autoBuild'), 'change', async () => {
325
398
  const toggle = $('#autoBuild');
326
399
  const want = toggle.checked;
327
- toggle.disabled = true;
328
400
  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);
401
+ const settings = await saveToggle(toggle, { auto_build: want });
332
402
  if (!B.alive()) return;
333
- toggle.checked = out.settings.auto_build === true;
403
+ toggle.checked = settings.auto_build === true;
334
404
  $('#autoBuildStatus').textContent = BoardUI.tryWords(buildKind).saved(toggle.checked);
335
405
  } catch (e) {
336
406
  if (!B.alive()) return;
337
407
  toggle.checked = !want;
338
408
  $('#autoBuildStatus').textContent = e.message || 'Could not save. Is the host awake?';
339
- } finally { if (B.alive()) toggle.disabled = false; }
409
+ }
340
410
  });
341
411
 
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 () => {
412
+ // These Auto toggles save with no confirmation: docs/adr/2026-11-02-1754-an-idle-board-pulls-in-its-top-todo-card.md,
413
+ // docs/adr/2026-11-04-1900-triage-runs-once-overnight-on-cards-waiting-on-your-answers.md and
414
+ // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md; Idle notification too:
415
+ // docs/adr/2026-11-04-0257-an-idle-board-pushes-once-when-it-has-gone-idle.md.
416
+ const autoToggle = (id, key, saved) => B.on($(`#${id}`), 'change', async () => {
345
417
  const toggle = $(`#${id}`);
346
418
  const want = toggle.checked;
347
- toggle.disabled = true;
348
419
  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);
420
+ const settings = await saveToggle(toggle, { [key]: want });
352
421
  if (!B.alive()) return;
353
- toggle.checked = out.settings[key] === true;
422
+ toggle.checked = settings[key] === true;
354
423
  $(`#${id}Status`).textContent = saved(toggle.checked);
355
424
  } catch (e) {
356
425
  if (!B.alive()) return;
357
426
  toggle.checked = !want;
358
427
  $(`#${id}Status`).textContent = e.message || 'Could not save. Is the host awake?';
359
- } finally { if (B.alive()) toggle.disabled = false; }
428
+ }
429
+ });
430
+ autoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. Claude starts ready Todo cards while an implementer slot is free.' : 'Saved. Todo cards wait for you.'));
431
+
432
+ // docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers saves on
433
+ // change, waiting on the server like a toggle; a number outside 1 to 99 or a refusal puts the saved one back.
434
+ B.on($('#maxImplementers'), 'change', async () => {
435
+ const input = $('#maxImplementers');
436
+ const status = $('#maxImplementersStatus');
437
+ const want = /^\d+$/.test(input.value) ? Number(input.value) : 0;
438
+ if (want < 1 || want > 99) { input.value = savedMax; status.textContent = 'Pick a whole number from 1 to 99.'; return; }
439
+ try {
440
+ const settings = await saveToggle(input, { max_implementers: want });
441
+ if (!B.alive()) return;
442
+ input.value = savedMax = String(settings.max_implementers);
443
+ 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.`;
444
+ } catch (e) {
445
+ if (!B.alive()) return;
446
+ input.value = savedMax;
447
+ status.textContent = e.message || 'Could not save. Is the host awake?';
448
+ }
449
+ });
450
+ 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.'));
451
+ 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.'));
452
+ 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.'));
453
+ 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.'));
454
+ // PRs off needs Push main after a land, which the server checks; its refusal shows in the status line.
455
+ autoToggle('prs', 'prs', (on) => {
456
+ prsSetting = on;
457
+ syncPRs();
458
+ return on ? 'Saved. Each card opens a PR.' : 'Saved. Each card lands on main with no PR, and main is pushed.';
459
+ });
460
+
461
+ // Each switch writes or removes one allow rule in the board folder's .claude/settings.local.json through POST
462
+ // api/permissions (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md); the hub lets only
463
+ // the owner reach the board (docs/adr/2026-11-04-2156-the-whole-hub-answers-only-the-owner.md). A tap waits on the server with the
464
+ // switch as it was and a spinner beside it, then draws the server's answer.
465
+ const permissions = $('#permissions');
466
+ const REFUSED = {
467
+ denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
468
+ asked: (command) => `The session still asked before running ${command}, so it hasn’t picked the rule up yet. It applies once the session restarts.`,
469
+ };
470
+ const permRow = (p) => `<label class="who" for="perm-${esc(p.id)}"><span><span>${esc(p.name)}</span>
471
+ <span class="muted small sub">${esc(p.allows)} Writes <code>${esc(p.rule)}</code></span></span>
472
+ <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' : ''}></span></label>
473
+ <p class="muted small" id="perm-${esc(p.id)}Status" role="status"></p>
474
+ ${p.refused ? `<p class="muted small refused-note">${esc(REFUSED[p.refused.how](p.refused.command))}</p>` : ''}`;
475
+ const drawPermissions = (d) => {
476
+ permissions.innerHTML = d.permissions.map(permRow).join('');
477
+ };
478
+ get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
479
+ if (!B.alive()) return;
480
+ if (!ok) { permissions.innerHTML = `<p class="muted small">${esc(body.error || 'Could not load permissions.')}</p>`; return; }
481
+ drawPermissions(body);
482
+ }).catch(() => { if (B.alive()) permissions.innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
483
+ B.on(permissions, 'change', async (e) => {
484
+ const toggle = e.target.closest?.('input[data-perm]');
485
+ if (!toggle) return;
486
+ const id = toggle.dataset.perm;
487
+ const on = toggle.checked;
488
+ toggle.checked = !on;
489
+ try {
490
+ const r = await BoardUI.waitOn(toggle, fetch(site.url('api/permissions'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, on }) }));
491
+ const out = await r.json().catch(() => ({}));
492
+ if (!r.ok) throw new Error(out.error);
493
+ if (!B.alive()) return;
494
+ drawPermissions(out);
495
+ $(`#perm-${id}Status`).textContent = on ? 'Saved. The session runs it without asking.' : 'Saved. The session asks first again.';
496
+ } catch (error) {
497
+ if (B.alive()) $(`#perm-${id}Status`).textContent = error.message || 'Could not save. Is the host awake?';
498
+ }
360
499
  });
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
500
 
364
501
  // Saving writes board.json through POST api/rules, whose `rules {scope: "board"}` event wakes Claude to acknowledge
365
502
  // 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};