@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
@@ -1,6 +1,6 @@
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.
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-progress-bar.js.
2
2
  (function () {
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;
3
+ const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, progressBar } = 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,10 +56,17 @@
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); then its condition, "blocked
61
+ // until <condition>" ("and until <condition>" after deps), then ", <reason>"
62
+ // (docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md).
60
63
  function blockedWords(it) {
61
64
  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>');
65
+ const waits = it.bumpedAt && !it.held;
66
+ const refs = deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
67
+ const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
68
+ const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
69
+ return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
63
70
  }
64
71
  const tryWords = (kind) => kind === 'app' ? {
65
72
  go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
@@ -112,16 +119,17 @@
112
119
  if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
113
120
  const words = tryWords(t.kind);
114
121
  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>`;
122
+ const attrs = BoardUI.pending.has(`${it.key}:try`) ? BoardUI.BUSY : reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : '';
123
+ return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
116
124
  }
117
125
  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
126
  // 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
127
  // 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
128
  // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
121
129
  // (`open`). Visit is left alone: it changes only once Main rebuilds.
122
- function mainPull(pull, { sending = false, open = false } = {}) {
130
+ function mainPull(pull, { open = false } = {}) {
123
131
  if (!pull) return '';
124
- const busy = pull.running || sending;
132
+ const busy = pull.running || BoardUI.pending.has('main:pull');
125
133
  const failed = !busy && pull.error;
126
134
  const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
127
135
  const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
@@ -140,60 +148,61 @@
140
148
  // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
141
149
  // 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,
142
150
  // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
151
+ // A blocked card's Merge shows disabled, its tooltip naming what blocks it
152
+ // (docs/adr/2026-11-06-0102-a-block-past-todo-bumps-a-build-and-holds-a-land.md).
143
153
  function mergeButton(it) {
154
+ if (it?.mergeBlocked) return `<button class="primary" disabled title="${esc(it.mergeBlocked)}">Merge</button>`;
144
155
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
145
156
  const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
146
- return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
157
+ // A merging card has no Action needed left, so resolvedBy is null by then: merging keeps the colour resolvedBy gave it.
158
+ return `<button class="${it.merging || it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
147
159
  }
148
- // The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
149
- // not: it confirms, then
150
- // sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
151
- // mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
152
- // 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).
153
- // 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).
154
- function buildButton(it) {
155
- if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
156
- return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
157
- }
158
- // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on, and
159
- // ends with how long ago that line was set.
160
- const builderLine = (b) => [b.progress || 'preheating', b.at && lasted(b.at)].filter(Boolean).join(' · ');
160
+ // A builder reads "starting" until its first status line, from the Implement tap or the session's dispatch on, and
161
+ // ends with how long ago that line was set. One whose process is gone leads with stopped; one whose live process
162
+ // 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).
163
+ // One the orchestrator stopped reads the stop's words and how long ago it stopped
164
+ // (docs/adr/2026-11-05-1809-the-orchestrator-stops-the-boards-own-runs.md).
165
+ const builderLine = (b) => (b.stopped ? `${b.stopped} · ${lasted(b.silentSince)}`
166
+ : [b.alive === false && 'stopped', b.progress || (b.alive === false ? null : 'starting'), b.at && lasted(b.at)].filter(Boolean).join(' · '));
161
167
  // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
162
168
  // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
163
169
  // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
164
170
  const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
165
171
  const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
166
172
  const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
167
- // What a card's bars show, or null off In Progress and on a card only a planner works on: how many phases it reached,
168
- // whether the last of them is running, whether it dims because the card waits on something other than a builder (its
173
+ // A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner works on: how many
174
+ // phases it reached, which of them is running, whether it dims because the card waits on something other than a builder (its
169
175
  // 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
170
176
  // build reached, undimmed
171
177
  // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
172
- // A Todo card a grill or prototype runs on draws the planner's dashes instead, in blue, the grill's first and the
173
- // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
178
+ // A Todo card a grill or prototype runs on draws the planner's dashes instead, in Todo's grey, the grill's first and the
179
+ // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
180
+ // docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
174
181
  function phaseStrip(it) {
175
182
  if (it.lane === 'todo' && it.builder?.planner) {
176
183
  const prototype = it.builder.kind === 'prototype';
177
- return { reached: prototype ? 2 : 1, running: true, dim: false, plan: true, label: prototype ? 'Prototyping' : 'Grilling' };
184
+ const done = prototype ? 2 : 1;
185
+ return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
178
186
  }
179
187
  if (it.lane !== 'progress' || it.builder?.planner) return null;
180
188
  const all = BUILD_PHASES.length;
181
189
  // Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
182
- if (it.waitsOn === 'checking') return { reached: BUILD_PHASES.indexOf('checking') + 1, running: true, dim: false, label: `Orchestrator review, ${stepWords('checking')}` };
190
+ const checking = BUILD_PHASES.indexOf('checking');
191
+ if (it.waitsOn === 'checking') return { count: all, done: checking + 1, now: checking, label: `Orchestrator review, ${stepWords('checking')}` };
183
192
  const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
184
193
  const answers = asking ? ', waiting on your answers' : '';
185
194
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
186
195
  if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
187
- const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
188
- 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}` };
196
+ const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
197
+ return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stalled || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
189
198
  }
190
199
  const dim = !it.builder || asking;
191
- if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
192
- const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
193
- const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
194
- return { reached, running: false, dim, label: `${label}${answers}` };
200
+ if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
201
+ const done = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
202
+ const label = done ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : 'No step yet';
203
+ return { count: all, done, dim, label: `${label}${answers}` };
195
204
  }
196
- // What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
205
+ // What the bars do when a card's done count goes from prev to next across redraws, null or undefined meaning no
197
206
  // strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
198
207
  function phaseMotion(prev, next) {
199
208
  const had = typeof prev === 'number';
@@ -201,24 +210,40 @@
201
210
  if (had !== has) return has ? 'enter' : 'leave';
202
211
  return has && next > prev ? 'fill' : null;
203
212
  }
204
- const phaseBars = (strip) => bars(strip, BUILD_PHASES);
205
- // docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
206
- // Every status line on an In Progress card is the phase-bar orange, and every build or plan line on a Todo card the
207
- // owner's blue (style.css).
208
- const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
213
+ // A run's status line takes its card's lane colour (style.css): grey in Todo, the phase-bar orange in In Progress, blue in
214
+ // In Review (docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). A Done card's line keeps the plain orange.
215
+ const tone = (it) => (['todo', 'progress', 'review'].includes(it.lane) ? ` in-${it.lane}` : '');
209
216
  const DOT = '<span class="working-dot" aria-hidden="true"></span>';
210
217
  // A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
211
218
  // review with its dot once the session picks it up, each with how long ago.
212
219
  function orchestratorStatus(it) {
213
220
  const { since, picked } = it.orchestrator;
214
- return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
221
+ const words = isPaused() ? `${PAUSED} · ${esc(lasted(picked || since))}` : picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`;
222
+ return `<div class="line3 sub agent${tone(it)}${picked && !isPaused() ? '' : ' waiting-gray'}">${words}</div>`;
215
223
  }
216
- // A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
224
+ // A stale or stopped build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
225
+ // A quiet one is grey, with no dot, and one the orchestrator stopped plain, since the orchestrator already knows.
217
226
  function builderStatus(it) {
218
- const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}`;
219
- const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
227
+ const { quiet } = it.builder;
228
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}`;
229
+ if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
230
+ const words = `${quiet ? '' : DOT}${esc(builderLine(it.builder))}`;
220
231
  if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
221
- 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>`;
232
+ const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
233
+ 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>`;
234
+ }
235
+ // The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
236
+ // (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
237
+ function statusWord(it) {
238
+ const b = it.builder;
239
+ if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
240
+ if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
241
+ if (it.waitsOn === 'checking') return 'Orchestrator review';
242
+ if (it.lane === 'done') return 'done';
243
+ if (it.grilling || it.prototyping) return 'preheating';
244
+ if (it.lane === 'todo') return it.staging ? 'staging' : it.ready ? 'ready' : 'to do';
245
+ if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
246
+ return 'in review';
222
247
  }
223
248
  const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
224
249
  // When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
@@ -238,15 +263,15 @@
238
263
  : `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
239
264
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
240
265
  }
241
- // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
242
- // or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
266
+ // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
267
+ // staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
243
268
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
244
269
  // 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).
245
270
  const SEP = '<span class="sep"> · </span>';
246
271
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
247
272
  function timeLine(it, unread, bars) {
248
273
  const iso = it.sortAt;
249
- 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);
274
+ const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.staging && '<span class="card-staging">staging</span>', it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
250
275
  const news = newWords(unread, 'message');
251
276
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
252
277
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
@@ -273,37 +298,38 @@
273
298
  const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
274
299
  const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
275
300
  : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
276
- const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
301
+ const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && thinkingLine()].filter(Boolean).join(' · ');
277
302
  // 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
278
303
  // its bump with the band's ⋯ menu.
279
304
  function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
280
305
  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);
281
- // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
306
+ // A work card always shows its ticket; New is only on an ask (askChip).
282
307
  const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
283
- const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
308
+ const ids = it.kind === 'ask' ? askChip(it) : chip;
284
309
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
285
310
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
286
- : it.orchestrator && !it.builder ? orchestratorStatus(it)
311
+ : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
287
312
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
288
313
  : it.builder ? builderStatus(it)
289
- : it.grilling || it.prototyping ? `<div class="line3 sub agent planner${tone(it)}">${DOT}${it.grilling ? 'preheating' : 'prototyping'}</div>`
290
- : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
291
- : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
292
- : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
293
- const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
314
+ : it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
315
+ : it.refusal ? `<div class="line3 sub refused">${escText(it.refusal)}</div>`
316
+ : it.landStopped ? `<div class="line3 sub">${escText(`${it.landStopped.words} · ${lasted(it.landStopped.at)}`)}</div>`
317
+ : it.working ? `<div class="line3 sub agent">${thinkingLine()}</div>`
318
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${escText(it.next)}</div>` : '';
319
+ const eta = it.eta && it.builder ? `<div class="line3 sub eta">${escText(etaLine(it.eta))}</div>` : '';
294
320
  // 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).
295
- const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
296
- : `<div class="line3 sub check">${esc(`Checks: ${it.ownerCheck.words}`)}</div>`;
297
- return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
321
+ const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
322
+ : `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
323
+ return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
298
324
  ${status}${check}${eta}
299
325
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
300
- ${timeLine(it, unread, phaseBars(phaseStrip(it)))}`;
326
+ ${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
301
327
  }
302
328
  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>';
303
329
  const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
304
330
  function planButton(items) {
305
- const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
306
- return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
331
+ const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
332
+ 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>` : '';
307
333
  }
308
334
 
309
335
  // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
@@ -323,11 +349,11 @@
323
349
  const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
324
350
  function reviewCard(c, open) {
325
351
  return `<details class="rv-card" data-review="${c.number}"${open ? ' open' : ''}><summary><span class="status ${REVIEW_DOTS[c.state]}"></span><div class="title-wrap">
326
- <div class="line1 title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
352
+ <div class="line1 title">${badge('normal', `PR #${c.number}`)} ${escText(c.title)}</div>
327
353
  <div class="line3 sub${c.state === 'reviewing' ? ' agent' : ''}">${c.state === 'reviewing' ? '<span class="working-dot" aria-hidden="true"></span>' : ''}<span class="rv-word s-${c.state}">${REVIEW_WORDS[c.state]}</span> · ${esc(reviewWhen(c))}</div>
328
354
  <div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
329
355
  }
330
- const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
356
+ const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${escText(c.title)}</div>
331
357
  <div class="sub">${esc([c.author, c.state === 'posted' ? `posted ${ago(c.at)}` : 'not posted', c.open ? '' : 'closed'].filter(Boolean).join(' · '))}</div></summary>${reviewDetail(c)}</details>`;
332
358
  // reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
333
359
  function reviewLane(reviews, { open = {} } = {}) {
@@ -344,5 +370,5 @@
344
370
  <div class="list">${list}${fold}</div></details>`;
345
371
  }
346
372
 
347
- 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 });
373
+ 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 });
348
374
  })();
@@ -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({ openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', 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' });
@@ -1,19 +1,27 @@
1
1
  // The board header's time and the In Review header's GitHub line, with when a refresh asks for a check. Needs ui.js.
2
2
  (function () {
3
3
  const { since, esc } = globalThis.BoardUI;
4
- // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh), and watcher is
5
- // /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): a grey asleep while
6
- // the hub can wake the session, waking… once a wake is under way (waking: the page asked for one and the board hasn't
7
- // said so yet), and a red not listening when nothing will wake it
8
- // (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
9
- // 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
10
- const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
11
- : link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
12
- : link === 'reconnecting' ? '<span class="link-state">reconnecting…</span>'
13
- : watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
14
- : watcher === 'asleep' ? '<span class="claude asleep" title="Asleep: its session idled out. Refresh, or any tap on the page, wakes it">asleep</span>'
15
- : watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
16
- : at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
4
+ // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age, or busy while the Mac is busy;
5
+ // the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
6
+ // /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): stopped
7
+ // (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy Mac (pause.machine,
8
+ // docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md) turns it orange too and reads busy in place of the age.
9
+ // link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
10
+ const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
11
+ const paused = pause?.state === 'paused';
12
+ const busy = pause?.machine?.state === 'busy';
13
+ const down = link === 'offline' || watcher === 'stopped';
14
+ const reason = link === 'offline' ? "Offline: can't reach the host"
15
+ : watcher === 'stopped' ? 'Claude not listening'
16
+ : paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
17
+ : busy ? 'This Mac is busy: new work waits until it eases'
18
+ : link === 'reconnecting' ? 'Reconnecting…'
19
+ : watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
20
+ : watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
21
+ if (!at && !reason) return '';
22
+ const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
23
+ return `<span class="fresh${down ? ' down' : paused || busy ? ' hold' : ''}" title="${esc(title)}">${busy ? 'busy' : at ? shortSince(at) : ''}</span>`;
24
+ };
17
25
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
18
26
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
19
27
  // fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
@@ -65,13 +73,13 @@
65
73
  ? `<ul class="hub-links">${links.map(linkRow).join('')}</ul>`
66
74
  : '<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
75
 
68
- // The checklist a new board shows at the top until its session answered the hello and GitHub checks reach it; setup
76
+ // The checklist a new board shows at the top until its session answered the ping and GitHub checks reach it; setup
69
77
  // is /api/board's, null once both pass. Cards can start meanwhile.
70
78
  const setupCard = (setup) => {
71
79
  if (!setup) return '';
72
80
  const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
73
81
  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')}
82
+ ${step(setup.events, 'Claude answered the ping', 'Waiting for Claude to answer the ping')}
75
83
  ${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
76
84
  </ul>`;
77
85
  };
@@ -83,10 +91,11 @@
83
91
 
84
92
  // Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
85
93
  // both turn on together once the first board load has answered or failed.
86
- // Offline, refresh stays on so a tap can retry the load (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md).
87
- const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
94
+ // Offline, refresh stays on so a tap can retry the load (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md),
95
+ // and Settings opens its read-only copy (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
96
+ const headerControls = ({ wired = false, boardSettled = false, reloading = false } = {}) => {
88
97
  const ready = wired && boardSettled && !reloading;
89
- return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
98
+ return { refresh: ready, settings: ready, spin: !boardSettled || reloading };
90
99
  };
91
100
 
92
101
  Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
@@ -15,6 +15,7 @@
15
15
  power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
16
16
  calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
17
17
  snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
18
+ pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
18
19
  coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v2"/><path d="M10 2v2"/><path d="M14 2v2"/>',
19
20
  laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
20
21
  key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
@@ -38,11 +39,21 @@
38
39
  const going = pause.state === 'going';
39
40
  const until = BoardUI.usageClock(pause.resets_at);
40
41
  const icon = going ? '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/>' : '<circle cx="12" cy="12" r="9"/><path d="M10 9v6"/><path d="M14 9v6"/>';
42
+ // Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): Resume ends it, which view-hub.js posts.
43
+ if (pause.manual) return `<div class="hold-banner ub-banner ub-paused" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(pause.resets_at === 'forever' ? 'Work paused: you paused the hub until you resume.' : `Work paused: you paused the hub. Resumes at ${until}.`)}</span><form method="post" action="/_hub/settings/hub-pause"><button class="btn quiet" name="pause" value="off">Resume</button></form></div>`;
41
44
  const text = going ? `Past the buffer: spending usage until ${until}`
42
45
  : `Work paused: Claude’s ${pause.window === 'five_hour' ? '5‑hour' : 'weekly'} usage is at ${Math.round(pause.used)}%. Resumes ${pause.window === 'five_hour' ? 'at ' : ''}${until}.`;
43
46
  return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
44
47
  }
45
48
 
49
+ // Health's Machine row: the gate's machine (shared/machine-gate.mjs), busy with its reason or normal, in words only
50
+ // (docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md).
51
+ const machineRow = (machine) => {
52
+ const busy = machine.state === 'busy';
53
+ return row({ icon: tile('power', busy ? '#f59e0b' : '#22c55e'), title: 'Machine', wrap: true,
54
+ sub: busy ? `<span class="st wait">busy</span> · ${esc(machine.reason)}` : '<span class="st ok">normal</span>' });
55
+ };
56
+
46
57
  // builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
47
58
  function bufferControl(pause, builder) {
48
59
  const at = pause.pause_at;
@@ -108,5 +119,5 @@
108
119
  return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
109
120
  }
110
121
 
111
- globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
122
+ globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, nowRow, deviceRow, usageCard };
112
123
  })();
@@ -2,7 +2,7 @@
2
2
  // ref opens that card, a Linear id or PR opens its page, and a bare URL becomes a link. Needs ui.js; load it before
3
3
  // the ui-*.js files that render text.
4
4
  (function () {
5
- const { esc, record } = globalThis.BoardUI;
5
+ const { esc, escText, record } = globalThis.BoardUI;
6
6
  // The Linear id shape, which an internal ref shares.
7
7
  const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
8
8
  const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
@@ -73,13 +73,13 @@
73
73
  // The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
74
74
  function wordLink(word, ctx) {
75
75
  for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
76
- const rest = esc(word.slice(w.length));
76
+ const rest = escText(word.slice(w.length));
77
77
  if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
78
78
  const team = LINEAR_ID.exec(w)?.[1];
79
79
  const prefix = team && team !== 'INT' && record(ctx.teams, team);
80
80
  if (prefix) return out(prefix + w, w) + rest;
81
81
  }
82
- return esc(word);
82
+ return escText(word);
83
83
  }
84
84
 
85
85
  // A bare #n that reads as a hex colour stays text.
@@ -92,16 +92,16 @@
92
92
  let at = 0;
93
93
  for (const m of s.matchAll(TOKEN)) {
94
94
  const [all, url, owner, name, n, bare, word] = m;
95
- html += esc(s.slice(at, m.index));
95
+ html += escText(s.slice(at, m.index));
96
96
  at = m.index + all.length;
97
97
  if (url) html += urlLink(url);
98
98
  else if (name) {
99
99
  const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
100
- html += repo ? out(prUrl(repo, n), all) : esc(all);
101
- } else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
100
+ html += repo ? out(prUrl(repo, n), all) : escText(all);
101
+ } else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : escText(all);
102
102
  else html += wordLink(word, ctx);
103
103
  }
104
- return html + esc(s.slice(at));
104
+ return html + escText(s.slice(at));
105
105
  }
106
106
 
107
107
  const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)(?:[^>"']|"[^"]*"|'[^']*')*>/g;
@@ -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
  })();
@@ -8,6 +8,10 @@
8
8
  const GEAR = icon(18, '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>');
9
9
  const MOON = icon(16, '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
10
10
  const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
11
+ // The pages switch (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): a bare ? and a bare check, drawn
12
+ // like the bulb and gear.
13
+ const QUESTION = icon(18, '<path d="M8 8a4 4 0 1 1 5.5 3.7c-.9.4-1.5 1.2-1.5 2.2V15"/><path d="M12 19.5h.01"/>');
14
+ const CHECK = icon(18, '<path d="M20 6 9 17l-5-5"/>');
11
15
  const id = (v) => (v ? ` id="${esc(v)}"` : '');
12
16
 
13
17
  // Where the back arrow goes (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): 'back', one step back in history, when
@@ -41,13 +45,15 @@
41
45
  }
42
46
 
43
47
  // lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
44
- // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
48
+ // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh) paint disabled with refresh spinning until the
45
49
  // page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
46
- // false, trail (HTML) takes their place. bulb puts the architecture review's bulb between them (app-arch.js). leadAttrs adds to or replaces the lead link's attributes, and level is the
50
+ // false, trail (HTML) takes their place. pages is the switch between a board's two pages below 900px
51
+ // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): 'questions' puts the ? after the controls, with
52
+ // the open-question count in its badge (app-needs.js), and 'board' puts the check, back up to the board, after trail. leadAttrs adds to or replaces the lead link's attributes, and level is the
47
53
  // title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
48
54
  // away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
49
55
  function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
50
- controls = true, trail = '', time = '…', bulb = false, moon = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
56
+ controls = true, trail = '', time = '…', pages = null, moon = false, logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
51
57
  const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
52
58
  const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
53
59
  const first = lead === 'back'
@@ -55,14 +61,20 @@
55
61
  : logo();
56
62
  const nav = controls ? `<nav class="nav">`
57
63
  + `<span class="refresh"><button class="ghost spinning" id="refresh" type="button" disabled aria-label="Refresh" title="Refresh">${REFRESH}</button><span class="muted" id="status">${esc(time)}</span></span>`
58
- + (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
59
- + (settingsHref ? `<a href="${esc(settingsHref)}" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>` : '')
60
- + `</nav>` : trail;
64
+ + (pages === 'questions' ? `<a href="questions.html" id="questionsLink" class="ghost icon pages-switch" aria-label="Open questions" title="Open questions">${QUESTION}<span class="q-badge" hidden></span></a>` : '')
65
+ + `</nav>` : trail + (pages === 'board' ? `<a class="ghost icon pages-switch back" href="./" aria-label="Back to the board" title="Back to the board">${CHECK}</a>` : '');
61
66
  const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
62
67
  const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
63
68
  const away = moon ? `<span class="away-moon" role="img" aria-label="Away" aria-hidden="true">${MOON}</span>` : '';
64
69
  return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}>${away}</div>${nav}</header>${script}`;
65
70
  }
66
71
 
67
- Object.assign(globalThis.BoardUI ||= {}, { header, backStep });
72
+ // The foot of the board, under the lanes: the architecture review's bulb (app-arch.js) and the gear, Board Settings,
73
+ // which paints disabled until app-header.js turns it on with the header's controls.
74
+ const boardFoot = () => '<div class="board-foot">'
75
+ + `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>`
76
+ + `<a href="settings.html" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>`
77
+ + '</div>';
78
+
79
+ Object.assign(globalThis.BoardUI ||= {}, { header, backStep, boardFoot });
68
80
  })();