@lovek4d/kickflip 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +32 -16
- package/SKILL.md +8 -6
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/bin/board.mjs +50 -28
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +4 -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 +6 -89
- package/board/public/app-cards.js +19 -10
- package/board/public/app-core.js +1 -20
- package/board/public/app-reader.js +174 -0
- package/board/public/app-threads.js +49 -35
- package/board/public/app.html +2 -0
- package/board/public/app.js +28 -16
- package/board/public/hub.css +1 -0
- package/board/public/new-task.js +121 -0
- package/board/public/shell.js +4 -0
- package/board/public/sidebar.css +1 -0
- package/board/public/style.css +91 -3
- package/board/public/ui-cards.js +46 -21
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +47 -18
- package/board/public/ui-sidebar.js +9 -3
- package/board/public/ui-threads.js +25 -12
- package/board/public/ui.js +42 -12
- package/board/public/view-frame.js +1 -0
- package/board/public/view-questions.js +45 -16
- package/board/public/view-settings.js +38 -19
- package/board/server.mjs +20 -18
- package/board/store.mjs +15 -13
- package/board/worktree.mjs +20 -20
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +9 -12
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/readiness.mjs +2 -2
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/package.json +1 -1
- package/shared/owner-check.mjs +3 -0
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
// whose options are prototype takes frames each take with ?take= (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
8
8
|
// Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
|
|
9
9
|
// with its note box as the note and its pick so far as the choice (docs/adr/2026-11-02-1822-each-question-has-its-own-prototype-eye.md).
|
|
10
|
-
// The
|
|
11
|
-
// (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md
|
|
10
|
+
// The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
|
|
11
|
+
// recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
|
|
12
|
+
// docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
13
|
+
// A topic's resolved questions fold under "resolved (N)", read-only; a resolved question's badge, once confirmed, sends
|
|
14
|
+
// `reask-request {key?, question}` (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
|
|
12
15
|
// fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
|
|
13
16
|
(globalThis.AppViews ||= {}).questions = ((fits) => ({
|
|
14
17
|
className: 'view-questions',
|
|
@@ -20,7 +23,7 @@
|
|
|
20
23
|
|
|
21
24
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
22
25
|
const { $, site } = B;
|
|
23
|
-
const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
26
|
+
const { esc, choiceLabel, choiceOf, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
24
27
|
// prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
|
|
25
28
|
const draftKey = (id) => 'qdraft:' + id;
|
|
26
29
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
|
|
@@ -42,6 +45,8 @@
|
|
|
42
45
|
let sentLine = '';
|
|
43
46
|
// The footer after a prototype request: the blue spinner, then these words.
|
|
44
47
|
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
48
|
+
// The bar's line while a suggested option is still pre-checked.
|
|
49
|
+
const SUGGESTED = 'suggested options are selected';
|
|
45
50
|
const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
|
|
46
51
|
let scrollTake = null;
|
|
47
52
|
const takeResources = new Map();
|
|
@@ -68,15 +73,17 @@
|
|
|
68
73
|
}
|
|
69
74
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
70
75
|
|
|
71
|
-
// Only questions changed here since their last answer,
|
|
76
|
+
// Only open questions changed here since their last answer, with files attached, or with a suggested option
|
|
77
|
+
// pre-checked and never answered, are sent; a resolved one is read-only.
|
|
72
78
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
73
79
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
74
80
|
function pending(qs) {
|
|
75
|
-
return qs.filter((q) => {
|
|
81
|
+
return qs.filter((q) => q.state !== 'resolved' && q.state !== 'requested').filter((q) => {
|
|
76
82
|
const d = getDraft(q.id);
|
|
77
83
|
if (filesOf(q).length) return true;
|
|
78
|
-
if (!('choice' in d) && !d.note) return false;
|
|
79
84
|
const last = q.answers[q.answers.length - 1];
|
|
85
|
+
if (!last && q.suggested) return true;
|
|
86
|
+
if (!('choice' in d) && !d.note) return false;
|
|
80
87
|
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
81
88
|
});
|
|
82
89
|
}
|
|
@@ -117,7 +124,7 @@
|
|
|
117
124
|
: `${topicBar()}${topicHead(t)}`;
|
|
118
125
|
el.innerHTML = `${head}<form id="qform">
|
|
119
126
|
${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
|
|
120
|
-
${done.length ? `<details class="done"><summary>
|
|
127
|
+
${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span><span class="names">${done.map((q) => esc(q.id)).join(', ')}</span></summary>${done.map((q) => questionCard(q, { links: links(q) })).join('')}</details>` : ''}
|
|
121
128
|
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
|
|
122
129
|
</form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
|
|
123
130
|
BoardAttach.paintAll();
|
|
@@ -138,11 +145,9 @@
|
|
|
138
145
|
function repaintTakes(sec) {
|
|
139
146
|
if (!sec.querySelector('.takes')) return;
|
|
140
147
|
const q = byId(sec.dataset.q);
|
|
141
|
-
const d = getDraft(q.id);
|
|
142
|
-
const chosen = d.choice ?? q.answers[q.answers.length - 1]?.choice;
|
|
143
148
|
const old = sec.querySelector('.takes');
|
|
144
149
|
clearTakeResources(old);
|
|
145
|
-
old.outerHTML = takes(q,
|
|
150
|
+
old.outerHTML = takes(q, choiceOf(q, getDraft(q.id)), links(q), fitOf(q));
|
|
146
151
|
sec.querySelectorAll('.take-shot').forEach(fitTake);
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -257,14 +262,28 @@
|
|
|
257
262
|
const at = leaving();
|
|
258
263
|
st.textContent = '';
|
|
259
264
|
const d = getDraft(q.id);
|
|
260
|
-
const last = q.answers[q.answers.length - 1];
|
|
261
265
|
let out;
|
|
262
|
-
try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice:
|
|
266
|
+
try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: choiceOf(q, d) })); }
|
|
263
267
|
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
264
268
|
setDraft(q.id, {});
|
|
265
269
|
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
266
270
|
}
|
|
267
271
|
|
|
272
|
+
// A resolved question's badge asks Claude to ask it again from the card's current notes, once the owner confirms.
|
|
273
|
+
// The topic's resolved questions load again, so the question shows its re-ask under way.
|
|
274
|
+
async function askAgain(q, button) {
|
|
275
|
+
if (!(await BoardDialogs.confirm('Ask this again from today’s context?')) || !B.alive()) return;
|
|
276
|
+
const st = $('#qstatus');
|
|
277
|
+
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
278
|
+
let out;
|
|
279
|
+
try { out = await BoardUI.waitOn(button, send('reask-request', { ...(key ? { key } : {}), question: q.id })); }
|
|
280
|
+
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
281
|
+
if (!B.alive()) return;
|
|
282
|
+
resolved = { topic: null, n: 0, questions: [] };
|
|
283
|
+
if (!land(out.board, false)) await load(false);
|
|
284
|
+
repaint(false);
|
|
285
|
+
}
|
|
286
|
+
|
|
268
287
|
function updateBar() {
|
|
269
288
|
const qs = visible();
|
|
270
289
|
const n = pending(qs).length;
|
|
@@ -272,6 +291,10 @@
|
|
|
272
291
|
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
273
292
|
const live = qs.filter((q) => q.state !== 'resolved');
|
|
274
293
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
294
|
+
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
295
|
+
const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
|
|
296
|
+
const st = $('#qstatus');
|
|
297
|
+
if (st && !sentLine && (prechecked || st.textContent === SUGGESTED)) st.textContent = prechecked ? SUGGESTED : '';
|
|
275
298
|
$('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
|
|
276
299
|
}
|
|
277
300
|
|
|
@@ -283,7 +306,7 @@
|
|
|
283
306
|
topicId = t ? t.id : null;
|
|
284
307
|
}
|
|
285
308
|
|
|
286
|
-
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
309
|
+
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])]);
|
|
287
310
|
// One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
|
|
288
311
|
// while one runs shares it. → true when the board's answer landed in this view.
|
|
289
312
|
let loading = null;
|
|
@@ -362,8 +385,15 @@
|
|
|
362
385
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
363
386
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
364
387
|
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
388
|
+
const reask = ev.target.closest && ev.target.closest('[data-reask]');
|
|
389
|
+
if (reask) { if (!reask.disabled) askAgain(byId(reask.dataset.reask), reask); return; }
|
|
365
390
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
366
|
-
if (b) {
|
|
391
|
+
if (b) {
|
|
392
|
+
soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = '';
|
|
393
|
+
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
394
|
+
if (first) history.replaceState(history.state, '', '#' + encodeURIComponent(first));
|
|
395
|
+
render(); scrollTo(0, 0);
|
|
396
|
+
}
|
|
367
397
|
});
|
|
368
398
|
// The full-width frame shows another take without reloading, where its page's switcher can
|
|
369
399
|
// (prototype-switcher.js current.go), so the page keeps its place. → false when it can't.
|
|
@@ -417,8 +447,7 @@
|
|
|
417
447
|
// docs/adr/2026-11-03-1519-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none. Send waits meanwhile.
|
|
418
448
|
const answers = list.map((q) => {
|
|
419
449
|
const d = getDraft(q.id);
|
|
420
|
-
const
|
|
421
|
-
const choice = d.choice ?? (last ? last.choice : null);
|
|
450
|
+
const choice = choiceOf(q, d);
|
|
422
451
|
const files = filesOf(q);
|
|
423
452
|
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
|
|
424
453
|
});
|
|
@@ -26,11 +26,7 @@
|
|
|
26
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
27
|
<input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
|
|
28
28
|
<p class="muted small" id="maxImplementersStatus" role="status"></p>
|
|
29
|
-
<label class="who" for="
|
|
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>
|
|
33
|
-
<label class="who" for="autoBuild"><span><span>Auto-build cards</span>
|
|
29
|
+
<label class="who" for="autoBuild"><span><span>Auto serve</span>
|
|
34
30
|
<span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
|
|
35
31
|
<input type="checkbox" class="toggle" id="autoBuild" disabled></label>
|
|
36
32
|
<p class="muted small" id="autoBuildStatus" role="status"></p>
|
|
@@ -38,8 +34,12 @@
|
|
|
38
34
|
<span class="muted small sub">Claude reviews teammates’ PRs that touch this board’s work. May use a lot of usage.</span></span>
|
|
39
35
|
<input type="checkbox" class="toggle" id="autoReview" disabled></label>
|
|
40
36
|
<p class="muted small" id="autoReviewStatus" role="status"></p>
|
|
37
|
+
<div id="prsRow" hidden><label class="who" for="prs"><span><span>PRs</span>
|
|
38
|
+
<span class="muted small sub">Each card opens a PR. Off, no PR opens: each card lands on main and main is pushed.</span></span>
|
|
39
|
+
<input type="checkbox" class="toggle" id="prs" disabled></label>
|
|
40
|
+
<p class="muted small" id="prsStatus" role="status"></p></div>
|
|
41
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>
|
|
42
|
+
<span class="muted small sub" id="autoMergeSub">Claude merges a PR once it is approved, green and merges cleanly.</span></span>
|
|
43
43
|
<input type="checkbox" class="toggle" id="autoMerge" disabled></label>
|
|
44
44
|
<p class="muted small" id="autoMergeStatus" role="status"></p>
|
|
45
45
|
<div id="skipDraftRow" hidden><label class="who" for="skipDraft"><span><span>Skip the draft</span>
|
|
@@ -58,6 +58,7 @@
|
|
|
58
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
59
|
<h2>Permissions</h2>
|
|
60
60
|
<div id="permissions"><p class="muted small">Loading…</p></div>
|
|
61
|
+
<div id="reviewersSection" hidden>
|
|
61
62
|
<h2>Reviewers</h2>
|
|
62
63
|
<p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
|
|
63
64
|
<div id="list"><p class="muted small">Loading…</p></div>
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
</form>
|
|
68
69
|
<div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
|
|
69
70
|
<div class="replies" id="replies"></div>
|
|
71
|
+
</div>
|
|
70
72
|
<h2>Rules</h2>
|
|
71
73
|
<p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
|
|
72
74
|
<div id="rules"><p class="muted small">Loading…</p></div>
|
|
@@ -184,10 +186,19 @@
|
|
|
184
186
|
const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
|
|
185
187
|
let repo = {}, closeRepo = () => {};
|
|
186
188
|
let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
|
|
187
|
-
//
|
|
189
|
+
// PRs shows on a board on GitHub that builds from its shared clone; a local board never opens PRs. Skip the draft and
|
|
190
|
+
// Reviewers show only while cards open PRs, and Auto-merge says what it merges
|
|
191
|
+
// (docs/adr/2026-11-04-1913-a-board-can-skip-the-draft.md, docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
|
|
192
|
+
let prsSetting = true;
|
|
193
|
+
const opensPRs = () => Boolean(repo.github) && !repo.local && prsSetting;
|
|
194
|
+
const syncPRs = () => {
|
|
195
|
+
$('#prsRow').hidden = !repo.github || Boolean(repo.local);
|
|
196
|
+
$('#skipDraftRow').hidden = $('#reviewersSection').hidden = !opensPRs();
|
|
197
|
+
$('#autoMergeSub').textContent = opensPRs() ? 'Claude merges a PR once it is approved, green and merges cleanly.' : 'Lands each verified card on main without waiting for your Merge.';
|
|
198
|
+
};
|
|
188
199
|
const showRepo = () => {
|
|
189
200
|
$('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
|
|
190
|
-
|
|
201
|
+
syncPRs();
|
|
191
202
|
};
|
|
192
203
|
get(repoURL).then(({ body }) => {
|
|
193
204
|
if (!B.alive()) return;
|
|
@@ -348,12 +359,13 @@
|
|
|
348
359
|
$('#autoTriage').disabled = false;
|
|
349
360
|
$('#autoReview').checked = d.settings?.auto_review === true;
|
|
350
361
|
$('#autoReview').disabled = false;
|
|
351
|
-
$('#headlessBuilders').checked = d.settings?.headless_builders === true;
|
|
352
|
-
$('#headlessBuilders').disabled = false;
|
|
353
362
|
$('#idleNotification').checked = d.settings?.idle_notification === true;
|
|
354
363
|
$('#idleNotification').disabled = false;
|
|
355
364
|
$('#skipDraft').checked = d.settings?.skip_draft === true;
|
|
356
365
|
$('#skipDraft').disabled = false;
|
|
366
|
+
$('#prs').checked = prsSetting = d.settings?.prs !== false;
|
|
367
|
+
$('#prs').disabled = false;
|
|
368
|
+
syncPRs();
|
|
357
369
|
}).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
|
|
358
370
|
// An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
|
|
359
371
|
// → the saved settings
|
|
@@ -365,14 +377,17 @@
|
|
|
365
377
|
})());
|
|
366
378
|
B.on(autoMerge, 'change', async () => {
|
|
367
379
|
const want = autoMerge.checked;
|
|
368
|
-
|
|
380
|
+
const ask = opensPRs() ? 'Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'
|
|
381
|
+
: 'Turn on auto-merge? Each verified card lands on main without asking you.';
|
|
382
|
+
if (want && !(await BoardDialogs.confirm(ask))) {
|
|
369
383
|
autoMerge.checked = false;
|
|
370
384
|
return;
|
|
371
385
|
}
|
|
372
386
|
if (!B.alive()) return;
|
|
373
387
|
try {
|
|
374
388
|
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.'
|
|
389
|
+
autoMergeStatus.textContent = opensPRs() ? (autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.')
|
|
390
|
+
: autoMerge.checked ? 'Saved. Each verified card lands on main by itself.' : 'Saved. Each verified card waits for your Merge.';
|
|
376
391
|
} catch (e) {
|
|
377
392
|
autoMerge.checked = !want;
|
|
378
393
|
autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
|
|
@@ -433,28 +448,32 @@
|
|
|
433
448
|
}
|
|
434
449
|
});
|
|
435
450
|
autoToggle('autoReview', 'auto_review', (on) => (on ? 'Saved. When idle, Claude reviews incoming PRs that touch this board’s work.' : 'Saved. Claude leaves incoming PRs alone.'));
|
|
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
451
|
autoToggle('idleNotification', 'idle_notification', (on) => (on ? 'Saved. One push once the board has been idle for 15 minutes.' : 'Saved. No push when the board goes idle.'));
|
|
438
452
|
autoToggle('autoTriage', 'auto_triage', (on) => (on ? 'Saved. Once a night, when cards wait on your answers, Claude triages them.' : 'Saved. Questions and Todo are left as they are.'));
|
|
439
453
|
autoToggle('skipDraft', 'skip_draft', (on) => (on ? 'Saved. A builder’s draft PR is marked ready for review once CI passes.' : 'Saved. A builder’s draft PR waits for your review.'));
|
|
454
|
+
// PRs off needs Push main after a land, which the server checks; its refusal shows in the status line.
|
|
455
|
+
autoToggle('prs', 'prs', (on) => {
|
|
456
|
+
prsSetting = on;
|
|
457
|
+
syncPRs();
|
|
458
|
+
return on ? 'Saved. Each card opens a PR.' : 'Saved. Each card lands on main with no PR, and main is pushed.';
|
|
459
|
+
});
|
|
440
460
|
|
|
441
461
|
// 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-
|
|
462
|
+
// api/permissions (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md); the hub lets only
|
|
463
|
+
// the owner reach the board (docs/adr/2026-11-04-2156-the-whole-hub-answers-only-the-owner.md). A tap waits on the server with the
|
|
444
464
|
// switch as it was and a spinner beside it, then draws the server's answer.
|
|
445
465
|
const permissions = $('#permissions');
|
|
446
466
|
const REFUSED = {
|
|
447
467
|
denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
|
|
448
468
|
asked: (command) => `The session still asked before running ${command}, so it hasn’t picked the rule up yet. It applies once the session restarts.`,
|
|
449
469
|
};
|
|
450
|
-
const permRow = (p
|
|
470
|
+
const permRow = (p) => `<label class="who" for="perm-${esc(p.id)}"><span><span>${esc(p.name)}</span>
|
|
451
471
|
<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' : ''}
|
|
472
|
+
<span class="toggle-slot"><span class="try-spin toggle-spin" aria-hidden="true"></span><input type="checkbox" class="toggle" data-perm="${esc(p.id)}" id="perm-${esc(p.id)}"${p.on ? ' checked' : ''}></span></label>
|
|
453
473
|
<p class="muted small" id="perm-${esc(p.id)}Status" role="status"></p>
|
|
454
474
|
${p.refused ? `<p class="muted small refused-note">${esc(REFUSED[p.refused.how](p.refused.command))}</p>` : ''}`;
|
|
455
475
|
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>');
|
|
476
|
+
permissions.innerHTML = d.permissions.map(permRow).join('');
|
|
458
477
|
};
|
|
459
478
|
get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
|
|
460
479
|
if (!B.alive()) return;
|