@lovek4d/kickflip 0.5.0 → 0.5.3

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 (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -1,9 +1,11 @@
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]}; 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, ✕,
2
+ // Saving reviewers sends a `reviewers` event {add: [{name, slack_user, github}], remove: [name]}; the server writes both
3
+ // itself and the list redraws from its answer. Add picks a Slack member and a GitHub login from lists the server fetches
4
+ // (GET api/reviewer-people), or a typed login, and stays off until both are chosen. Tickets save on each change, and Done, ✕,
5
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.
6
+ // asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
7
+ // lists the hub's GET /_hub/share/<route>, who can look and who was just refused, and saves each Add and ✕ at once
8
+ // through POST api/share.
7
9
  (globalThis.AppViews ||= {}).settings = {
8
10
  className: 'view-settings',
9
11
  title: () => 'Board Settings',
@@ -15,17 +17,30 @@
15
17
  <h2>Tickets</h2>
16
18
  <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>
17
19
  <form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
18
- <div class="tk-inline" id="ticketsKey" hidden></div>
19
20
  <p class="muted small" id="ticketsStatus" role="status"></p>
21
+ <div id="landingSection" hidden>
22
+ <h2>Landing</h2>
23
+ <p class="muted small" id="landingSub"></p>
24
+ <div class="tk-seg landing-seg" id="landing" role="group" aria-label="Landing">
25
+ <button type="button" data-landing="draft" aria-pressed="false" disabled><span>Draft PR</span><span class="small sub">Opens a draft; you mark it ready.</span></button>
26
+ <button type="button" data-landing="ready" aria-pressed="false" disabled><span>Ready PR</span><span class="small sub">Opens ready for review once CI passes.</span></button>
27
+ <button type="button" data-landing="merge" aria-pressed="false" disabled><span>Merge</span><span class="small sub">No PR; lands on the base branch.</span></button>
28
+ </div>
29
+ <p class="muted small" id="landingStatus" role="status"></p>
30
+ </div>
20
31
  <h2>Auto</h2>
21
32
  <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>
33
+ <span class="muted small sub">Claude grills each card that lands in Todo, and 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
34
  <input type="checkbox" class="toggle" id="autoPull" disabled></label>
24
35
  <p class="muted small" id="autoPullStatus" role="status"></p>
25
36
  <label class="who" for="maxImplementers"><span><span>Maximum parallel implementers</span>
26
37
  <span class="muted small sub">Cards building or waiting in Orchestrator review count. Grills and cards waiting on you don’t.</span></span>
27
38
  <input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
28
39
  <p class="muted small" id="maxImplementersStatus" role="status"></p>
40
+ <label class="who" for="maxPlanners"><span><span>Maximum parallel planners</span>
41
+ <span class="muted small sub">Grills and prototypes running at once. A card that lands in Todo past it waits its turn.</span></span>
42
+ <input type="number" class="count" id="maxPlanners" min="1" max="99" step="1" inputmode="numeric" disabled></label>
43
+ <p class="muted small" id="maxPlannersStatus" role="status"></p>
29
44
  <label class="who" for="autoBuild"><span><span>Auto serve</span>
30
45
  <span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
31
46
  <input type="checkbox" class="toggle" id="autoBuild" disabled></label>
@@ -34,19 +49,10 @@
34
49
  <span class="muted small sub">Claude reviews teammates’ PRs that touch this board’s work. May use a lot of usage.</span></span>
35
50
  <input type="checkbox" class="toggle" id="autoReview" disabled></label>
36
51
  <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
52
  <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>
53
+ <span class="muted small sub">Claude merges a PR once it is approved, green and merges cleanly, and lands a build waiting to merge into the base branch.</span></span>
43
54
  <input type="checkbox" class="toggle" id="autoMerge" disabled></label>
44
55
  <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
56
  <label class="who" for="autoTriage"><span><span>Triage overnight</span>
51
57
  <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
58
  <input type="checkbox" class="toggle" id="autoTriage" disabled></label>
@@ -62,13 +68,21 @@
62
68
  <h2>Reviewers</h2>
63
69
  <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>
64
70
  <div id="list"><p class="muted small">Loading…</p></div>
65
- <form class="add" id="addForm">
66
- <input id="addName" placeholder="Add someone by name" autocomplete="off">
67
- <button type="submit">Add</button>
71
+ <form class="add pick" id="addForm">
72
+ <div class="pick-col"><input id="slackFilter" placeholder="Slack member" autocomplete="off"><div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
73
+ <div class="pick-col"><input id="githubFilter" placeholder="GitHub login" autocomplete="off"><div id="githubPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
74
+ <button type="submit" id="addBtn" disabled>Add</button>
68
75
  </form>
76
+ <p class="muted small" id="pickStatus" role="status"></p>
69
77
  <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
70
- <div class="replies" id="replies"></div>
71
78
  </div>
79
+ <h2>Sharing</h2>
80
+ <p class="muted small">People listed here can look at this board, view only. Nothing else on the host opens for them.</p>
81
+ <div id="shareList"><p class="muted small">Loading…</p></div>
82
+ <div id="shareRefused"></div>
83
+ <form class="add" id="shareAdd"><input id="shareLogin" placeholder="Add by login, like name@github" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit">Add</button></form>
84
+ <div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page ↗</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
85
+ <p class="muted small" id="shareStatus" role="status"></p>
72
86
  <h2>Rules</h2>
73
87
  <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>
74
88
  <div id="rules"><p class="muted small">Loading…</p></div>
@@ -186,15 +200,24 @@
186
200
  const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
187
201
  let repo = {}, closeRepo = () => {};
188
202
  let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
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;
203
+ // Landing shows on a board on GitHub that builds from its shared clone; a local board never opens PRs. Its choice is
204
+ // the board's prs and skip_draft settings read together: Draft PR, prs on and skip_draft off; Ready PR, both on;
205
+ // Merge, prs off. Reviewers show only while cards open PRs
206
+ // (docs/adr/2026-11-05-0200-landing-is-one-choice-draft-pr-ready-pr-or-merge.md).
207
+ const LANDING = {
208
+ draft: { set: { prs: true, skip_draft: false }, sub: 'Each card opens a draft PR and waits for you to mark it ready for review.', saved: 'Saved. Each card opens a draft PR.' },
209
+ ready: { set: { prs: true, skip_draft: true }, sub: 'Each card opens a PR ready for review, without waiting for you.', saved: 'Saved. Each card opens a PR ready for review.' },
210
+ merge: { set: { prs: false }, sub: 'No PR opens: each card lands on the base branch and the base branch is pushed.', saved: 'Saved. Each card lands on the base branch with no PR, and the base branch is pushed.' },
211
+ };
212
+ let landing = 'draft';
213
+ const landingButtons = () => Object.keys(LANDING).map((value) => $(`[data-landing="${value}"]`));
214
+ const landingOf = (settings) => (settings?.prs === false ? 'merge' : settings?.skip_draft === true ? 'ready' : 'draft');
215
+ const opensPRs = () => Boolean(repo.github) && !repo.local && landing !== 'merge';
194
216
  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.';
217
+ $('#landingSection').hidden = !repo.github || Boolean(repo.local);
218
+ $('#reviewersSection').hidden = !opensPRs();
219
+ $('#landingSub').textContent = LANDING[landing].sub;
220
+ for (const button of landingButtons()) button.setAttribute('aria-pressed', String(button === $(`[data-landing="${landing}"]`)));
198
221
  };
199
222
  const showRepo = () => {
200
223
  $('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
@@ -276,11 +299,12 @@
276
299
  B.on(ticketsForm, 'submit', (e) => { e.preventDefault(); saveTickets(); });
277
300
  let buildKind = 'web';
278
301
  let current = [];
279
- const add = new Set();
302
+ let githubOf = {};
303
+ const add = new Map();
280
304
  const remove = new Set();
281
305
  function render() {
282
- const rows = [...current.map((n) => ({ n, cls: remove.has(n) ? 'gone' : '' })), ...[...add].map((n) => ({ n, cls: 'new' }))];
283
- $('#list').innerHTML = rows.length ? rows.map(({ n, cls }) => `<div class="who ${cls}"><span>${esc(n)}${cls === 'new' ? ' <span class="muted small">new</span>' : ''}</span>
306
+ const rows = [...current.map((n) => ({ n, cls: remove.has(n) ? 'gone' : '', github: githubOf[n] })), ...[...add.values()].map((a) => ({ n: a.name, cls: 'new', github: a.github }))];
307
+ $('#list').innerHTML = rows.length ? rows.map(({ n, cls, github }) => `<div class="who ${cls}"><span>${esc(n)}${cls === 'new' ? ' <span class="muted small">new</span>' : ''}${github ? `<span class="muted small sub">github: ${esc(github)}</span>` : ''}</span>
284
308
  <button class="ghost" data-name="${esc(n)}" aria-label="${cls === 'gone' ? 'Keep' : 'Remove'} ${esc(n)}">${cls === 'gone' ? 'Undo' : '✕'}</button></div>`).join('') : '<p class="muted small">No reviewers yet.</p>';
285
309
  $('#save').disabled = !add.size && !remove.size;
286
310
  if (add.size || remove.size) $('#list').setAttribute('data-dirty', '');
@@ -292,6 +316,7 @@
292
316
  buildKind = d.kind || 'web';
293
317
  $('#autoBuildSub').textContent = BoardUI.tryWords(buildKind).autoSub;
294
318
  current = d.reviewers || [];
319
+ githubOf = d.reviewerGithub || {};
295
320
  render();
296
321
  }).catch(() => { if (B.alive()) $('#list').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
297
322
 
@@ -304,39 +329,66 @@
304
329
  else remove.add(n);
305
330
  render();
306
331
  });
332
+
333
+ // The pickers: tap a Slack member and a GitHub login (or type one), then Add stages them for Save.
334
+ let people = { slack: [], github: [] };
335
+ const picked = { slack: null, github: '' };
336
+ const fits = (text, filter) => text.toLowerCase().includes(filter.trim().toLowerCase());
337
+ function renderPick() {
338
+ const slackFilter = $('#slackFilter').value, githubFilter = $('#githubFilter').value.trim();
339
+ const taken = new Set([...current, ...add.keys()].map((n) => n.toLowerCase()));
340
+ const slack = (people.slack || []).filter((p) => fits(p.name, slackFilter) && !taken.has(p.name.toLowerCase()));
341
+ $('#slackPeople').innerHTML = people.slack ? slack.map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}" aria-pressed="${picked.slack?.id === p.id}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>'
342
+ : `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
343
+ const logins = people.github.filter((l) => fits(l, githubFilter));
344
+ const typed = githubFilter && !people.github.some((l) => l.toLowerCase() === githubFilter.toLowerCase()) ? [githubFilter] : [];
345
+ $('#githubPeople').innerHTML = [...typed, ...logins].map((l) => `<button type="button" class="ghost" data-github="${esc(l)}" aria-pressed="${picked.github === l}">${typed.includes(l) ? `Use ${esc(l)}` : esc(l)}</button>`).join('')
346
+ + (people.githubProblem ? `<p class="muted small">${esc(people.githubProblem)}</p>` : '');
347
+ $('#addBtn').disabled = !picked.slack || !picked.github;
348
+ }
349
+ get(site.url('api/reviewer-people'), waitIn('#slackPeople')).then(({ body }) => { if (!B.alive()) return; people = { github: [], ...body }; renderPick(); })
350
+ .catch(() => { if (B.alive()) $('#slackPeople').innerHTML = '<p class="muted small">Offline. Is the host awake?</p>'; });
351
+ B.on($('#slackFilter'), 'input', renderPick);
352
+ B.on($('#githubFilter'), 'input', renderPick);
353
+ B.on($('#slackPeople'), 'click', (ev) => {
354
+ const b = ev.target.closest('button[data-slack]');
355
+ if (b) { picked.slack = { id: b.dataset.slack, name: b.dataset.name }; renderPick(); }
356
+ });
357
+ B.on($('#githubPeople'), 'click', (ev) => {
358
+ const b = ev.target.closest('button[data-github]');
359
+ if (b) { picked.github = b.dataset.github; renderPick(); }
360
+ });
307
361
  B.on($('#addForm'), 'submit', (ev) => {
308
362
  ev.preventDefault();
309
- const input = $('#addName');
310
- const n = input.value.trim();
311
- if (n && !current.some((c) => c.toLowerCase() === n.toLowerCase())) add.add(n);
312
- input.value = '';
363
+ if (!picked.slack || !picked.github) return;
364
+ add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github: picked.github });
365
+ picked.slack = null; picked.github = '';
366
+ $('#slackFilter').value = ''; $('#githubFilter').value = '';
367
+ renderPick();
313
368
  render();
314
369
  });
315
370
  B.on($('#save'), 'click', async () => {
316
371
  const status = $('#saveStatus');
317
372
  try {
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';
373
+ const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add.values()], remove: [...remove] }));
374
+ status.textContent = 'Saved';
320
375
  current = out.board.reviewers;
321
- add.clear(); remove.clear(); render();
376
+ githubOf = out.board.reviewerGithub || {};
377
+ add.clear(); remove.clear(); render(); renderPick();
322
378
  } catch (e) { status.textContent = e?.reason || 'Could not send. Is the host awake?'; }
323
379
  });
324
- B.watch((events) => {
325
- $('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
326
- }, { kind: 'reviewers' });
327
380
 
328
381
  // The toggle saves at once through POST api/settings, which writes board.json itself, so it takes effect with no
329
382
  // session awake (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
330
383
  const autoMerge = $('#autoMerge');
331
384
  const autoMergeStatus = $('#autoMergeStatus');
332
- let savedMax = '';
385
+ const savedCaps = {};
333
386
  get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
334
387
  if (!B.alive()) return;
335
388
  savedTickets = d.settings?.tickets || null;
336
389
  tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
337
390
  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),
391
+ keyState: async () => (await get('/_hub/api/settings')).body.linear,
340
392
  change(_value, _valid, { loaded, typing } = {}) {
341
393
  if (!loaded) ticketsTouched = true;
342
394
  if (typing) syncTickets(); else saveTickets();
@@ -353,32 +405,32 @@
353
405
  $('#autoBuild').disabled = false;
354
406
  $('#autoPull').checked = d.settings?.auto_pull === true;
355
407
  $('#autoPull').disabled = false;
356
- $('#maxImplementers').value = savedMax = String(d.settings?.max_implementers);
357
- $('#maxImplementers').disabled = false;
408
+ for (const [id, key] of [['maxImplementers', 'max_implementers'], ['maxPlanners', 'max_planners']]) {
409
+ $(`#${id}`).value = savedCaps[key] = String(d.settings?.[key]);
410
+ $(`#${id}`).disabled = false;
411
+ }
358
412
  $('#autoTriage').checked = d.settings?.auto_triage === true;
359
413
  $('#autoTriage').disabled = false;
360
414
  $('#autoReview').checked = d.settings?.auto_review === true;
361
415
  $('#autoReview').disabled = false;
362
416
  $('#idleNotification').checked = d.settings?.idle_notification === true;
363
417
  $('#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;
418
+ landing = landingOf(d.settings);
419
+ for (const button of landingButtons()) button.disabled = false;
368
420
  syncPRs();
369
421
  }).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
370
422
  // An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
371
423
  // → the saved settings
372
- const saveToggle = (toggle, body) => BoardUI.waitOn(toggle, (async () => {
424
+ const saveToggle = (toggle, body, wait) => BoardUI.waitOn(toggle, (async () => {
373
425
  const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
374
426
  const out = await r.json().catch(() => ({}));
375
427
  if (!r.ok) throw new Error(out.error);
376
428
  return out.settings;
377
- })());
429
+ })(), wait);
378
430
  B.on(autoMerge, 'change', async () => {
379
431
  const want = autoMerge.checked;
380
432
  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.';
433
+ : 'Turn on auto-merge? Each verified card lands on the base branch without asking you.';
382
434
  if (want && !(await BoardDialogs.confirm(ask))) {
383
435
  autoMerge.checked = false;
384
436
  return;
@@ -387,7 +439,7 @@
387
439
  try {
388
440
  autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
389
441
  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.';
442
+ : autoMerge.checked ? 'Saved. Each verified card lands on the base branch by itself.' : 'Saved. Each verified card waits for your Merge.';
391
443
  } catch (e) {
392
444
  autoMerge.checked = !want;
393
445
  autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
@@ -427,41 +479,99 @@
427
479
  $(`#${id}Status`).textContent = e.message || 'Could not save. Is the host awake?';
428
480
  }
429
481
  });
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.'));
482
+ autoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. Claude grills each card that lands in Todo and starts ready ones while an implementer slot is free.' : 'Saved. Todo cards wait for you.'));
431
483
 
432
- // docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers saves on
484
+ // docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers, and
485
+ // Maximum parallel planners (docs/adr/2026-11-04-2359-every-card-arriving-in-todo-is-grilled-by-the-board.md), save on
433
486
  // 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');
487
+ const capInput = (id, key, saidOf) => B.on($(`#${id}`), 'change', async () => {
488
+ const input = $(`#${id}`);
489
+ const status = $(`#${id}Status`);
437
490
  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; }
491
+ if (want < 1 || want > 99) { input.value = savedCaps[key]; status.textContent = 'Pick a whole number from 1 to 99.'; return; }
439
492
  try {
440
- const settings = await saveToggle(input, { max_implementers: want });
493
+ const settings = await saveToggle(input, { [key]: want });
441
494
  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.`;
495
+ input.value = savedCaps[key] = String(settings[key]);
496
+ status.textContent = saidOf(settings[key]);
444
497
  } catch (e) {
445
498
  if (!B.alive()) return;
446
- input.value = savedMax;
499
+ input.value = savedCaps[key];
447
500
  status.textContent = e.message || 'Could not save. Is the host awake?';
448
501
  }
449
502
  });
503
+ capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1 card builds or waits' : `${n} cards build or wait`} in Orchestrator review at once.`);
504
+ capInput('maxPlanners', 'max_planners', (n) => `Saved. Up to ${n === 1 ? '1 grill or prototype runs' : `${n} grills or prototypes run`} at once.`);
450
505
  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
506
  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
507
  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;
508
+ // A Landing tap saves at once with no confirmation, its choice's settings in one POST. Merge needs Push main after a
509
+ // land, which the server checks; its refusal shows in the status line and the saved choice stays pressed.
510
+ for (const want of Object.keys(LANDING)) B.on($(`[data-landing="${want}"]`), 'click', async () => {
511
+ const button = $(`[data-landing="${want}"]`);
512
+ if (want === landing) return;
513
+ try {
514
+ const settings = await saveToggle(button, LANDING[want].set, { group: landingButtons() });
515
+ if (!B.alive()) return;
516
+ landing = landingOf(settings);
517
+ $('#landingStatus').textContent = LANDING[landing].saved;
518
+ } catch (err) {
519
+ if (!B.alive()) return;
520
+ $('#landingStatus').textContent = err.message || 'Could not save. Is the host awake?';
521
+ }
457
522
  syncPRs();
458
- return on ? 'Saved. Each card opens a PR.' : 'Saved. Each card lands on main with no PR, and main is pushed.';
459
523
  });
460
524
 
461
525
  // Each switch writes or removes one allow rule in the board folder's .claude/settings.local.json through POST
462
526
  // api/permissions (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md); the hub lets only
463
527
  // 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
528
  // switch as it was and a spinner beside it, then draws the server's answer.
529
+ // Sharing: each row is the person's name, then their login and when; a login Tailscale sent no name with is its own.
530
+ let sharing = null;
531
+ const shareRow = (title, sub, button) => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span></span>${button}</div>`;
532
+ const shareWhen = (p) => (p.now ? '<span class="share-now">viewing now</span>' : esc(p.at ? BoardUI.since(p.at) : 'not seen yet'));
533
+ const shareSub = (p, when) => (p.name ? `${esc(p.login)} · ${when}` : when);
534
+ function drawSharing() {
535
+ $('#shareList').innerHTML = sharing.people.length ? sharing.people.map((p) => shareRow(p.name || p.login, shareSub(p, shareWhen(p)),
536
+ `<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button>`)).join('') : '<p class="muted small">Nobody yet.</p>';
537
+ $('#shareRefused').innerHTML = sharing.refused.length ? `<p class="muted small">Just refused. Tap to share with them:</p>${sharing.refused.map((r) => shareRow(r.name || r.login, shareSub(r, esc(BoardUI.since(r.at))),
538
+ `<button type="button" data-share-add="${esc(r.login)}">Add</button>`)).join('')}` : '';
539
+ }
540
+ const loadSharing = () => get(`/_hub/share/${encodeURIComponent(route)}`).then(({ ok, body }) => {
541
+ if (!B.alive()) return;
542
+ if (!ok) { $('#shareList').innerHTML = `<p class="muted small">${esc(body.error || 'Could not load sharing.')}</p>`; return; }
543
+ sharing = body;
544
+ drawSharing();
545
+ }).catch(() => { if (B.alive()) $('#shareList').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
546
+ async function saveShare(change) {
547
+ $('#shareStatus').textContent = '';
548
+ try {
549
+ const r = await fetch(site.url('api/share'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(change) });
550
+ const out = await r.json().catch(() => ({}));
551
+ if (!r.ok) throw new Error(out.error);
552
+ await loadSharing();
553
+ return true;
554
+ } catch (error) {
555
+ if (B.alive()) $('#shareStatus').textContent = error.message || 'Could not save. Is the host awake?';
556
+ return false;
557
+ }
558
+ }
559
+ loadSharing();
560
+ B.on($('#shareList'), 'click', (e) => { const b = e.target.closest?.('[data-share-remove]'); if (b) saveShare({ remove: b.dataset.shareRemove }); });
561
+ B.on($('#shareRefused'), 'click', (e) => { const b = e.target.closest?.('[data-share-add]'); if (b) saveShare({ add: b.dataset.shareAdd }); });
562
+ B.on($('#shareAdd'), 'submit', async (e) => {
563
+ e.preventDefault?.();
564
+ const input = $('#shareLogin');
565
+ const login = input.value.trim();
566
+ if (!login) { $('#shareStatus').textContent = 'Type a login first, like name@github.'; return; }
567
+ if (await saveShare({ add: login }) && B.alive()) input.value = '';
568
+ });
569
+ B.on($('#shareCopy'), 'click', async () => {
570
+ const link = sharing?.link || new URL(`/${encodeURIComponent(route)}/`, location.href).href;
571
+ try { await navigator.clipboard.writeText(link); $('#shareStatus').textContent = `Copied ${link}`; }
572
+ catch { $('#shareStatus').textContent = link; }
573
+ });
574
+
465
575
  const permissions = $('#permissions');
466
576
  const REFUSED = {
467
577
  denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
@@ -475,6 +585,12 @@
475
585
  const drawPermissions = (d) => {
476
586
  permissions.innerHTML = d.permissions.map(permRow).join('');
477
587
  };
588
+ // A switch whose saved face no answer has drawn in BoardUI's PENDING_LATE_MS: the switches load once and rest.
589
+ function stillWaiting() {
590
+ if (!B.alive()) return;
591
+ B.toast('Still waiting on the board');
592
+ get(site.url('api/permissions')).then(({ ok, body }) => { if (ok && B.alive()) drawPermissions(body); }, () => {});
593
+ }
478
594
  get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
479
595
  if (!B.alive()) return;
480
596
  if (!ok) { permissions.innerHTML = `<p class="muted small">${esc(body.error || 'Could not load permissions.')}</p>`; return; }
@@ -486,14 +602,17 @@
486
602
  const id = toggle.dataset.perm;
487
603
  const on = toggle.checked;
488
604
  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 }) }));
605
+ // The switch spins until the answer is drawn, so it never shows its old face in between
606
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
607
+ const saving = (async () => {
608
+ const r = await fetch(site.url('api/permissions'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, on }) });
491
609
  const out = await r.json().catch(() => ({}));
492
610
  if (!r.ok) throw new Error(out.error);
493
611
  if (!B.alive()) return;
494
612
  drawPermissions(out);
495
613
  $(`#perm-${id}Status`).textContent = on ? 'Saved. The session runs it without asking.' : 'Saved. The session asks first again.';
496
- } catch (error) {
614
+ })();
615
+ try { await BoardUI.waitOn(toggle, BoardUI.pending.within(saving, stillWaiting)); } catch (error) {
497
616
  if (B.alive()) $(`#perm-${id}Status`).textContent = error.message || 'Could not save. Is the host awake?';
498
617
  }
499
618
  });