@lovek4d/kickflip 0.1.0 → 0.3.1

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 (96) hide show
  1. package/CONTEXT.md +177 -78
  2. package/README.md +46 -5
  3. package/SKILL.md +58 -37
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +87 -32
  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/bin/agent.mjs +1 -1
  11. package/board/bin/board.mjs +241 -69
  12. package/board/bin/builder-stop-hook.mjs +4 -3
  13. package/board/domain.mjs +5 -3
  14. package/board/on-host.mjs +1 -0
  15. package/board/public/app-arch.js +75 -0
  16. package/board/public/app-asks.js +33 -14
  17. package/board/public/app-cards.js +242 -49
  18. package/board/public/app-core.js +39 -5
  19. package/board/public/app-header.js +24 -6
  20. package/board/public/app-lanes.js +76 -26
  21. package/board/public/app-needs.js +1 -0
  22. package/board/public/app-snapshot.js +45 -11
  23. package/board/public/app-threads.js +13 -11
  24. package/board/public/app.html +4 -1
  25. package/board/public/app.js +131 -20
  26. package/board/public/dialogs.js +114 -116
  27. package/board/public/hub-kit.js +19 -2
  28. package/board/public/hub.css +24 -18
  29. package/board/public/linear-key.js +141 -0
  30. package/board/public/router.js +6 -3
  31. package/board/public/rules-editor.js +1 -1
  32. package/board/public/shell.js +1 -1
  33. package/board/public/sidebar.css +31 -11
  34. package/board/public/sidebar.js +88 -16
  35. package/board/public/style.css +141 -46
  36. package/board/public/toasts.js +6 -4
  37. package/board/public/ui-cards.js +102 -37
  38. package/board/public/ui-header.js +17 -8
  39. package/board/public/ui-hub.js +30 -12
  40. package/board/public/ui-links.js +18 -10
  41. package/board/public/ui-order.js +18 -0
  42. package/board/public/ui-page-header.js +4 -2
  43. package/board/public/ui-questions.js +37 -16
  44. package/board/public/ui-sidebar.js +49 -11
  45. package/board/public/ui-snapshot.js +15 -9
  46. package/board/public/ui-threads.js +25 -16
  47. package/board/public/ui.js +23 -11
  48. package/board/public/view-hub-settings.js +46 -30
  49. package/board/public/view-hub.js +64 -13
  50. package/board/public/view-notes.js +7 -4
  51. package/board/public/view-polling.js +3 -3
  52. package/board/public/view-questions.js +42 -22
  53. package/board/public/view-settings.js +90 -35
  54. package/board/review.mjs +1 -0
  55. package/board/server.mjs +12 -7
  56. package/board/store.mjs +14 -6
  57. package/board/tickets.mjs +3 -3
  58. package/board/worktree.mjs +14 -13
  59. package/docs/hub-session.md +3 -3
  60. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  61. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  62. package/hub/awake.mjs +1 -0
  63. package/hub/board-repo.mjs +1 -1
  64. package/hub/boards.mjs +4 -4
  65. package/hub/close-board.mjs +1 -1
  66. package/hub/duration.mjs +1 -0
  67. package/hub/github.mjs +4 -5
  68. package/hub/home.mjs +1 -1
  69. package/hub/hub.mjs +12 -10
  70. package/hub/linear.mjs +2 -2
  71. package/hub/new-board.mjs +2 -2
  72. package/hub/pr-report.mjs +1 -0
  73. package/hub/push.mjs +2 -2
  74. package/hub/readiness.mjs +2 -0
  75. package/hub/repos.mjs +3 -2
  76. package/hub/restart-board.mjs +1 -0
  77. package/hub/routes.mjs +1 -1
  78. package/hub/schedule.mjs +1 -1
  79. package/hub/sessions.mjs +2 -2
  80. package/hub/shell.mjs +2 -1
  81. package/hub/slack.mjs +2 -0
  82. package/hub/stream.mjs +12 -12
  83. package/hub/sw.js +1 -1
  84. package/hub/trust.mjs +2 -0
  85. package/hub/try.mjs +4 -3
  86. package/hub/upgrade.mjs +1 -0
  87. package/hub/usage.mjs +3 -3
  88. package/hub/views.mjs +1 -1
  89. package/package.json +1 -1
  90. package/shared/colors.css +7 -5
  91. package/shared/fake-gh.mjs +54 -0
  92. package/shared/service.mjs +16 -16
  93. package/shared/skills.mjs +2 -0
  94. package/shared/usage-gate.mjs +1 -1
  95. package/starter/public/claude.js +3 -2
  96. package/bin/web-ts.mjs +0 -2
@@ -1,14 +1,15 @@
1
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.
2
+ // settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, and
3
+ // 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,25 +21,33 @@
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
 
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/0096-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
+
31
40
  // The on/off, snooze and weekend rows (docs/adr/0032-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
+ const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
42
51
  const sectionOf = (hash) => {
43
52
  const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
44
53
  return sections.some(([section]) => section === id) ? id : null;
@@ -60,6 +69,7 @@
60
69
  let flash = null;
61
70
  let rules = null;
62
71
  let rulesJSON = '';
72
+ let linearKey = null;
63
73
 
64
74
  function paint() {
65
75
  if (!last) return;
@@ -68,21 +78,27 @@
68
78
  const scrollTop = B.$('.settings-body').scrollTop;
69
79
  if (active === 'notifications') {
70
80
  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);
81
+ } else if (active === 'keep-awake') {
82
+ content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
83
+ : `<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
84
  } 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;
85
+ if (linearKey) linearKey.set(last.linear);
86
+ else {
87
+ linearKey = LinearKey.mount(content, { linear: last.linear,
88
+ saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
89
+ busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
90
+ }
79
91
  } else if (active === 'closed') {
80
92
  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>`;
81
93
  }
94
+ const input = B.$('[data-dur-at]');
95
+ if (input) customBounds(input, true);
82
96
  B.$('.settings-body').scrollTop = scrollTop;
97
+ B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
83
98
  const summaries = {
84
- notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.title, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
99
+ notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
85
100
  polling: last.polling,
101
+ 'keep-awake': last.awake.summary,
86
102
  linear: last.linear?.summary || 'No key',
87
103
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
88
104
  closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
@@ -125,6 +141,7 @@
125
141
  polling?.unmount(); polling = null;
126
142
  unfitModal?.(); unfitModal = null;
127
143
  modal.close();
144
+ linearKey?.destroy(); linearKey = null;
128
145
  content.innerHTML = '';
129
146
  rules = null;
130
147
  active = null;
@@ -161,7 +178,10 @@
161
178
  history.replaceState(history.state, '', settingsURL);
162
179
  return true;
163
180
  };
164
- const dismiss = () => B.openSettings({ kind: 'hub' });
181
+ const dismiss = () => {
182
+ if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
183
+ linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
184
+ };
165
185
  B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
166
186
  B.on(root, 'click', (e) => {
167
187
  const opener = e.target.closest?.('[data-settings-open]');
@@ -181,7 +201,7 @@
181
201
  device.ready.then(() => { if (B.alive()) paint(); });
182
202
  let openPicker = null;
183
203
  B.on(root, 'click', (e) => {
184
- const input = e.target.closest?.('[data-snooze-at]');
204
+ const input = e.target.closest?.('[data-dur-at]');
185
205
  if (!input) return;
186
206
  openPicker = input;
187
207
  customBounds(input, input.value === input.defaultValue);
@@ -189,7 +209,7 @@
189
209
  });
190
210
  // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
191
211
  B.on(root, 'pointerdown', (e) => {
192
- const input = e.target.closest?.('.snooze-choices button') && B.$('[data-snooze-at]');
212
+ const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
193
213
  if (!input) return;
194
214
  openPicker = null;
195
215
  input.value = input.defaultValue;
@@ -199,16 +219,16 @@
199
219
  if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
200
220
  const at = new Date(input.value);
201
221
  if (Number.isNaN(at.getTime())) return;
202
- input.form.querySelector('[name="snooze"]').value = at.toISOString();
222
+ input.form.querySelector('input[type="hidden"]').value = at.toISOString();
203
223
  input.form.requestSubmit();
204
224
  openPicker = null;
205
225
  input.defaultValue = input.value;
206
226
  };
207
227
  B.on(root, 'change', (e) => {
208
- if (e.target.matches?.('[data-snooze-at]') && document.activeElement !== e.target) saveCustom(e.target);
228
+ if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
209
229
  });
210
230
  B.on(root, 'focusout', (e) => {
211
- if (e.target.matches?.('[data-snooze-at]')) saveCustom(e.target);
231
+ if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
212
232
  });
213
233
  B.on(root, 'click', async (e) => {
214
234
  const button = e.target.closest?.('[data-reopen]');
@@ -227,10 +247,6 @@
227
247
  reopening = null;
228
248
  if (B.alive()) paint();
229
249
  });
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
250
  B.on(root, 'change', async (e) => {
235
251
  const on = e.target;
236
252
  if (on.id !== 'notifyOn') return;
@@ -1,6 +1,6 @@
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 (ADR 0075). A board's row can carry Restart (hub/restart-board.mjs). Health comes from GET /_hub/api/hub.
4
4
  // Its data refreshes every 30 seconds while it shows (hub-kit.js live).
5
5
  (function () {
6
6
  // The last data, which a return to the view paints at once.
@@ -11,30 +11,56 @@
11
11
  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
12
  }
13
13
 
14
- function sitesHtml(groups) {
14
+ const RESTARTING = '<span class="st wait">Restarting</span> · stopping its session, then starting it again';
15
+
16
+ // A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
17
+ // button can't sit inside a link.
18
+ function restartRow(s, sub, busy) {
19
+ const href = esc(s.href);
20
+ 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>`
21
+ + `<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 ? ' disabled aria-busy="true"' : ''}>${busy ? '<span class="try-spin" aria-hidden="true"></span>Restarting…' : 'Restart'}</button>`
22
+ + `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
23
+ }
24
+
25
+ // restarting: the routes whose Restart is out.
26
+ function sitesHtml(groups, restarting) {
15
27
  const { row, CHEVRON } = HubUI;
16
28
  const site = (s) => {
29
+ const busy = restarting.has(s.route);
17
30
  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);
31
+ const sub = busy ? RESTARTING : `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`;
32
+ 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
33
  };
20
34
  return groups.map((g) => `<h2 class="label">${esc(g.label)}</h2>\n<div class="group">${g.sites.map(site).join('')}</div>\n`).join('');
21
35
  }
22
36
 
37
+ function integrationsHtml(integrations) {
38
+ const { row, tile } = HubUI;
39
+ const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
40
+ ? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
41
+ const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
42
+ sub: it.ready ? `<span class="st ok">Ready</span> · ${esc(it.detail)}`
43
+ : `<span class="st wait">Not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span><span class="rd-waits">${esc(it.waits)}</span></span>` })).join('');
44
+ return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
45
+ <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
46
+ `;
47
+ }
48
+
23
49
  // A pending prompt from a session that owns no board waits here (prompts.js).
24
50
  function promptsHtml(list, prompts) {
25
51
  const shown = list.filter(prompts.shows);
26
52
  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
53
  }
28
54
 
29
- function html(d, { flash, prompts }) {
55
+ function html(d, { flash, prompts, restarting }) {
30
56
  const { row, tile, nowRow } = HubUI;
31
- const usage = HubUI.usageCard(d.usage, d.codex, d.pause);
57
+ const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
32
58
  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 })}
59
+ <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<button type="button" class="st ${esc(d.hub.tone)}" data-jump="integrations">${esc(d.hub.word)}</button> ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true })}
34
60
  ${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>
61
+ ${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
62
  <h2 class="label">Polling status</h2>
37
- <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div>`;
63
+ <div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
38
64
  }
39
65
 
40
66
  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 +85,36 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
59
85
  B.atUnmount(() => settings.unmount());
60
86
  B.on(B.$('#refresh'), 'click', () => settings.B.refreshSettings?.());
61
87
  let flash = null;
88
+ const restarting = new Set();
62
89
  B.openSettings = (...args) => settings.B.openSettings(...args);
90
+ async function restart(route, title) {
91
+ 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;
92
+ restarting.add(route);
93
+ flash = null;
94
+ paint();
95
+ let out = null;
96
+ try {
97
+ const r = await fetch('/_hub/restart-board', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ route }) });
98
+ out = await r.json().catch(() => null);
99
+ if (!r.ok || !out) throw new Error();
100
+ if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
101
+ } catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
102
+ restarting.delete(route);
103
+ if (B.alive()) { paint(); load(); }
104
+ }
105
+ B.on(main, 'click', (e) => {
106
+ const opener = e.target.closest?.('[data-settings-open]');
107
+ if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
108
+ // The Hub's status word jumps to what's behind it: Integrations, then each board's links below them.
109
+ const jump = e.target.closest?.('[data-jump]');
110
+ if (jump) B.$(`#${jump.dataset.jump}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
111
+ const restarter = e.target.closest?.('[data-restart]');
112
+ if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
113
+ });
63
114
  const foot = B.$('.hub-foot');
64
115
  const paint = () => {
65
116
  if (!last) return;
66
- main.innerHTML = html(last, { flash, prompts });
117
+ main.innerHTML = html(last, { flash, prompts, restarting });
67
118
  foot.innerHTML = footer(last);
68
119
  };
69
120
  const prompts = PromptAnswers.wire(B, {
@@ -73,9 +124,9 @@ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions witho
73
124
  after: () => load(),
74
125
  });
75
126
  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')),
127
+ 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
128
  answer(out, action) {
78
- if (['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
129
+ if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
79
130
  last = out; flash = null; paint();
80
131
  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
132
  } 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
2
  // (docs/adr/0053-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>`;
@@ -14,10 +14,10 @@
14
14
  + ` aria-label="${esc(g.aria)}"${g.w === sel ? ' aria-current="true"' : ''}>${g.label ? `<span>${esc(g.label)}</span>` : ''}</a>`).join('');
15
15
  const pins = day.pins.map((p) => `<span class="pin" style="left:${p.left}" title="${esc(p.title)}" aria-hidden="true">▼</span>`).join('');
16
16
  const ticks = ['12am', '6am', '12pm', '6pm', '12am'].map((t, i) => `<span style="left:${i * 25}%">${t}</span>`).join('');
17
- const legend = ['<span><i style="background:var(--accent)"></i>darker checks more often</span>',
18
- day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--accent) 0 3px,var(--chip) 3px 5px)"></i>pushes held</span>',
17
+ const legend = ['<span><i style="background:var(--muted)"></i>darker checks more often</span>',
18
+ day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--muted) 0 3px,var(--chip) 3px 5px)"></i>pushes held</span>',
19
19
  day.wakes && '<span style="color:var(--color-notice)">▼ wake</span>',
20
- '<span><i style="background:var(--color-danger);width:2px"></i>now</span>'].filter(Boolean).join('');
20
+ '<span><i style="background:var(--text);width:2px"></i>now</span>'].filter(Boolean).join('');
21
21
  return `<div class="day">${pins}<span class="now" style="left:${day.now}" aria-hidden="true"></span><div class="bar">${segs}</div><div class="ticks" aria-hidden="true">${ticks}</div></div><div class="legend">${legend}</div>`;
22
22
  }
23
23
 
@@ -5,6 +5,8 @@
5
5
  // open question goes back to the board, where the Sent line shows as a toast (BoardUI.sentNotice). An open question
6
6
  // with `urgent: true` in owner.json shows alone first, and its Send follows the same rule for its topic. A question
7
7
  // whose options are prototype takes frames each take with ?take= (docs/adr/0048-a-questions-takes-show-inline.md).
8
+ // Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
9
+ // with its note box as the note and its pick so far as the choice (docs/adr/0126-each-question-has-its-own-prototype-eye.md).
8
10
  // fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
9
11
  (globalThis.AppViews ||= {}).questions = ((fits) => ({
10
12
  className: 'view-questions',
@@ -16,7 +18,7 @@
16
18
 
17
19
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
18
20
  const { $, site } = B;
19
- const { esc, choiceLabel, questionCard, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
21
+ const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
20
22
  // prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
21
23
  const draftKey = (id) => 'qdraft:' + id;
22
24
  const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
@@ -52,13 +54,14 @@
52
54
  return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
53
55
  `<button data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`).join('')}</div>`;
54
56
  }
55
- const topicHead = (t) => t ? `<div class="topichead muted small">${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</div>` : '';
57
+ const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
56
58
 
57
59
  // Only questions changed here since their last answer, or with files attached, are sent.
58
60
  const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
59
61
  const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
60
62
  function pending(qs) {
61
63
  return qs.filter((q) => {
64
+ if (q.state === 'requested') return false;
62
65
  const d = getDraft(q.id);
63
66
  if (filesOf(q).length) return true;
64
67
  if (!('choice' in d) && !d.note) return false;
@@ -199,7 +202,7 @@
199
202
  function waitingTopics() {
200
203
  const list = topicRow(topics, topicId).waiting;
201
204
  return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
202
- `<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
205
+ `<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.requested ? 'prototype requested' : t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
203
206
  }
204
207
 
205
208
  function resolvedTopics() {
@@ -208,6 +211,20 @@
208
211
  `<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
209
212
  }
210
213
 
214
+ async function askPrototype(q) {
215
+ const st = $('#qstatus');
216
+ const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
217
+ st.textContent = 'Sending…';
218
+ const d = getDraft(q.id);
219
+ const last = q.answers[q.answers.length - 1];
220
+ try { await site.send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) }); }
221
+ catch { st.textContent = 'Could not send. Is the host awake?'; return; }
222
+ setDraft(q.id, {});
223
+ sentLine = 'Prototype please.';
224
+ await loading;
225
+ if (await load(false)) { shown = sigNow(); render(); }
226
+ }
227
+
211
228
  function updateBar() {
212
229
  const qs = visible();
213
230
  const n = pending(qs).length;
@@ -226,7 +243,7 @@
226
243
  topicId = t ? t.id : null;
227
244
  }
228
245
 
229
- const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
246
+ 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))])]);
230
247
  // One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
231
248
  // while one runs shares it. → true when the board's answer landed in this view.
232
249
  let loading = null;
@@ -290,6 +307,8 @@
290
307
 
291
308
  B.on(root, 'click', (ev) => {
292
309
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
310
+ const eye = ev.target.closest && ev.target.closest('[data-proto]');
311
+ if (eye) { if (!eye.disabled) askPrototype(byId(eye.closest('section.q').dataset.q)); return; }
293
312
  const b = ev.target.closest && ev.target.closest('[data-topic]');
294
313
  if (b) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
295
314
  });
@@ -344,24 +363,25 @@
344
363
  const from = soloId ? topicOf(soloId)?.id : topicId;
345
364
  const pills = topicRow(topics, from).pills;
346
365
  const name = topicName(topics.find((t) => t.id === from) || { title: '' });
347
- let sent = 0;
348
- try {
349
- for (const q of list) {
350
- const d = getDraft(q.id);
351
- const last = q.answers[q.answers.length - 1];
352
- const choice = d.choice ?? (last ? last.choice : null);
353
- const files = filesOf(q);
354
- await site.send('owner-answer', { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) });
355
- setDraft(q.id, {});
356
- BoardAttach.clear('q:' + q.id, site.root);
357
- sent++;
358
- }
359
- const what = `Sent ${sent} answer${sent === 1 ? '' : 's'}${name ? ` to ${name}` : ''}.`;
360
- // A load that started before these answers went out would read them as still open.
361
- await loading;
362
- if (await load(false)) moveOn(from, pills, what);
363
- else if (B.alive()) { sentLine = what; $('#qstatus').textContent = what; $('#qstatus').classList.add('sent'); }
364
- } catch { st.textContent = sent ? `Sent ${sent} of ${list.length}. Is the host awake?` : 'Could not send. Is the host awake?'; }
366
+ // docs/adr/0163-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none.
367
+ const answers = list.map((q) => {
368
+ const d = getDraft(q.id);
369
+ const last = q.answers[q.answers.length - 1];
370
+ const choice = d.choice ?? (last ? last.choice : null);
371
+ const files = filesOf(q);
372
+ return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
373
+ });
374
+ try { await site.send('owner-answers', { answers }); }
375
+ catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
376
+ for (const q of list) {
377
+ setDraft(q.id, {});
378
+ BoardAttach.clear('q:' + q.id, site.root);
379
+ }
380
+ const what = `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${name ? ` to ${name}` : ''}.`;
381
+ // A load that started before these answers went out would read them as still open.
382
+ await loading;
383
+ if (await load(false)) moveOn(from, pills, what);
384
+ else if (B.alive()) { sentLine = what; $('#qstatus').textContent = what; $('#qstatus').classList.add('sent'); }
365
385
  });
366
386
  // Each composer painted raises a bubbling attach-change (attach.js).
367
387
  B.on(root, 'attach-change', updateBar);