@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,6 +1,6 @@
1
1
  // Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-phase-bars.js.
2
2
  (function () {
3
- const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = globalThis.BoardUI;
3
+ const { esc, ago, lasted, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = globalThis.BoardUI;
4
4
  const EXTERNAL = { target: '_blank', rel: 'noopener' };
5
5
  // The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
6
6
  // title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
@@ -56,17 +56,19 @@
56
56
  const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
57
57
  return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
58
58
  }
59
- // Each unfinished dep, linking to its card: "blocked by #53, #54".
59
+ // Each unfinished dep, linking to its card: "blocked by #53, #54", or "waits on #53" on a bumped card not held, which
60
+ // the pull takes once nothing blocks it (docs/adr/2026-11-05-0802-todo-has-no-queue.md).
60
61
  function blockedWords(it) {
61
62
  const deps = it.blockedBy || [];
62
- return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
63
+ const lead = it.bumpedAt && !it.held ? 'waits on ' : 'blocked by ';
64
+ return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : lead}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
63
65
  }
64
66
  const tryWords = (kind) => kind === 'app' ? {
65
- go: 'Build', going: 'building…', done: 'Install', failed: 'build failed',
66
- current: 'Nothing new to build: Install already has this card’s latest push.',
67
- busy: 'Building now. The badge turns back into Install when the new build is ready.',
68
- autoSub: 'The hub builds each card after each push, and main after each merge.',
69
- saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
67
+ go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
68
+ current: 'Nothing new to serve: Install already has this card’s latest push.',
69
+ busy: 'Serving now. The badge turns back into Install when the new build is ready.',
70
+ autoSub: 'The hub serves each card after each push, and main after each merge.',
71
+ saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
70
72
  } : {
71
73
  go: 'Serve', going: 'serving…', done: 'Visit', failed: 'serve failed',
72
74
  current: 'Nothing new to serve: Visit already shows this card’s latest push.',
@@ -112,16 +114,17 @@
112
114
  if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
113
115
  const words = tryWords(t.kind);
114
116
  const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
115
- return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
117
+ const attrs = BoardUI.pending.has(`${it.key}:try`) ? BoardUI.BUSY : reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : '';
118
+ return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
116
119
  }
117
120
  const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
118
121
  // Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
119
122
  // the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
120
123
  // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
121
124
  // (`open`). Visit is left alone: it changes only once Main rebuilds.
122
- function mainPull(pull, { sending = false, open = false } = {}) {
125
+ function mainPull(pull, { open = false } = {}) {
123
126
  if (!pull) return '';
124
- const busy = pull.running || sending;
127
+ const busy = pull.running || BoardUI.pending.has('main:pull');
125
128
  const failed = !busy && pull.error;
126
129
  const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
127
130
  const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
@@ -137,24 +140,19 @@
137
140
  return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
138
141
  }
139
142
  // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
140
- // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it
141
- // (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
143
+ // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
144
+ // card with no PR, and from the tap until a board shows the card left In Review or its merge refused (ui.js pending) (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md,
145
+ // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
142
146
  function mergeButton(it) {
143
147
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
144
- return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${it.merging ? BoardUI.BUSY : ''}>Merge</button>`;
145
- }
146
- // The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
147
- // not: it confirms, then
148
- // sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
149
- // mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
150
- // docs/adr/2026-11-03-0219-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
151
- // Implement is plain until the card is ready, then builder orange (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
152
- function buildButton(it) {
153
- if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
154
- return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
148
+ const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
149
+ // A merging card has no Action needed left, so resolvedBy is null by then: merging keeps the colour resolvedBy gave it.
150
+ return `<button class="${it.merging || it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
155
151
  }
156
- // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on.
157
- const builderLine = (b) => [b.progress || 'preheating', b.at && ago(b.at)].filter(Boolean).join(' · ');
152
+ // A builder reads "starting" until its first status line, from the Implement tap or the session's dispatch on, and
153
+ // ends with how long ago that line was set. One whose process is gone leads with stopped; one whose live process
154
+ // went quiet keeps its words and only turns grey (domain.mjs builderOf; docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
155
+ const builderLine = (b) => [b.alive === false && 'stopped', b.progress || (b.alive === false ? null : 'starting'), b.at && lasted(b.at)].filter(Boolean).join(' · ');
158
156
  // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
159
157
  // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
160
158
  // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
@@ -166,21 +164,28 @@
166
164
  // questions, a blocker or queue, a reopening) or its build is stale, and its label. An install is no build: its card keeps the phase its
167
165
  // build reached, undimmed
168
166
  // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
167
+ // A Todo card a grill or prototype runs on draws the planner's dashes instead, in blue, the grill's first and the
168
+ // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
169
169
  function phaseStrip(it) {
170
+ if (it.lane === 'todo' && it.builder?.planner) {
171
+ const prototype = it.builder.kind === 'prototype';
172
+ return { reached: prototype ? 2 : 1, running: true, dim: false, plan: true, label: prototype ? 'Prototyping' : 'Grilling' };
173
+ }
170
174
  if (it.lane !== 'progress' || it.builder?.planner) return null;
171
175
  const all = BUILD_PHASES.length;
172
- if (it.waitsOn === 'checking') return { reached: all, running: false, dim: false, label: `Orchestrator review, ${ALL_DONE}` };
176
+ // Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
177
+ if (it.waitsOn === 'checking') return { reached: BUILD_PHASES.indexOf('checking') + 1, running: true, dim: false, label: `Orchestrator review, ${stepWords('checking')}` };
173
178
  const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
174
179
  const answers = asking ? ', waiting on your answers' : '';
175
180
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
176
181
  if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
177
182
  const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
178
- return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Preheating, no step yet'}${answers}` };
183
+ return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled || it.builder.quiet), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
179
184
  }
180
185
  const dim = !it.builder || asking;
181
186
  if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
182
187
  const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
183
- const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
188
+ const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : 'No step yet';
184
189
  return { reached, running: false, dim, label: `${label}${answers}` };
185
190
  }
186
191
  // What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
@@ -192,12 +197,39 @@
192
197
  return has && next > prev ? 'fill' : null;
193
198
  }
194
199
  const phaseBars = (strip) => bars(strip, BUILD_PHASES);
195
- // A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
200
+ // docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
201
+ // Every status line on an In Progress card is the phase-bar orange, and every build or plan line on a Todo card the
202
+ // owner's blue (style.css).
203
+ const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
204
+ const DOT = '<span class="working-dot" aria-hidden="true"></span>';
205
+ // A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
206
+ // review with its dot once the session picks it up, each with how long ago.
207
+ function orchestratorStatus(it) {
208
+ const { since, picked } = it.orchestrator;
209
+ return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
210
+ }
211
+ // A stale or stopped build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
212
+ // A quiet one is grey, with no dot.
196
213
  function builderStatus(it) {
197
- const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}`;
198
- const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
214
+ const { quiet } = it.builder;
215
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}`;
216
+ const words = `${quiet ? '' : DOT}${esc(builderLine(it.builder))}`;
199
217
  if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
200
- return `<button type="button" class="${classes} stalled" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
218
+ const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
219
+ return `<button type="button" class="${classes} stalled${nudging}" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
220
+ }
221
+ // The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
222
+ // (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
223
+ function statusWord(it) {
224
+ const b = it.builder;
225
+ if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
226
+ if (b && b.kind !== BUILDER_KIND.install) return b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing';
227
+ if (it.waitsOn === 'checking') return 'Orchestrator review';
228
+ if (it.lane === 'done') return 'done';
229
+ if (it.grilling || it.prototyping) return 'preheating';
230
+ if (it.lane === 'todo') return it.ready ? 'ready' : 'to do';
231
+ if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
232
+ return 'in review';
201
233
  }
202
234
  const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
203
235
  // When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
@@ -218,15 +250,15 @@
218
250
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
219
251
  }
220
252
  // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
221
- // or ready, then an In Progress card's phase bars, then its new questions and unread messages, questions first, which app-cards.js
253
+ // or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
222
254
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
223
255
  // docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
224
256
  const SEP = '<span class="sep"> · </span>';
225
257
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
226
- function timeLine(it, unread, questions, bars) {
258
+ function timeLine(it, unread, bars) {
227
259
  const iso = it.sortAt;
228
260
  const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
229
- const news = [newWords(questions, 'question'), newWords(unread, 'message')].filter(Boolean).join(' · ');
261
+ const news = newWords(unread, 'message');
230
262
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
231
263
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
232
264
  if (!parts.length && !count && !tail) return '';
@@ -255,30 +287,34 @@
255
287
  const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
256
288
  // In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
257
289
  // its bump with the band's ⋯ menu.
258
- function workLines(it, { unread = 0, newQuestions = 0, top = false, step = 0, actions = null } = {}) {
290
+ function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
259
291
  const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
260
292
  // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
261
293
  const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
262
294
  const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
263
295
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
264
296
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
265
- : it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
297
+ : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${esc(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
298
+ : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
266
299
  : it.builder ? builderStatus(it)
267
- : it.grilling || it.prototyping ? `<div class="line3 sub agent planner"><span class="working-dot" aria-hidden="true"></span>${it.grilling ? 'preheating' : 'prototyping'}</div>`
300
+ : it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
268
301
  : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
269
302
  : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
270
- : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
303
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
271
304
  const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
305
+ // The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
306
+ const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
307
+ : `<div class="line3 sub check">${esc(`Checks: ${it.ownerCheck.words}`)}</div>`;
272
308
  return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
273
- ${status}${eta}
309
+ ${status}${check}${eta}
274
310
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
275
- ${timeLine(it, unread, newQuestions, phaseBars(phaseStrip(it)))}`;
311
+ ${timeLine(it, unread, phaseBars(phaseStrip(it)))}`;
276
312
  }
277
313
  const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
278
314
  const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
279
315
  function planButton(items) {
280
316
  const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
281
- return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
317
+ return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
282
318
  }
283
319
 
284
320
  // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
@@ -319,5 +355,5 @@
319
355
  <div class="list">${list}${fold}</div></details>`;
320
356
  }
321
357
 
322
- Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
358
+ Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
323
359
  })();
@@ -3,7 +3,7 @@
3
3
  // badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
4
4
  // since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
5
5
  (function () {
6
- const BUTTON = Object.freeze({ grill: 'grill', openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
7
7
  const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
@@ -65,13 +65,13 @@
65
65
  ? `<ul class="hub-links">${links.map(linkRow).join('')}</ul>`
66
66
  : '<p class="muted">The hub doesn\'t list this board yet. It picks a board up from the manifest on its next check.</p>');
67
67
 
68
- // The checklist a new board shows at the top until its session answered the hello and GitHub checks reach it; setup
68
+ // The checklist a new board shows at the top until its session answered the ping and GitHub checks reach it; setup
69
69
  // is /api/board's, null once both pass. Cards can start meanwhile.
70
70
  const setupCard = (setup) => {
71
71
  if (!setup) return '';
72
72
  const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
73
73
  return `<h2>Connecting this board</h2><ul class="setup-steps">
74
- ${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
74
+ ${step(setup.events, 'Claude answered the ping', 'Waiting for Claude to answer the ping')}
75
75
  ${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
76
76
  </ul>`;
77
77
  };
@@ -1,21 +1,23 @@
1
1
  // The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
2
2
  // tap it draws at once by, so the card lands where the server will put it
3
- // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, queued, bumpedAt, sortAt, order and key.
3
+ // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
6
  const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
7
- // New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md);
8
- // then bumped cards, the latest bump on top (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest
9
- // newest first by sortAt, a card with no time last, the last added to its wave file on top. Todo's Queued cards
10
- // lead it, bumped first and then oldest first, so the top one is the next to build
11
- // (docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md).
7
+ // Todo lists bumped cards first, the latest bump on top, a bumped New ask included
8
+ // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest oldest first by sortAt, a card with no
9
+ // time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
10
+ // top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
11
+ // docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
12
+ // lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
12
13
  function laneOrder(a, b) {
13
14
  const bumped = (i) => i.bumpedAt || '';
14
- const unsorted = (i) => (i.isNew ? 0 : 1);
15
- const queued = (i) => (i.queued ? 1 : 0);
16
15
  const at = (i) => String(i.sortAt || '');
17
- const age = a.isNew || a.queued ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
18
- return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || queued(b) - queued(a) || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
16
+ const oldestFirst = a.lane === 'todo';
17
+ const timeless = (i) => (at(i) ? 0 : 1);
18
+ const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
19
+ const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
20
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || bumped(b).localeCompare(bumped(a)) || age || added || a.key.localeCompare(b.key);
19
21
  }
20
22
  Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
21
23
  })();
@@ -4,14 +4,14 @@
4
4
  // hub/hub.mjs renders the shell's first sidebar with it in a vm context. style.css draws the bars.
5
5
  (function () {
6
6
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
- // strip: {reached, running, dim, label, fill}, or null for no bars. reached counts the phases reached, the last of them
8
- // running when running is set; fill, a percent, fills the next bar that far.
7
+ // strip: {reached, running, dim, label, fill, plan}, or null for no bars. reached counts the phases reached, the last of
8
+ // them running when running is set; fill, a percent, fills the next bar that far; plan draws them in the planner blue.
9
9
  function phaseBars(strip, phases) {
10
10
  if (!strip) return '';
11
- const { reached, running, dim, label, fill } = strip;
11
+ const { reached, running, dim, label, fill, plan } = strip;
12
12
  const bar = (i) => (i === reached - 1 && running ? '<i class="now"></i>' : i < reached ? '<i class="done"></i>'
13
13
  : i === reached && fill !== undefined ? `<i class="fill" style="--p:${fill}%"></i>` : '<i class=""></i>');
14
- return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
14
+ return `<span class="phase-bars${dim ? ' dim' : ''}${plan ? ' plan' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
15
15
  }
16
16
  Object.assign(globalThis.BoardUI ||= {}, { phaseBars });
17
17
  })();
@@ -1,24 +1,34 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, time, replyBody, attachments, attachButton, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
- // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it. prototype-switcher.js answerLabel matches.
3
+ const { esc, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
+ // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
5
5
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
6
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
- const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}>
8
- <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${o.recommended ? '<span class="rec">Recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div></span></label>`;
7
+ // suggested: the line under a suggested option saying where the owner chose it before, as HTML.
8
+ const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
9
+ <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
10
+ // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
11
+ // A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
12
+ // suggested, pre-checked so the topic's Send sends it.
13
+ const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
14
+ // A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
15
+ // phone's deck (app-needs.js) and the prototype viewer's Pick (prototype-switcher.js) write it.
16
+ const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
17
+ const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
18
+ const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
9
19
 
10
20
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
11
- // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which resolves it
12
- // (view-questions.js); a resolved question has none.
13
- const questionEye = (q) => q.state === 'resolved' ? '' : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead' });
21
+ // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
22
+ // requested until its prototype round arrives (view-questions.js).
23
+ const questionEye = (q) => protoButton('q:' + q.id, { label: 'Ask for a prototype instead' });
14
24
  const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
15
- <div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
25
+ <div class="note-tools">${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
16
26
 
17
27
  const QUESTION_STATE = {
18
28
  open: { label: 'open', tone: 'normal' },
19
29
  answered: { label: 'waiting for claude', tone: 'notice' },
20
30
  replied: { label: 'claude replied', tone: 'ready' },
21
- resolved: { label: 'resolved', tone: 'quiet' },
31
+ requested: { label: `${SPIN}prototype requested`, tone: 'normal' },
22
32
  };
23
33
  // docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
24
34
  // A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
@@ -34,7 +44,9 @@
34
44
  const last = q.answers[q.answers.length - 1];
35
45
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
36
46
  }
37
- // A prototype page in the site's public/ opens in place, and its switcher picks a take (board/public/prototype-switcher.js).
47
+ // A prototype page in the site's public/ is the question's full-page viewer: it opens in place, shows one take at a
48
+ // time, and its switcher drafts a pick (board/public/prototype-switcher.js,
49
+ // docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
38
50
  const PROTOTYPE_PAGE = /^prototype-[\w-]+\.html(?:[?#]|$)/;
39
51
  const isPrototype = (url) => Boolean(url) && PROTOTYPE_PAGE.test(globalThis.location && url.startsWith(location.origin + '/') ? url.replace(/^[^?#]*\//, '') : url);
40
52
  const withParam = (url, key, value) => {
@@ -42,13 +54,11 @@
42
54
  return `${url.slice(0, at)}${url.slice(0, at).includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${url.slice(at)}`;
43
55
  };
44
56
  const questionLink = (url) => !url ? ''
45
- : isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
57
+ : isPrototype(url) ? `<a class="button proto-view" href="${esc(url)}">View prototypes</a>`
46
58
  : `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
47
59
 
48
- // A question's takes show inline (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): an option with `diagram` draws
49
- // it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
50
- const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
51
- const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
60
+ // An option with `diagram` draws it with its `practice` in the question (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
61
+ const drawn = (q) => q.options.some((o) => o.diagram);
52
62
 
53
63
  // rows: [[{text, tone?, to?}]], top to bottom. A box points at the boxes of the next row listed in `to`, else at all
54
64
  // of them; tone is a status colour's name.
@@ -66,42 +76,48 @@
66
76
  }
67
77
  const practice = (lines, links) => `<div class="practice"><b>In practice</b><ul>${lines.map((l) => `<li>${linkText(l, links)}</li>`).join('')}</ul></div>`;
68
78
 
69
- const fullTakes = (q, fit) => isPrototype(q.url) && q.options.every((o) => !o.diagram && !o.practice)
70
- && (q.options.length === 1 || q.options.some((o) => fit(o.id)?.scrolls));
71
- // Grid heights decide the layout; fullH measures the chosen take at the full content width.
72
- function takeBody(q, o, links, fit, full = false) {
73
- if (o.diagram) return diagram(o.diagram) + practice(o.practice || [], links);
74
- if (!isPrototype(q.url)) return '';
75
- const size = fit(o.id);
76
- const h = full ? size?.fullH : size?.h;
77
- // Every take loads so one outside the viewport can still decide the question's layout.
78
- return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
79
- }
80
- function takes(q, chosen, links, fit) {
79
+ function takes(q, chosen, links, locked = false) {
81
80
  const name = 'c-' + q.id;
82
- if (fullTakes(q, fit)) {
83
- const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
84
- return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
85
- }
86
- return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
81
+ return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
87
82
  }
88
- // A confirm links its one take alone, and says why it is asked.
89
- const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
90
- : `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
91
- // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
92
- // fit(option id) supplies the measured grid and full-width heights.
93
- function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
83
+ // A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
84
+ const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
85
+ // docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
86
+ // A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
87
+ // `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
88
+ // checked among locked options, and its last note and Claude's reply. reask is a re-ask under way, which locks the badge.
89
+ function resolvedCard(q, links) {
94
90
  const last = q.answers[q.answers.length - 1];
95
- const chosen = draft.choice ?? (last ? last.choice : null);
91
+ const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
92
+ const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
93
+ const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
94
+ ${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
95
+ return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
96
+ <div class="row" style="margin:0"><span class="muted small">${esc(q.source || '')}</span>${badge('ready', 'resolved', { button: true, title: 'Ask this question again', attrs: { type: 'button', 'data-reask': q.id, disabled: q.reask ? '' : null } })}</div>
97
+ <h3>${linkText(q.question, links)}</h3>
98
+ ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
99
+ ${questionLink(q.url)}
100
+ ${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
101
+ ${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
102
+ ${said}
103
+ ${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
104
+ </section>`;
105
+ }
106
+ // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext.
107
+ // A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
108
+ // round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
109
+ function questionCard(q, { n = 0, draft = {}, links = {} } = {}) {
110
+ if (q.state === 'resolved') return resolvedCard(q, links);
111
+ const locked = q.state === 'requested';
112
+ const chosen = locked ? q.request.choice : choiceOf(q, draft);
96
113
  return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
97
- <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
114
+ <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
98
115
  <h3>${linkText(q.question, links)}</h3>
99
116
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
100
- ${framed(q) ? '' : questionLink(q.url)}
101
- ${confirmLines(q)}
102
- ${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'} A new answer reopens it.</div>` : ''}
103
- ${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id })).join('')}
104
- ${noteComposer(q, draft.note)}
117
+ ${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
118
+ ${confirmLine(q)}
119
+ ${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
120
+ ${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft.note)}
105
121
  ${answerHistory(q, links)}
106
122
  </section>`;
107
123
  }
@@ -113,7 +129,7 @@
113
129
  const byKey = new Map(cards.map((c) => [c.key, c]));
114
130
  return topics.map((t) => {
115
131
  const c = t.title === undefined && byKey.get(t.card);
116
- return c ? { ...t, title: `${c.ref}: ${c.title}`, summary: 'Questions about this work item.', ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
132
+ return c ? { ...t, title: `${c.ref}: ${c.title}`, ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
117
133
  });
118
134
  }
119
135
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
@@ -150,5 +166,5 @@
150
166
  },
151
167
  };
152
168
 
153
- Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
169
+ Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
154
170
  })();