@lovek4d/kickflip 0.4.0 → 0.5.3

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 (90) hide show
  1. package/CONTEXT.md +69 -48
  2. package/README.md +1 -1
  3. package/SKILL.md +22 -20
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +27 -18
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -0
  9. package/board/bin/board.mjs +108 -39
  10. package/board/domain.mjs +7 -7
  11. package/board/finish.mjs +4 -3
  12. package/board/headless.mjs +5 -3
  13. package/board/merge3.mjs +1 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -2
  16. package/board/public/app-asks.js +9 -92
  17. package/board/public/app-banner.js +7 -13
  18. package/board/public/app-cards.js +70 -37
  19. package/board/public/app-core.js +24 -25
  20. package/board/public/app-header.js +2 -1
  21. package/board/public/app-lanes.js +55 -23
  22. package/board/public/app-needs.js +129 -4
  23. package/board/public/app-reader.js +174 -0
  24. package/board/public/app-snapshot.js +6 -5
  25. package/board/public/app-threads.js +83 -58
  26. package/board/public/app.html +2 -0
  27. package/board/public/app.js +66 -24
  28. package/board/public/attach.js +2 -2
  29. package/board/public/dialogs.js +9 -39
  30. package/board/public/hub-kit.js +4 -2
  31. package/board/public/hub.css +1 -0
  32. package/board/public/linear-key.js +46 -35
  33. package/board/public/new-task.js +100 -0
  34. package/board/public/prototype-kit.js +28 -3
  35. package/board/public/prototype-switcher.js +42 -107
  36. package/board/public/shell.js +7 -1
  37. package/board/public/sidebar.css +1 -0
  38. package/board/public/sidebar.js +44 -5
  39. package/board/public/style.css +194 -35
  40. package/board/public/ui-cards.js +80 -44
  41. package/board/public/ui-enums.js +1 -1
  42. package/board/public/ui-header.js +2 -2
  43. package/board/public/ui-order.js +12 -10
  44. package/board/public/ui-phase-bars.js +4 -4
  45. package/board/public/ui-questions.js +63 -47
  46. package/board/public/ui-sidebar.js +27 -15
  47. package/board/public/ui-threads.js +27 -14
  48. package/board/public/ui.js +76 -14
  49. package/board/public/view-frame.js +1 -0
  50. package/board/public/view-hub-settings.js +11 -10
  51. package/board/public/view-hub.js +17 -3
  52. package/board/public/view-questions.js +174 -202
  53. package/board/public/view-settings.js +208 -70
  54. package/board/server.mjs +19 -18
  55. package/board/store.mjs +20 -13
  56. package/board/transcript.mjs +2 -0
  57. package/board/worktree.mjs +24 -20
  58. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  59. package/hooks-plugin/hooks/hooks.json +9 -0
  60. package/hub/boards.mjs +4 -4
  61. package/hub/front-door.mjs +20 -0
  62. package/hub/github.mjs +3 -3
  63. package/hub/hub.mjs +11 -14
  64. package/hub/linear.mjs +1 -2
  65. package/hub/live.mjs +2 -2
  66. package/hub/locks.mjs +1 -1
  67. package/hub/new-board.mjs +2 -2
  68. package/hub/owner-checks.mjs +1 -0
  69. package/hub/pr-report.mjs +2 -2
  70. package/hub/presence.mjs +1 -1
  71. package/hub/push.mjs +2 -2
  72. package/hub/readiness.mjs +1 -2
  73. package/hub/review-send.mjs +1 -0
  74. package/hub/secrets.mjs +2 -0
  75. package/hub/self-restart.mjs +2 -0
  76. package/hub/sessions.mjs +2 -2
  77. package/hub/share.mjs +1 -0
  78. package/hub/slack.mjs +2 -2
  79. package/hub/stream.mjs +10 -13
  80. package/hub/try.mjs +7 -7
  81. package/hub/upgrade.mjs +1 -1
  82. package/hub/usage.mjs +3 -3
  83. package/hub/views.mjs +1 -1
  84. package/package.json +2 -1
  85. package/shared/audit.mjs +4 -0
  86. package/shared/fake-gh.mjs +17 -4
  87. package/shared/owner-check.mjs +3 -0
  88. package/shared/service.mjs +4 -4
  89. package/shared/tailscale.mjs +1 -0
  90. package/shared/usage-gate.mjs +1 -1
@@ -1,18 +1,13 @@
1
- // The answer banner at the top of the board view: asks Claude answered without making work, whose answer this
2
- // browser hasn't seen (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md). Needs app-core.js.
1
+ // The answer banner at the top of the board view: asks Claude answered without making work, whose answer came after the
2
+ // card's read mark (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md,
3
+ // docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). Needs app-core.js.
3
4
  BoardView.part((B) => {
4
- const { $, store, state, seen, markSeen, plural, svg } = B;
5
+ const { $, state, seen, markSeen, markRead, plural, svg } = B;
5
6
  const { esc } = BoardUI;
6
- const DISMISSED = 'dismissedAnswers';
7
7
 
8
- function unseenAnswers(d) {
9
- const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
10
- return (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key] && !(dismissed[a.id] >= a.at));
11
- }
8
+ const unseenAnswers = (d) => (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key]);
12
9
 
13
10
  function renderBanner(d) {
14
- // A New card seen before its answer counts the answer as new; one first seen in Done starts from now (app-core.js markSeen).
15
- for (const it of d.items) if (it.isNew) markSeen(it, true);
16
11
  const shown = unseenAnswers(d);
17
12
  const el = $('#answers');
18
13
  el.hidden = !shown.length;
@@ -28,10 +23,9 @@ BoardView.part((B) => {
28
23
  B.on($('#answers'), 'click', (ev) => {
29
24
  const d = state.board;
30
25
  if (!d) return;
26
+ // ✕ marks each answer shown read, on every device.
31
27
  if (ev.target.closest('.answer-dismiss')) {
32
- const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
33
- for (const a of unseenAnswers(d)) dismissed[a.id] = a.at;
34
- store.set(DISMISSED, dismissed);
28
+ markRead(unseenAnswers(d).map((a) => [a.key, a.at]));
35
29
  renderBanner(d);
36
30
  return;
37
31
  }
@@ -3,7 +3,7 @@
3
3
  // app-core.js and app-threads.js.
4
4
  BoardView.part((B) => {
5
5
  const { $, store, state, markSeen, seen, toast, links } = B;
6
- const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
6
+ const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
7
7
 
8
8
  const mainSheet = $('#mainLogSheet');
9
9
  const mainRetry = $('#mainLogRetry');
@@ -116,20 +116,29 @@ BoardView.part((B) => {
116
116
  }
117
117
  ev.preventDefault();
118
118
  const pull = state.board?.main?.pull;
119
- if (!pull || pull.running || state.mainPull.sending) return;
119
+ if (!pull || pull.running || BoardUI.pending.has('main:pull')) return;
120
120
  if (pull.error && !matchMedia('(hover: hover)').matches) {
121
121
  state.mainPull.open = !state.mainPull.open;
122
122
  paintMain();
123
123
  return;
124
124
  }
125
- state.mainPull = { sending: true, open: false };
125
+ state.mainPull = { open: false };
126
+ // Spins until a board shows the pull running or changed (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
127
+ // A pull that brought no commits changes nothing a board shows, so its answer ends the spinner; one that did waits
128
+ // for Main to rebuild.
129
+ const mainFace = (main) => JSON.stringify([main?.pull, main?.sha, main?.state, main?.at]);
130
+ const before = mainFace(state.board.main);
131
+ BoardUI.pending.start('main:pull', (board) => mainFace(board.main) !== before, B.stillWaiting);
126
132
  paintMain();
127
133
  try {
128
134
  const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
129
135
  const out = await response.json();
130
136
  if (!response.ok) throw new Error(out.error || 'The hub refused the pull.');
131
- } catch (error) { if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?'); }
132
- state.mainPull.sending = false;
137
+ if (out.commits) BoardUI.pending.answered('main:pull'); else BoardUI.pending.drop('main:pull');
138
+ } catch (error) {
139
+ BoardUI.pending.drop('main:pull');
140
+ if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?');
141
+ }
133
142
  if (B.alive()) { paintMain(); B.reload(); }
134
143
  });
135
144
 
@@ -216,18 +225,16 @@ BoardView.part((B) => {
216
225
  }
217
226
  }
218
227
  if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
219
- // building is an implementer at work, since Build means the package (docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
220
- if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
228
+ rows.push(['Status', esc(BoardUI.statusWord(it))]);
221
229
  // docs/adr/2026-11-03-0526-a-follow-up-is-a-work-card-with-a-parent.md: each link row is a row of labels that open their cards.
222
230
  for (const [label, list] of [['Parent', it.parent ? [it.parent] : []], ['Children', it.children], ['Related to', it.related], ['Blocked by', it.blockedBy], ['Waits on it', it.waitsOnIt]]) {
223
231
  if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
224
232
  }
225
233
  if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
226
234
  if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
227
- // docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
228
235
  const external = [
229
- it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
230
- // A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets one.
236
+ // A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets a live one; elsewhere it is muted, with a tooltip and a tap toast.
237
+ it.worktree && !state.board.onHost ? `<a aria-disabled="true" title="not on kickflip host" data-tip="not on kickflip host">Open in VS Code</a>` : '',
231
238
  it.worktree && state.board.onHost ? `<a href="vscode://file${esc(encodeURI(it.worktree))}" title="Open the worktree in VS Code">Open in VS Code ${LINK_ARROW}</a>` : '',
232
239
  installButton(it),
233
240
  pr?.state === 'OPEN' ? `<a${classes(it, BUTTON.github)} href="${esc(pr.url)}" target="_blank" rel="noopener">${pr.isDraft ? 'Review draft on GitHub' : 'Review on GitHub'} ${LINK_ARROW}</a>` : '',
@@ -263,13 +270,13 @@ BoardView.part((B) => {
263
270
  const CONFIRM = {
264
271
  resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
265
272
  nudge: () => 'Nudge Claude to check this implementer?',
266
- grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
267
- prototype: (it, ref) => `Ask Claude for prototypes of ${ref}?`,
268
- merge: (it, ref) => (openPRs(it).length > 1
273
+ // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
274
+ merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
275
+ : openPRs(it).length > 1
269
276
  ? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
270
277
  : `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
271
278
  'card-build': (it, ref) => (it.blockedBy?.length
272
- ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It waits in In Progress until then.`
279
+ ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It is bumped to the top of Todo until then.`
273
280
  : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
274
281
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
275
282
  : it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
@@ -282,33 +289,37 @@ BoardView.part((B) => {
282
289
  };
283
290
  const actionRow = (kind, html) => (html.trim() ? `<div class="action-row ${kind}">${html}</div>` : '');
284
291
 
292
+ // The reader sheet opens it (app-reader.js); docs/adr/2026-11-05-0830-notes-is-a-button-on-the-hold-row.md.
293
+ const notesButton = (it) => (it.hasNotes ? `<a class="button" href="notes.html?item=${encodeURIComponent(it.key)}">Notes</a>` : '');
294
+
285
295
  function actions(it) {
286
296
  const key = esc(it.key);
287
297
  const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
288
298
  const hold = it.kind !== 'work' || !it.holdable ? ''
289
- : it.held ? (it.buildable ? '' : ask('resume', 'Unhold', 'unhold')) : ask('hold', 'Hold');
290
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it)}`)}</div>`;
299
+ : it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
300
+ // A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
301
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${notesButton(it)}`)}</div>`;
291
302
  }
292
303
 
293
304
  // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
294
305
  const ACTION_BUTTONS = {
295
306
  [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
296
307
  [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
297
- // Grill and Prototype each ask for a planner; both wait while either is under way.
298
- [BUTTON.grill]: (it) => ['grill', 'prototype'].map((act) => `<button data-confirm="${act}" data-key="${esc(it.key)}"${it.grilling || it.prototyping ? ' disabled' : ''}>${act === 'grill' ? 'Grill' : 'Prototype'}</button>`).join(''),
299
308
  };
300
309
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
301
310
 
302
311
  // The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
303
312
  const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
304
313
  // band {step, actions} draws the card in a wave band (app-lanes.js).
305
- // Its open questions asked since this browser last opened it count as new, as its messages do.
314
+ // Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
315
+ // questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
306
316
  function card(it, band = {}) {
307
317
  const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
318
+ const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
308
319
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
309
320
  <summary class="work">
310
321
  <span class="status ${it.lane}"></span>
311
- <div class="title-wrap">${workLines(it, { unread: unseen(it.othersAt), newQuestions: unseen(it.questions?.askedAt), top: latestBump(it), ...band })}</div>
322
+ <div class="title-wrap">${workLines(it, { unread, top: latestBump(it), ...band })}</div>
312
323
  </summary>
313
324
  ${state.openItems.has(it.key) ? detail(it) : ''}
314
325
  </details>`;
@@ -383,6 +394,7 @@ BoardView.part((B) => {
383
394
  state.openItems.add(key);
384
395
  const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
385
396
  markSeen(it);
397
+ B.renderBanner(state.board);
386
398
  const unread = el.querySelector('summary .card-unread');
387
399
  const line = unread?.parentNode;
388
400
  unread?.remove();
@@ -396,12 +408,6 @@ BoardView.part((B) => {
396
408
  store.set('openItems', [...state.openItems]);
397
409
  }, true);
398
410
 
399
- function redrawActions(key) {
400
- const it = state.board.items.find((i) => i.key === key);
401
- const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
402
- if (it && row) row.outerHTML = actions(it);
403
- }
404
-
405
411
  B.on(B.root, 'click', async (ev) => {
406
412
  const log = ev.target.closest('[data-try-log]');
407
413
  if (log && state.openItems.has(log.dataset.tryLog)) {
@@ -410,6 +416,8 @@ BoardView.part((B) => {
410
416
  card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
411
417
  return;
412
418
  }
419
+ const off = ev.target.closest('a[data-tip]');
420
+ if (off) { ev.preventDefault(); toast(off.dataset.tip); return; }
413
421
  const button = ev.target.closest('button[data-try]');
414
422
  if (!button) return;
415
423
  ev.preventDefault();
@@ -417,13 +425,22 @@ BoardView.part((B) => {
417
425
  const key = button.dataset.try;
418
426
  const it = state.board.items.find((i) => i.key === key);
419
427
  if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
420
- // Serve / Build waits until the hub has started the build; the card's chip then says it runs.
421
- const post = (async () => {
428
+ // Serve / Build spins until a board shows the card's build changed: running, or already done
429
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
430
+ const before = JSON.stringify(it.try);
431
+ const id = `${key}:try`;
432
+ BoardUI.pending.start(id, (board) => JSON.stringify(board.items.find((i) => i.key === key)?.try) !== before, B.stillWaiting);
433
+ B.render();
434
+ try {
422
435
  const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
423
436
  const out = await r.json();
424
437
  if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
425
- })();
426
- try { await waitOn(button, post); } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
438
+ BoardUI.pending.answered(id);
439
+ } catch (error) {
440
+ BoardUI.pending.drop(id);
441
+ B.render();
442
+ toast(error.message || 'Could not start. Is the host awake?');
443
+ }
427
444
  B.reload();
428
445
  });
429
446
 
@@ -445,12 +462,13 @@ BoardView.part((B) => {
445
462
  if (!b) return;
446
463
  ev.preventDefault();
447
464
  const { key, confirm: act } = b.dataset;
465
+ if (b.classList.contains('nudging')) return;
448
466
  const it = state.board.items.find((i) => i.key === key);
449
467
  if (it && act === 'hold') { openHold(it); return; }
450
468
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
451
469
  // Unhold, Merge (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md), Implement and Done wait while the
452
470
  // server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
453
- const sent = { grill: ['grill-more', { key }], prototype: ['prototype-request', { key }], resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
471
+ const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
454
472
  finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
455
473
  if (sent) {
456
474
  // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
@@ -461,16 +479,31 @@ BoardView.part((B) => {
461
479
  state.openItems.delete(key);
462
480
  store.set('openItems', [...state.openItems]);
463
481
  } : null;
464
- try { await waitOn(b, B.sentHere(key, () => B.send(...sent, close))); if (act === 'grill' || act === 'prototype') B.loadThread(key, { force: true }); }
465
- catch (e) { B.failed(e); }
482
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
483
+ // In Review, or no longer merging there, which is its refusal.
484
+ const pend = act === 'merge' ? `${key}:merge` : null;
485
+ if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
486
+ try {
487
+ const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
488
+ if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
489
+ } catch (e) {
490
+ if (pend) { BoardUI.pending.drop(pend); B.render(); }
491
+ B.failed(e);
492
+ }
466
493
  if (act === 'finish') B.syncDone(key);
467
494
  return;
468
495
  }
469
- // The tapped button waits and the rest of its action row is disabled until the server answers.
470
- const group = { group: $(`.actions[data-actions="${CSS.escape(key)}"]`)?.querySelectorAll('button') ?? [] };
496
+ // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
497
+ // changed, the builder no longer stalled or the card gone (style.css .nudging).
498
+ const id = `${key}:nudge`;
499
+ const { progress, at } = it.builder;
500
+ BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
501
+ B.render();
471
502
  try {
472
- await waitOn(b, B.sentHere(key, () => B.send('builder-nudge', { key })), group); b.disabled = true; toast('Nudge sent');
473
- } catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
503
+ const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
504
+ BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
505
+ } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
506
+ B.render();
474
507
  });
475
508
 
476
509
  Object.assign(B, { captureStrips, moveStrips, detail, card });
@@ -86,32 +86,31 @@ BoardView.part((B) => {
86
86
 
87
87
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
88
88
 
89
- // A card this browser has never seen starts from now, so a first load badges nothing.
90
- const seen = Object.assign(Object.create(null), store.get('seenThreads', {}));
91
- const latestOf = (it) => [new Date().toISOString(), ...(it?.othersAt || [])].sort().pop();
92
- function markSeen(it, onlyNew = false) {
93
- if (!it || (onlyNew && seen[it.key])) return;
94
- seen[it.key] = latestOf(it);
95
- store.set('seenThreads', seen);
89
+ // Read marks live on the board, one set for every device: each card's readAt, the newest message the owner has seen
90
+ // on it (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). seen is card key → the later of the board's mark
91
+ // and this page's own, so a card read here reads at once, before the board comes back with it. seenBefore is an open
92
+ // card's mark from before it opened, where its thread's unread lines start (app-threads.js); a card closing drops it,
93
+ // and a board left and opened again in this page keeps it.
94
+ const seen = Object.create(null);
95
+ const seenBefore = new Map(B.place?.seenBefore);
96
+ B.keep(() => ({ seenBefore: [...seenBefore] }));
97
+ function takeMarks(items) {
98
+ for (const it of items) if (String(it.readAt || '') > (seen[it.key] || '')) seen[it.key] = it.readAt;
96
99
  }
97
-
98
- // iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
99
- // So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
100
- const quiet = new Map();
101
- function quieted(draftKey, ta) {
102
- const until = quiet.get(draftKey) || 0;
103
- if (until <= Date.now()) return false;
104
- if (until !== Infinity) ta.value = '';
105
- return true;
100
+ // [[card key, time]] → each card read up to its time, here at once and on the board for every device.
101
+ function markRead(pairs) {
102
+ const marks = Object.fromEntries(pairs.filter(([key, at]) => at && String(at) > (seen[key] || '')));
103
+ if (!Object.keys(marks).length) return;
104
+ Object.assign(seen, marks);
105
+ fetch(B.site.url('api/read'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ marks }) }).catch(() => {});
106
106
  }
107
- async function sendDraft(draftKey, ta, current, send) {
108
- ta.blur();
109
- quiet.set(draftKey, Infinity);
110
- try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
111
- store.set(draftKey, '');
112
- const now = current();
113
- if (now) now.value = '';
114
- quiet.set(draftKey, Date.now() + 1000);
107
+ // The newest message card it shows: its counted messages (domain.mjs othersAt), its questions' asks and its answer.
108
+ const newestOf = (it) => [...(it.othersAt || []), ...(it.questions?.askedAt || []), it.ask?.answerAt].filter(Boolean).map(String).sort().pop();
109
+ // Card it, open, reads up to the newest message it shows.
110
+ function markSeen(it) {
111
+ if (!it) return;
112
+ if (!seenBefore.has(it.key)) seenBefore.set(it.key, seen[it.key] || null);
113
+ markRead([[it.key, newestOf(it)]]);
115
114
  }
116
115
 
117
116
  // The ui-links.js linkContext for text shown on item `key`'s card or thread.
@@ -124,5 +123,5 @@ BoardView.part((B) => {
124
123
  const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
125
124
  const notice = dialogs.notice;
126
125
 
127
- Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, links, svg, confirm, notice });
126
+ Object.assign(B, { steady, restorePlace, plural, seen, seenBefore, takeMarks, markRead, markSeen, links, svg, confirm, notice });
128
127
  });
@@ -77,7 +77,8 @@ BoardView.part((B) => {
77
77
  } catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
78
78
  finally { state.waking = false; if (B.alive()) renderStatus(); }
79
79
  }
80
- const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online') wake(); };
80
+ // A shared person's tap never wakes the owner's session (shell.js .shared-view).
81
+ const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online' && !document.documentElement.classList.contains('shared-view')) wake(); };
81
82
  B.on(B.root, 'click', wakeOnTap, true);
82
83
  B.on(B.root, 'submit', wakeOnTap, true);
83
84
 
@@ -1,4 +1,4 @@
1
- // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Todo's Queued section, Done's and Asks' Show more,
1
+ // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
2
2
  // Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
3
3
  // Needs app-core.js and app-cards.js.
4
4
  BoardView.part((B) => {
@@ -25,7 +25,7 @@ BoardView.part((B) => {
25
25
  const n = left + (older || 0);
26
26
  const words = `${n} older card${n === 1 ? '' : 's'}`;
27
27
  const fetches = older !== 0 && shown + STEP > cards.length;
28
- return cards.slice(0, shown).map((it) => B.card(it)).join('')
28
+ return waveGroups(laneId, cards.slice(0, shown))
29
29
  + (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
30
30
  }
31
31
 
@@ -97,7 +97,7 @@ BoardView.part((B) => {
97
97
  if (lane.hideEmpty && !items.length) return '';
98
98
  const expanded = laneState[lane.id] ?? lane.open;
99
99
  const open = items.filter((i) => !i.fold);
100
- const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
100
+ const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
101
101
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
102
102
  <summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
103
103
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
@@ -106,8 +106,36 @@ BoardView.part((B) => {
106
106
  }).join('');
107
107
  }
108
108
 
109
+ // docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
110
+ // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
111
+ // band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
112
+ // counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
113
+ // the lane's own number bold in its colour and a tooltip that spells the counts out.
114
+ // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
115
+ let countsShown = null;
116
+ const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
117
+ function waveCounts(band, laneId) {
118
+ const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
119
+ const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
120
+ const here = laneId === 'stale' ? 'progress' : laneId;
121
+ const id = `${laneId}/${band.slug}`;
122
+ const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
123
+ const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
124
+ return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
125
+ }
126
+ function waveGroups(laneId, cards) {
127
+ const bands = state.board.waveBands || [];
128
+ const inBand = (b) => cards.filter((i) => b.keys.includes(i.key));
129
+ const grouped = bands.filter((b) => inBand(b).length);
130
+ if (!grouped.length) return cards.map((it) => B.card(it)).join('');
131
+ const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
132
+ const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
133
+ return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, esc(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
134
+ + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
135
+ }
136
+
109
137
  // docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
110
- // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under Not in a wave. A band's open state lives
138
+ // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
111
139
  // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
112
140
  // any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
113
141
  // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
@@ -125,33 +153,25 @@ BoardView.part((B) => {
125
153
  const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
126
154
  const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
127
155
 
128
- // docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md
129
- // Todo's Queued section, which holds the cards waiting for an implementer slot, the next to build on top, as they
130
- // arrive (domain.mjs laneOrder), then its wave bands and loose cards.
131
- function todoRows(d, shown, all) {
132
- const queued = all.filter((i) => i.queued);
133
- const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
134
- return head + bandRows(d, shown, all.filter((i) => !i.queued));
135
- }
136
-
137
- function bandRows(d, shown, todo) {
138
- const bands = d.waveBands || [];
139
- const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
140
- const banded = new Set(bands.flatMap((b) => b.keys));
156
+ // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
157
+ function todoRows(d, shown, todo) {
158
+ const byKey = new Map(shown.map((i) => [i.key, i]));
159
+ const bands = (d.waveBands || []).filter((b) => b.keys.some((k) => byKey.get(k)?.lane === 'todo'));
160
+ const banded = new Set((d.waveBands || []).flatMap((b) => b.keys));
141
161
  const loose = todo.filter((i) => !banded.has(i.key));
142
162
  const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
143
163
  if (!bands.length) return loose.map((it) => B.card(it)).join('');
144
164
  return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
145
- + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">Not in a wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
165
+ + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">No wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
146
166
  }
147
167
 
148
168
  function bandHtml(band, cards, open) {
149
169
  const todo = cards.filter((i) => i.lane === 'todo');
150
170
  const done = cards.filter((i) => i.lane === 'done');
151
- const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${i.queued ? 'queued' : LANE_WORDS[i.lane]}`);
171
+ const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
152
172
  const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
153
173
  const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
154
- const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
174
+ const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
155
175
  const next = band.next;
156
176
  const go = !next ? '' : next.ready
157
177
  ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
@@ -159,7 +179,7 @@ BoardView.part((B) => {
159
179
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
160
180
  const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
161
181
  + `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
162
- + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
182
+ + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
163
183
  if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
164
184
  const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
165
185
  const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
@@ -220,8 +240,14 @@ BoardView.part((B) => {
220
240
  const pb = ev.target.closest('button[data-plan]');
221
241
  if (pb) {
222
242
  if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
223
- try { await waitOn(pb, B.send('plan-waves', { keys: pb.dataset.plan.split(' ') })); pb.textContent = 'Planning… its questions appear in Needs you'; pb.disabled = true; }
224
- catch { B.notice('Could not send. Is the host awake?'); }
243
+ // Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
244
+ const keys = pb.dataset.plan.split(' ');
245
+ BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
246
+ try {
247
+ const out = await waitOn(pb, B.send('plan-waves', { keys }));
248
+ BoardUI.pending.answered('todo:plan', out.board.generatedAt);
249
+ B.render();
250
+ } catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
225
251
  return;
226
252
  }
227
253
  const tb = ev.target.closest('button[data-triage]');
@@ -264,6 +290,12 @@ BoardView.part((B) => {
264
290
  for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
265
291
  B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
266
292
  B.on(B.root, 'click', async (ev) => {
293
+ const counts = ev.target.closest?.('[data-counts]');
294
+ if (counts || countsShown) {
295
+ countsShown = counts && counts.dataset.counts !== countsShown ? counts.dataset.counts : null;
296
+ B.render();
297
+ if (counts) return;
298
+ }
267
299
  if (pressed) { pressed = false; ev.preventDefault(); return; }
268
300
  if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
269
301
  const dots = ev.target.closest('button[data-band-menu]');