@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.
- package/CONTEXT.md +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- 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]};
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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>
|
|
18
|
-
<label class="who" for="
|
|
19
|
-
<span
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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]'))
|
|
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)
|
|
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 =
|
|
93
|
-
$('#closeBoardReason').textContent =
|
|
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
|
|
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()
|
|
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 ||
|
|
161
|
+
if (closing || typed.value !== closure?.title) return;
|
|
123
162
|
closing = true;
|
|
124
|
-
|
|
125
|
-
|
|
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 =
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
} catch (e) {
|
|
197
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
}
|
|
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
|
package/board/review.mjs
ADDED
|
@@ -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};
|