diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml
index 190f9f1254..eb50159eb5 100644
--- a/.github/workflows/website-checks.yml
+++ b/.github/workflows/website-checks.yml
@@ -123,6 +123,11 @@ jobs:
# memory only; whoami never carries X-Agent-Key. Pure, no browser.
- name: TRI identity
run: npm run check:tri-identity
+ # Why free bees are idle, read from /queen/status alone: real snapshots
+ # and malformed ones through the pure reader, no fabricated zeros, a
+ # stale round called stale. Pure, no browser.
+ - name: Queen idle reason
+ run: npm run check:queen-idle
- name: Catalog generators
run: npm run test:sync-skills && npm run test:sync-crons
# The tiers, the touch minimum and the QA matrix come from
diff --git a/apps/website/package.json b/apps/website/package.json
index f2d1bf40bd..8484e901de 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -44,6 +44,8 @@
"check:queen-review": "node qa/queen-review-lifecycle-contract.mjs",
"check:queen-viewport": "node qa/queen-viewport-contract.mjs",
"check:queen-honesty": "node --experimental-strip-types qa/queen-honesty-contract.mjs",
+ "check:queen-idle": "node --experimental-strip-types qa/queen-idle-reason-contract.mjs",
+ "check:queen-idle-layout": "node qa/queen-idle-layout-contract.mjs",
"check:queen-coverage": "node --experimental-strip-types qa/queen-coverage-contract.mjs",
"check:queen-displays": "node --experimental-strip-types qa/queen-hive-display-contract.mjs",
"check:queen-worlds": "node --experimental-strip-types qa/queen-repository-worlds.mjs",
diff --git a/apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json b/apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json
new file mode 100644
index 0000000000..da1722edcc
--- /dev/null
+++ b/apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json
@@ -0,0 +1 @@
+{"status":"ok","swarmState":"healthy_idle","workers":{"capacity":4,"active":0,"idle":4,"utilization":0},"queue":{"state":"no-eligible-work","observedAt":"2026-09-15T12:50:33.230Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T12:50:33.230Z","allowed":false,"refusal":"nothing to choose","skippedCount":488,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":449,"issues":[3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328,3316],"more":424}},"skipIssueListCap":25},"dispatches":{"total":442,"finished":442,"running":0,"unreviewed":3,"latest":{"issue":3576,"dispatchedAt":"2026-09-15T12:20:33.844Z","finishedAt":"2026-09-15T12:43:01.735Z","outcome":"finished"}}}
\ No newline at end of file
diff --git a/apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json b/apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json
new file mode 100644
index 0000000000..06524c7d46
--- /dev/null
+++ b/apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json
@@ -0,0 +1 @@
+{"status":"ok","swarmState":"working","workers":{"capacity":4,"active":2,"idle":2,"utilization":50},"queue":{"state":"work-dispatched","observedAt":"2026-09-15T14:00:24.187Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T13:59:58.978Z","allowed":true,"refusal":null,"skippedCount":96,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":57,"issues":[3622,3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328],"more":32}},"skipIssueListCap":25},"dispatches":{"total":443,"finished":441,"running":2,"unreviewed":3,"latest":{"issue":3624,"dispatchedAt":"2026-09-15T14:00:00.585Z","finishedAt":null,"outcome":null}}}
\ No newline at end of file
diff --git a/apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json b/apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json
new file mode 100644
index 0000000000..d801d0d95a
--- /dev/null
+++ b/apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json
@@ -0,0 +1 @@
+{"status":"ok","swarmState":"working","workers":{"capacity":12,"active":1,"idle":11,"utilization":8},"queue":{"state":"work-dispatched","observedAt":"2026-09-15T15:33:47.532Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T15:30:11.960Z","allowed":true,"refusal":null,"skippedCount":439,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":400,"issues":[3672,3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328],"more":375}},"skipIssueListCap":25},"dispatches":{"total":443,"finished":442,"running":1,"unreviewed":3,"latest":{"issue":3624,"dispatchedAt":"2026-09-15T15:30:12.894Z","finishedAt":null,"outcome":null}}}
\ No newline at end of file
diff --git a/apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json b/apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json
new file mode 100644
index 0000000000..b069decda2
--- /dev/null
+++ b/apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json
@@ -0,0 +1 @@
+{"status":"ok","swarmState":"working","workers":{"capacity":4,"active":1,"idle":3,"utilization":25},"queue":{"state":"work-dispatched","observedAt":"2026-09-15T13:50:45.629Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T13:50:37.349Z","allowed":false,"refusal":"nothing to choose","skippedCount":487,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":448,"issues":[3622,3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328],"more":423}},"skipIssueListCap":25},"dispatches":{"total":442,"finished":441,"running":1,"unreviewed":3,"latest":{"issue":3576,"dispatchedAt":"2026-09-15T13:45:36.746Z","finishedAt":null,"outcome":null}}}
\ No newline at end of file
diff --git a/apps/website/qa/queen-honesty-contract.mjs b/apps/website/qa/queen-honesty-contract.mjs
index 5a682cba86..bdc085242b 100644
--- a/apps/website/qa/queen-honesty-contract.mjs
+++ b/apps/website/qa/queen-honesty-contract.mjs
@@ -143,8 +143,8 @@ check(/`\$\{c\.hudSince\} \$\{formatMoment\(lastRoundAt, lang\)\} · ≤ \$\{for
check(/schedulerOff \|\| !roundKnown \? "—" : `\+\$\{formatCountdown\(elapsedSeconds\)\}`/.test(src), "the value counts up since the last round; no countdown promises the next round at a second");
// the status pill reads swarmState through COPY keys; the VERDICTS tile adds the unreviewed count only when the wire has it (P1-28)
check(/data\?\.swarmState === "working"[\s\S]{0,40}c\.swarmWorking[\s\S]{0,120}c\.swarmIdle[\s\S]{0,120}c\.swarmPaused[\s\S]{0,80}data\.swarmState\.toUpperCase\(\)[\s\S]{0,40}c\.swarmUnknown/.test(src), "the pill maps working/idle/paused to COPY keys, prints an unfamiliar wire state as itself, and a missing one as the unknown key");
-check(/swarmWorking:\s*"WORKING"/.test(src) && /swarmWorking:\s*"РАБОТАЕТ"/.test(src) && /hudReady:\s*"ready"/.test(src) && /hudReady:\s*"готово"/.test(src), "the four swarm words and the ready word exist in both languages");
-check(/typeof data\?\.dispatches\.unreviewed === "number" \? ` · \$\{data\.dispatches\.unreviewed\} \$\{c\.hudReady\}` : ""/.test(src), "the VERDICTS sub-line prints the unreviewed count only when the wire carries the field, never 0 for an absent one");
+check(/swarmWorking:\s*"WORKING"/.test(src) && /swarmWorking:\s*"РАБОТАЕТ"/.test(src) && /hudNoVerdict:\s*"finished, no verdict"/.test(src) && /hudNoVerdict:\s*"без вердикта"/.test(src), "the four swarm words and the no-verdict words exist in both languages (dispatches.unreviewed counts finished rows with no verdict, not issues ready)");
+check(/typeof data\?\.dispatches\.unreviewed === "number" \? ` · \$\{data\.dispatches\.unreviewed\} \$\{c\.hudNoVerdict\}` : ""/.test(src), "the VERDICTS sub-line prints the unreviewed count only when the wire carries the field, never 0 for an absent one");
check(/`\$\{decisionInfo\} · \$\{decision\.allowed \? c\.chose : c\.stoodDown\}`/.test(src), "the gold block's decision line leads with the wire field (the refusal or what the round did), the verb follows");
// the INTEL FEED header states what it holds, from its rows (P1-27)
const cov = feedCoverage([{ at: "2026-09-05T00:40:00Z" }, { at: "2026-09-05T00:41:02Z" }, { at: "2026-09-05T00:40:30Z" }]);
diff --git a/apps/website/qa/queen-idle-layout-contract.mjs b/apps/website/qa/queen-idle-layout-contract.mjs
new file mode 100644
index 0000000000..ab8a23b2c1
--- /dev/null
+++ b/apps/website/qa/queen-idle-layout-contract.mjs
@@ -0,0 +1,189 @@
+// Queen idle-line layout contract, in a real browser. The line that says why
+// free bees are idle (queen27-hud-idle) sits among the map's controls, and in
+// its first version it took taps it had no right to: on a phone it lay over
+// TRI's screen tabs and the specs view's first disclosure, on a landscape phone
+// over every map tool, and on a desktop over the head's bottom rule
+// (measured 2026-09-16 with trusted input). The words are
+// qa/queen-idle-reason-contract.mjs; this is where they are drawn.
+//
+// Builds the site (unless --no-build; --dist
serves another build) and
+// drives the installed Chrome over CDP. The build is served AS https://t27.ai
+// through CDP Fetch, because the identity chip, and so the head's height, is
+// only its real size there; /queen/status is answered with the committed
+// healthy-idle snapshot, decidedAt moved to 30 s ago on every poll. Every tap
+// claimed below is trusted CDP input after a hit test.
+// 1 1440x900 and 1280x720, en and ru: the line is on screen, unclipped, and
+// starts at or below the head's bottom (1 px of rounding)
+// 2 1440x900 with the rail collapsed by a trusted click, then 390x844: the
+// line is inside the window
+// 3 390x844, en and ru: the line lies between the head and the body, the page
+// is one screen, and TRI's screen tabs and the specs view's first
+// disclosure are topmost at all five sample points; in en a trusted tap on
+// TRI's second tab switches it, and one on the disclosure opens it
+// 4 844x390: no line is drawn, every map tool and the identity chip inside the
+// window is topmost, and a trusted tap on the foundation layer toggles it
+// 5 /queen/status starts failing: the line goes, though the page keeps the
+// last status it read (a kept round would age into "round stale")
+// node qa/queen-idle-layout-contract.mjs [--no-build] [--dist ]
+import { execSync, spawn } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { extname, join, normalize, resolve } from 'node:path';
+
+const ROOT = new URL('..', import.meta.url).pathname;
+const argAt = (flag) => { const i = process.argv.indexOf(flag); return i > 0 ? process.argv[i + 1] : null; };
+const DIST = resolve(argAt('--dist') ?? join(ROOT, 'dist'));
+const CHROME = [process.env.CHROME_PATH, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium'].filter(Boolean).find((p) => existsSync(p));
+if (!CHROME) { console.log(' no Chrome found — skipping the idle-line layout check.'); process.exit(0); }
+if (!process.argv.includes('--no-build') && !argAt('--dist')) { console.log(' building…'); execSync('npx vite build', { cwd: ROOT, stdio: ['ignore', 'ignore', 'inherit'] }); }
+
+const SNAP = JSON.parse(readFileSync(new URL('./fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json', import.meta.url), 'utf8'));
+const ORIGIN = 'https://t27.ai';
+const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.wasm': 'application/wasm', '.woff2': 'font/woff2', '.webp': 'image/webp' };
+const distFile = (pathname) => { const p = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, ''); const f = join(DIST, p); return !f.startsWith(DIST) || p === '/' || !existsSync(f) ? join(DIST, 'index.html') : f; };
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const fails = [];
+let checks = 0;
+const check = (cond, msg) => { checks += 1; if (!cond) fails.push(msg); console.log(` ${cond ? '✓' : '✗'} ${msg}`); };
+
+const profile = mkdtempSync(join(tmpdir(), 'queen-idle-layout-'));
+const chrome = spawn(CHROME, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--mute-audio', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
+const cleanup = () => { try { chrome.kill('SIGKILL'); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} };
+const watchdog = setTimeout(() => { console.log(' ✗ watchdog: 20 min'); cleanup(); process.exit(2); }, 20 * 60 * 1000);
+
+let statusFails = false;
+try {
+ const wsUrl = await new Promise((res, rej) => { const t = setTimeout(() => rej(new Error('no devtools port')), 60000); let buf = ''; chrome.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) { clearTimeout(t); res(m[1]); } }); });
+ const ws = new WebSocket(wsUrl); await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
+ let id = 0; const pending = new Map(); const listeners = [];
+ ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result); } else if (m.method) listeners.forEach((f) => f(m)); };
+ const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const i = ++id; pending.set(i, { resolve: res, reject: rej }); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
+ const call = (m, p) => send(m, p, sessionId);
+ listeners.push((m) => {
+ if (m.sessionId !== sessionId || m.method !== 'Fetch.requestPaused') return;
+ const { requestId, request } = m.params;
+ if (/\/queen\/status/.test(request.url)) {
+ const s = structuredClone(SNAP); const decided = new Date(Date.now() - 30000).toISOString(); s.lastTick.decidedAt = decided; if (s.queue) s.queue.observedAt = decided;
+ const body = statusFails ? '{"error":"bad gateway"}' : JSON.stringify(s);
+ call('Fetch.fulfillRequest', { requestId, responseCode: statusFails ? 502 : 200, responseHeaders: [{ name: 'Content-Type', value: 'application/json' }, { name: 'Access-Control-Allow-Origin', value: '*' }], body: Buffer.from(body).toString('base64') }).catch(() => {});
+ return;
+ }
+ if (request.url.startsWith(`${ORIGIN}/`)) {
+ const f = distFile(new URL(request.url).pathname);
+ call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{ name: 'Content-Type', value: MIME[extname(f)] || 'application/octet-stream' }], body: readFileSync(f).toString('base64') }).catch(() => {});
+ return;
+ }
+ call('Fetch.continueRequest', { requestId }).catch(() => {});
+ });
+ await call('Runtime.enable'); await call('Page.enable');
+ await call('Fetch.enable', { patterns: [{ urlPattern: `${ORIGIN}/*` }, { urlPattern: '*queen/status*' }] });
+
+ const evaluate = async (expr) => { const r = await call('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; };
+ const until = async (expr, ms) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { try { const v = await evaluate(expr); if (v) return v; } catch {} await wait(500); } return null; };
+ const size = (w, h) => call('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile: w < 768 || h <= 500 });
+ const tap = async (x, y) => { for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) await call('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mousePressed' ? 1 : 0, clickCount: 1 }); };
+ const ready = `(() => { const b = document.getElementById('stat-bees'); return !!document.querySelector('main[data-view]') && !!b && /^\\s*\\d+\\s*\\//.test(b.textContent); })()`;
+ const load = async (w, h, lang, tag) => {
+ await size(w, h);
+ await call('Page.navigate', { url: `${ORIGIN}/?lang=${lang}&idle-layout=${process.pid}-${tag}#/queen` });
+ if (!(await until(ready, 150000))) throw new Error(`the Queen never rendered at ${w}x${h} ${lang}`);
+ await until(`!!document.querySelector('.queen27-hud-idle')`, 30000);
+ await wait(2500);
+ };
+ const view = async (name) => {
+ await evaluate(`location.hash = ${JSON.stringify(name === 'comb' ? '#/queen' : `#/queen?tab=${name}`)}; true`);
+ await until(`document.querySelector('main[data-view]')?.getAttribute('data-view') === ${JSON.stringify(name)}`, 20000);
+ await wait(name === 'comb' ? 1500 : 3000);
+ };
+ // In-page geometry: a box, and how many of five points of an element are the element itself.
+ const GEOM = `const R = (e) => { if (!e) return null; const cs = getComputedStyle(e); const r = e.getBoundingClientRect(); return cs.display === 'none' || r.width === 0 ? null : { l: r.left, t: r.top, r: r.right, b: r.bottom, w: r.width, h: r.height }; };
+ const own = (e) => { const r = e.getBoundingClientRect(); return [[.5,.5],[.15,.2],[.85,.2],[.15,.8],[.85,.8]].filter(([fx, fy]) => { const u = document.elementFromPoint(r.left + r.width * fx, r.top + r.height * fy); return !!u && (u === e || e.contains(u)); }).length; };
+ const centre = (e) => { const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; };`;
+ const boxes = () => evaluate(`(() => { ${GEOM} const line = document.querySelector('.queen27-hud-idle'); return { line: R(line), head: R(document.querySelector('.queen27-hud-vp-head')), body: R(document.querySelector('.queen27-hud-vp-body')), clipped: !!line && (line.scrollWidth > line.clientWidth + 1 || line.scrollHeight > line.clientHeight + 1), text: line ? line.textContent.trim() : null, iw: innerWidth, ih: innerHeight, sh: document.documentElement.scrollHeight }; })()`);
+
+ // 1. desktop
+ for (const lang of ['en', 'ru']) {
+ await load(1440, 900, lang, `desk-${lang}`);
+ for (const [w, h] of [[1440, 900], [1280, 720]]) {
+ await size(w, h); await wait(1500);
+ const b = await boxes();
+ const where = `${w}x${h} ${lang}`;
+ check(!!b.line && b.line.l >= 0 && b.line.r <= b.iw && b.line.t >= 0 && b.line.b <= b.ih && !b.clipped, `${where}: the idle line is on screen and unclipped ("${(b.text ?? '').slice(0, 60)}…")`);
+ check(!!b.line && !!b.head && b.line.t + 1 >= b.head.b, `${where}: the line starts at or below the head's bottom (line ${b.line?.t}, head ${b.head?.b})`);
+ }
+ if (lang !== 'en') continue;
+ // 2. collapsed on a desktop, then narrowed to a phone
+ await size(1440, 900); await wait(1500);
+ const c = await evaluate(`(() => { ${GEOM} const b = document.querySelector('nav.queen27-hud-command > button.queen27-hud-cmd-collapse'); return b ? { ...centre(b), own: own(b) } : null; })()`);
+ check(!!c && c.own > 0, 'the rail\'s collapse button is reachable at 1440x900');
+ if (c?.own) {
+ await tap(c.x, c.y);
+ check(!!(await until(`!!document.querySelector('main.is-command-collapsed')`, 5000)), 'a trusted click collapses the rail');
+ await size(390, 844); await wait(2500);
+ const b = await boxes();
+ check(!!b.line && b.line.l >= 0 && b.line.r <= b.iw + 0.5, `collapsed at 1440, then 390x844: the line is inside the window (${b.line?.l}-${b.line?.r} of ${b.iw})`);
+ await size(1440, 900); await wait(800);
+ const again = await evaluate(`(() => { const b = document.querySelector('nav.queen27-hud-command > button.queen27-hud-cmd-collapse'); if (!b) return null; const r = b.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
+ if (again) { await tap(again.x, again.y); await wait(800); }
+ }
+ }
+
+ // 3. phone portrait
+ for (const lang of ['en', 'ru']) {
+ await load(390, 844, lang, `phone-${lang}`);
+ await view('specs');
+ await until(`!!document.querySelector('.queen27-hud-vp-body details > summary')`, 20000);
+ const specs = await evaluate(`(() => { ${GEOM} const s = document.querySelector('.queen27-hud-vp-body details > summary'); return s ? { own: own(s), ...centre(s), open: s.parentElement.open, text: s.textContent.trim().slice(0, 30) } : null; })()`);
+ const b = await boxes();
+ const where = `390x844 ${lang}`;
+ check(!!b.line && !!b.head && !!b.body && b.line.t + 1 >= b.head.b && b.body.t + 1 >= b.line.b, `${where}: the line lies between the head and the body (head to ${b.head?.b}, line ${b.line?.t}-${b.line?.b}, body from ${b.body?.t})`);
+ check(b.sh <= b.ih && !!b.line && b.line.l >= 0 && b.line.r <= b.iw, `${where}: one screen (document ${b.sh} of ${b.ih}), the line inside the window`);
+ check(specs?.own === 5, `${where}: the specs view's first disclosure ("${specs?.text}") is topmost at 5 of 5 points (got ${specs?.own})`);
+ if (lang === 'en' && specs?.own) {
+ await tap(specs.x, specs.y); await wait(1200);
+ check((await evaluate(`document.querySelector('.queen27-hud-vp-body details > summary')?.parentElement.open`)) !== specs.open, `${where}: a trusted tap on that disclosure opens it`);
+ }
+ await view('tri');
+ await until(`document.querySelectorAll('button.queen27-tri-screen').length > 1`, 20000);
+ const tabs = await evaluate(`(() => { ${GEOM} return [...document.querySelectorAll('button.queen27-tri-screen')].map((t) => ({ text: t.textContent.trim(), own: own(t), ...centre(t), active: t.classList.contains('is-active') })); })()`);
+ check(tabs.length > 1 && tabs.every((t) => t.own === 5), `${where}: all ${tabs.length} of TRI's screen tabs are topmost at 5 of 5 points (${tabs.map((t) => `${t.text} ${t.own}`).join(', ')})`);
+ if (lang === 'en' && tabs.length > 1) {
+ const target = tabs.find((t) => !t.active) ?? tabs[1];
+ await tap(target.x, target.y); await wait(1500);
+ const active = await evaluate(`document.querySelector('button.queen27-tri-screen.is-active')?.textContent.trim() ?? null`);
+ check(active === target.text, `${where}: a trusted tap on "${target.text}" switches TRI to it (active: "${active}")`);
+ }
+ }
+
+ // 4. phone landscape
+ await load(844, 390, 'en', 'landscape');
+ await view('comb');
+ const land = await evaluate(`(() => { ${GEOM} const line = document.querySelector('.queen27-hud-idle');
+ const els = [...document.querySelectorAll('.queen27-hud-vp-tools button, .queen27-hud-vp-tools .queen27-identity')].filter((e) => { const r = R(e); return r && r.l >= 0 && r.r <= innerWidth && r.t >= 0 && r.b <= innerHeight; });
+ const layer = document.querySelector('.queen27-hud-vp-tools button[data-layer]');
+ return { present: !!line, drawn: !!R(line), items: els.map((e) => ({ k: e.getAttribute('data-tool') || e.getAttribute('data-layer') || e.className.split(' ')[0], own: own(e) })), layer: layer ? { ...centre(layer), own: own(layer), pressed: layer.getAttribute('aria-pressed') } : null }; })()`);
+ check(land.present && !land.drawn, '844x390: the idle line is in the page but not drawn');
+ check(land.items.length >= 5 && land.items.every((i) => i.own === 5), `844x390: all ${land.items.length} map tools and the identity chip are topmost at 5 of 5 points (${land.items.filter((i) => i.own !== 5).map((i) => `${i.k} ${i.own}`).join(', ') || 'none short'})`);
+ if (land.layer?.own) {
+ await tap(land.layer.x, land.layer.y); await wait(1000);
+ const pressed = await evaluate(`document.querySelector('.queen27-hud-vp-tools button[data-layer]')?.getAttribute('aria-pressed')`);
+ check(pressed !== land.layer.pressed, `844x390: a trusted tap on the foundation layer toggles it (${land.layer.pressed} -> ${pressed})`);
+ } else check(false, '844x390: the foundation layer button is reachable');
+
+ // 5. the status endpoint fails: the kept status explains nothing
+ await load(1440, 900, 'en', 'failing');
+ check(!!(await evaluate(`!!document.querySelector('.queen27-hud-idle')`)), 'live: the idle line is shown');
+ statusFails = true;
+ const gone = await until(`!document.querySelector('.queen27-hud-idle') && !document.querySelector('#stat-status.is-live')`, 30000);
+ const bees = await evaluate(`document.querySelector('.queen27-hud-res-bees > span')?.textContent.trim() ?? null`);
+ check(!!gone && !/nothing to choose|round stale/.test(bees ?? ''), `after /queen/status fails: no idle line, and the BEES tile claims no reason (sub-line "${bees}")`);
+} catch (e) {
+ check(false, `harness: ${e && e.message}`);
+}
+clearTimeout(watchdog);
+cleanup();
+if (fails.length) { console.log(`Queen idle-line layout contract: FAIL (${fails.length} of ${checks})`); process.exit(1); }
+console.log(`Queen idle-line layout contract: PASS (${checks} checks)`);
diff --git a/apps/website/qa/queen-idle-reason-contract.mjs b/apps/website/qa/queen-idle-reason-contract.mjs
new file mode 100644
index 0000000000..b039816983
--- /dev/null
+++ b/apps/website/qa/queen-idle-reason-contract.mjs
@@ -0,0 +1,191 @@
+// Queen idle-reason contract. BEES read 0/4 on 2026-09-15 and the owner asked
+// why the bees were broken: the swarm was healthy_idle and the last round said
+// "nothing to choose" - 449 of 488 candidates had no ## Boundary. The status
+// the page already fetched carried the reason; the HUD did not say it. This
+// runs the pure reader and formatter on real /queen/status snapshots
+// (qa/fixtures/queen-status), on a round built from queend's own sentences and
+// on malformed ones, then checks the page uses them. Pure, no browser. Node 22
+// needs --experimental-strip-types. The layout is qa/queen-idle-layout-contract.mjs.
+import { readFileSync } from "node:fs";
+import { idleLine, idleReason, skipCounts } from "../src/components/queenHud.ts";
+
+const fails = [];
+let checks = 0;
+const check = (cond, msg) => { checks += 1; if (!cond) fails.push(msg); };
+const fixture = (name) => JSON.parse(readFileSync(new URL(`./fixtures/queen-status/${name}`, import.meta.url), "utf8"));
+const at = (status, seconds) => Date.parse(status.lastTick.decidedAt) + seconds * 1000;
+const clone = (value) => structuredClone(value);
+
+const EN = {
+ idle: "idle", nothingToChoose: "nothing to choose", refused: "round refused", checked: "checked",
+ stale: "round stale", staleDetail: "last decision {age} ago, rounds every {interval}",
+ unitS: "s", unitMin: "min", unitH: "h",
+ reasons: { missingBoundary: "no ## Boundary", claimed: "claimed", completed: "done but open", fileConflict: "touch held files", notFirst: "not first", other: "other" },
+};
+const RU = {
+ idle: "свободно", nothingToChoose: "нечего выбрать", refused: "раунд отказал", checked: "проверено",
+ stale: "раунд устарел", staleDetail: "последнее решение {age} назад, раунды каждые {interval}",
+ unitS: "с", unitMin: "мин", unitH: "ч",
+ reasons: { missingBoundary: "без ## Boundary", claimed: "заняты", completed: "сделаны и не закрыты", fileConflict: "задевают занятые файлы", notFirst: "не первые", other: "прочие" },
+};
+const line = (status, nowMs, words = EN) => { const r = idleReason(status, nowMs); return r ? idleLine(r, words) : null; };
+// No count is followed by a word, so none has to agree with one (21 задача, 3 задачи, 1 issue).
+const agreesWithNothing = (tail) => !/\d+ [A-Za-zА-яЁё]/.test(tail ?? "");
+
+// 1. the real healthy_idle snapshot: every number comes from the file
+const idle = fixture("healthy-idle-2026-09-15T1250Z.json");
+const t = idle.lastTick, sum = t.skipSummary, free = idle.workers.capacity - idle.workers.active;
+const en = line(idle, at(idle, 60));
+check(en !== null && en.text === `${free} idle: nothing to choose — checked: ${t.skippedCount}; no ## Boundary: ${sum.missingBoundary.count}, done but open: ${sum.completed.count}, claimed: ${sum.claimed.count}`, `healthy_idle (en): the refusal, how many were checked, then the three reasons by size, numbers from the wire (got "${en?.text}")`);
+check(en?.text === "4 idle: nothing to choose — checked: 488; no ## Boundary: 449, done but open: 27, claimed: 12", `healthy_idle (en) literal, so a reader sees the sentence the owner will see (got "${en?.text}")`);
+check(en?.head === "4 idle: nothing to choose" && en?.example === true, "healthy_idle: the tile's short head is the free count and the refusal; 449 without a boundary, so the format example is offered");
+const ru = line(idle, at(idle, 60), RU);
+check(ru?.text === "4 свободно: нечего выбрать — проверено: 488; без ## Boundary: 449, сделаны и не закрыты: 27, заняты: 12", `healthy_idle (ru) (got "${ru?.text}")`);
+check(!/\d/.test(JSON.stringify(EN) + JSON.stringify(RU)) && !/[А-яЁё]/.test(en?.text ?? ""), "no digit lives in the copy, no Cyrillic leaks into English");
+check(agreesWithNothing(en?.tail) && agreesWithNothing(ru?.tail), "healthy_idle: every count stands after its label");
+
+// 2. working snapshots: the round dispatched, so there is no idle line
+const working = fixture("working-2026-09-15T1400Z.json");
+const cap12 = fixture("working-capacity12-2026-09-15T1533Z.json");
+check(line(working, at(working, 60)) === null, "working (allowed, 2 of 4 busy): no idle line");
+check(line(cap12, at(cap12, 60)) === null, "working (allowed, capacity 12): no idle line");
+// a real snapshot where one bee works and the round still refused: three slots are idle and it says why
+const busyRefused = fixture("working-refused-2026-09-15T1350Z.json");
+check(line(busyRefused, at(busyRefused, 60))?.text === "3 idle: nothing to choose — checked: 487; no ## Boundary: 448, done but open: 27, claimed: 12", `1 of 4 busy, round refused: the three idle slots are explained (got "${line(busyRefused, at(busyRefused, 60))?.text}")`);
+
+// 3. a round built from queend's own sentences (main.swift, choose) and filed as
+// the server files them (queen-public-status.ts, classifySkipReason). An issue
+// with a boundary but an incomplete spec gets TWO lines: "delegatable but ..."
+// and, since the round chose nothing, " held by ...". skippedCount counts lines.
+const file = (l) => l.includes(" held by ") ? "fileConflict"
+ : /a worker already has it|claimed, but no worker is attached yet|spoken for by a task with no recorded state|so it is finished or waiting on you/.test(l) ? "claimed"
+ : l.includes("the work already landed") ? "completed"
+ : l.includes("no issue body was supplied") ? "missingBoundary"
+ : l.includes("delegatable but") ? "incompleteSpec"
+ : /not yet a spec|declares no boundary/.test(l) ? "missingBoundary"
+ : l.includes("not first") ? "notFirst" : "other";
+const refusedRound = ({ unbounded, claimed, done, heldIncomplete, heldExtra = 0 }) => {
+ const lines = []; let n = 1000;
+ for (let i = 0; i < unbounded; i++) lines.push(`#${n++}: not yet a spec - missing boundary, requirements`);
+ for (let i = 0; i < claimed; i++) lines.push(`#${n++}: a worker already has it (running)`);
+ for (let i = 0; i < done; i++) lines.push(`#${n++}: the work already landed (merged) - the issue is open and nobody closed it`);
+ for (let i = 0; i < heldIncomplete; i++) { const k = n++; lines.push(`#${k}: delegatable but not yet a spec - missing success criteria`); lines.push(`#${k}: rings/SR-00/A.swift held by gHashTag/trios#1286`); }
+ const s = clone(idle); const summary = {};
+ for (const l of lines) { const c = file(l); summary[c] = { count: (summary[c]?.count ?? 0) + 1 }; }
+ for (let i = 0; i < heldExtra; i++) summary.incompleteSpec.count += 1, lines.push("#9: delegatable but (contradiction)");
+ s.lastTick.skippedCount = lines.length; s.lastTick.skipSummary = summary;
+ return { status: s, issues: unbounded + claimed + done + heldIncomplete };
+};
+const twoLine = refusedRound({ unbounded: 400, claimed: 12, done: 27, heldIncomplete: 30 });
+const tl = line(twoLine.status, at(twoLine.status, 60));
+check(twoLine.status.lastTick.skippedCount === 499 && twoLine.issues === 469, "the built round: 499 skip lines for 469 issues");
+check(tl?.text === "4 idle: nothing to choose — checked: 469; no ## Boundary: 400, touch held files: 30, done but open: 27", `incompleteSpec is no reason (it never blocks) and not a second issue: checked 469, not 499 (got "${tl?.text}")`);
+const tlRu = line(twoLine.status, at(twoLine.status, 60), RU);
+check(tlRu?.text === "4 свободно: нечего выбрать — проверено: 469; без ## Boundary: 400, задевают занятые файлы: 30, сделаны и не закрыты: 27" && !/неполн|incomplete/.test(tl.text + tlRu.text), `the same in Russian, and no word for an incomplete spec in either (got "${tlRu?.text}")`);
+const bigHeld = refusedRound({ unbounded: 72, claimed: 12, done: 15, heldIncomplete: 200 });
+check(line(bigHeld.status, at(bigHeld.status, 60))?.text === "4 idle: nothing to choose — checked: 299; touch held files: 200, no ## Boundary: 72, done but open: 15", `200 held and incomplete: named once, as held files (got "${line(bigHeld.status, at(bigHeld.status, 60))?.text}")`);
+const contradicts = refusedRound({ unbounded: 400, claimed: 12, done: 27, heldIncomplete: 30, heldExtra: 1 });
+check(line(contradicts.status, at(contradicts.status, 60))?.text === "4 idle: nothing to choose", "more incompleteSpec than fileConflict lines in a round that chose nothing contradicts queend: the refusal alone");
+
+// 4. counts of 1, 21 and 3 in both languages: labels first, so nothing to decline
+const small = clone(idle);
+small.lastTick.skippedCount = 25;
+small.lastTick.skipSummary = { claimed: { count: 1 }, completed: { count: 3 }, missingBoundary: { count: 21 } };
+const smallEn = line(small, at(small, 60)), smallRu = line(small, at(small, 60), RU);
+check(smallEn?.text === "4 idle: nothing to choose — checked: 25; no ## Boundary: 21, done but open: 3, claimed: 1", `1, 21 and 3 in English (got "${smallEn?.text}")`);
+check(smallRu?.text === "4 свободно: нечего выбрать — проверено: 25; без ## Boundary: 21, сделаны и не закрыты: 3, заняты: 1", `1, 21 and 3 in Russian (got "${smallRu?.text}")`);
+check(agreesWithNothing(smallEn?.tail) && agreesWithNothing(smallRu?.tail), "1, 21 and 3: no count followed by a word in either language");
+
+// 5. staleness: older than two intervals says the round is stale, never the reasons
+check(line(idle, at(idle, 600))?.head === "4 idle: nothing to choose", "exactly two intervals old is still fresh (the server's own bound)");
+const stale = line(idle, at(idle, 601));
+check(stale?.text === "4 idle: round stale — last decision 10 min ago, rounds every 5 min" && stale.example === false, `601 s past a 300 s round: stale, no reasons (got "${stale?.text}")`);
+check(line(idle, at(idle, 11 * 3600 + 5), RU)?.text === "4 свободно: раунд устарел — последнее решение 11 ч назад, раунды каждые 5 мин", "stale in Russian, hours");
+check(line(cap12, at(cap12, 3600))?.text === "11 idle: round stale — last decision 60 min ago, rounds every 5 min", "a stale ALLOW with idle slots is stale too: its dispatch is no longer news");
+check(line(idle, at(idle, 90))?.text.startsWith("4 idle: nothing to choose") && line(idle, at(idle, -30)) !== null, "a tick a little in the future (clock skew) is fresh, not negative");
+
+// 6. missing or malformed skipSummary: the refusal alone, no fabricated zeros, no example
+const without = (mutate) => { const s = clone(idle); mutate(s); return line(s, at(s, 60)); };
+const bare = "4 idle: nothing to choose";
+for (const [label, mutate] of [
+ ["absent", (s) => { delete s.lastTick.skipSummary; }],
+ ["null", (s) => { s.lastTick.skipSummary = null; }],
+ ["a string", (s) => { s.lastTick.skipSummary = "449 missing"; }],
+ ["an array", (s) => { s.lastTick.skipSummary = [449, 12, 27]; }],
+ ["a count as text", (s) => { s.lastTick.skipSummary.claimed.count = "12"; }],
+ ["a negative count", (s) => { s.lastTick.skipSummary.missingBoundary.count = -1; }],
+ ["a fractional count", (s) => { s.lastTick.skipSummary.completed.count = 27.5; }],
+ ["an entry without count", (s) => { s.lastTick.skipSummary.claimed = { issues: [1] }; }],
+ ["a count above skippedCount", (s) => { s.lastTick.skipSummary.missingBoundary.count = 489; }],
+ ["counts summing past skippedCount (449 three times of 488)", (s) => { s.lastTick.skipSummary.claimed.count = 449; s.lastTick.skipSummary.completed.count = 449; }],
+ ["counts summing short of skippedCount", (s) => { s.lastTick.skipSummary.missingBoundary.count = 400; }],
+ ["empty", (s) => { s.lastTick.skipSummary = {}; }],
+ ["all zero", (s) => { s.lastTick.skipSummary = { claimed: { count: 0, issues: [], more: 0 } }; }],
+]) {
+ const got = without(mutate);
+ check(got?.text === bare && got.tail === null && got.example === false, `skipSummary ${label}: "${bare}", nothing about reasons, no example (got "${got?.text}")`);
+}
+check(without((s) => { delete s.lastTick.skippedCount; })?.text === "4 idle: nothing to choose — no ## Boundary: 449, done but open: 27, claimed: 12", "no skippedCount: the counts stand, no 'checked' is invented");
+const alien = without((s) => { s.lastTick.skipSummary = { claimed: { count: 12 }, toString: { count: 449 }, completed: { count: 27 } }; });
+check(alien?.text === "4 idle: nothing to choose — checked: 488; other: 449, done but open: 27, claimed: 12" && alien.example === false, `a key outside the server's closed set is "other", never words made from its name, and offers no format example (got "${alien?.text}")`);
+const noBoundary = without((s) => { s.lastTick.skippedCount = 39; s.lastTick.skipSummary = { claimed: { count: 12 }, completed: { count: 27 } }; });
+check(noBoundary?.text === "4 idle: nothing to choose — checked: 39; done but open: 27, claimed: 12" && noBoundary.example === false, "every candidate bounded but claimed or done: no format example, since no boundary is missing");
+check(skipCounts({ claimed: 17, completed: 6, missingBoundary: 2, fileConflict: 1, incompleteSpec: 1, notFirst: 6 }, 33)?.map((r) => `${r.key}=${r.count}`).join(",") === "claimed=17,completed=6,missingBoundary=2,fileConflict=1,incompleteSpec=1,notFirst=6", "the older wire shape (bare numbers) reads as the same counts, in wire order");
+check(skipCounts({ claimed: 17, missingBoundary: 2 }, 33) === null && skipCounts({ claimed: 17, missingBoundary: 2 })?.length === 2, "counts that do not sum to skippedCount are null; with no skippedCount there is nothing to sum against");
+check(skipCounts(idle.lastTick.skipSummary, 488)?.map((r) => r.count).join(",") === "12,27,449" && skipCounts(undefined) === null && skipCounts({ x: { count: 1 } }, 0) === null, "skipCounts: wire order; absent or contradictory is null");
+check(skipCounts({ oddNewReason: { count: 1 }, claimed: { count: 4 }, other: { count: 2 } }, 7)?.map((r) => `${r.key}=${r.count}`).join(",") === "other=3,claimed=4", "unknown keys fold into one 'other', at the place the first of them stood");
+const many = clone(idle);
+many.lastTick.skippedCount = 124;
+many.lastTick.skipSummary = { claimed: { count: 40 }, other: { count: 0 }, fileConflict: { count: 40 }, oddNewReason: { count: 41 }, missingBoundary: { count: 3 } };
+const manyLine = line(many, at(many, 60));
+check(manyLine?.tail === "checked: 124; other: 41, claimed: 40, touch held files: 40" && manyLine.example === true, `only the three largest, ties in wire order; 3 without a boundary still offer the format example (got "${manyLine?.tail}")`);
+
+// 7. other refusals: the refusal text itself in the line, a short word in the tile
+const refusing = (refusal, workers) => { const s = clone(idle); s.lastTick.refusal = refusal; if (workers) s.workers = { ...s.workers, ...workers }; return line(s, at(s, 60)); };
+const mirror = refusing("no registry mirror published yet");
+check(mirror?.text === "4 idle: no registry mirror published yet" && mirror.head === "4 idle: round refused", "no registry mirror: verbatim in the line, 'round refused' in the tile");
+const capacity = refusing("4 workers already running (limit 4)", { capacity: 12, active: 4 });
+check(capacity?.text === "8 idle: 4 workers already running (limit 4)" && capacity.tail === null && capacity.example === false, `the policy limit under a larger capacity: verbatim, eight slots idle (got "${capacity?.text}")`);
+const dailyCap = "the swarm has spent about $41.20 today, $21.20 past its $20.00 daily limit (raise it with TRIOS_DAILY_USD_CAP)";
+const cap = refusing(dailyCap);
+check(cap?.text === `4 idle: ${dailyCap}` && cap.head === "4 idle: round refused" && cap.head.length < 30, "a long refusal stays whole in the line and title; the tile's head stays short, so its two-line clamp cuts nothing");
+const s1 = clone(idle); s1.lastTick.refusal = "daily cap reached: $20.00 of $20.00";
+const s1Ru = line(s1, at(s1, 60), RU);
+check(s1Ru?.text === "4 свободно: daily cap reached: $20.00 of $20.00" && s1Ru.head === "4 свободно: раунд отказал", "the daily cap in Russian: the wire's words, not a translation guessed from them");
+
+// 8. nothing to explain, or nothing trustworthy to explain it with
+const gate = (mutate) => { const s = clone(idle); mutate(s); return line(s, at(idle, 60)); };
+for (const [label, value] of [
+ ["null status", line(null, 0)], ["a string status", line("ok", 0)], ["an empty object", line({}, 0)],
+ ["no lastTick", gate((s) => { s.lastTick = null; })],
+ ["an undatable decidedAt", gate((s) => { s.lastTick.decidedAt = "yesterday"; })],
+ ["allowed not a boolean", gate((s) => { s.lastTick.allowed = "false"; })],
+ ["a refusal of null", gate((s) => { s.lastTick.refusal = null; })],
+ ["an empty refusal", gate((s) => { s.lastTick.refusal = " "; })],
+ ["the scheduler off", gate((s) => { s.scheduler.enabled = false; })],
+ ["no interval", gate((s) => { s.scheduler.intervalSeconds = 0; })],
+ ["no workers", gate((s) => { delete s.workers; })],
+ ["workers as text", gate((s) => { s.workers.capacity = "4"; })],
+ ["every slot busy", gate((s) => { s.workers.active = 4; })],
+ ["no capacity", gate((s) => { s.workers = { capacity: 0, active: 0, idle: 0, utilization: 0 }; })],
+]) check(value === null, `${label}: no line`);
+
+// 9. the page: a live read only, one response for the tile, the words above
+const src = readFileSync(new URL("../src/pages/Queen.tsx", import.meta.url), "utf8");
+check(/const isLive = state\.kind === "ready";/.test(src) && /const idleNow = isLive \? idleReason\(data, now \+ \(state\.offsetMs \?\? 0\)\) : null;/.test(src) && /idleLine\(/.test(src), "Queen.tsx reads the idle reason only from a live status (a kept copy after a failed fetch says nothing), on the server's clock");
+check((src.match(/idleReason\(/g) || []).length === 1, "no second, ungated call of idleReason");
+check(/className="queen27-hud-idle"/.test(src) && /\{idleWhy\.example && \(/.test(src) && /href="https:\/\/github\.com\/gHashTag\/t27\/issues\/3587"/.test(src), "the line has its own element; the format example is behind idleWhy.example");
+check(/idleExample:\s*"an issue written the way bees can take it"/.test(src) && /idleExample:\s*"задача в формате, который пчёлы берут"/.test(src), "the link is labelled as a format, not as an issue bees can take now, in both languages");
+check(/idleRefused:\s*"round refused"/.test(src) && /idleRefused:\s*"раунд отказал"/.test(src) && /idleChecked:\s*"checked"/.test(src) && /idleChecked:\s*"проверено"/.test(src) && /idleMissingBoundary:\s*"no ## Boundary"/.test(src) && /idleMissingBoundary:\s*"без ## Boundary"/.test(src), "the page's words are the ones this contract formats with");
+check(!/idleIncompleteSpec|idleOf:|idleIssues:/.test(src), "no word for incompleteSpec in the idle copy, and no 'issues' noun for a count of lines");
+check(/data\?\.workers\s*\?\s*`\$\{data\.workers\.active\}\/\$\{data\.workers\.capacity\}`/.test(src) && /data\?\.workers \? data\.workers\.capacity - data\.workers\.active :/.test(src), "the BEES tile reads bees, slots and free slots from /queen/status alone when it carries workers");
+check(/skipCounts\(decision\?\.skipSummary\)/.test(src) && !/skipEntries\.map\(\(\[reason, count\]\)/.test(src), "the round popover lists counts read through skipCounts: the wire's {count, issues, more} objects crashed it (React #31)");
+check(/hudNoVerdict:\s*"finished, no verdict"/.test(src) && /hudNoVerdict:\s*"без вердикта"/.test(src) && !/hudReady/.test(src), "dispatches.unreviewed is named for what it counts: finished rows with no verdict, not issues ready");
+
+// self-test: a changed wire number changes the sentence, so the numbers are read, not written
+const moved = clone(idle); moved.lastTick.skipSummary.missingBoundary.count = 448; moved.lastTick.skippedCount = 487;
+const movedText = line(moved, at(moved, 60))?.text ?? "";
+check(movedText.includes("checked: 487; no ## Boundary: 448") && !movedText.includes("449"), `self-test: 448 of 487 in the wire reads so on the page (got "${movedText}")`);
+
+if (fails.length) { for (const f of fails) console.log(" ✗ " + f); console.log(`Queen idle-reason contract: FAIL (${fails.length} of ${checks})`); process.exit(1); }
+console.log(`Queen idle-reason contract: PASS (${checks} checks)`);
diff --git a/apps/website/src/components/queenHud.ts b/apps/website/src/components/queenHud.ts
index bd70c813eb..64be318535 100644
--- a/apps/website/src/components/queenHud.ts
+++ b/apps/website/src/components/queenHud.ts
@@ -303,6 +303,151 @@ export function skipReasonWords(key: string): string {
return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
}
+/** One skip category and how many candidates the round filed under it. */
+export interface SkipCount { key: string; count: number }
+
+const isCount = (value: unknown): value is number =>
+ typeof value === "number" && Number.isInteger(value) && value >= 0;
+
+/** The server's closed category set (queen-public-status.ts, SKIP_CATEGORIES). */
+const SKIP_CATEGORIES = new Set(["claimed", "completed", "missingBoundary", "fileConflict", "incompleteSpec", "notFirst", "other"]);
+
+/**
+ * lastTick.skipSummary as counts in wire order. The wire sends
+ * { count, issues, more } per category (older servers a bare number); only
+ * the count is read. A key outside the server's closed set is filed under
+ * "other", as the server files a sentence it does not know. Null when the
+ * summary is absent, any entry is unreadable, or the counts do not sum to
+ * skippedCount, which the server guarantees they do: a malformed summary says
+ * nothing, never a zero.
+ */
+export function skipCounts(summary: unknown, skipped: number | null = null): SkipCount[] | null {
+ if (!summary || typeof summary !== "object" || Array.isArray(summary)) return null;
+ const counts: SkipCount[] = [];
+ let total = 0;
+ for (const [wireKey, value] of Object.entries(summary)) {
+ const count = value && typeof value === "object" ? (value as { count?: unknown }).count : value;
+ if (!isCount(count)) return null;
+ const key = SKIP_CATEGORIES.has(wireKey) ? wireKey : "other";
+ const filed = counts.find((r) => r.key === key);
+ if (filed) filed.count += count;
+ else counts.push({ key, count });
+ total += count;
+ }
+ return skipped !== null && total !== skipped ? null : counts;
+}
+
+/** The refusal queend writes when no candidate survives a round. */
+const NOTHING_TO_CHOOSE = "nothing to choose";
+
+export type IdleReason =
+ | { kind: "stale"; free: number; ageSeconds: number; intervalSeconds: number }
+ | { kind: "refused"; free: number; refusal: string; checked: number | null; counts: SkipCount[] | null };
+
+/**
+ * Why free worker slots started nothing, from /queen/status alone. Measured
+ * 2026-09-15: BEES 0/4 was read as broken bees while the round had said
+ * "nothing to choose" and 449 of 488 candidates had no ## Boundary. Null when
+ * there is nothing to explain or nothing trustworthy to explain it with: no
+ * free slot, a round that dispatched, a scheduler that is off, an unreadable
+ * tick. A tick older than two intervals (the server's TICK_STALENESS_INTERVALS)
+ * is stale, and a stale round explains nothing.
+ */
+export function idleReason(status: unknown, serverNowMs: number): IdleReason | null {
+ if (!status || typeof status !== "object") return null;
+ const { scheduler, workers, lastTick } = status as {
+ scheduler?: { enabled?: unknown; intervalSeconds?: unknown } | null;
+ workers?: { capacity?: unknown; active?: unknown } | null;
+ lastTick?: { decidedAt?: unknown; allowed?: unknown; refusal?: unknown; skippedCount?: unknown; skipSummary?: unknown } | null;
+ };
+ if (!scheduler || scheduler.enabled !== true) return null;
+ const interval = scheduler.intervalSeconds;
+ if (typeof interval !== "number" || !Number.isFinite(interval) || interval <= 0) return null;
+ if (!workers || !isCount(workers.capacity) || !isCount(workers.active)) return null;
+ const free = workers.capacity - workers.active;
+ if (free <= 0) return null;
+ if (!lastTick || typeof lastTick.decidedAt !== "string" || typeof lastTick.allowed !== "boolean") return null;
+ const decidedMs = Date.parse(lastTick.decidedAt);
+ if (Number.isNaN(decidedMs)) return null;
+ const ageMs = serverNowMs - decidedMs;
+ if (ageMs > interval * 1000 * 2) {
+ return { kind: "stale", free, ageSeconds: Math.floor(ageMs / 1000), intervalSeconds: interval };
+ }
+ if (lastTick.allowed || typeof lastTick.refusal !== "string" || !lastTick.refusal.trim()) return null;
+ if (lastTick.refusal !== NOTHING_TO_CHOOSE) return { kind: "refused", free, refusal: lastTick.refusal, checked: null, counts: null };
+ // skippedCount counts skip LINES, not issues. queend (main.swift, choose)
+ // files "delegatable but ..." and goes on judging the same issue; in a round
+ // that chose nothing, that issue always files " held by " next. So an
+ // incompleteSpec line never blocked anything and counts its issue twice: it
+ // is left out of the reasons and out of how many issues were checked. More
+ // of them than fileConflict lines contradicts the decider, and says nothing.
+ const skipped = isCount(lastTick.skippedCount) ? lastTick.skippedCount : null;
+ const read = skipCounts(lastTick.skipSummary, skipped);
+ const countOf = (key: string) => read?.find((r) => r.key === key)?.count ?? 0;
+ const incomplete = countOf("incompleteSpec");
+ const counts = read && incomplete <= countOf("fileConflict") ? read.filter((r) => r.key !== "incompleteSpec") : null;
+ const checked = counts && skipped !== null ? skipped - incomplete : null;
+ return { kind: "refused", free, refusal: lastTick.refusal, checked, counts };
+}
+
+export interface IdleWords {
+ idle: string;
+ nothingToChoose: string;
+ /** The tile's word for any other refusal; the line keeps the wire's text. */
+ refused: string;
+ /** Label for how many issues the round checked. */
+ checked: string;
+ stale: string;
+ /** With {age} and {interval}. */
+ staleDetail: string;
+ unitS: string;
+ unitMin: string;
+ unitH: string;
+ /** A label per skip category, printed "label: count", so no count has to agree with a word. */
+ reasons: Record;
+}
+
+export interface IdleLine { head: string; tail: string | null; text: string; example: boolean }
+
+function spanWords(seconds: number, words: IdleWords): string {
+ if (seconds < 120) return `${seconds} ${words.unitS}`;
+ if (seconds < 7200) return `${Math.floor(seconds / 60)} ${words.unitMin}`;
+ return `${Math.floor(seconds / 3600)} ${words.unitH}`;
+}
+
+/**
+ * The idle reason as one line. The head is short enough for the BEES tile:
+ * the free slots and "nothing to choose", "round refused" or "round stale".
+ * The tail is what the wire counted: how many issues the round checked and
+ * the three largest skip reasons, or the stale tick's age. Any other refusal
+ * prints in the line as the wire wrote it; only queend's fixed "nothing to
+ * choose" has words of its own. The format example is offered only when
+ * issues were skipped for having no ## Boundary.
+ */
+export function idleLine(reason: IdleReason, words: IdleWords): IdleLine {
+ const lead = `${reason.free} ${words.idle}`;
+ if (reason.kind === "stale") {
+ const head = `${lead}: ${words.stale}`;
+ const tail = words.staleDetail
+ .replace("{age}", spanWords(reason.ageSeconds, words))
+ .replace("{interval}", spanWords(reason.intervalSeconds, words));
+ return { head, tail, text: `${head} — ${tail}`, example: false };
+ }
+ if (reason.refusal !== NOTHING_TO_CHOOSE) {
+ return { head: `${lead}: ${words.refused}`, tail: null, text: `${lead}: ${reason.refusal}`, example: false };
+ }
+ const head = `${lead}: ${words.nothingToChoose}`;
+ const top = (reason.counts ?? []).filter((r) => r.count > 0).sort((a, b) => b.count - a.count).slice(0, 3);
+ const said = top.map((r) => `${words.reasons[r.key] ?? words.reasons.other}: ${r.count}`).join(", ");
+ const tail = top.length === 0 ? null : reason.checked !== null ? `${words.checked}: ${reason.checked}; ${said}` : said;
+ return {
+ head,
+ tail,
+ text: tail ? `${head} — ${tail}` : head,
+ example: (reason.counts ?? []).some((r) => r.key === "missingBoundary" && r.count > 0),
+ };
+}
+
/**
* A hardware device's family string -> the crystal hue on the comb: gold for
* CPU (the default), cyan for FPGA, green for GPU - the ring colours of the
diff --git a/apps/website/src/pages/Queen.css b/apps/website/src/pages/Queen.css
index 008f07f954..b2e38129f9 100644
--- a/apps/website/src/pages/Queen.css
+++ b/apps/website/src/pages/Queen.css
@@ -5573,6 +5573,88 @@ main:where(.queen27-page.is-shell) section {
}
}
+/* ---- why free bees are idle (2026-09-15) ----------------------------------
+ BEES 0/4 was read as broken bees while the round had said "nothing to
+ choose". On a desktop the line floats under the map's head, over the top
+ edge of the field, and wraps rather than being cut: the head itself is one
+ row that clips anything taller. Its top is the head as served from t27.ai,
+ 42px (measured 1440x900 and 1280x720, en and ru: the identity chip is 28px
+ there, and the head's bottom rule is its last pixel; 40px covered that
+ rule). qa/queen-idle-layout-contract.mjs holds the line at or below the
+ head. */
+.queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle {
+ position: absolute;
+ z-index: 3;
+ grid-area: 1 / 1 / -1 / -1;
+ top: calc(var(--hud-chrome-top) + 42px);
+ right: calc(var(--hud-col-w) + var(--hud-gap) * 2);
+ left: calc(var(--hud-rail-w) + var(--hud-gap) * 2);
+ align-self: start;
+ margin: 0;
+ padding: 0.3rem 0.6rem;
+ border: 1px solid var(--hud-line);
+ background: #000000b8;
+ color: var(--hud-gold);
+ font-size: 0.66rem;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+
+/* Desktop only: at 0,5,0 it outranked the phone block, and a page collapsed at
+ 1440 then narrowed to 390 pushed the line 84px out of the window. */
+@media (min-width: 901px) {
+ .queen27-page.is-shell.is-command-collapsed .queen27-hud-viewport > .queen27-hud-idle {
+ left: 84px;
+ }
+}
+
+.queen27-page.is-shell .queen27-hud-idle a {
+ color: var(--hud-cyan);
+ text-decoration: underline;
+}
+
+.queen27-page.is-shell .queen27-hud-idle a:focus-visible {
+ outline: 2px solid var(--hud-cyan);
+ outline-offset: 2px;
+}
+
+.queen27-page.is-shell.is-bare .queen27-hud-idle {
+ display: none;
+}
+
+/* On a phone the line is a row of its own between the head and the body.
+ Laid over the top of the body's row it took the taps meant for what starts
+ there (measured 390x844 on t27.ai: TRI's screen tabs, and the first
+ disclosure of the specs, skills and crons views), so the body gives up the
+ line's height instead and the page stays one screen. Portrait, or tall
+ enough; a landscape phone is the next block. */
+@media (max-width: 900px) and (orientation: portrait), (max-width: 900px) and (min-height: 501px) {
+ .queen27-page.is-shell .queen27-hud-viewport:has(> .queen27-hud-idle) {
+ grid-template-rows: auto auto minmax(0, 1fr);
+ }
+
+ .queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle {
+ position: static;
+ grid-area: 2 / 1 / 3 / -1;
+ margin: var(--hud-gap) var(--hud-gap) 0;
+ }
+
+ .queen27-page.is-shell .queen27-hud-viewport:has(> .queen27-hud-idle) > .queen27-hud-vp-body {
+ grid-area: 3 / 1 / -1 / -1;
+ }
+}
+
+/* A landscape phone has no row to give. Its head is a 40px band whose tools
+ spill below it (measured 844x390: tools at y 126-154 under a head ending at
+ 124), and the line over that band took every tool and the identity chip.
+ The line is not shown there. The BEES tile's sub-line is hidden at that
+ size as well, so this orientation says what it said before the line. */
+@media (max-height: 500px) and (orientation: landscape) {
+ .queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle {
+ display: none;
+ }
+}
+
/* ---- a round's resolution moment (loop cycle 004, P1-1) ------------------- */
.queen27-page.is-shell .queen27-hud-res-round.is-resolved,
.queen27-page.is-shell .queen27-hud-round.is-resolved {
@@ -6588,7 +6670,7 @@ body.queen-shell .queen-starfield-source {
markup rather than hidden here — four previews each booting Babylon is four
more WebGL contexts on one page. */
.queen27-page.is-shell.is-embed .queen27-hud-viewport
- > :is(.queen27-hud-top, .queen27-hud-command, .queen27-hud-intel, .queen27-hud-vp-head, .queen27-hud-bottom, .queen27-context-chip) {
+ > :is(.queen27-hud-top, .queen27-hud-command, .queen27-hud-intel, .queen27-hud-vp-head, .queen27-hud-idle, .queen27-hud-bottom, .queen27-context-chip) {
display: none;
}
diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx
index 4f205e4b89..4bdc8d3b2d 100644
--- a/apps/website/src/pages/Queen.tsx
+++ b/apps/website/src/pages/Queen.tsx
@@ -27,6 +27,9 @@ import {
rewriteEndpoints,
roundStrip,
skipReasonWords,
+ skipCounts,
+ idleReason,
+ idleLine,
latestEventFor,
sectorRows,
type CombHandle,
@@ -161,6 +164,8 @@ interface QueenStatus {
status: "ok";
/** The swarm's own word for its state on the wire (working, idle, …). */
swarmState?: string | null;
+ /** Paid worker slots: configured capacity and the started, unfinished bees. */
+ workers?: { capacity: number; active: number; idle: number } | null;
scheduler: {
enabled: boolean;
intervalSeconds: number;
@@ -170,7 +175,8 @@ interface QueenStatus {
allowed: boolean;
refusal: string | null;
skippedCount: number;
- skipSummary?: Record;
+ /** Per category { count, issues, more }; older servers sent a bare count. */
+ skipSummary?: Record;
} | null;
dispatches: {
total: number;
@@ -265,7 +271,19 @@ const COPY = {
swarmIdle: "IDLE",
swarmPaused: "PAUSED",
swarmUnknown: "STATE —",
- hudReady: "ready",
+ hudNoVerdict: "finished, no verdict",
+ idleNothing: "nothing to choose",
+ idleRefused: "round refused",
+ idleChecked: "checked",
+ idleStale: "round stale",
+ idleStaleDetail: "last decision {age} ago, rounds every {interval}",
+ idleMissingBoundary: "no ## Boundary",
+ idleClaimed: "claimed",
+ idleCompleted: "done but open",
+ idleFileConflict: "touch held files",
+ idleNotFirst: "not first",
+ idleOther: "other",
+ idleExample: "an issue written the way bees can take it",
unavailable: "BACKEND UNAVAILABLE",
checking: "CHECKING BACKEND",
scheduler: "Scheduler",
@@ -607,7 +625,19 @@ const COPY = {
swarmIdle: "ЖДЁТ",
swarmPaused: "ПАУЗА",
swarmUnknown: "СОСТОЯНИЕ —",
- hudReady: "готово",
+ hudNoVerdict: "без вердикта",
+ idleNothing: "нечего выбрать",
+ idleRefused: "раунд отказал",
+ idleChecked: "проверено",
+ idleStale: "раунд устарел",
+ idleStaleDetail: "последнее решение {age} назад, раунды каждые {interval}",
+ idleMissingBoundary: "без ## Boundary",
+ idleClaimed: "заняты",
+ idleCompleted: "сделаны и не закрыты",
+ idleFileConflict: "задевают занятые файлы",
+ idleNotFirst: "не первые",
+ idleOther: "прочие",
+ idleExample: "задача в формате, который пчёлы берут",
unavailable: "BACKEND НЕДОСТУПЕН",
checking: "ПРОВЕРЯЮ BACKEND",
scheduler: "Планировщик",
@@ -2360,6 +2390,34 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
const decisionInfo = decision
? decisionDetail(decision, data?.dispatches.running ?? null, latest?.issue ?? null, c)
: null;
+ // Why free slots are idle, from the status already fetched, on the server's
+ // clock. Measured 2026-09-15: BEES 0/4 was read as broken bees while the
+ // round said "nothing to choose" and 449 of 488 issues had no ## Boundary.
+ // Only on a live read: after a failed fetch the hook keeps the last data,
+ // and a kept round would age into "round stale", blaming the scheduler for
+ // a page that cannot see the server.
+ const idleNow = isLive ? idleReason(data, now + (state.offsetMs ?? 0)) : null;
+ const idleWhy = idleNow
+ ? idleLine(idleNow, {
+ idle: c.factoryIdle,
+ nothingToChoose: c.idleNothing,
+ refused: c.idleRefused,
+ checked: c.idleChecked,
+ stale: c.idleStale,
+ staleDetail: c.idleStaleDetail,
+ unitS: c.unitS,
+ unitMin: c.unitMin,
+ unitH: c.unitH,
+ reasons: {
+ missingBoundary: c.idleMissingBoundary,
+ claimed: c.idleClaimed,
+ completed: c.idleCompleted,
+ fileConflict: c.idleFileConflict,
+ notFirst: c.idleNotFirst,
+ other: c.idleOther,
+ },
+ })
+ : null;
// wire field first (P1-18): the refusal or what the round did leads, the
// verb follows, so a narrow gold block cuts the verb, never the reason
@@ -2519,7 +2577,7 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
? c.unavailable
: c.checking;
const statusTone = isLive ? "is-live" : state.kind === "error" ? "is-cold" : "is-muted";
- const skipEntries = Object.entries(decision?.skipSummary ?? {});
+ const skipEntries = skipCounts(decision?.skipSummary) ?? [];
// The feed used to be a second list beside the Queen, saying the same things
// she now says in her own log — and it left her 90px of a column she is meant
@@ -2606,7 +2664,7 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
)}
{skipEntries.length > 0 && (
- {skipEntries.map(([reason, count]) => (
+ {skipEntries.map(({ key: reason, count }) => (
-
{count} {skipReasonWords(reason)}
@@ -2845,6 +2903,19 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
+ {idleWhy && (
+
+ {idleWhy.text}
+ {idleWhy.example && (
+ <>
+ {" · "}
+
+ {c.idleExample}
+
+ >
+ )}
+
+ )}
{/* Embedded, the scene is skipped — a page of previews would be a page
of WebGL contexts — except on the comb, where the scene IS the
@@ -3073,11 +3144,18 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
◆
{c.hudBees}
+ {/* One response for the whole tile: /queen/status's started, unfinished
+ bees over its slots, the reading idleReason takes free slots from.
+ The research poll's reading only when the status carries none. */}
- {data ? data.dispatches.running : "—"}/{workers?.capacity ?? "—"}
+ {data?.workers
+ ? `${data.workers.active}/${data.workers.capacity}`
+ : `${data ? data.dispatches.running : "—"}/${workers?.capacity ?? "—"}`}
-
- {workers?.idle ?? "—"} {c.factoryIdle}
+
+ {idleWhy
+ ? idleWhy.head
+ : `${data?.workers ? data.workers.capacity - data.workers.active : (workers?.idle ?? "—")} ${c.factoryIdle}`}
@@ -3096,7 +3174,7 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
{pulse?.verdicts ?? "—"}
{c.hud24h} · {board ? `${reviewCards.length} ${reviewColumnTitle}` : "—"}
- {typeof data?.dispatches.unreviewed === "number" ? ` · ${data.dispatches.unreviewed} ${c.hudReady}` : ""}
+ {typeof data?.dispatches.unreviewed === "number" ? ` · ${data.dispatches.unreviewed} ${c.hudNoVerdict}` : ""}