diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 1c82cb05da..5621e608c8 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -144,6 +144,15 @@ jobs: # request count does not move however many times the board is filtered. - name: The clients filter can only ever hide run: npm run check:clients-filter + # The ALL board is the person's own CRM, mail, code work and AI-browser + # session, by whose move it is. Its cards arrive from a service and part + # of each is other people's text, so this gate feeds the reader a hostile + # answer: a link survives only as https to github.com or t27.ai, or as + # exactly the app's own browser view; a browser card carries no stream URL + # and no token whatever the host sends; a spec path cannot climb out of + # specs/; the request has no arguments; nothing is stored or logged. + - name: The ALL board shows only what it can vouch for + run: npm run check:ball-board # The task lane grew the same kind of control, over what a card is ABOUT # rather than whose it is, and it is held to the same property: the cards # drawn are a SUBSET of the cards that arrived, for every selection diff --git a/apps/website/package.json b/apps/website/package.json index 281c669437..577d0de2d2 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -78,6 +78,7 @@ "check:tri-identity": "node --experimental-strip-types qa/tri-identity-contract.mjs", "check:hive-board": "node --experimental-strip-types qa/hive-board-contract.mjs", "check:clients-filter": "node --experimental-strip-types qa/clients-filter-contract.mjs", + "check:ball-board": "node --experimental-strip-types qa/ball-board-contract.mjs", "check:queen-direction": "node --experimental-strip-types qa/queen-direction-contract.mjs", "check:queen-identity": "node qa/queen-identity-contract.mjs", "check:queen-redirect": "node --experimental-strip-types qa/queen-redirect-contract.mjs", diff --git a/apps/website/qa/ball-board-contract.mjs b/apps/website/qa/ball-board-contract.mjs new file mode 100644 index 0000000000..91f9f6c727 --- /dev/null +++ b/apps/website/qa/ball-board-contract.mjs @@ -0,0 +1,176 @@ +// The ALL lane: what may reach the screen from a ball_board answer. +// +// The board is the person's own -- CRM, mail, code work and their AI-browser +// session, by whose move it is (t27 specs/automation/ball-board.t27, host +// render src/agent/ball-board.ts). This gate feeds lib/ballBoard.ts a hostile +// answer and asserts what survives: links only to places this app already +// sends people, spec paths that are spec paths, no key on a browser card, no +// request with arguments, and nothing written down or logged. Pure, no browser. + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import { PLAYER_TOOLS } from '../src/lib/triIdentity.ts' +import { + BALL_BOARD_TOOL, + BROWSER_VIEW_PATH, + ballLane, + loadBallBoard, + readBallBoard, + safeLink, + specPath, +} from '../src/lib/ballBoard.ts' + +let checks = 0 +const A = (cond, message) => { + checks += 1 + assert.ok(cond, message) +} +const EQ = (actual, expected, message) => { + checks += 1 + assert.deepEqual(actual, expected, message) +} + +const envelope = (payload) => ({ jsonrpc: '2.0', id: 1, result: { structuredContent: payload } }) + +// ── 1. the tool is one the player may ask for, and asked with no arguments ── +EQ(BALL_BOARD_TOOL, 'ball_board', 'the tool is ball_board') +A(PLAYER_TOOLS.includes(BALL_BOARD_TOOL), 'and it is on the player allowlist, so it compiles and is not refused') + +const calls = [] +const caller = (answer) => ({ + async callAsPlayer(tool, args) { + calls.push({ tool, args }) + return answer + }, +}) + +// ── 2. links: only https to github.com / t27.ai, or exactly the browser view ─ +EQ(safeLink('https://github.com/gHashTag/t27/pull/5375'), 'https://github.com/gHashTag/t27/pull/5375', 'a GitHub link is kept') +EQ(safeLink('https://app.t27.ai/game/specs'), 'https://app.t27.ai/game/specs', 'a t27.ai subdomain is kept') +EQ(safeLink(BROWSER_VIEW_PATH), '/game/browser', 'the app browser view is kept, as the bare path') +for (const hostile of [ + 'javascript:alert(1)', + 'http://github.com/x', + 'https://github.com.evil.example/x', + 'https://evilt27.ai/x', + 'https://user:pass@github.com/x', + '//github.com/x', + '/game/browser?token=abc', + '/live/abc', + 'data:text/html,hi', + 42, + null, +]) { + EQ(safeLink(hostile), null, `a link that is not ours is dropped, not rewritten: ${String(hostile)}`) +} + +// ── 3. spec paths: specs/…/*.t27 and nothing that climbs out ──────────────── +EQ(specPath('specs/automation/ball-board.t27'), 'specs/automation/ball-board.t27', 'a spec path is kept') +for (const hostile of ['specs/../../etc/passwd.t27', '/specs/a.t27', 'specs/a.ts', 'https://x/specs/a.t27', 'specs/a b.t27', 7]) { + EQ(specPath(hostile), null, `not a spec path: ${String(hostile)}`) +} + +// ── 4. a hostile answer: what reaches the lane ────────────────────────────── +const HOSTILE = { + total: 6, + counts: { ours: 2, due: -1, theirs: 'x', none: 0 }, + unspecced: 1, + sources: { + crm: { status: 'ok', cards: 2 }, + mail: { status: 'stale', age_h: 30, cards: 1 }, + browser: { status: 'ok', cards: 1, view_token: 'SECRET' }, + evil: { status: 'ok' }, + }, + clients: [ + { + key: 'client:arxiv', + name: 'arXiv', + ball: 'ours', + hidden: 0, + cards: [ + { source: 'mail', ref: 'm1', title: 'Reply', ball: 'ours', days: 2, because: null, link: 'javascript:alert(1)', spec: 'specs/automation/mail-push.t27' }, + { source: 'github', ref: 'gHashTag/t27#1', title: 'PR', ball: 'theirs', days: null, because: 'review asked', link: 'https://github.com/gHashTag/t27/pull/1', spec: '../x.t27' }, + { source: 'nope', ref: 'z', title: 'unknown source', ball: 'ours' }, + { source: 'crm', ref: '', title: 'no ref' }, + ], + }, + { + key: 'name:browser', + name: 'browser', + ball: 'theirs', + cards: [ + { + source: 'browser', + ref: 'browser:me', + title: 'browser_open example.com/login', + ball: 'theirs', + days: 0, + because: 'live', + link: 'https://vibee-browser.example/live/abc?token=SECRET', + spec: 'specs/automation/browser-sign-in.t27', + view_token: 'SECRET', + endpoint: 'wss://SECRET', + }, + ], + }, + { key: '', name: 'no key', cards: [{ source: 'crm', ref: 'c', title: 'x' }] }, + 'not a client', + ], + clients_total: 2, +} + +const board = readBallBoard(envelope(HOSTILE)) +A(board, 'a board was read') +EQ(board.counts, { ours: 2, due: null, theirs: null, none: 0 }, 'a count that is not a count is null, never zero') +EQ(Object.keys(board.sources).sort(), ['browser', 'crm', 'mail'], 'only known sources are read') +EQ(board.sources.mail, { status: 'stale', cards: 1, ageH: 30 }, 'a source state is status, cards and age only') +EQ(board.clients.map((client) => client.key), ['client:arxiv', 'name:browser'], 'a client with no key or no readable card is dropped') +const cards = board.clients.flatMap((client) => client.cards) +EQ(cards.map((card) => card.ref), ['m1', 'gHashTag/t27#1', 'browser:me'], 'a card with an unknown source or no ref is dropped') +EQ(cards[0].link, null, 'a javascript: link never reaches a card') +EQ(cards[1].spec, null, 'a path that climbs is not a spec') +const browser = cards[2] +EQ(browser.link, null, 'a browser card cannot carry the stream URL, even if the host sent one') +EQ( + Object.keys(browser).sort(), + ['ball', 'because', 'days', 'link', 'ref', 'source', 'spec', 'title'], + 'a card is these fields and no others: no token, no endpoint', +) +A(!JSON.stringify(board).includes('SECRET'), 'nothing the host should not have sent survives the reader') + +// ── 5. not a board is not an empty board ──────────────────────────────────── +EQ(readBallBoard({ jsonrpc: '2.0', id: 1, error: { message: 'no' } }), null, 'an envelope error is not a board') +EQ(readBallBoard(envelope({ total: 0 })), null, 'an answer with no clients list is not a board') +EQ(readBallBoard(envelope({ clients: [] }))?.clients, [], 'an empty clients list IS an empty board') + +// ── 6. the lane: search can only hide, null draws nothing ────────────────── +EQ(ballLane(null), null, 'no board, no lane') +const all = ballLane(board) +EQ(all.shown, 3, 'every readable card is drawn') +EQ(all.groups.map((group) => group.ball), ['ours', 'due', 'theirs', 'none'], 'the columns run ours, due, theirs, none') +EQ(all.browser?.ref, 'browser:me', 'the lane knows which card is the browser, so the head can offer to open it') +for (const needle of ['arxiv', 'ARXIV', 'pr', 'zzz', '', ' ', 'x'.repeat(500)]) { + const lane = ballLane(board, needle) + const drawn = lane.groups.flatMap((group) => group.cards.map((card) => card.ref)) + A(drawn.every((ref) => cards.some((card) => card.ref === ref)), `search "${needle.slice(0, 12)}" draws a subset of what arrived`) + EQ(lane.shown, drawn.length, 'and the count is of what is drawn') +} + +// ── 7. the request: one tool, no arguments, failures keep their reason ───── +EQ(await loadBallBoard(caller({ ok: true, body: envelope(HOSTILE) })).then((a) => a.ok), true, 'a board loads') +EQ(calls.at(-1), { tool: 'ball_board', args: undefined }, 'asked with no arguments: identity is the token, not the page') +for (const reason of ['signed-out', 'refused', 'offline']) { + EQ(await loadBallBoard(caller({ ok: false, reason })), { ok: false, reason }, `${reason} stays ${reason}`) +} +EQ(await loadBallBoard(caller({ ok: true, body: { nonsense: true } })), { ok: false, reason: 'unreadable' }, 'nonsense is unreadable, not empty') + +// ── 8. the module holds no credential and writes nothing down ────────────── +const reader = readFileSync('src/lib/ballBoard.ts', 'utf8') +A(!/\bfetch\s*\(/.test(reader), 'the reader does not fetch') +A(!/(localStorage|sessionStorage|document\.cookie|indexedDB)/.test(reader), 'nothing is written down in a browser') +A(!/\bconsole\.\w+\(/.test(reader), 'nothing is logged') +A(!/^\s*import[^\n]*crmClient/m.test(reader), 'the console credential path is not imported') +A(!/dangerouslySetInnerHTML/.test(readFileSync('src/pages/Queen.tsx', 'utf8').split('function BallLaneView')[1]?.split('\nfunction ')[0] ?? 'missing'), 'the lane builds no markup from text') + +console.log(`Ball board contract: PASS (${checks} checks)`) diff --git a/apps/website/qa/clients-filter-contract.mjs b/apps/website/qa/clients-filter-contract.mjs index c9ac5ac91a..6f5112b4b3 100644 --- a/apps/website/qa/clients-filter-contract.mjs +++ b/apps/website/qa/clients-filter-contract.mjs @@ -366,6 +366,36 @@ for (const className of ['queen27-clients-lane', 'queen27-lane-head is-private'] ) } +// The ALL lane is the same kind of private and obeys the same rule: drawn only +// when chosen. It lives in its own component, so the guard is asked of every +// place that draws that component. +function guardedByChosenBall(node) { + const test = (at) => { + if (!ts.isJsxExpression(at) || !at.expression) return false + const expression = at.expression + const left = + ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + ? expression.left + : null + return !!left && /\bboard\s*===\s*['"]ball['"]/.test(left.getText(source)) + } + for (let at = node.parent; at; at = at.parent) if (test(at)) return true + let name = null + for (let at = node.parent; at && !name; at = at.parent) + if (ts.isFunctionDeclaration(at) && at.name && /^[A-Z]/.test(at.name.text)) name = at.name.text + if (!name) return false + const uses = [] + const visit = (n) => { + if (ts.isJsxOpeningLikeElement(n) && n.tagName.getText(source) === name) uses.push(n) + ts.forEachChild(n, visit) + } + visit(source) + return uses.length > 0 && uses.every((use) => guardedByChosenBall(use)) +} +const ballLanes = elementsWithClass('queen27-ball-lane') +EQ(ballLanes.length, 1, 'the ALL lane is built in exactly one place') +A(guardedByChosenBall(ballLanes[0]), 'and only when the reader has chosen it: a mailbox, a pipeline and a browser session are not drawn unbidden') + let initialBoard = null const findBoardState = (node) => { if ( diff --git a/apps/website/qa/hive-board-contract.mjs b/apps/website/qa/hive-board-contract.mjs index c336cf72fa..674b3801c5 100644 --- a/apps/website/qa/hive-board-contract.mjs +++ b/apps/website/qa/hive-board-contract.mjs @@ -177,7 +177,10 @@ try { const before6 = world.log.fetches.length EQ(await world.client.callAsPlayer('crm_history'), { ok: false, reason: 'refused' }, 'a tool the token may not ask for is refused') EQ(world.log.fetches.length, before6, 'and refused BEFORE a request is made') - EQ([...PLAYER_TOOLS], ['hive_board'], 'the game token asks for exactly one tool') + // Two boards, each the caller's own: hive_board (their clients) and + // ball_board (everything they are part of, qa/ball-board-contract.mjs). + // Nothing that can write, and nothing that names somebody else. + EQ([...PLAYER_TOOLS], ['hive_board', 'ball_board'], 'the player asks for exactly the two read-only boards') } finally { undoTraps() } @@ -246,8 +249,33 @@ function guardedByClients(node) { ? expression.condition : null if (test && /\bclients\b/.test(test.getText(source))) return true + // Reaching a component's own function without a guard is not the end of + // the question: the component is guarded if EVERY place that draws it is. + // One unguarded `` and none of its markup is. } - return false + const owner = enclosingComponent(node) + if (!owner) return false + const uses = jsxUsesOf(owner) + return uses.length > 0 && uses.every((use) => guardedByClients(use)) +} + +/** The PascalCase function declaration this node sits in, if any. */ +function enclosingComponent(node) { + for (let at = node.parent; at; at = at.parent) { + if (ts.isFunctionDeclaration(at) && at.name && /^[A-Z]/.test(at.name.text)) return at.name.text + } + return null +} + +/** Every `` in the page. */ +function jsxUsesOf(name) { + const found = [] + const visit = (node) => { + if (ts.isJsxOpeningLikeElement(node) && node.tagName.getText(source) === name) found.push(node) + ts.forEachChild(node, visit) + } + visit(source) + return found } const lane = elementsWithClass('queen27-clients-lane') @@ -262,10 +290,15 @@ for (const head of heads) A(guardedByClients(head), 'a lane heading is itself cl // PUBLIC board must NOT be behind that gate. A refactor that tidied both lanes // into one conditional would pass every assertion above and would blank the // task board for every visitor who is not signed in. -const publicBoard = elementsWithClass('queen27-kanban').filter((node) => !elementsWithClass('queen27-clients-lane').includes(node)) +const privateBoards = [...elementsWithClass('queen27-clients-lane'), ...elementsWithClass('queen27-ball-lane')] +const publicBoard = elementsWithClass('queen27-kanban').filter((node) => !privateBoards.includes(node)) EQ(publicBoard.length, 1, 'there is one public task board') A(!guardedByClients(publicBoard[0]), 'and it is drawn for everybody, signed in or not, exactly as it was') +const ballBoard = elementsWithClass('queen27-ball-lane') +EQ(ballBoard.length, 1, 'the ALL lane is drawn in exactly one place') +A(guardedByClients(ballBoard[0]), 'and only for somebody the hive has identified') + A(!page.includes(AGENT_HEADER), 'the page names no credential at all') console.log(`Hive board contract: PASS (${checks} checks, 1 identity header, 0 counts, 0 requests signed out)`) diff --git a/apps/website/qa/queen-viewport-contract.mjs b/apps/website/qa/queen-viewport-contract.mjs index 9c6790e478..f614f39279 100644 --- a/apps/website/qa/queen-viewport-contract.mjs +++ b/apps/website/qa/queen-viewport-contract.mjs @@ -240,7 +240,8 @@ const DECLARED = [ // carries: this list is the declaration, and a scroll owner that is declared // only as a side effect of sharing a class is one nobody has declared. // It does not appear in this gate's own runs, which are signed out. - '.queen27-cards', '.queen27-kanban', '.queen27-clients-lane', '.queen27-mission-map', + // The ALL lane (lib/ballBoard.ts) is the third, declared the same way. + '.queen27-cards', '.queen27-kanban', '.queen27-clients-lane', '.queen27-ball-lane', '.queen27-mission-map', '.queen27-factory', '.queen27-factory-bays ol', '.queen27-tech', '.queen27-tech-console', '.queen27-tech-map', '.queen27-tech-details', '.queen27-city-build-queue ol', '.queen27-hardware-foundry ol', '.queen27-city-console ol', diff --git a/apps/website/src/lib/ballBoard.ts b/apps/website/src/lib/ballBoard.ts new file mode 100644 index 0000000000..13eecaeea3 --- /dev/null +++ b/apps/website/src/lib/ballBoard.ts @@ -0,0 +1,257 @@ +/** + * ball_board: the ALL lane of the Queen's kanban -- every open thing the + * signed-in person is part of, by client, with whose move it is. + * + * The spec of record is specs/automation/ball-board.t27 in the t27 repo; the + * host that answers is the render's `ball_board` tool. This file decides + * nothing about where a ball lies. It reads the answer, refuses what it cannot + * vouch for, and hands the lane a shape it can draw. + * + * WHY THIS FILE HOLDS NO CREDENTIAL + * + * Same rule as hiveBoard.ts, kept in the same place: this module names the + * tool (`callAsPlayer`) and triIdentity makes the request with the token it + * already holds. A tool the token may not ask for cannot be named here, + * because BALL_BOARD_TOOL is typed as PlayerTool. + * + * WHY A LINK IS CHECKED HERE AND NOT TRUSTED + * + * A card's link comes from a remote service, and part of it is other people's + * text (an issue URL, a mail subject's host). A link is drawn only if it is + * https on a host this app already sends people to (github.com, t27.ai), or + * exactly the app's own browser view. Anything else is dropped, not rewritten: + * a rewritten link is a link nobody checked. + * + * WHY A BROWSER CARD CARRIES NO KEY + * + * The board shows the caller's own AI-browser session as a card so the person + * can see what the agent is doing and step in. The card holds a state, a time + * and the last step's bare host and path -- never the stream URL, never a view + * token, never typed text (the host's BROWSER_FIELDS and CARD_CARRIES_NO_TOKEN). + * Opening the browser is the app's own view, behind the app's own sign-in; + * this module only says that a card IS a browser card. + * + * Everything that comes back is DATA. React renders it as text nodes; nothing + * here builds markup and nothing here is treated as an instruction. + */ + +import { mcpPayload } from './mcpAnswer.ts' +import { triIdentity } from './triIdentity.ts' +import type { PlayerTool, TriIdentity } from './triIdentity.ts' + +export const BALL_BOARD_TOOL: PlayerTool = 'ball_board' + +/** Whose move it is. The order is the spec's: ours first, nobody's last. */ +export const BALLS = ['ours', 'due', 'theirs', 'none'] as const +export type Ball = (typeof BALLS)[number] + +export const SOURCES = ['crm', 'mail', 'github', 'browser'] as const +export type BallSource = (typeof SOURCES)[number] + +/** The app's own browser view: the only relative link a card may carry. */ +export const BROWSER_VIEW_PATH = '/game/browser' + +/** A spec path the specs view can open. Anything else is not a spec. */ +const SPEC_PATH = /^specs\/[\w./-]+\.t27$/ +const LINK_HOSTS = ['github.com', 't27.ai'] + +export interface BallCard { + source: BallSource + ref: string + title: string + ball: Ball + /** Days since it moved. null when the service did not say -- never 0. */ + days: number | null + because: string | null + /** A checked link, or null. See the header. */ + link: string | null + /** The .t27 spec the card stands on, or null when it names none. */ + spec: string | null +} + +export interface BallClient { + key: string + name: string + ball: Ball + cards: BallCard[] + /** Cards the service counted but did not send (its own cap). */ + hidden: number +} + +export interface BallSourceState { + /** ok, empty, stale, unreachable -- or whatever the service said, capped. */ + status: string + cards: number | null + ageH: number | null +} + +export interface BallBoard { + total: number | null + counts: Record + /** Cards that stand on no spec. Counted, never given one. */ + unspecced: number | null + sources: Partial> + clients: BallClient[] + clientsTotal: number | null +} + +export type BallBoardReason = 'signed-out' | 'refused' | 'offline' | 'unreadable' +export type BallBoardAnswer = { ok: true; board: BallBoard } | { ok: false; reason: BallBoardReason } + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const text = (value: unknown, max = 64): string => + typeof value === 'string' ? value.trim().slice(0, max) : '' + +/** A count, or null. Absent is not zero. */ +const count = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null + +const ballOf = (value: unknown): Ball => + (BALLS as readonly string[]).includes(value as string) ? (value as Ball) : 'none' + +/** The link if this app may send a person there, else null. */ +export function safeLink(value: unknown): string | null { + const raw = text(value, 400) + if (!raw) return null + if (raw === BROWSER_VIEW_PATH) return raw + let url: URL + try { + url = new URL(raw) + } catch { + return null + } + if (url.protocol !== 'https:' || url.username || url.password) return null + const host = url.hostname.toLowerCase() + return LINK_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`)) ? url.href : null +} + +/** The spec path if it is one, else null. `..` never names a spec. */ +export function specPath(value: unknown): string | null { + const raw = text(value, 200) + return SPEC_PATH.test(raw) && !raw.includes('..') ? raw : null +} + +function readCard(card: unknown): BallCard | null { + if (!isRecord(card)) return null + const source = text(card.source, 16) + if (!(SOURCES as readonly string[]).includes(source)) return null + const ref = text(card.ref, 120) + if (!ref) return null + return { + source: source as BallSource, + ref, + title: text(card.title, 160), + ball: ballOf(card.ball), + days: count(card.days), + because: text(card.because, 120) || null, + link: safeLink(card.link), + spec: specPath(card.spec), + } +} + +/** + * Read one `ball_board` answer. Pure, like readHiveBoard, so the gate can feed + * it a hostile answer. Null means "not a board", which is not an empty board. + */ +export function readBallBoard(body: unknown): BallBoard | null { + if (isRecord(body) && body.error !== undefined) return null + const payload = mcpPayload(isRecord(body) ? body.result : undefined) + if (!isRecord(payload) || !Array.isArray(payload.clients)) return null + + const rawCounts = isRecord(payload.counts) ? payload.counts : {} + const counts = Object.fromEntries(BALLS.map((ball) => [ball, count(rawCounts[ball])])) as Record< + Ball, + number | null + > + + const rawSources = isRecord(payload.sources) ? payload.sources : {} + const sources: Partial> = {} + for (const source of SOURCES) { + const state = rawSources[source] + if (!isRecord(state)) continue + sources[source] = { + status: text(state.status, 16) || 'unknown', + cards: count(state.cards), + ageH: count(state.age_h), + } + } + + const clients: BallClient[] = payload.clients + .map((client) => { + if (!isRecord(client)) return null + const key = text(client.key, 120) + if (!key) return null + const cards = (Array.isArray(client.cards) ? client.cards : []) + .map(readCard) + .filter((card): card is BallCard => card !== null) + if (cards.length === 0) return null + return { + key, + name: text(client.name, 64) || key, + ball: ballOf(client.ball), + cards, + hidden: count(client.hidden) ?? 0, + } + }) + .filter((client): client is BallClient => client !== null) + + return { + total: count(payload.total), + counts, + unspecced: count(payload.unspecced), + sources, + clients, + clientsTotal: count(payload.clients_total), + } +} + +/** Ask for this person's board. No arguments: identity is the token's. */ +export async function loadBallBoard( + caller: Pick = triIdentity(), +): Promise { + const answer = await caller.callAsPlayer(BALL_BOARD_TOOL) + if (!answer.ok) return { ok: false, reason: answer.reason } + const board = readBallBoard(answer.body) + return board ? { ok: true, board } : { ok: false, reason: 'unreadable' } +} + +/** One card on screen, with the client it belongs to. */ +export interface BallLaneCard extends BallCard { + client: string + clientKey: string +} + +export interface BallLane { + groups: Array<{ ball: Ball; cards: BallLaneCard[] }> + /** Cards drawn, after the search. A count of what is on screen. */ + shown: number + browser: BallLaneCard | null +} + +/** + * The lane, ready to render -- or null, which means draw nothing private. + * Columns follow the cards' own balls (a client's row ball is its most urgent + * card's, so a column per card is the honest split). The search is local and + * can only narrow what arrived. + */ +export function ballLane(board: BallBoard | null, search = ''): BallLane | null { + if (!board) return null + const needle = typeof search === 'string' ? search.trim().slice(0, 64).toLowerCase() : '' + const cards: BallLaneCard[] = board.clients.flatMap((client) => + client.cards.map((card) => ({ ...card, client: client.name, clientKey: client.key })), + ) + const kept = needle + ? cards.filter((card) => + [card.client, card.title, card.ref, card.because ?? '', card.spec ?? ''].some((field) => + field.toLowerCase().includes(needle), + ), + ) + : cards + return { + groups: BALLS.map((ball) => ({ ball, cards: kept.filter((card) => card.ball === ball) })), + shown: kept.length, + browser: cards.find((card) => card.source === 'browser') ?? null, + } +} diff --git a/apps/website/src/lib/triIdentity.ts b/apps/website/src/lib/triIdentity.ts index 6dd240c2cb..c3db870344 100644 --- a/apps/website/src/lib/triIdentity.ts +++ b/apps/website/src/lib/triIdentity.ts @@ -90,8 +90,15 @@ export const PLAYER_CALL_TIMEOUT_MS = 8000 * * whoami is deliberately absent: it is this module's own call, made once per * person from loadProfile, not something a component asks for. + * + * Being on this list is necessary, not sufficient. The render keeps its own + * list of what a bare game token may call (GAME_TOKEN_TOOLS in render + * src/agent/routes.ts) and the two private boards are not on it, so on t27.ai + * they answer "refused" and the lane draws nothing private. They answer on + * app.t27.ai, where the token is the app session's. That is fail-closed and + * meant: widening the game token is the render's decision, not this page's. */ -export const PLAYER_TOOLS = ['hive_board'] as const +export const PLAYER_TOOLS = ['hive_board', 'ball_board'] as const export type PlayerTool = (typeof PLAYER_TOOLS)[number] /** diff --git a/apps/website/src/pages/Queen.css b/apps/website/src/pages/Queen.css index 83cd2e4dda..4f5d549a53 100644 --- a/apps/website/src/pages/Queen.css +++ b/apps/website/src/pages/Queen.css @@ -7447,18 +7447,21 @@ body.queen-shell .queen-starfield-source { automatic -- as many as arrive, each no narrower than a card needs to be legible, and the lane scrolls sideways when they do not fit, which is what the six fixed ones already do. */ -.queen27-clients-lane { +.queen27-clients-lane, +.queen27-ball-lane { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: minmax(210px, 1fr); } -.queen27-page.is-shell .queen27-clients-lane { +.queen27-page.is-shell .queen27-clients-lane, +.queen27-page.is-shell .queen27-ball-lane { grid-auto-columns: minmax(160px, 1fr); } @media (max-width: 760px) { - .queen27-clients-lane { + .queen27-clients-lane, + .queen27-ball-lane { grid-auto-columns: minmax(78vw, 1fr); } } @@ -7667,7 +7670,8 @@ body.queen-shell .queen-starfield-source { the chips read through the honeycomb, a hex line crossing every second glyph — and the arithmetic agreed: 1.6:1. The map may run under a column of cards. It does not get to run under somebody's name. */ -.queen27-lane-head.is-private { +.queen27-lane-head.is-private, +.queen27-lane-head.is-ball { flex-wrap: wrap; border-radius: 0.6rem; background: var(--hud-panel, rgba(2, 8, 6, 0.88)); @@ -7738,7 +7742,8 @@ body.queen-shell .queen-starfield-source { reached for after looking at a photograph. The rule is restated at 0,4,0 rather than moved, so that adding a third layer below cannot quietly undo it again: whatever the generic heading is given, this one keeps the panel. */ -.queen27-page.is-shell .queen27-lane-head.is-private { +.queen27-page.is-shell .queen27-lane-head.is-private, +.queen27-page.is-shell .queen27-lane-head.is-ball { background: var(--hud-panel, rgba(2, 8, 6, 0.88)); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); @@ -7861,3 +7866,62 @@ body.queen-shell .queen-starfield-source { backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } + +/* The ALL lane's cards (lib/ballBoard.ts). A card is a container, not a link: + it holds the press that opens the thing (an issue, the AI browser) AND the + press that opens its .t27 spec, and one link cannot hold another. The open + press fills the card and reads as the card itself; the spec chip sits under + it. A card with no spec says so in words -- the board counts it, never + invents one. */ +.queen27-ball-lane .queen27-card-open { + display: grid; + gap: 0.2rem; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: start; + text-decoration: none; + cursor: pointer; +} + +.queen27-ball-lane .queen27-card-source { + margin-inline-start: auto; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.queen27-ball-lane .queen27-card-spec { + justify-self: start; + max-width: 100%; + margin-top: 0.3rem; + padding: 0.05rem 0.45rem; + overflow: hidden; + border: 1px solid var(--q-gold); + border-radius: 999px; + background: none; + color: var(--q-gold); + font: inherit; + font-size: 0.6rem; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.queen27-ball-lane .queen27-card-spec.is-none { + border-style: dashed; + color: var(--muted); + font-style: normal; + cursor: default; +} + +/* A touch target is a finger, not a cursor (WCAG 2.5.8). */ +@media (pointer: coarse) { + .queen27-ball-lane .queen27-card-spec { + min-height: 24px; + } +} diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx index 09840c20f9..b264852f4d 100644 --- a/apps/website/src/pages/Queen.tsx +++ b/apps/website/src/pages/Queen.tsx @@ -104,7 +104,8 @@ import { QueenIdentity } from "../components/QueenIdentity"; import { TRI_BUTTONS, hashParamsOf, tabAddress, triAddress, triGroupOf, triScreenOf } from "../lib/triScreens"; import { OPEN_TARGETS, screenExcerpt } from "../lib/queenDirectives"; import { sendToAgentAsMe } from "../services/queenModel"; -import { triIdentity } from "../lib/triIdentity"; +import { triIdentity, type TriIdentity } from "../lib/triIdentity"; +import { ballLane, loadBallBoard, type BallBoard, type BallBoardReason, type BallLane, type BallLaneCard } from "../lib/ballBoard"; import { clientsLane, loadHiveBoard, type ClientsLane, type HiveBoard, type HiveBoardReason } from "../lib/hiveBoard"; import { REVIEW_STATES, @@ -589,6 +590,21 @@ const COPY = { clientsWaiting: "waiting for a reply", clientsQuiet: "days quiet", clientsTouched: "last touch", + // The ALL board (lib/ballBoard.ts): CRM, mail, code work and the person's + // own AI-browser session on one board, by whose move it is. + laneBall: "ALL", + ballLaneAria: "Everything, by whose move it is", + ballPrivateHint: "your clients, mail and browser — only you were shown this", + ballOurs: "OUR MOVE", + ballDue: "DUE", + ballTheirs: "THEIR MOVE", + ballNone: "NOBODY'S", + ballDays: "days", + ballEmpty: "Nothing open on your board.", + ballSearch: "find on the board", + ballUnspecced: "no spec", + ballNoSpec: "no .t27 spec", + ballOpenBrowser: "open the AI browser", command: "LIVE COMMAND ROOM", commandTitle: "Queen reviews the swarm herself.", commandCopy: @@ -998,6 +1014,19 @@ const COPY = { clientsWaiting: "ждёт ответа", clientsQuiet: "дней тишины", clientsTouched: "последний контакт", + laneBall: "ВСЁ", + ballLaneAria: "Всё, по тому, чей ход", + ballPrivateHint: "ваши клиенты, почта и браузер — это показали только вам", + ballOurs: "НАШ ХОД", + ballDue: "СРОК", + ballTheirs: "ИХ ХОД", + ballNone: "НИЧЕЙ", + ballDays: "дн.", + ballEmpty: "На вашей доске нет открытого.", + ballSearch: "найти на доске", + ballUnspecced: "без спеки", + ballNoSpec: "нет спеки .t27", + ballOpenBrowser: "открыть ИИ-браузер", command: "ЖИВОЙ КОМАНДНЫЙ ЦЕНТР", commandTitle: "Королева сама ревьюит работу роя.", commandCopy: @@ -1370,7 +1399,24 @@ function useQueenBoard(): { * may see what — the question simply did not come back — and blanking a * correct panel because one poll missed is its own kind of lie. */ -function useHiveBoard(enabled: boolean): { +function useHiveBoard(enabled: boolean) { + return usePlayerBoard(enabled, loadHiveBoard); +} + +/** + * The ALL lane's data: ball_board, asked the same way and under the same two + * rules as hive_board above -- one poll, one gate, two boards. Written once + * rather than copied: a second copy of "refused drops the board" is a second + * place for it to stop being true. + */ +function useBallBoard(enabled: boolean) { + return usePlayerBoard(enabled, loadBallBoard); +} + +function usePlayerBoard( + enabled: boolean, + load: (caller: TriIdentity) => Promise<{ ok: true; board: B } | { ok: false; reason: R }>, +): { /** * Whether anything client-scoped may be drawn at all: a person the hive has * identified, on a board that is on screen. The view's single gate hangs off @@ -1378,14 +1424,14 @@ function useHiveBoard(enabled: boolean): { * separately. */ showing: boolean; - board: HiveBoard | null; - reason: HiveBoardReason | null; + board: B | null; + reason: R | null; } { const identity = triIdentity(); const me = useSyncExternalStore(identity.subscribe, identity.getSnapshot); const signedIn = me.state === "signed-in"; - const [board, setBoard] = useState(null); - const [reason, setReason] = useState(null); + const [board, setBoard] = useState(null); + const [reason, setReason] = useState(null); useEffect(() => { if (!enabled || !signedIn) { @@ -1395,7 +1441,7 @@ function useHiveBoard(enabled: boolean): { } let active = true; const read = async () => { - const answer = await loadHiveBoard(identity); + const answer = await load(identity); if (!active) return; if (answer.ok) { setBoard(answer.board); @@ -1411,7 +1457,7 @@ function useHiveBoard(enabled: boolean): { active = false; window.clearInterval(timer); }; - }, [enabled, identity, signedIn]); + }, [enabled, identity, signedIn, load]); return { showing: enabled && signedIn, board, reason }; } @@ -2209,6 +2255,27 @@ interface ClientsPanel { reason: HiveBoardReason | null; } +/** + * The ALL lane, under the same rule: null is signed out and draws nothing. + * `board` rides along for the head line (sources, unspecced), which describes + * this person's own answer and nobody else's. + */ +interface BallPanel { + lane: BallLane | null; + board: BallBoard | null; + reason: BallBoardReason | null; +} + +/** "crm ok · mail stale 30h · …": each source's state, as the host said it. */ +function ballSourcesLine(board: BallBoard | null): string | null { + if (!board) return null; + const parts = Object.entries(board.sources).map( + ([source, state]) => + `${source} ${state.status}${state.ageH !== null ? ` ${state.ageH}h` : ""}`, + ); + return parts.length ? parts.join(" · ") : null; +} + /** * The reason the clients lane has nothing new, in the reader's language — or * null when there is nothing to say. @@ -2266,6 +2333,11 @@ function KanbanView({ onNarrow, search, onSearch, + ball, + ballSearch, + onBallSearch, + onOpenSpec, + onOpenBrowser, }: { columns: QueenColumn[]; cards: QueenCard[]; @@ -2285,6 +2357,14 @@ function KanbanView({ /** What has been typed into the find box. Also never leaves this page. */ search: string; onSearch: (text: string) => void; + /** The ALL lane. Null is signed out, exactly like `clients`. */ + ball: BallPanel | null; + ballSearch: string; + onBallSearch: (text: string) => void; + /** Open a card's .t27 spec in the specs view. */ + onOpenSpec: (path: string) => void; + /** Open the app's own AI-browser view, to watch or help the agent. */ + onOpenBrowser: () => void; }) { // Which directions the reader is looking at, empty for all of them. It lives // here and nowhere else: it is a property of this screen, not of the visitor, @@ -2327,13 +2407,13 @@ function KanbanView({ // // So: one lane at a time, and 'tasks' first. The private board is one press // away and is never the thing that happens to be on screen. - const [board, setBoard] = useState<"tasks" | "clients">("tasks"); + const [board, setBoard] = useState<"tasks" | "clients" | "ball">("tasks"); // Signing out takes the lane with it, and a view pointing at a board that no // longer exists would render as an empty screen with no way back. The switch // itself disappears at the same moment, so nothing else could return it. useEffect(() => { - if (!clients) setBoard("tasks"); - }, [clients]); + if (!clients || (board === "ball" && !ball)) setBoard("tasks"); + }, [clients, ball, board]); const showTasks = board === "tasks" || !clients; // How deep into each column the reader has asked to go. Per column, because // BACKLOG holding 569 and REVIEW holding 9 are not one question: opening the @@ -2405,6 +2485,17 @@ function KanbanView({ it is drawn, and that decision is worth one word of warning. */} {c.lanePrivate} + {ball && ( + + )} )} {showTasks && ( @@ -2767,6 +2858,159 @@ function KanbanView({ )} + {clients && ball && board === "ball" && ( + + )} + + ); +} + +/** + * The ALL lane: one board for everything this person is part of, by whose + * move it is. Every card names its source and, when it has one, the .t27 spec + * it stands on -- a press opens the spec. A browser card is the person's own + * AI-browser session, and its press opens the app's browser view so they can + * watch, take the wheel or answer what the agent asked. No card carries a key: + * the link a card holds was checked in lib/ballBoard.ts, and the browser card's + * is the app's own route. + */ +function BallLaneView({ + ball, + c, + search, + onSearch, + onOpenSpec, + onOpenBrowser, +}: { + ball: BallPanel; + c: Copy; + search: string; + onSearch: (text: string) => void; + onOpenSpec: (path: string) => void; + onOpenBrowser: () => void; +}) { + const lane = ball.lane; + const sentence = clientsReasonSentence(ball.reason, c); + const note = !lane ? (sentence ?? c.clientsPending) : lane.shown === 0 && !search ? c.ballEmpty : null; + const sources = ballSourcesLine(ball.board); + const unspecced = ball.board?.unspecced ?? null; + const columnTitle: Record = { + ours: c.ballOurs, + due: c.ballDue, + theirs: c.ballTheirs, + none: c.ballNone, + }; + const cardBody = (card: BallLaneCard) => ( + <> +
+ {card.client} + {card.source} +
+ {/* Other people's text from a remote service: text nodes only. */} + {card.title || card.ref} + {card.because && {card.because}} + {card.days !== null && ( + + {card.days} {c.ballDays} + + )} + + ); + return ( + <> +
+

{c.laneBall}

+ + {c.lanePrivate} + {c.ballPrivateHint} + + {lane ? lane.shown : "—"} + {sources && {sources}} + {unspecced !== null && unspecced > 0 && ( + + {c.ballUnspecced}: {unspecced} + + )} + {lane?.browser && ( + + )} + onSearch(event.target.value)} + /> +
+ + {lane && lane.shown > 0 + ? lane.groups.map((group) => ( + +
+

{columnTitle[group.ball]}

+ {group.cards.length} +
+
+ {group.cards.slice(0, CARD_PAGE).map((card) => ( +
+ {card.source === "browser" ? ( + + ) : card.link ? ( + + {cardBody(card)} + + ) : ( + cardBody(card) + )} + {card.spec ? ( + + ) : ( + {c.ballNoSpec} + )} +
+ ))} + {group.cards.length > CARD_PAGE && ( + + +{group.cards.length - CARD_PAGE} + + )} + {group.cards.length === 0 && {c.empty}} +
+
+ )) + : null} + {note && {note}} +
); } @@ -3096,6 +3340,19 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) : null, [hive.showing, hive.board, hive.reason, clientsNarrow, clientsSearch, lang], ); + // The ALL lane: every open thing this person is part of -- CRM, mail, code + // work and their own AI-browser session -- by client, with whose move it is + // (lib/ballBoard.ts). Asked under the same gate as the clients lane, so a + // signed-out reader is sent nothing and drawn nothing. + const ball = useBallBoard(boardView === "kanban"); + const [ballSearch, setBallSearch] = useState(""); + const ballPanel = useMemo( + () => + ball.showing + ? { lane: ballLane(ball.board, ballSearch), board: ball.board, reason: ball.reason } + : null, + [ball.showing, ball.board, ball.reason, ballSearch], + ); const runningCards = useMemo( () => cards.filter((card) => card.column === "running"), [cards], @@ -3966,6 +4223,11 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) onNarrow={setClientsNarrow} search={clientsSearch} onSearch={setClientsSearch} + ball={ballPanel} + ballSearch={ballSearch} + onBallSearch={setBallSearch} + onOpenSpec={(path) => setView("specs", path)} + onOpenBrowser={() => setView("browser")} /> ) : boardView === "map" ? (