@lovek4d/kickflip 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,14 +1,15 @@
1
- // HubSettings: summary rows below Hub’s health and status open centred settings modals (ADR 0075), using
2
- // settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications and saves
3
- // at once. The rules editor saves through POST /_hub/settings/rules and marks [data-dirty] while an edit is unsaved, so
4
- // the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
1
+ // HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
2
+ // settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
3
+ // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key does through linear-key.js; Done waits for
4
+ // a key's save to land. The rules editor saves through POST /_hub/settings/rules
5
+ // and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
5
6
  (function () {
6
7
  // The last data, which a return to the view paints at once.
7
8
  let last = null;
8
9
  const { esc } = HubUI;
9
10
  const ACTION = '/_hub/settings/notifications';
10
11
 
11
- // datetime-local speaks the device's clock; the hidden snooze field carries its UTC ISO value to the hub.
12
+ // datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the hub.
12
13
  const localInput = (d) => {
13
14
  const pad = (n) => String(n).padStart(2, '0');
14
15
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
@@ -20,27 +21,46 @@
20
21
  if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
21
22
  }
22
23
 
23
- function snoozeRow(d) {
24
- const icon = HubUI.tile('snooze', '#a855f7');
25
- if (d.snoozed) return `<div class="row snooze-on">${icon}<span class="body"><span class="text title">${esc(d.snoozed.title)}</span><form method="post" action="${ACTION}"><button class="btn quiet" name="snooze" value="off">End</button></form></span></div>`;
26
- if (!d.snooze) return '';
27
- const chips = d.snooze.choices.map((c) => `<button class="btn quiet" name="snooze" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
28
- return `<div class="block" role="group" aria-labelledby="snoozeTitle"><div class="snooze-head">${icon}<span class="title" id="snoozeTitle">Snooze</span></div><div class="snooze-chips"><form class="snooze-choices" method="post" action="${ACTION}">${chips}</form><form class="snooze-custom" method="post" action="${ACTION}"><input type="hidden" name="snooze"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-snooze-at required aria-label="Custom snooze until a date and time"></form></div></div>`;
24
+ // The one duration picker Snooze and Keep awake share (hub/duration.mjs): chips that post `name` to `action` at once,
25
+ // then Custom, whose date and time posts as `name` too. While its setting is on, `on` replaces it with when it ends
26
+ // and a button that posts `name`=off.
27
+ function durationPicker({ id, title, icon, action, name, choices, on, button }) {
28
+ if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
29
+ if (!choices) return '';
30
+ const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
31
+ return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
29
32
  }
30
33
 
31
- // The on/off, snooze and weekend rows (docs/adr/0032-notifications-can-be-off-snoozed-or-off-on-weekends.md).
34
+ const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
35
+ choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
36
+ // Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
37
+ const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
38
+ action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
39
+
40
+ // The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
32
41
  function notificationRows(d) {
33
42
  const { tile } = HubUI;
34
43
  const toggle = ({ icon, set, title, checked, attrs = '' }) => `<form class="row field" method="post" action="${ACTION}"><input type="hidden" name="set" value="${set}">${icon}<label class="body"><span class="text"><span class="title">${title}</span></span><input type="checkbox" name="value" value="on" class="toggle"${attrs}${checked ? ' checked' : ''}></label></form>`;
35
44
  const onRow = toggle({ icon: tile('power', d.on.on ? '#22c55e' : '#6b7280'), set: 'on', title: 'Push notifications', checked: d.on.on,
36
45
  attrs: ` id="notifyOn" data-confirm="${esc(d.on.confirm)}"` });
37
- const weekendRow = toggle({ icon: tile('calendar', '#0ea5e9'), set: 'weekends', title: 'Off on weekends', checked: d.weekends.on, attrs: ' data-submit' });
46
+ const weekendRow = toggle({ icon: tile('calendar', 'var(--color-quiet)'), set: 'weekends', title: 'Off on weekends', checked: d.weekends.on, attrs: ' data-submit' });
38
47
  return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
39
48
  }
40
49
 
41
- const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
50
+ // Quiet hours (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md): start and
51
+ // end, each saved once its picker closes, the overnight hold, the muted cadence hint and what the snoozes and wake read.
52
+ function quietRows(q) {
53
+ const { row, tile } = HubUI;
54
+ const time = (name, title, value) => `<form class="row field" method="post" action="/_hub/settings/quiet"><label class="body"><span class="text title">${title}</span><input type="time" name="${name}" value="${esc(value)}" required data-autosave></label></form>`;
55
+ const holdRow = `<form class="row field" method="post" action="/_hub/settings/quiet"><input type="hidden" name="set" value="hold">${tile('moon', 'var(--accent)')}<label class="body"><span class="text"><span class="title">Hold pushes</span><span class="sub wrap">${esc(q.hold.sub)}</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${q.hold.on ? ' checked' : ''}></label></form>`;
56
+ const reads = q.reads.map((r) => row({ title: esc(r.title), sub: `<span class="sub wrap">${esc(r.sub)}</span>` })).join('');
57
+ return `<div class="group">${time('start', 'Starts', q.start)}${time('end', 'Ends', q.end)}${holdRow}</div><p class="quiet-hint">${esc(q.hint)}</p>`
58
+ + `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
59
+ }
60
+
61
+ const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
42
62
  const sectionOf = (hash) => {
43
- const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
63
+ const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
44
64
  return sections.some(([section]) => section === id) ? id : null;
45
65
  };
46
66
  const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
@@ -56,10 +76,10 @@
56
76
  let settingsURL = '/_hub/';
57
77
  let returnFocus = null;
58
78
  let unfitModal = null;
59
- let polling = null;
60
79
  let flash = null;
61
80
  let rules = null;
62
81
  let rulesJSON = '';
82
+ let linearKey = null;
63
83
 
64
84
  function paint() {
65
85
  if (!last) return;
@@ -68,21 +88,29 @@
68
88
  const scrollTop = B.$('.settings-body').scrollTop;
69
89
  if (active === 'notifications') {
70
90
  content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}${last.presence ? row({ icon: tile('laptop', 'var(--color-quiet)'), title: esc(last.presence.title) }) : ''}</div>`;
71
- const input = B.$('[data-snooze-at]');
72
- if (input) customBounds(input, true);
91
+ } else if (active === 'quiet') {
92
+ content.innerHTML = quietRows(last.quiet);
93
+ } else if (active === 'keep-awake') {
94
+ content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
95
+ : `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
73
96
  } else if (active === 'linear') {
74
- const typed = B.$('#linearKey')?.value || '';
75
- const l = last.linear;
76
- const remove = l.set ? `<form method="post" action="/_hub/settings/linear"><input type="hidden" name="remove" value="1"><button type="button" class="btn quiet danger" data-linear-remove>Remove</button></form>` : '';
77
- content.innerHTML = `<div class="group">${row({ icon: tile('key', l.set ? '#5e6ad2' : '#6b7280'), title: esc(l.title), sub: esc(l.sub), wrap: !l.set, trail: remove })}<form class="block lk-form" method="post" action="/_hub/settings/linear"><input id="linearKey" name="key" type="password" required placeholder="${l.set ? 'Paste a new key to replace it' : 'Paste a Linear API key'}" aria-label="Linear API key" autocomplete="off" autocapitalize="off" spellcheck="false"><button class="btn">Save</button></form></div><p class="note">Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. Without a key, each board’s session does that through its Linear MCP.</p>`;
78
- B.$('#linearKey').value = typed;
97
+ if (linearKey) linearKey.set(last.linear);
98
+ else {
99
+ linearKey = LinearKey.mount(content, { linear: last.linear,
100
+ saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
101
+ busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
102
+ }
79
103
  } else if (active === 'closed') {
80
- content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening ? ' disabled' : ''}>${reopening === b.route ? 'Reopening…' : 'Reopen'}</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
104
+ content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening === b.route ? BoardUI.BUSY : reopening ? ' disabled' : ''}>Reopen</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
81
105
  }
106
+ const input = B.$('[data-dur-at]');
107
+ if (input) customBounds(input, true);
82
108
  B.$('.settings-body').scrollTop = scrollTop;
109
+ B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
83
110
  const summaries = {
84
- notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.title, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
85
- polling: last.polling,
111
+ notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
112
+ quiet: last.quiet.summary,
113
+ 'keep-awake': last.awake.summary,
86
114
  linear: last.linear?.summary || 'No key',
87
115
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
88
116
  closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
@@ -122,9 +150,9 @@
122
150
  }
123
151
 
124
152
  function closeModal(restore = true) {
125
- polling?.unmount(); polling = null;
126
153
  unfitModal?.(); unfitModal = null;
127
154
  modal.close();
155
+ linearKey?.destroy(); linearKey = null;
128
156
  content.innerHTML = '';
129
157
  rules = null;
130
158
  active = null;
@@ -151,17 +179,16 @@
151
179
  modal.showModal();
152
180
  B.$('.settings-body').scrollTop = 0;
153
181
  B.$('#hubSettingsTitle').focus({ preventScroll: true });
154
- if (id === 'polling') {
155
- polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
156
- polling.mount();
157
- }
158
182
  }
159
- } else if (id === 'polling') polling?.B.selectWindow?.(view.w || null);
160
- settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}), ...(id === 'polling' && view.w ? { w: view.w } : {}) });
183
+ }
184
+ settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}) });
161
185
  history.replaceState(history.state, '', settingsURL);
162
186
  return true;
163
187
  };
164
- const dismiss = () => B.openSettings({ kind: 'hub' });
188
+ const dismiss = () => {
189
+ if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
190
+ linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
191
+ };
165
192
  B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
166
193
  B.on(root, 'click', (e) => {
167
194
  const opener = e.target.closest?.('[data-settings-open]');
@@ -181,7 +208,7 @@
181
208
  device.ready.then(() => { if (B.alive()) paint(); });
182
209
  let openPicker = null;
183
210
  B.on(root, 'click', (e) => {
184
- const input = e.target.closest?.('[data-snooze-at]');
211
+ const input = e.target.closest?.('[data-dur-at]');
185
212
  if (!input) return;
186
213
  openPicker = input;
187
214
  customBounds(input, input.value === input.defaultValue);
@@ -189,7 +216,7 @@
189
216
  });
190
217
  // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
191
218
  B.on(root, 'pointerdown', (e) => {
192
- const input = e.target.closest?.('.snooze-choices button') && B.$('[data-snooze-at]');
219
+ const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
193
220
  if (!input) return;
194
221
  openPicker = null;
195
222
  input.value = input.defaultValue;
@@ -199,16 +226,16 @@
199
226
  if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
200
227
  const at = new Date(input.value);
201
228
  if (Number.isNaN(at.getTime())) return;
202
- input.form.querySelector('[name="snooze"]').value = at.toISOString();
229
+ input.form.querySelector('input[type="hidden"]').value = at.toISOString();
203
230
  input.form.requestSubmit();
204
231
  openPicker = null;
205
232
  input.defaultValue = input.value;
206
233
  };
207
234
  B.on(root, 'change', (e) => {
208
- if (e.target.matches?.('[data-snooze-at]') && document.activeElement !== e.target) saveCustom(e.target);
235
+ if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
209
236
  });
210
237
  B.on(root, 'focusout', (e) => {
211
- if (e.target.matches?.('[data-snooze-at]')) saveCustom(e.target);
238
+ if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
212
239
  });
213
240
  B.on(root, 'click', async (e) => {
214
241
  const button = e.target.closest?.('[data-reopen]');
@@ -227,10 +254,6 @@
227
254
  reopening = null;
228
255
  if (B.alive()) paint();
229
256
  });
230
- B.on(root, 'click', async (e) => {
231
- const remove = e.target.closest?.('[data-linear-remove]');
232
- if (remove && await BoardDialogs.confirm('Remove the Linear key? Boards on Linear can’t create issues or move tickets until you add one again.', { ok: 'Remove' }) && B.alive()) remove.closest('form').requestSubmit();
233
- });
234
257
  B.on(root, 'change', async (e) => {
235
258
  const on = e.target;
236
259
  if (on.id !== 'notifyOn') return;
@@ -238,7 +261,7 @@
238
261
  else on.checked = true;
239
262
  });
240
263
  const forms = HubKit.forms(B, {
241
- accepts: (action) => action !== '/_hub/push/flush' && action !== '/_hub/settings/polling',
264
+ accepts: (action) => action !== '/_hub/push/flush',
242
265
  answer(out) {
243
266
  last = out;
244
267
  flash = null;
@@ -1,6 +1,7 @@
1
- // The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Codex share, Needs you for the pending
2
- // prompts of sessions that own no board, the hub's health, each site's health and board links,
3
- // polling status, then hub settings summary rows (ADR 0075). Health comes from GET /_hub/api/hub.
1
+ // The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Claude model and Codex share, Needs you for the pending
2
+ // prompts of sessions that own no board, Health, Integrations (readiness.mjs words), each site's health and board links,
3
+ // polling status, then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). A board's row can carry Restart (hub/restart-board.mjs), and
4
+ // the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
4
5
  // Its data refreshes every 30 seconds while it shows (hub-kit.js live).
5
6
  (function () {
6
7
  // The last data, which a return to the view paints at once.
@@ -11,30 +12,65 @@
11
12
  return sessions.map((s) => `<div class="remembered"><p>Always allow · session <code>${esc(s.session)}</code></p><ul>${s.rules.map((rule) => `<li><code>${esc(rule)}</code></li>`).join('')}</ul></div>`).join('');
12
13
  }
13
14
 
14
- function sitesHtml(groups) {
15
+ const RESTARTING = '<span class="st wait">restarting</span> · stopping its session, then starting it again';
16
+
17
+ // A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
18
+ // button can't sit inside a link. Restart waits (ui.js BUSY) until its POST answers, once the board is back up.
19
+ function restartRow(s, sub, busy) {
20
+ const href = esc(s.href);
21
+ return `<div class="row site-row"><a class="site-link" href="${href}">${s.icon}<span class="text"><span class="title">${esc(s.title)}</span><span class="sub wrap">${sub}</span></span></a>`
22
+ + `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ? BoardUI.BUSY : ''}>Restart</button>`
23
+ + `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
24
+ }
25
+
26
+ // restarting: the routes whose Restart is out.
27
+ function sitesHtml(groups, restarting) {
15
28
  const { row, CHEVRON } = HubUI;
16
29
  const site = (s) => {
30
+ const busy = restarting.has(s.route);
17
31
  const lines = s.lines.length ? `<span class="links">${s.lines.map((l) => `<span><b>${esc(l.link)}</b> ${esc(l.detail)}</span>`).join('')}</span>` : '';
18
- return row({ icon: s.icon, title: esc(s.title), sub: `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`, wrap: true, trail: CHEVRON, href: s.href }) + rememberedHtml(s.remembered);
32
+ const sub = busy ? RESTARTING : `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`;
33
+ return (s.restart ? restartRow(s, sub, busy) : row({ icon: s.icon, title: esc(s.title), sub, wrap: true, trail: CHEVRON, href: s.href })) + rememberedHtml(s.remembered);
19
34
  };
20
35
  return groups.map((g) => `<h2 class="label">${esc(g.label)}</h2>\n<div class="group">${g.sites.map(site).join('')}</div>\n`).join('');
21
36
  }
22
37
 
38
+ function integrationsHtml(integrations) {
39
+ const { row, tile } = HubUI;
40
+ const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
41
+ ? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
42
+ const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
43
+ sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
44
+ : `<span class="st wait">not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span>${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` })).join('');
45
+ return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
46
+ <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
47
+ `;
48
+ }
49
+
23
50
  // A pending prompt from a session that owns no board waits here (prompts.js).
24
51
  function promptsHtml(list, prompts) {
25
52
  const shown = list.filter(prompts.shows);
26
53
  return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';
27
54
  }
28
55
 
29
- function html(d, { flash, prompts }) {
56
+ // Restart hub, while the hub runs older code than its files (hub/views.mjs hubView's hub.restart). It waits (ui.js BUSY)
57
+ // from the tap until the hub answers with its new code and the page reloads.
58
+ const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
59
+
60
+ function html(d, { flash, prompts, restarting, restartingHub }) {
30
61
  const { row, tile, nowRow } = HubUI;
31
- const usage = HubUI.usageCard(d.usage, d.codex, d.pause);
62
+ const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
63
+ // The status word is plain text, except while the hub runs older code: then it opens the Restart hub confirm and waits like that button.
64
+ const word = d.hub.restart
65
+ ? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
66
+ : `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
32
67
  return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
33
- <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}
68
+ <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
69
+ trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
34
70
  ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</code>.' }) : ''}</div>
35
- ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
71
+ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>
36
72
  <h2 class="label">Polling status</h2>
37
- <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div>`;
73
+ <div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
38
74
  }
39
75
 
40
76
  const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
@@ -59,11 +95,67 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
59
95
  B.atUnmount(() => settings.unmount());
60
96
  B.on(B.$('#refresh'), 'click', () => settings.B.refreshSettings?.());
61
97
  let flash = null;
98
+ const restarting = new Set();
62
99
  B.openSettings = (...args) => settings.B.openSettings(...args);
100
+ async function restart(route, title) {
101
+ if (restarting.has(route) || !(await BoardDialogs.confirm(`This stops ${title}’s session mid-task, along with any implementer it started, then restarts its server and resumes the session. Its cards and threads stay. For a stuck board. Restart ${title}?`))) return;
102
+ restarting.add(route);
103
+ flash = null;
104
+ paint();
105
+ let out = null;
106
+ try {
107
+ const r = await fetch('/_hub/restart-board', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ route }) });
108
+ out = await r.json().catch(() => null);
109
+ if (!r.ok || !out) throw new Error();
110
+ if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
111
+ } catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
112
+ restarting.delete(route);
113
+ if (B.alive()) { paint(); load(); }
114
+ }
115
+ // Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
116
+ // with other code than it ran (its x-hub-code) and reloads the app; past HUB_WAIT tries it says how to restart by hand.
117
+ let restartingHub = false;
118
+ const HUB_WAIT = 120;
119
+ async function restartHub() {
120
+ if (restartingHub || !(await BoardDialogs.confirm('This restarts the hub on the host so it runs the code in its files. Its pages stop answering until it’s back, then this page reloads. Restart the hub?'))) return;
121
+ restartingHub = true;
122
+ flash = null;
123
+ paint();
124
+ let out = null, before = null;
125
+ try {
126
+ const r = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
127
+ out = await r.json().catch(() => null);
128
+ if (!r.ok || !out) throw new Error();
129
+ before = r.headers.get('x-hub-code');
130
+ } catch {
131
+ restartingHub = false;
132
+ flash = { text: out?.error || 'Could not restart the hub. Is the host awake?', error: true };
133
+ if (B.alive()) paint();
134
+ return;
135
+ }
136
+ for (let i = 0; i < HUB_WAIT && B.alive(); i++) {
137
+ await new Promise((resolve) => setTimeout(resolve, 1000));
138
+ try {
139
+ const r = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
140
+ const code = r.headers.get('x-hub-code');
141
+ if (r.ok && code && code !== before) return AppShell.reload();
142
+ } catch { /* the hub is down while it restarts */ }
143
+ }
144
+ restartingHub = false;
145
+ flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
146
+ if (B.alive()) paint();
147
+ }
148
+ B.on(main, 'click', (e) => {
149
+ const opener = e.target.closest?.('[data-settings-open]');
150
+ if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
151
+ if (e.target.closest?.('[data-restart-hub]')) restartHub();
152
+ const restarter = e.target.closest?.('[data-restart]');
153
+ if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
154
+ });
63
155
  const foot = B.$('.hub-foot');
64
156
  const paint = () => {
65
157
  if (!last) return;
66
- main.innerHTML = html(last, { flash, prompts });
158
+ main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
67
159
  foot.innerHTML = footer(last);
68
160
  };
69
161
  const prompts = PromptAnswers.wire(B, {
@@ -73,9 +165,9 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
73
165
  after: () => load(),
74
166
  });
75
167
  const forms = HubKit.forms(B, {
76
- accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
168
+ accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
77
169
  answer(out, action) {
78
- if (['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
170
+ if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
79
171
  last = out; flash = null; paint();
80
172
  if (action.endsWith('/keep-going')) B.toast(out.pause.state === 'going' ? `Keep going: work runs past the buffer until ${BoardUI.usageClock(out.pause.resets_at)}.` : 'Stopped: new work waits again.');
81
173
  } else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
@@ -1,9 +1,10 @@
1
1
  // An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
2
- // (docs/adr/0053-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
2
+ // (docs/adr/2026-10-29-1816-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
3
+ // ?wave=<slug> shows a wave's goal, order and note instead, and back goes to the board.
3
4
  (globalThis.AppViews ||= {}).notes = {
4
5
  className: 'view-notes',
5
6
  title: () => 'Notes',
6
- markup: ({ item }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: `./#item=${encodeURIComponent(item || '')}`, controls: false })}
7
+ markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#item=${encodeURIComponent(item || '')}`, controls: false })}
7
8
  <main>
8
9
  <article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
9
10
  </main>`,
@@ -12,15 +13,17 @@
12
13
  const { $, site } = B;
13
14
  const { esc, markdown, linkContext, fetchJSON, restartNotice } = BoardUI;
14
15
  const key = ctx.view.item || '';
16
+ const wave = ctx.view.wave;
15
17
  const el = $('#plan');
16
18
  const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
17
19
  try {
18
20
  const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
19
- const r = await get(site.url(`api/notes?item=${encodeURIComponent(key)}`), { onWait: () => { el.innerHTML = restartNotice(); } });
21
+ const query = wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`;
22
+ const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
20
23
  if (!B.alive()) return;
21
24
  if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
22
- $('#ref').textContent = `${r.body.ref} notes`;
23
- document.title = `${r.body.ref} notes`;
25
+ $('#ref').textContent = `${wave ? r.body.title : r.body.ref} notes`;
26
+ document.title = $('#ref').textContent;
24
27
  el.innerHTML = markdown(r.body.markdown, linkContext((await board).items || [], key, { base: './' }));
25
28
  } catch (e) {
26
29
  el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;