@lovek4d/kickflip 0.1.0 → 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 (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -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 +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,7 +1,9 @@
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. An unsaved edit marks [data-dirty], and the
4
- // shell asks before closing the modal then.
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, ✕,
5
+ // Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
6
+ // asks before closing the modal then.
5
7
  (globalThis.AppViews ||= {}).settings = {
6
8
  className: 'view-settings',
7
9
  title: () => 'Board Settings',
@@ -12,21 +14,50 @@
12
14
  <p class="muted small" id="boardRepoStatus"></p>
13
15
  <h2>Tickets</h2>
14
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>
15
- <form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div><button type="submit" id="ticketsSave" disabled>Save</button></form>
17
+ <form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
18
+ <div class="tk-inline" id="ticketsKey" hidden></div>
16
19
  <p class="muted small" id="ticketsStatus" role="status"></p>
17
- <h2>Merging</h2>
18
- <label class="who" for="autoMerge">
19
- <span><span>Auto-merge approved PRs</span>
20
- <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>
21
- <input type="checkbox" class="toggle" id="autoMerge" disabled>
22
- </label>
23
- <p class="muted small" id="autoMergeStatus"></p>
24
- <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>
25
33
  <label class="who" for="autoBuild"><span><span>Auto-build cards</span>
26
34
  <span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
27
35
  <input type="checkbox" class="toggle" id="autoBuild" disabled></label>
28
36
  <p class="muted small" id="autoBuildStatus" role="status"></p>
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>
52
+ <input type="checkbox" class="toggle" id="autoTriage" disabled></label>
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>
29
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>
30
61
  <h2>Reviewers</h2>
31
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>
32
63
  <div id="list"><p class="muted small">Loading…</p></div>
@@ -66,12 +97,20 @@
66
97
  const modal = $('#boardSettingsModal');
67
98
  const previous = document.activeElement;
68
99
  const unfitModal = BoardDialogs.fitSheet(modal);
69
- B.on(modal, 'cancel', (e) => { e.preventDefault(); ctx.dismiss?.(); });
100
+ let dismissing = false;
101
+ function close() {
102
+ if (tickets) saveTickets();
103
+ if (!savingTickets) { ctx.dismiss?.(); return; }
104
+ if (dismissing) return;
105
+ dismissing = true;
106
+ savingTickets.then(() => { dismissing = false; if (B.alive()) ctx.dismiss?.(); });
107
+ }
108
+ B.on(modal, 'cancel', (e) => { e.preventDefault(); close(); });
70
109
  B.on(modal, 'click', (e) => {
71
- if (e.target.closest?.('[data-settings-dismiss]')) ctx.dismiss?.();
110
+ if (e.target.closest?.('[data-settings-dismiss]')) close();
72
111
  else if (e.target === modal) {
73
112
  const rect = modal.getBoundingClientRect();
74
- if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) ctx.dismiss?.();
113
+ if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) close();
75
114
  }
76
115
  });
77
116
  B.atUnmount(() => { unfitModal(); modal.close(); if (previous?.isConnected) previous.focus({ preventScroll: true }); });
@@ -89,13 +128,13 @@
89
128
  const { body: d } = await get(`/_hub/close-board/${encodeURIComponent(route)}`);
90
129
  if (!B.alive()) return;
91
130
  closure = d;
92
- closeRow.disabled = d.hub === true;
93
- $('#closeBoardReason').textContent = d.hub ? 'The hub board cannot be closed while it is flagged as the hub.' : '';
131
+ closeRow.disabled = false;
132
+ $('#closeBoardReason').textContent = '';
94
133
  $('#closeBoardName').textContent = d.title;
95
134
  $('#closeBoardTypeName').textContent = d.title;
96
135
  const warnings = [...(d.prs || []).map((p) => `<li>PR #${esc(p.pr)} (${esc(p.repo)}) — ${esc(p.wave)}/${esc(p.id)}</li>`),
97
136
  ...(d.builders || []).map((b) => `<li>${esc(b.label)} — ${esc(b.key)}</li>`)];
98
- $('#closeBoardWarning').innerHTML = warnings.length ? `<h3>This board still has active work</h3><ul>${warnings.join('')}</ul><p>Running builder records will be closed with a note. PRs stay open on GitHub; this board stops polling them.</p>` : '';
137
+ $('#closeBoardWarning').innerHTML = warnings.length ? `<h3>This board still has active work</h3><ul>${warnings.join('')}</ul><p>Running implementers will be stopped with a note. PRs stay open on GitHub; this board stops polling them.</p>` : '';
99
138
  };
100
139
  loadClosure().catch(() => { if (B.alive()) $('#closeBoardReason').textContent = 'Could not load Close board. Is the host awake?'; });
101
140
  B.on(closeRow, 'click', async () => {
@@ -103,7 +142,7 @@
103
142
  closeRow.disabled = true;
104
143
  try {
105
144
  await loadClosure();
106
- if (!B.alive() || closure.hub) return;
145
+ if (!B.alive()) return;
107
146
  typed.value = '';
108
147
  closeConfirm.disabled = true;
109
148
  $('#closeBoardError').textContent = '';
@@ -119,13 +158,17 @@
119
158
  B.on(closeSheet, 'click', (e) => { if (e.target === closeSheet && !closing) closeSheet.close(); });
120
159
  B.on($('#closeBoardForm'), 'submit', async (e) => {
121
160
  e.preventDefault();
122
- if (closing || closure?.hub || typed.value !== closure?.title) return;
161
+ if (closing || typed.value !== closure?.title) return;
123
162
  closing = true;
124
- closeConfirm.disabled = true;
125
- try {
163
+ // Close board waits until the hub has stopped the board's session and server.
164
+ const post = (async () => {
126
165
  const r = await fetch('/_hub/close-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route, name: typed.value }) });
127
166
  const out = await r.json();
128
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);
129
172
  if (!B.alive()) return;
130
173
  closeSheet.close();
131
174
  ctx.sidebarChanged?.();
@@ -140,19 +183,17 @@
140
183
  B.atUnmount(() => { unfit?.(); closeSheet.close(); });
141
184
  const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
142
185
  let repo = {}, closeRepo = () => {};
143
- let tickets, savedTickets, savingTickets = false, ticketsTouched = false;
144
- const showRepo = () => { $('#boardRepoValue').textContent = repo.github ? `${repo.github} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo'; };
186
+ let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
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
+ };
145
192
  get(repoURL).then(({ body }) => {
146
193
  if (!B.alive()) return;
147
194
  repo = body;
148
195
  tickets?.render();
149
196
  if (tickets) syncTickets();
150
- if (repo.hub) {
151
- $('#boardRepoValue').textContent = 'Kickflip’s own clone';
152
- $('#boardRepoChange').hidden = true;
153
- $('#boardRepoStatus').textContent = `${repo.repo} · The hub board’s builders branch from its main.`;
154
- return;
155
- }
156
197
  showRepo();
157
198
  $('#boardRepo').disabled = false;
158
199
  }).catch(() => { $('#boardRepoValue').textContent = 'Could not load repo.'; });
@@ -172,30 +213,56 @@
172
213
  B.atUnmount(() => closeRepo());
173
214
  B.atUnmount(() => tickets?.destroy());
174
215
  const ticketsForm = $('#ticketsForm');
175
- const ticketsSave = $('#ticketsSave');
176
216
  const ticketsStatus = $('#ticketsStatus');
217
+ const unsaved = () => ticketsTouched && JSON.stringify(tickets.read()) !== JSON.stringify(savedTickets);
177
218
  function syncTickets() {
178
- const dirty = ticketsTouched && JSON.stringify(tickets.read()) !== JSON.stringify(savedTickets);
179
- if (dirty) ticketsForm.setAttribute('data-dirty', '');
219
+ if (unsaved()) ticketsForm.setAttribute('data-dirty', '');
180
220
  else ticketsForm.removeAttribute('data-dirty');
181
- ticketsSave.disabled = savingTickets || !dirty || !tickets.valid();
182
221
  }
183
- B.on(ticketsForm, 'submit', async (e) => {
184
- e.preventDefault();
185
- if (!tickets || ticketsSave.disabled || savingTickets) return;
186
- savingTickets = true;
187
- const value = tickets.read();
188
- syncTickets();
222
+ async function postTickets(value) {
189
223
  try {
190
224
  const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tickets: value }) });
191
225
  const out = await r.json();
192
226
  if (!r.ok) throw new Error(out.error || 'Could not save tickets.');
193
- if (!B.alive()) return;
194
227
  savedTickets = out.settings.tickets;
195
- ticketsStatus.textContent = 'Saved.';
196
- } catch (e) { if (B.alive()) ticketsStatus.textContent = e.message || 'Could not save. Is the host awake?'; }
197
- finally { savingTickets = false; if (B.alive()) syncTickets(); }
198
- });
228
+ return true;
229
+ } catch (e) {
230
+ if (B.alive()) ticketsStatus.textContent = e.message || 'Could not save. Is the host awake?';
231
+ return false;
232
+ }
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);
244
+ // One save at a time: a change made while one is out saves once it lands.
245
+ function saveTickets() {
246
+ syncTickets();
247
+ if (savingTickets || !unsaved() || !tickets.valid()) return;
248
+ ticketsStatus.textContent = '';
249
+ LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), true);
250
+ savingTickets = (async () => {
251
+ let saved, value;
252
+ do {
253
+ value = tickets.read();
254
+ saved = await postTickets(value);
255
+ } while (saved && B.alive() && tickets.valid() && JSON.stringify(tickets.read()) !== JSON.stringify(value));
256
+ savingTickets = null;
257
+ if (!B.alive()) return;
258
+ if (saved) ticketsStatus.textContent = 'Saved';
259
+ LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), false);
260
+ syncTickets();
261
+ })();
262
+ BoardUI.waitOn(ticketsForm.querySelector(ticketsTapped) || ticketsForm.querySelector('[data-tk-source][aria-pressed="true"]'), savingTickets,
263
+ { group: ticketsForm.querySelectorAll('button, select, input') });
264
+ }
265
+ B.on(ticketsForm, 'submit', (e) => { e.preventDefault(); saveTickets(); });
199
266
  let buildKind = 'web';
200
267
  let current = [];
201
268
  const add = new Set();
@@ -237,25 +304,32 @@
237
304
  B.on($('#save'), 'click', async () => {
238
305
  const status = $('#saveStatus');
239
306
  try {
240
- 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;
241
310
  add.clear(); remove.clear(); render();
242
- status.textContent = 'Sent. Claude updates the list and replies below.';
243
- } catch { status.textContent = 'Could not send. Is the host awake?'; }
311
+ } catch (e) { status.textContent = e?.reason || 'Could not send. Is the host awake?'; }
244
312
  });
245
313
  B.watch((events) => {
246
314
  $('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
247
315
  }, { kind: 'reviewers' });
248
316
 
249
317
  // The toggle saves at once through POST api/settings, which writes board.json itself, so it takes effect with no
250
- // 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).
251
319
  const autoMerge = $('#autoMerge');
252
320
  const autoMergeStatus = $('#autoMergeStatus');
321
+ let savedMax = '';
253
322
  get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
254
323
  if (!B.alive()) return;
255
324
  savedTickets = d.settings?.tickets || null;
256
325
  tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
257
326
  loadLinear: async () => (await get('/_hub/api/linear')).body,
258
- change(_value, _valid, { loaded } = {}) { if (!loaded) ticketsTouched = true; syncTickets(); }, createProject: async (team, name) => {
327
+ keyState: async () => (await get('/_hub/api/settings')).body.linear, keySlot: $('#ticketsKey'),
328
+ keyBusy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on),
329
+ change(_value, _valid, { loaded, typing } = {}) {
330
+ if (!loaded) ticketsTouched = true;
331
+ if (typing) syncTickets(); else saveTickets();
332
+ }, createProject: async (team, name) => {
259
333
  const r = await fetch('/_hub/linear/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ team, name }) });
260
334
  const out = await r.json().catch(() => ({}));
261
335
  if (!r.ok) throw new Error(out.error || 'Could not create the project. Is the host awake?');
@@ -266,7 +340,29 @@
266
340
  autoMerge.disabled = false;
267
341
  $('#autoBuild').checked = d.settings?.auto_build === true;
268
342
  $('#autoBuild').disabled = false;
343
+ $('#autoPull').checked = d.settings?.auto_pull === true;
344
+ $('#autoPull').disabled = false;
345
+ $('#maxImplementers').value = savedMax = String(d.settings?.max_implementers);
346
+ $('#maxImplementers').disabled = false;
347
+ $('#autoTriage').checked = d.settings?.auto_triage === true;
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;
269
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
+ })());
270
366
  B.on(autoMerge, 'change', async () => {
271
367
  const want = autoMerge.checked;
272
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.'))) {
@@ -274,36 +370,113 @@
274
370
  return;
275
371
  }
276
372
  if (!B.alive()) return;
277
- autoMerge.disabled = true;
278
373
  try {
279
- const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ auto_merge: want }) });
280
- const out = await r.json().catch(() => ({}));
281
- if (!r.ok) throw new Error(out.error);
282
- autoMerge.checked = out.settings.auto_merge === true;
374
+ autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
283
375
  autoMergeStatus.textContent = autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.';
284
376
  } catch (e) {
285
377
  autoMerge.checked = !want;
286
378
  autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
287
379
  }
288
- autoMerge.disabled = false;
289
380
  });
290
381
 
291
382
  B.on($('#autoBuild'), 'change', async () => {
292
383
  const toggle = $('#autoBuild');
293
384
  const want = toggle.checked;
294
- toggle.disabled = true;
295
385
  try {
296
- const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ auto_build: want }) });
297
- const out = await r.json();
298
- if (!r.ok) throw new Error(out.error);
386
+ const settings = await saveToggle(toggle, { auto_build: want });
299
387
  if (!B.alive()) return;
300
- toggle.checked = out.settings.auto_build === true;
388
+ toggle.checked = settings.auto_build === true;
301
389
  $('#autoBuildStatus').textContent = BoardUI.tryWords(buildKind).saved(toggle.checked);
302
390
  } catch (e) {
303
391
  if (!B.alive()) return;
304
392
  toggle.checked = !want;
305
393
  $('#autoBuildStatus').textContent = e.message || 'Could not save. Is the host awake?';
306
- } finally { if (B.alive()) toggle.disabled = false; }
394
+ }
395
+ });
396
+
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 () => {
402
+ const toggle = $(`#${id}`);
403
+ const want = toggle.checked;
404
+ try {
405
+ const settings = await saveToggle(toggle, { [key]: want });
406
+ if (!B.alive()) return;
407
+ toggle.checked = settings[key] === true;
408
+ $(`#${id}Status`).textContent = saved(toggle.checked);
409
+ } catch (e) {
410
+ if (!B.alive()) return;
411
+ toggle.checked = !want;
412
+ $(`#${id}Status`).textContent = e.message || 'Could not save. Is the host awake?';
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
+ }
307
480
  });
308
481
 
309
482
  // Saving writes board.json through POST api/rules, whose `rules {scope: "board"}` event wakes Claude to acknowledge
@@ -0,0 +1 @@
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};