@lovek4d/kickflip 0.4.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.
- package/CONTEXT.md +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- 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
|
|
3
|
-
// redraws from its answer
|
|
4
|
-
//
|
|
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,22 +17,31 @@
|
|
|
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>
|
|
29
|
-
<label class="who" for="
|
|
30
|
-
<span class="muted small sub">
|
|
31
|
-
<input type="
|
|
32
|
-
<p class="muted small" id="
|
|
33
|
-
<label class="who" for="autoBuild"><span><span>Auto
|
|
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>
|
|
44
|
+
<label class="who" for="autoBuild"><span><span>Auto serve</span>
|
|
34
45
|
<span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
|
|
35
46
|
<input type="checkbox" class="toggle" id="autoBuild" disabled></label>
|
|
36
47
|
<p class="muted small" id="autoBuildStatus" role="status"></p>
|
|
@@ -39,14 +50,9 @@
|
|
|
39
50
|
<input type="checkbox" class="toggle" id="autoReview" disabled></label>
|
|
40
51
|
<p class="muted small" id="autoReviewStatus" role="status"></p>
|
|
41
52
|
<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>
|
|
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>
|
|
@@ -58,15 +64,25 @@
|
|
|
58
64
|
<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
65
|
<h2>Permissions</h2>
|
|
60
66
|
<div id="permissions"><p class="muted small">Loading…</p></div>
|
|
67
|
+
<div id="reviewersSection" hidden>
|
|
61
68
|
<h2>Reviewers</h2>
|
|
62
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>
|
|
63
70
|
<div id="list"><p class="muted small">Loading…</p></div>
|
|
64
|
-
<form class="add" id="addForm">
|
|
65
|
-
<input id="
|
|
66
|
-
<
|
|
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>
|
|
67
75
|
</form>
|
|
76
|
+
<p class="muted small" id="pickStatus" role="status"></p>
|
|
68
77
|
<div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
|
|
69
|
-
|
|
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>
|
|
70
86
|
<h2>Rules</h2>
|
|
71
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>
|
|
72
88
|
<div id="rules"><p class="muted small">Loading…</p></div>
|
|
@@ -184,10 +200,28 @@
|
|
|
184
200
|
const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
|
|
185
201
|
let repo = {}, closeRepo = () => {};
|
|
186
202
|
let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
|
|
187
|
-
//
|
|
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';
|
|
216
|
+
const syncPRs = () => {
|
|
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}"]`)));
|
|
221
|
+
};
|
|
188
222
|
const showRepo = () => {
|
|
189
223
|
$('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
|
|
190
|
-
|
|
224
|
+
syncPRs();
|
|
191
225
|
};
|
|
192
226
|
get(repoURL).then(({ body }) => {
|
|
193
227
|
if (!B.alive()) return;
|
|
@@ -265,11 +299,12 @@
|
|
|
265
299
|
B.on(ticketsForm, 'submit', (e) => { e.preventDefault(); saveTickets(); });
|
|
266
300
|
let buildKind = 'web';
|
|
267
301
|
let current = [];
|
|
268
|
-
|
|
302
|
+
let githubOf = {};
|
|
303
|
+
const add = new Map();
|
|
269
304
|
const remove = new Set();
|
|
270
305
|
function render() {
|
|
271
|
-
const rows = [...current.map((n) => ({ n, cls: remove.has(n) ? 'gone' : '' })), ...[...add].map((
|
|
272
|
-
$('#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>
|
|
273
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>';
|
|
274
309
|
$('#save').disabled = !add.size && !remove.size;
|
|
275
310
|
if (add.size || remove.size) $('#list').setAttribute('data-dirty', '');
|
|
@@ -281,6 +316,7 @@
|
|
|
281
316
|
buildKind = d.kind || 'web';
|
|
282
317
|
$('#autoBuildSub').textContent = BoardUI.tryWords(buildKind).autoSub;
|
|
283
318
|
current = d.reviewers || [];
|
|
319
|
+
githubOf = d.reviewerGithub || {};
|
|
284
320
|
render();
|
|
285
321
|
}).catch(() => { if (B.alive()) $('#list').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
|
|
286
322
|
|
|
@@ -293,39 +329,66 @@
|
|
|
293
329
|
else remove.add(n);
|
|
294
330
|
render();
|
|
295
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
|
+
});
|
|
296
361
|
B.on($('#addForm'), 'submit', (ev) => {
|
|
297
362
|
ev.preventDefault();
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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();
|
|
302
368
|
render();
|
|
303
369
|
});
|
|
304
370
|
B.on($('#save'), 'click', async () => {
|
|
305
371
|
const status = $('#saveStatus');
|
|
306
372
|
try {
|
|
307
|
-
const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add], remove: [...remove] }));
|
|
308
|
-
status.textContent =
|
|
373
|
+
const out = await BoardUI.waitOn($('#save'), site.send('reviewers', { add: [...add.values()], remove: [...remove] }));
|
|
374
|
+
status.textContent = 'Saved';
|
|
309
375
|
current = out.board.reviewers;
|
|
310
|
-
|
|
376
|
+
githubOf = out.board.reviewerGithub || {};
|
|
377
|
+
add.clear(); remove.clear(); render(); renderPick();
|
|
311
378
|
} catch (e) { status.textContent = e?.reason || 'Could not send. Is the host awake?'; }
|
|
312
379
|
});
|
|
313
|
-
B.watch((events) => {
|
|
314
|
-
$('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
|
|
315
|
-
}, { kind: 'reviewers' });
|
|
316
380
|
|
|
317
381
|
// The toggle saves at once through POST api/settings, which writes board.json itself, so it takes effect with no
|
|
318
382
|
// session awake (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
|
|
319
383
|
const autoMerge = $('#autoMerge');
|
|
320
384
|
const autoMergeStatus = $('#autoMergeStatus');
|
|
321
|
-
|
|
385
|
+
const savedCaps = {};
|
|
322
386
|
get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
|
|
323
387
|
if (!B.alive()) return;
|
|
324
388
|
savedTickets = d.settings?.tickets || null;
|
|
325
389
|
tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
|
|
326
390
|
loadLinear: async () => (await get('/_hub/api/linear')).body,
|
|
327
|
-
keyState: async () => (await get('/_hub/api/settings')).body.linear,
|
|
328
|
-
keyBusy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on),
|
|
391
|
+
keyState: async () => (await get('/_hub/api/settings')).body.linear,
|
|
329
392
|
change(_value, _valid, { loaded, typing } = {}) {
|
|
330
393
|
if (!loaded) ticketsTouched = true;
|
|
331
394
|
if (typing) syncTickets(); else saveTickets();
|
|
@@ -342,37 +405,41 @@
|
|
|
342
405
|
$('#autoBuild').disabled = false;
|
|
343
406
|
$('#autoPull').checked = d.settings?.auto_pull === true;
|
|
344
407
|
$('#autoPull').disabled = false;
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
}
|
|
347
412
|
$('#autoTriage').checked = d.settings?.auto_triage === true;
|
|
348
413
|
$('#autoTriage').disabled = false;
|
|
349
414
|
$('#autoReview').checked = d.settings?.auto_review === true;
|
|
350
415
|
$('#autoReview').disabled = false;
|
|
351
|
-
$('#headlessBuilders').checked = d.settings?.headless_builders === true;
|
|
352
|
-
$('#headlessBuilders').disabled = false;
|
|
353
416
|
$('#idleNotification').checked = d.settings?.idle_notification === true;
|
|
354
417
|
$('#idleNotification').disabled = false;
|
|
355
|
-
|
|
356
|
-
|
|
418
|
+
landing = landingOf(d.settings);
|
|
419
|
+
for (const button of landingButtons()) button.disabled = false;
|
|
420
|
+
syncPRs();
|
|
357
421
|
}).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
|
|
358
422
|
// An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
|
|
359
423
|
// → the saved settings
|
|
360
|
-
const saveToggle = (toggle, body) => BoardUI.waitOn(toggle, (async () => {
|
|
424
|
+
const saveToggle = (toggle, body, wait) => BoardUI.waitOn(toggle, (async () => {
|
|
361
425
|
const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
362
426
|
const out = await r.json().catch(() => ({}));
|
|
363
427
|
if (!r.ok) throw new Error(out.error);
|
|
364
428
|
return out.settings;
|
|
365
|
-
})());
|
|
429
|
+
})(), wait);
|
|
366
430
|
B.on(autoMerge, 'change', async () => {
|
|
367
431
|
const want = autoMerge.checked;
|
|
368
|
-
|
|
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.'
|
|
433
|
+
: 'Turn on auto-merge? Each verified card lands on the base branch without asking you.';
|
|
434
|
+
if (want && !(await BoardDialogs.confirm(ask))) {
|
|
369
435
|
autoMerge.checked = false;
|
|
370
436
|
return;
|
|
371
437
|
}
|
|
372
438
|
if (!B.alive()) return;
|
|
373
439
|
try {
|
|
374
440
|
autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
|
|
375
|
-
autoMergeStatus.textContent = autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.'
|
|
441
|
+
autoMergeStatus.textContent = opensPRs() ? (autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait 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.';
|
|
376
443
|
} catch (e) {
|
|
377
444
|
autoMerge.checked = !want;
|
|
378
445
|
autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
|
|
@@ -412,50 +479,118 @@
|
|
|
412
479
|
$(`#${id}Status`).textContent = e.message || 'Could not save. Is the host awake?';
|
|
413
480
|
}
|
|
414
481
|
});
|
|
415
|
-
autoToggle('autoPull', 'auto_pull', (on) => (on ? 'Saved. Claude starts ready
|
|
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.'));
|
|
416
483
|
|
|
417
|
-
// docs/adr/2026-11-04-0057-auto-implement-fills-in-progress-up-to-a-cap.md: Maximum parallel implementers
|
|
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
|
|
418
486
|
// 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($(
|
|
420
|
-
const input = $(
|
|
421
|
-
const status = $(
|
|
487
|
+
const capInput = (id, key, saidOf) => B.on($(`#${id}`), 'change', async () => {
|
|
488
|
+
const input = $(`#${id}`);
|
|
489
|
+
const status = $(`#${id}Status`);
|
|
422
490
|
const want = /^\d+$/.test(input.value) ? Number(input.value) : 0;
|
|
423
|
-
if (want < 1 || want > 99) { input.value =
|
|
491
|
+
if (want < 1 || want > 99) { input.value = savedCaps[key]; status.textContent = 'Pick a whole number from 1 to 99.'; return; }
|
|
424
492
|
try {
|
|
425
|
-
const settings = await saveToggle(input, {
|
|
493
|
+
const settings = await saveToggle(input, { [key]: want });
|
|
426
494
|
if (!B.alive()) return;
|
|
427
|
-
input.value =
|
|
428
|
-
status.textContent =
|
|
495
|
+
input.value = savedCaps[key] = String(settings[key]);
|
|
496
|
+
status.textContent = saidOf(settings[key]);
|
|
429
497
|
} catch (e) {
|
|
430
498
|
if (!B.alive()) return;
|
|
431
|
-
input.value =
|
|
499
|
+
input.value = savedCaps[key];
|
|
432
500
|
status.textContent = e.message || 'Could not save. Is the host awake?';
|
|
433
501
|
}
|
|
434
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.`);
|
|
435
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.'));
|
|
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
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.'));
|
|
438
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.'));
|
|
439
|
-
|
|
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
|
+
}
|
|
522
|
+
syncPRs();
|
|
523
|
+
});
|
|
440
524
|
|
|
441
525
|
// Each switch writes or removes one allow rule in the board folder's .claude/settings.local.json through POST
|
|
442
|
-
// api/permissions
|
|
443
|
-
// (docs/adr/2026-11-
|
|
526
|
+
// api/permissions (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md); the hub lets only
|
|
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
|
|
444
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
|
+
|
|
445
575
|
const permissions = $('#permissions');
|
|
446
576
|
const REFUSED = {
|
|
447
577
|
denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
|
|
448
578
|
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
579
|
};
|
|
450
|
-
const permRow = (p
|
|
580
|
+
const permRow = (p) => `<label class="who" for="perm-${esc(p.id)}"><span><span>${esc(p.name)}</span>
|
|
451
581
|
<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' : ''}
|
|
582
|
+
<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>
|
|
453
583
|
<p class="muted small" id="perm-${esc(p.id)}Status" role="status"></p>
|
|
454
584
|
${p.refused ? `<p class="muted small refused-note">${esc(REFUSED[p.refused.how](p.refused.command))}</p>` : ''}`;
|
|
455
585
|
const drawPermissions = (d) => {
|
|
456
|
-
permissions.innerHTML = d.permissions.map(
|
|
457
|
-
+ (d.owner ? '' : '<p class="muted small" id="permissionsNotOwner">Only the signed-in owner can change these.</p>');
|
|
586
|
+
permissions.innerHTML = d.permissions.map(permRow).join('');
|
|
458
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
|
+
}
|
|
459
594
|
get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
|
|
460
595
|
if (!B.alive()) return;
|
|
461
596
|
if (!ok) { permissions.innerHTML = `<p class="muted small">${esc(body.error || 'Could not load permissions.')}</p>`; return; }
|
|
@@ -467,14 +602,17 @@
|
|
|
467
602
|
const id = toggle.dataset.perm;
|
|
468
603
|
const on = toggle.checked;
|
|
469
604
|
toggle.checked = !on;
|
|
470
|
-
|
|
471
|
-
|
|
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 }) });
|
|
472
609
|
const out = await r.json().catch(() => ({}));
|
|
473
610
|
if (!r.ok) throw new Error(out.error);
|
|
474
611
|
if (!B.alive()) return;
|
|
475
612
|
drawPermissions(out);
|
|
476
613
|
$(`#perm-${id}Status`).textContent = on ? 'Saved. The session runs it without asking.' : 'Saved. The session asks first again.';
|
|
477
|
-
}
|
|
614
|
+
})();
|
|
615
|
+
try { await BoardUI.waitOn(toggle, BoardUI.pending.within(saving, stillWaiting)); } catch (error) {
|
|
478
616
|
if (B.alive()) $(`#perm-${id}Status`).textContent = error.message || 'Could not save. Is the host awake?';
|
|
479
617
|
}
|
|
480
618
|
});
|