@lovek4d/kickflip 0.5.0 → 0.6.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 (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -39,8 +39,12 @@
39
39
  const { row, tile } = HubUI;
40
40
  const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
41
41
  ? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
42
+ // One line per device with notifications on: kind, browser, last seen, and a Remove that forgets it.
43
+ const mine = (() => { try { return localStorage.getItem('kickflip.push.id'); } catch { return null; } })();
44
+ const deviceLine = (d) => `<span class="rd-device">${esc([d.kind, d.browser, `seen ${BoardUI.ago(d.added)}`].filter(Boolean).join(' · '))}${d.id === mine ? ' · <b>this device</b>' : ''} <button type="button" class="link" data-forget-device="${esc(d.id)}">Remove</button></span>`;
45
+ const devices = (it) => it.devices?.length ? `<span class="links">${it.devices.map(deviceLine).join('')}</span>` : '';
42
46
  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)}`
47
+ sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
44
48
  : `<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
49
  return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
46
50
  <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
@@ -67,13 +71,14 @@
67
71
  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>
68
72
  <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
73
  trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
74
+ ${HubUI.machineRow(d.pause.machine)}
70
75
  ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</code>.' }) : ''}</div>
71
76
  ${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>
72
77
  <h2 class="label">Polling status</h2>
73
78
  <div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
74
79
  }
75
80
 
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>`;
81
+ 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><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v${esc(d.version)}</span></footer>`;
77
82
 
78
83
  // iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
79
84
  function badge(n) {
@@ -84,7 +89,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
84
89
  (globalThis.AppViews ||= {}).hub = {
85
90
  className: 'hub-view view-hub',
86
91
  title: () => 'Hub',
87
- markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', settingsHref: null })}
92
+ markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png' })}
88
93
  <main class="has-top"><div class="hub-status"></div><section class="hub-settings" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
89
94
 
90
95
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
@@ -109,8 +114,11 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
109
114
  if (!r.ok || !out) throw new Error();
110
115
  if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
111
116
  } catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
117
+ // The row keeps its Restart busy until the hub's data shows the board's new word, and only a refusal loads after
118
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
119
+ if (!flash && B.alive()) await BoardUI.pending.within(load(), () => { B.toast('Still waiting on the board'); load(); });
112
120
  restarting.delete(route);
113
- if (B.alive()) { paint(); load(); }
121
+ if (B.alive()) { paint(); if (flash) load(); }
114
122
  }
115
123
  // Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
116
124
  // 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.
@@ -145,7 +153,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
145
153
  flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
146
154
  if (B.alive()) paint();
147
155
  }
156
+ async function forgetDevice(id) {
157
+ const out = await fetch('/_hub/push/forget', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then((r) => r.ok, () => false);
158
+ if (!out) { flash = { text: 'Could not remove that device. Is the host awake?', error: true }; paint(); return; }
159
+ load();
160
+ }
148
161
  B.on(main, 'click', (e) => {
162
+ const forgetter = e.target.closest?.('[data-forget-device]');
163
+ if (forgetter) forgetDevice(forgetter.dataset.forgetDevice);
149
164
  const opener = e.target.closest?.('[data-settings-open]');
150
165
  if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
151
166
  if (e.target.closest?.('[data-restart-hub]')) restartHub();
@@ -11,22 +11,42 @@
11
11
 
12
12
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, async (B) => {
13
13
  const { $, site } = B;
14
- const { esc, markdown, linkContext, fetchJSON, restartNotice } = BoardUI;
14
+ const { esc, markdown, linkContext, fetchJSON, downLanding, copyNote, pageCopy } = BoardUI;
15
15
  const key = ctx.view.item || '';
16
16
  const wave = ctx.view.wave;
17
17
  const el = $('#plan');
18
18
  const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
19
+ const paint = (body, items, note = '') => {
20
+ $('#ref').textContent = `${wave ? body.title : body.ref} notes`;
21
+ document.title = $('#ref').textContent;
22
+ el.innerHTML = note + markdown(body.markdown, linkContext(items, key, { base: './' }));
23
+ };
24
+ // While the board can't be reached, the notes' last answer here shows under its copy line, or with none the landing
25
+ // for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
26
+ const url = site.url(`api/notes?${wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`}`);
27
+ let copy = null;
28
+ let live = false;
29
+ let copied = false;
30
+ const waiting = (down) => {
31
+ copy ||= pageCopy.read(url);
32
+ copy.then((c) => {
33
+ if (!B.alive() || live || copied) return;
34
+ if (!c) { el.innerHTML = downLanding(down); return; }
35
+ copied = true;
36
+ paint(c.body, [], copyNote('the notes', c.savedAt));
37
+ });
38
+ };
19
39
  try {
20
40
  const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
21
- const query = wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`;
22
- const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
41
+ const r = await get(url, { onWait: (ms, down) => waiting(down) });
23
42
  if (!B.alive()) return;
24
43
  if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
25
- $('#ref').textContent = `${wave ? r.body.title : r.body.ref} notes`;
26
- document.title = $('#ref').textContent;
27
- el.innerHTML = markdown(r.body.markdown, linkContext((await board).items || [], key, { base: './' }));
44
+ live = true;
45
+ paint(r.body, (await board).items || []);
46
+ pageCopy.save(url, r.body);
28
47
  } catch (e) {
29
- el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
48
+ if (e.down) waiting(e.down);
49
+ else el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
30
50
  }
31
51
  }),
32
52
  };