From aab1a6345f78bf50ed102d67c7b5b5e7392a1ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:23:34 -0500 Subject: [PATCH 01/10] Hub: an Overview of every project, to see at a glance that each is moving A tab after the box's Terminal and before the projects, and the hub's landing page when the address names nothing else. A card per project: - whether it is building, moving, waiting on the owner, stalled, idle, parked or not answering; what needs a look sorts first - its kit release (amber when behind the newest kit on the machine), its Inbox (a click opens it), workers mid-ticket, and when the fleet last wrote anything - tickets merged of planned, as a bar - what needs attention: the box alert, questions with no default holding up work, open work with nothing moving for 2 h, workers silent over an hour, open incidents, a drain - twelve weeks of the fleet's daily activity, GitHub-style The data is a pulse in each project's hello, computed from the log in one pass; the hub and the gateway pass it through as they did the rest. A dashboard on an older kit has no pulse, and its card says to update. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/dashboard/hub.html | 231 ++++++++++++++++++++++++++++--- docs/devteam/scripts/devteam.mjs | 57 +++++++- test/hub-overview.test.mjs | 47 +++++++ 3 files changed, 311 insertions(+), 24 deletions(-) create mode 100644 test/hub-overview.test.mjs diff --git a/docs/devteam/dashboard/hub.html b/docs/devteam/dashboard/hub.html index dc54c2ec..5dc06e7b 100644 --- a/docs/devteam/dashboard/hub.html +++ b/docs/devteam/dashboard/hub.html @@ -53,6 +53,52 @@ @media (max-width: 640px) { header { flex-wrap: wrap; } nav { order: 3; flex-basis: 100%; } } #drop-hint { position: fixed; left: 50%; top: 52px; transform: translateX(-50%); z-index: 10; padding: 8px 14px; border-radius: 6px; background: var(--fg); color: var(--bg); font-size: 13px; pointer-events: none; } #empty { margin: 48px auto; max-width: 520px; padding: 0 16px; color: var(--fg-2); text-align: center; } + /* the Overview: a card per project, read at a glance */ + #overview { position: absolute; inset: 0; overflow: auto; padding: 20px 16px 40px; } + #overview[hidden] { display: none; } + .ov-head { max-width: 1200px; margin: 0 auto 16px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 16px; } + .ov-head h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -.01em; } + .ov-head .sum { color: var(--fg-2); } + .ov-grid { max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; } + .ov-card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; box-shadow: var(--shadow); } + .ov-card.bad { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); } + .ov-card.warn { border-color: color-mix(in srgb, var(--amber) 55%, var(--line)); } + .ov-top { display: flex; align-items: center; gap: 8px; } + .ov-name { font: 600 15px/1.3 var(--sans); color: var(--fg); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; } + .ov-name:hover { text-decoration: underline; } + .pill { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; background: var(--well); color: var(--fg-2); } + .pill::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } + .pill.green { color: var(--green-fg); background: color-mix(in srgb, var(--green) 14%, transparent); } + .pill.blue { color: var(--blue-fg); background: color-mix(in srgb, var(--blue) 12%, transparent); } + .pill.amber { color: var(--amber-fg); background: color-mix(in srgb, var(--amber) 16%, transparent); } + .pill.red { color: var(--red-fg); background: color-mix(in srgb, var(--red) 12%, transparent); } + .ov-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } + .ov-stat { display: flex; flex-direction: column; min-width: 0; } + .ov-stat .k { color: var(--muted); font-size: 11px; } + .ov-stat .v { font: 500 14px/1.3 var(--sans); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ov-stat .v.red { color: var(--red-fg); } + .ov-stat .v.amber { color: var(--amber-fg); } + .ov-stat button.v { background: none; border: 0; padding: 0; text-align: left; cursor: pointer; font: inherit; font: 500 14px/1.3 var(--sans); } + .ov-bar { height: 6px; border-radius: 3px; background: var(--well); overflow: hidden; } + .ov-bar > span { display: block; height: 100%; background: var(--green); border-radius: 3px; } + .ov-prog { display: flex; flex-direction: column; gap: 4px; } + .ov-prog .k { color: var(--fg-2); font-size: 12px; } + .ov-att { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; } + .ov-att li { position: relative; padding-left: 14px; font-size: 12.5px; color: var(--fg); } + .ov-att li::before { content: ''; position: absolute; left: 2px; top: 7px; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); } + .ov-att li.red::before { background: var(--red); } + .ov-ok { font-size: 12.5px; color: var(--fg-2); } + .ov-mosaic { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 9px); grid-auto-columns: 9px; gap: 2px; } + .ov-mosaic i { border-radius: 2px; background: var(--well); } + .ov-mosaic i.l1 { background: color-mix(in srgb, var(--green) 30%, var(--well)); } + .ov-mosaic i.l2 { background: color-mix(in srgb, var(--green) 55%, var(--well)); } + .ov-mosaic i.l3 { background: color-mix(in srgb, var(--green) 78%, var(--well)); } + .ov-mosaic i.l4 { background: var(--green); } + .ov-mosaic i.pad { background: none; } + .ov-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; } + .ov-foot .k { color: var(--muted); font-size: 11px; } + .ov-none { color: var(--fg-2); font-size: 12.5px; } + @media (max-width: 640px) { .ov-stat .v { font-size: 13px; } #overview { padding: 14px 12px 32px; } .ov-grid { grid-template-columns: 1fr; } } @@ -61,7 +107,7 @@ -

Looking for devteam dashboards on this machine…

+

Looking for devteam dashboards on this machine…

diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 7c9fb36c..b327ed45 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -12906,7 +12906,7 @@ async function cmdDashboard() { }; refreshHello(); setInterval(refreshHello, 10000).unref(); - const helloBody = () => JSON.stringify({ devteam: 1, root: fs.realpathSync(mainRoot), name: loadSettings().project ?? path.basename(mainRoot), port, kit, viewer: 1, inbox: hello?.inbox ?? null, fleet: hello?.fleet ?? null, ...(hello?.alert ? { alert: hello.alert } : {}), previews: readPreviews().slice(0, PREVIEW_MAX).map((r) => previewHelloRow(r, previewUp.get(r.name) ?? null)) }); + const helloBody = () => JSON.stringify({ devteam: 1, root: fs.realpathSync(mainRoot), name: loadSettings().project ?? path.basename(mainRoot), port, kit, viewer: 1, inbox: hello?.inbox ?? null, fleet: hello?.fleet ?? null, ...(hello?.alert ? { alert: hello.alert } : {}), pulse: hello?.pulse ?? null, previews: readPreviews().slice(0, PREVIEW_MAX).map((r) => previewHelloRow(r, previewUp.get(r.name) ?? null)) }); // Previews: the tick's probes feed hello and the Library, one run at a time so a tmux that has // stalled never stacks them up. A file in run/previews that isn't a preview is named once. const previewsNamedBad = new Set(); @@ -16387,6 +16387,59 @@ function boxAlert() { } catch { return null; } } const helloAlert = () => [accountsState()?.supervisor_limit ? 'The supervisor stopped at a usage limit: type "continue" in its terminal' : null, boxAlert()].filter(Boolean).join(' · ') || null; +// The hub's Overview: is this project moving, and if not, why. From the log in one pass (no git +// beyond the merge facts the fold already keeps), so hello stays cheap at every 10 s. +// state: parked (every goal held), building (a worker mid-ticket and the fleet wrote in the last +// hour), waiting (nothing building and a question with no default holds work up), stalled (open +// work and nothing written by the fleet for 2 h), idle (no open work), moving (anything else: the +// supervisor planning, reviewing, merging). activity: the fleet's log lines per day, oldest first, +// the last 84 days ending today (UTC). +const PULSE_DAYS = 84; +function helloPulse() { + const now = Date.now(); + const lines = readLog().lines; + const day0 = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate()); + const activity = new Array(PULSE_DAYS).fill(0); + const lastBy = new Map(); + const phaseBy = new Map(); + let last = 0; + for (const l of lines) { + if (l.by === 'owner') continue; + const t = Date.parse(l.at); + if (!(t > 0)) continue; + if (t > last) last = t; + if (t > (lastBy.get(l.by) ?? 0)) lastBy.set(l.by, t); + if (l.type === 'phase' && (!phaseBy.has(l.by) || l.at >= phaseBy.get(l.by).at)) phaseBy.set(l.by, l); + const back = Math.floor((day0 - t) / 86400000) + 1; + if (t >= day0) activity[PULSE_DAYS - 1]++; + else if (back < PULSE_DAYS) activity[PULSE_DAYS - 1 - back]++; + } + const ts = [...tickets().values()].filter(isActive); + const done = ts.filter((t) => mergeShaFor(t.id)).length; + const open = ts.length - done; + const working = ts.filter((t) => !mergeShaFor(t.id) && phaseBy.has(t.id) && !['done', 'parked'].includes(phaseBy.get(t.id).to)); + const quietMin = last ? Math.round((now - last) / 60000) : null; + const silent = working.filter((t) => now - (lastBy.get(t.id) ?? 0) > 3600000); + const qs = questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go'); + const holding = qs.filter((q) => (q.blocks ?? []).length && q.default == null).length; + const mode = fleetMode() ?? 'run'; + const state = mode === 'park' ? 'parked' + : working.length > silent.length && quietMin !== null && quietMin < 60 ? 'building' + : holding ? 'waiting' + : open && (quietMin === null || quietMin >= 120) ? 'stalled' + : !open ? 'idle' + : 'moving'; + const ago = (m) => (m < 120 ? `${m} min` : m < 2880 ? `${Math.round(m / 60)} h` : `${Math.round(m / 1440)} days`); + const incidents = (() => { try { return incidentList().filter((p) => p.status !== 'resolved').length; } catch { return 0; } })(); + const attention = [ + holding ? `${holding} question${holding === 1 ? '' : 's'} with no default ${holding === 1 ? 'is' : 'are'} holding up work` : null, + state === 'stalled' ? (quietMin === null ? 'Open work, and the fleet has never written to the log' : `Open work, and nothing has moved in ${ago(quietMin)}`) : null, + silent.length ? `${silent.length} worker${silent.length === 1 ? ' has' : 's have'} been silent for over an hour` : null, + incidents ? `${incidents} open production incident${incidents === 1 ? '' : 's'}` : null, + mode === 'drain' ? 'Draining: workers finish what they have, nothing new starts' : null, + ].filter(Boolean); + return { state, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; +} function cmdHello() { const s = loadSettings(); // the Inbox as the dashboard counts it: open questions and the lessons sent to the owner to keep @@ -16394,7 +16447,7 @@ function cmdHello() { const workers = ourWindows(tmuxSession()).length; // --pane: whether the supervisor has a pane the kit can type into, for the dashboard's 10 s run of // this (its threads' "hasn't replied yet"), so the dashboard's own process never asks tmux itself - const r = { devteam: 1, root: fs.realpathSync(mainRoot), name: s.project ?? path.basename(mainRoot), kit: flags['no-kit'] ? null : kitVersion(), inbox, fleet: { mode: fleetMode() ?? 'run', workers }, ...(helloAlert() ? { alert: helloAlert() } : {}), ...(flags.pane ? { supervisor_pane: !resolvePane(supervisorTarget()).problem } : {}) }; + const r = { devteam: 1, root: fs.realpathSync(mainRoot), name: s.project ?? path.basename(mainRoot), kit: flags['no-kit'] ? null : kitVersion(), inbox, fleet: { mode: fleetMode() ?? 'run', workers }, ...(helloAlert() ? { alert: helloAlert() } : {}), pulse: helloPulse(), ...(flags.pane ? { supervisor_pane: !resolvePane(supervisorTarget()).problem } : {}) }; return flags.json ? json(r) : out(`${r.name} at ${r.root}: kit ${r.kit}, ${inbox} open question(s), fleet ${r.fleet.mode}, ${workers} session(s)`); } // The kit's release (settings.upstream_base), +local when the kit that is running differs from the diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs new file mode 100644 index 00000000..0844b7ec --- /dev/null +++ b/test/hub-overview.test.mjs @@ -0,0 +1,47 @@ +// The hub's Overview reads each project's hello: its pulse says whether the project is moving, how +// far along it is, what needs the owner, and what the fleet did each day for twelve weeks. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fleetFixture, json, line } from './lib/kit.mjs'; + +const SUP = { DEVTEAM_ROLE: 'supervisor' }; + +test('hello carries a pulse: idle, moving, waiting on the owner, building', () => { + const F = fleetFixture('hub-pulse'); + const { old, ok } = F; + const pulse = () => json(ok(old, ['hello', '--json', '--no-kit'])).pulse; + try { + let p = pulse(); + assert.equal(p.state, 'idle', 'nothing open'); + assert.equal(p.activity.length, 84, 'twelve weeks, a day each'); + assert.ok(p.activity.every((n) => Number.isInteger(n) && n >= 0)); + assert.deepEqual(p.attention, []); + + // a goal, a feature and a ticket: open work + const G = ok(old, ['goal', 'add', '--title', 'Billing', '--outcome', 'it works', '--priority', '1'], SUP); + ok(old, ['feature', 'add', 'bill', '--json', JSON.stringify({ title: 'bill', goals: [G], stories: [{ id: 'BILL-S1', text: 'it works', release: 'v1.0.0' }] })], SUP); + ok(old, ['ticket', 'add', 'app-01-bill', '--json', JSON.stringify({ title: 'Billing page', feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: [], touches: ['src/bill/**'], done_when: ['its test passes in CI'] })], SUP); + p = pulse(); + assert.equal(p.open_tickets, 1); + assert.equal(p.done_tickets, 0); + assert.equal(p.state, 'moving', 'open work, and the fleet wrote just now'); + assert.ok(p.activity.at(-1) >= 3, 'what the supervisor just wrote counts as today\'s activity'); + assert.ok(p.last_activity && Date.now() - Date.parse(p.last_activity) < 60000); + + // a question with no default holding the ticket up: waiting on the owner + ok(old, ['ask', '--question', 'Charge per seat or per team?', '--blocks', 'app-01-bill', '--recommendation', 'Per team'], SUP); + p = pulse(); + assert.equal(p.state, 'waiting'); + assert.equal(p.holding, 1); + assert.match(p.attention[0], /1 question with no default is holding up work/); + + // a worker mid-ticket, writing now: building, even with the question open + const ym = new Date().toISOString().slice(0, 7); + fs.appendFileSync(path.join(old, 'docs/devteam/run/log', 'app-01-bill.jsonl'), line({ v: 1, id: `h-${ym.replace('-', '')}01-000000000-app-01-bill`, type: 'phase', op: 'new', at: new Date().toISOString(), by: 'app-01-bill', from: null, to: 'build' })); + p = pulse(); + assert.equal(p.working, 1); + assert.equal(p.state, 'building'); + } finally { F.done(); } +}); From c1bd295fbc1fef9c565b1e877b43187f86ca610e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:35:19 -0500 Subject: [PATCH 02/10] Hub Overview: fully blocked and partly blocked, told apart A project is fully blocked when every open ticket waits on the owner: named in the --blocks of an open question with no default, or depending on one that is. Its card says "Fully blocked" in red and it sorts first, whatever else is running. Partly blocked is some of them: the card keeps what the project is doing (Building, Moving) and adds an amber "Partly blocked" beside it. Either way the progress bar shows the held tickets in amber after the merged ones, and the attention line says how many of how many wait on how many answers. The headline leads with fully blocked projects and counts partly blocked ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/dashboard/hub.html | 26 +++++++++++++++++++------- docs/devteam/scripts/devteam.mjs | 30 +++++++++++++++++++++++------- test/hub-overview.test.mjs | 31 ++++++++++++++++++++++++------- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/docs/devteam/dashboard/hub.html b/docs/devteam/dashboard/hub.html index 5dc06e7b..8fadab35 100644 --- a/docs/devteam/dashboard/hub.html +++ b/docs/devteam/dashboard/hub.html @@ -80,7 +80,10 @@ .ov-stat .v.amber { color: var(--amber-fg); } .ov-stat button.v { background: none; border: 0; padding: 0; text-align: left; cursor: pointer; font: inherit; font: 500 14px/1.3 var(--sans); } .ov-bar { height: 6px; border-radius: 3px; background: var(--well); overflow: hidden; } - .ov-bar > span { display: block; height: 100%; background: var(--green); border-radius: 3px; } + .ov-bar { display: flex; } + .ov-bar > span { display: block; height: 100%; background: var(--green); } + .ov-bar > span.held { background: var(--amber); } + .ov-pills { display: flex; gap: 6px; flex: 0 0 auto; } .ov-prog { display: flex; flex-direction: column; gap: 4px; } .ov-prog .k { color: var(--fg-2); font-size: 12px; } .ov-att { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; } @@ -209,6 +212,7 @@ const STATES = { building: { label: 'Building', tone: 'green', rank: 3 }, moving: { label: 'Moving', tone: 'blue', rank: 3 }, + blocked: { label: 'Fully blocked', tone: 'red', rank: 0 }, waiting: { label: 'Waiting on you', tone: 'amber', rank: 1 }, stalled: { label: 'Stalled', tone: 'red', rank: 0 }, idle: { label: 'Idle', tone: '', rank: 4 }, @@ -259,7 +263,11 @@ const name = el('button', 'ov-name', x.name); name.title = `Open ${x.name}'s dashboard\n${x.root}`; name.onclick = () => select(x.root); - top.append(name, el('span', `pill ${S.tone}`, S.label)); + const pills = el('span', 'ov-pills'); + // partly blocked is beside what the project is doing: it is still doing it + if (p?.blocking === 'partial' && st !== 'blocked') { const pb = el('span', 'pill amber', 'Partly blocked'); pb.title = `${p.blocked_tickets} of ${p.open_tickets} open tickets wait on your answers`; pills.append(pb); } + pills.append(el('span', `pill ${S.tone}`, S.label)); + top.append(name, pills); card.append(top); const stats = el('div', 'ov-stats'); @@ -279,11 +287,13 @@ if (p) { const total = p.open_tickets + p.done_tickets; const prog = el('div', 'ov-prog'); - prog.append(el('span', 'k', total ? `${p.done_tickets} of ${plural(total, 'ticket')} merged` : 'No tickets planned yet')); + const heldN = Math.max(0, Number(p.blocked_tickets) || 0); + prog.append(el('span', 'k', total ? `${p.done_tickets} of ${plural(total, 'ticket')} merged${heldN ? `, ${heldN} waiting on you` : ''}` : 'No tickets planned yet')); const bar = el('div', 'ov-bar'); const fill = el('span'); - fill.style.width = `${total ? Math.round((p.done_tickets / total) * 100) : 0}%`; + fill.style.width = `${total ? (p.done_tickets / total) * 100 : 0}%`; bar.append(fill); + if (heldN && total) { const h = el('span', 'held'); h.style.width = `${(heldN / total) * 100}%`; h.title = `${plural(heldN, 'ticket')} waiting on your answers`; bar.append(h); } bar.setAttribute('role', 'progressbar'); bar.setAttribute('aria-valuemin', '0'); bar.setAttribute('aria-valuemax', String(total)); @@ -292,7 +302,7 @@ card.append(prog); } - const notes = [...(x.alert ? [{ t: String(x.alert), red: true }] : []), ...(it.goneAt ? [{ t: 'Its dashboard stopped answering', red: true }] : []), ...((p?.attention ?? []).map((t) => ({ t: String(t), red: st === 'stalled' && /moved/.test(t) })))]; + const notes = [...(x.alert ? [{ t: String(x.alert), red: true }] : []), ...(it.goneAt ? [{ t: 'Its dashboard stopped answering', red: true }] : []), ...((p?.attention ?? []).map((t) => ({ t: String(t), red: (st === 'stalled' && /moved/.test(t)) || /^Fully blocked/.test(t) })))]; if (notes.length) { const ul = el('ul', 'ov-att'); for (const n of notes.slice(0, 5)) ul.append(el('li', n.red ? 'red' : '', n.t)); @@ -316,9 +326,11 @@ const count = (s_) => all.filter((it) => stateOf(it) === s_).length; const owed = all.reduce((n, it) => n + (typeof it.x.inbox === 'number' ? it.x.inbox : 0), 0); const stuck = count('stalled') + count('gone'); + const full = count('blocked') + count('waiting'); + const partly = all.filter((it) => it.x.pulse?.blocking === 'partial' && stateOf(it) !== 'blocked').length; const head = el('div', 'ov-head'); - head.append(el('h1', '', !all.length ? 'No projects running' : stuck ? `${plural(stuck, 'project')} ${stuck === 1 ? 'needs' : 'need'} a look` : count('waiting') ? `${plural(count('waiting'), 'project')} waiting on you` : 'Everything is moving')); - const parts = [plural(all.length, 'project'), count('building') + count('moving') ? `${count('building') + count('moving')} moving` : null, owed ? `${plural(owed, 'answer')} owed` : null].filter(Boolean); + head.append(el('h1', '', !all.length ? 'No projects running' : full ? `${plural(full, 'project')} fully blocked on you` : stuck ? `${plural(stuck, 'project')} ${stuck === 1 ? 'needs' : 'need'} a look` : 'Everything is moving')); + const parts = [plural(all.length, 'project'), count('building') + count('moving') ? `${count('building') + count('moving')} moving` : null, partly ? `${partly} partly blocked` : null, full && stuck ? `${stuck} stalled` : null, owed ? `${plural(owed, 'answer')} owed` : null].filter(Boolean); head.append(el('span', 'sum', parts.join(' · '))); const grid = el('div', 'ov-grid'); for (const it of all) grid.append(overviewCard(it, newest)); diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index b327ed45..d7f78d80 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -16389,9 +16389,12 @@ function boxAlert() { const helloAlert = () => [accountsState()?.supervisor_limit ? 'The supervisor stopped at a usage limit: type "continue" in its terminal' : null, boxAlert()].filter(Boolean).join(' · ') || null; // The hub's Overview: is this project moving, and if not, why. From the log in one pass (no git // beyond the merge facts the fold already keeps), so hello stays cheap at every 10 s. -// state: parked (every goal held), building (a worker mid-ticket and the fleet wrote in the last -// hour), waiting (nothing building and a question with no default holds work up), stalled (open -// work and nothing written by the fleet for 2 h), idle (no open work), moving (anything else: the +// Blocked on the owner, per open ticket: named in the --blocks of an open question with no default, +// or depending on one that is, however far down. blocking: 'full' when every +// open ticket is, 'partial' when some are and the rest can move. +// state: parked (every goal held), blocked (fully: nothing can move until the owner answers), +// building (a worker mid-ticket and the fleet wrote in the last hour), stalled (open work and +// nothing written by the fleet for 2 h), idle (no open work), moving (anything else: the // supervisor planning, reviewing, merging). activity: the fleet's log lines per day, oldest first, // the last 84 days ending today (UTC). const PULSE_DAYS = 84; @@ -16421,24 +16424,37 @@ function helloPulse() { const quietMin = last ? Math.round((now - last) / 60000) : null; const silent = working.filter((t) => now - (lastBy.get(t.id) ?? 0) > 3600000); const qs = questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go'); - const holding = qs.filter((q) => (q.blocks ?? []).length && q.default == null).length; + const holdingQs = qs.filter((q) => (q.blocks ?? []).length && q.default == null); + const holding = holdingQs.length; + const named = new Set(holdingQs.flatMap((q) => q.blocks)); + const openTs = ts.filter((t) => !mergeShaFor(t.id)); + const openIds = new Set(openTs.map((t) => t.id)); + const held = new Set(openTs.filter((t) => named.has(t.id)).map((t) => t.id)); + // and whatever depends on a held ticket, however far down + for (let grew = true; grew;) { + grew = false; + for (const t of openTs) if (!held.has(t.id) && (t.depends_on ?? []).some((d) => held.has(d) && openIds.has(d))) { held.add(t.id); grew = true; } + } + const blocking = !held.size ? null : held.size === openTs.length ? 'full' : 'partial'; const mode = fleetMode() ?? 'run'; const state = mode === 'park' ? 'parked' + : blocking === 'full' ? 'blocked' : working.length > silent.length && quietMin !== null && quietMin < 60 ? 'building' - : holding ? 'waiting' : open && (quietMin === null || quietMin >= 120) ? 'stalled' : !open ? 'idle' : 'moving'; const ago = (m) => (m < 120 ? `${m} min` : m < 2880 ? `${Math.round(m / 60)} h` : `${Math.round(m / 1440)} days`); const incidents = (() => { try { return incidentList().filter((p) => p.status !== 'resolved').length; } catch { return 0; } })(); const attention = [ - holding ? `${holding} question${holding === 1 ? '' : 's'} with no default ${holding === 1 ? 'is' : 'are'} holding up work` : null, + blocking === 'full' ? `Fully blocked: all ${open} open ticket${open === 1 ? '' : 's'} wait on ${holding === 1 ? 'your answer to 1 question' : `your answers to ${holding} questions`}` + : blocking === 'partial' ? `Partly blocked: ${held.size} of ${open} open tickets wait on ${holding === 1 ? 'your answer to 1 question' : `your answers to ${holding} questions`}; the rest can move` + : holding ? `${holding} question${holding === 1 ? '' : 's'} with no default ${holding === 1 ? 'is' : 'are'} holding up work not planned into tickets yet` : null, state === 'stalled' ? (quietMin === null ? 'Open work, and the fleet has never written to the log' : `Open work, and nothing has moved in ${ago(quietMin)}`) : null, silent.length ? `${silent.length} worker${silent.length === 1 ? ' has' : 's have'} been silent for over an hour` : null, incidents ? `${incidents} open production incident${incidents === 1 ? '' : 's'}` : null, mode === 'drain' ? 'Draining: workers finish what they have, nothing new starts' : null, ].filter(Boolean); - return { state, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; + return { state, blocking, blocked_tickets: held.size, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; } function cmdHello() { const s = loadSettings(); diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 0844b7ec..45bf3cc2 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -8,7 +8,7 @@ import { fleetFixture, json, line } from './lib/kit.mjs'; const SUP = { DEVTEAM_ROLE: 'supervisor' }; -test('hello carries a pulse: idle, moving, waiting on the owner, building', () => { +test('hello carries a pulse: idle, moving, fully and partly blocked, building', () => { const F = fleetFixture('hub-pulse'); const { old, ok } = F; const pulse = () => json(ok(old, ['hello', '--json', '--no-kit'])).pulse; @@ -30,18 +30,35 @@ test('hello carries a pulse: idle, moving, waiting on the owner, building', () = assert.ok(p.activity.at(-1) >= 3, 'what the supervisor just wrote counts as today\'s activity'); assert.ok(p.last_activity && Date.now() - Date.parse(p.last_activity) < 60000); - // a question with no default holding the ticket up: waiting on the owner + // a question with no default holding up the only ticket: fully blocked ok(old, ['ask', '--question', 'Charge per seat or per team?', '--blocks', 'app-01-bill', '--recommendation', 'Per team'], SUP); p = pulse(); - assert.equal(p.state, 'waiting'); - assert.equal(p.holding, 1); - assert.match(p.attention[0], /1 question with no default is holding up work/); + assert.equal(p.state, 'blocked'); + assert.equal(p.blocking, 'full'); + assert.equal(p.blocked_tickets, 1); + assert.match(p.attention[0], /^Fully blocked: all 1 open ticket wait on your answer to 1 question/); - // a worker mid-ticket, writing now: building, even with the question open + // a ticket that depends on it waits too; one that doesn't can move: partly blocked + const add = (id, deps) => ok(old, ['ticket', 'add', id, '--json', JSON.stringify({ title: id, feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: deps, touches: [`src/${id}/**`], done_when: ['its test passes in CI'] })], SUP); + add('app-02-invoices', ['app-01-bill']); + p = pulse(); + assert.equal(p.blocking, 'full', 'what depends on a held ticket is held too'); + assert.equal(p.blocked_tickets, 2); + add('app-03-receipts', []); + p = pulse(); + assert.equal(p.blocking, 'partial'); + assert.equal(p.blocked_tickets, 2); + assert.equal(p.open_tickets, 3); + assert.notEqual(p.state, 'blocked'); + assert.match(p.attention[0], /^Partly blocked: 2 of 3 open tickets wait on your answer to 1 question; the rest can move/); + + // a worker mid-ticket, writing now: building, and still partly blocked const ym = new Date().toISOString().slice(0, 7); - fs.appendFileSync(path.join(old, 'docs/devteam/run/log', 'app-01-bill.jsonl'), line({ v: 1, id: `h-${ym.replace('-', '')}01-000000000-app-01-bill`, type: 'phase', op: 'new', at: new Date().toISOString(), by: 'app-01-bill', from: null, to: 'build' })); + fs.appendFileSync(path.join(old, 'docs/devteam/run/log', 'app-03-receipts.jsonl'), line({ v: 1, id: `h-${ym.replace('-', '')}01-000000000-app-03-receipts`, type: 'phase', op: 'new', at: new Date().toISOString(), by: 'app-03-receipts', from: null, to: 'build' })); p = pulse(); assert.equal(p.working, 1); assert.equal(p.state, 'building'); + assert.equal(p.blocking, 'partial'); + } finally { F.done(); } }); From 0665a3bfab813f43ed2f28cf38fd1ba78ba4afbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:45:02 -0500 Subject: [PATCH 03/10] Hub Overview: the goal it is working on, and Run, Drain and Park for every project - Each card names the approved goal first in priority, with its tickets merged of planned and how many other goals are open. A project with no approved goal says so. - Each card has Run, Drain and Park (Park asks to confirm), and the page has Park all (confirmed) and Resume all while any project is held. - The hub still posts no owner action. It asks the project's own dashboard, in its frame, by message; the dashboard posts the fleet action to its own origin, as a click there would. The dashboard takes the message only from its parent, and only when the parent is another origin, which frame-ancestors limits to the hub. Three ops, each once per nonce, and the answer goes back to that parent alone. The log's reason says it came from the hub's Overview. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/dashboard/hub.html | 122 +++++++++++++++++++++++++++++- docs/devteam/dashboard/index.html | 25 ++++++ docs/devteam/scripts/devteam.mjs | 7 +- test/hub-overview.test.mjs | 7 ++ 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/docs/devteam/dashboard/hub.html b/docs/devteam/dashboard/hub.html index 8fadab35..4b9930f7 100644 --- a/docs/devteam/dashboard/hub.html +++ b/docs/devteam/dashboard/hub.html @@ -84,6 +84,25 @@ .ov-bar > span { display: block; height: 100%; background: var(--green); } .ov-bar > span.held { background: var(--amber); } .ov-pills { display: flex; gap: 6px; flex: 0 0 auto; } + .ov-goal { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; min-width: 0; } + .ov-goal .k { color: var(--muted); font-size: 11px; flex: 0 0 auto; } + .ov-goal .t { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .ov-goal .n { color: var(--fg-2); flex: 0 0 auto; font-variant-numeric: tabular-nums; } + .seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 7px; overflow: hidden; flex: 0 0 auto; } + .seg button { font: 500 12px/1 var(--sans); color: var(--fg-2); background: none; border: 0; padding: 6px 10px; cursor: pointer; } + .seg button + button { border-left: 1px solid var(--line-2); } + .seg button:hover:not(:disabled) { background: var(--hover); color: var(--fg); } + .seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); } + .seg button.confirm { background: var(--red); color: #fff; } + .seg button:disabled { cursor: default; opacity: .6; } + .seg button[aria-pressed="true"]:disabled { opacity: 1; } + .ov-ctl { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } + .ov-ctl .msg { font-size: 12px; color: var(--fg-2); } + .ov-ctl .msg.err { color: var(--red-fg); } + .ov-head .acts { margin-left: auto; display: flex; gap: 8px; } + .ov-head .acts button { font: 500 12.5px/1 var(--sans); padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line-2); background: var(--surface); color: var(--fg); cursor: pointer; } + .ov-head .acts button:hover { background: var(--hover); } + .ov-head .acts button.confirm { background: var(--red); border-color: var(--red); color: #fff; } .ov-prog { display: flex; flex-direction: column; gap: 4px; } .ov-prog .k { color: var(--fg-2); font-size: 12px; } .ov-att { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; } @@ -193,18 +212,67 @@ if (!it) return; selected = root; history.replaceState(null, '', `#${encodeURIComponent(keyOf(it))}`); + frameFor(it); + showOnly(root); +} +// A project's dashboard in its frame, made once and kept; hidden until its tab is chosen. The +// Overview's fleet controls use it too: the dashboard does the owner action, on its own origin. +function frameFor(it) { if (!it.frame) { it.frame = document.createElement('iframe'); it.frame.title = String(it.x.name); + it.frame.hidden = selected !== it.x.root; // A dashboard runs scripts, fetches its own origin, posts forms and opens links in new tabs; it // never navigates this page, whatever answers in the block. A nested frame can't gain what this // one lacks, so modals, pointer lock and fullscreen are here for the dashboard's artifact viewer. it.frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-pointer-lock'); it.frame.setAttribute('allow', 'clipboard-write; fullscreen'); + it.loaded = new Promise((resolve) => it.frame.addEventListener('load', resolve, { once: true })); it.frame.src = urlOf(it.x); framesEl.append(it.frame); } - showOnly(root); + return it.frame; +} +// ----- Run, Drain and Park from the Overview ------------------------------------------------------ +// The hub can't post an owner action: it asks the project's own dashboard, in its frame, which +// posts it as a click there would (index.html, "The hub's Overview"). The answer comes back by +// message from that frame alone; nothing else is listened to. +const fleetWait = new Map(); // nonce -> { resolve, origin, win } +const fleetBusy = new Map(); // root -> what is being asked, or the last error +window.addEventListener('message', (ev) => { + const m = ev.data; + if (!m || typeof m !== 'object' || m.devteam !== 'hub-fleet-done' || typeof m.nonce !== 'string') return; + const w = fleetWait.get(m.nonce); + if (!w || ev.source !== w.win || ev.origin !== w.origin) return; + fleetWait.delete(m.nonce); + w.resolve({ ok: m.ok === true, error: typeof m.error === 'string' ? m.error : null }); +}); +async function fleetAsk(it, mode, all = false) { + const frame = frameFor(it); + await Promise.race([it.loaded, new Promise((r) => setTimeout(r, 15000))]); + const origin = new URL(urlOf(it.x)).origin; + const nonce = Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(36).padStart(2, '0')).join('').slice(0, 24); + const answer = new Promise((resolve) => fleetWait.set(nonce, { resolve, origin, win: frame.contentWindow })); + // asked again until it answers (a dashboard still starting its script); it does it once per nonce + const send = () => { try { frame.contentWindow.postMessage({ devteam: 'hub-fleet', mode, nonce, all }, origin); } catch {} }; + send(); + const again = setInterval(send, 1500); + const r = await Promise.race([answer, new Promise((r_) => setTimeout(() => r_({ ok: false, error: 'its dashboard didn\'t answer (an older kit can\'t be parked from here)' }), 20000))]); + clearInterval(again); + fleetWait.delete(nonce); + return r; +} +async function setFleet(roots, mode) { + const words = { park: 'Parking…', drain: 'Draining…', run: 'Resuming…' }; + for (const r of roots) fleetBusy.set(r, { asking: words[mode] }); + paintOverview(); + await Promise.all(roots.map(async (r) => { + const it = items.get(r); + const res = it ? await fleetAsk(it, mode, roots.length > 1) : { ok: false, error: 'gone' }; + if (res.ok) fleetBusy.delete(r); else fleetBusy.set(r, { error: res.error ?? 'it didn\'t work' }); + })); + await draw(); + paintOverview(); } // ----- the Overview: every project on one page, to see at a glance that each is moving ---------- // Each card is that project's own hello (its pulse), as text. A dashboard on an older kit sends no @@ -253,6 +321,36 @@ box.setAttribute('aria-label', `Activity over the last ${Math.round(n / 7)} weeks: ${active} active days`); return box; } +// A press that needs a second press to confirm (Park, Park all): remembered across redraws, 5 s. +const confirming = new Map(); // key -> until +const confirmed = (key) => { const until = confirming.get(key) ?? 0; if (until > Date.now()) { confirming.delete(key); return true; } confirming.set(key, Date.now() + 5000); setTimeout(paintOverview, 5100); paintOverview(); return false; }; +const isConfirming = (key) => (confirming.get(key) ?? 0) > Date.now(); +const modeOf = (it) => (['park', 'drain'].includes(it.x.fleet?.mode) ? it.x.fleet.mode : 'run'); +// what the Overview can steer: a dashboard answering, on a kit that sends a pulse +const steerable = (it) => !it.goneAt && Boolean(it.x.pulse); +function fleetControl(it) { + const root = it.x.root; + const ctl = el('div', 'ov-ctl'); + const seg = el('div', 'seg'); + seg.setAttribute('role', 'group'); + seg.setAttribute('aria-label', `${it.x.name}: run, drain or park`); + const busy = fleetBusy.get(root); + const now = modeOf(it); + const opts = [['run', 'Run', 'Workers start and continue tickets'], ['drain', 'Drain', 'Workers finish what they have; nothing new starts'], ['park', 'Park', 'Every session stops where it is']]; + for (const [mode, label, why] of opts) { + const key = `${root}:${mode}`; + const b = el('button', isConfirming(key) ? 'confirm' : '', isConfirming(key) ? 'Confirm park' : label); + b.title = why; + b.setAttribute('aria-pressed', String(now === mode)); + b.disabled = Boolean(busy?.asking) || now === mode; + b.onclick = () => { if (mode === 'park' && !confirmed(key)) return; setFleet([root], mode); }; + seg.append(b); + } + ctl.append(seg); + if (busy?.asking) ctl.append(el('span', 'msg', busy.asking)); + else if (busy?.error) ctl.append(el('span', 'msg err', busy.error)); + return ctl; +} function overviewCard(it, newest) { const { x } = it; const p = x.pulse ?? null; @@ -284,6 +382,14 @@ stat('Last moved', p ? agoText(p.last_activity) : '–'); card.append(stats); + if (p?.goal) { + const g = el('div', 'ov-goal'); + const more = (Number(p.goals_open) || 1) - 1; + g.append(el('span', 'k', 'Goal'), el('span', 't', p.goal.title), el('span', 'n', `${p.goal.total ? `${p.goal.done} of ${p.goal.total}` : 'not planned yet'}${more > 0 ? ` · +${more} more` : ''}`)); + g.title = `${p.goal.title}\nthe approved goal first in priority${p.goal.mode && p.goal.mode !== 'run' ? ` (${p.goal.mode === 'park' ? 'parked' : 'draining'})` : ''}`; + card.append(g); + } else if (p && p.goals_open === 0 && st !== 'idle') { const g = el('div', 'ov-goal'); g.append(el('span', 'k', 'Goal'), el('span', 'n', 'none approved yet')); card.append(g); } + if (p) { const total = p.open_tickets + p.done_tickets; const prog = el('div', 'ov-prog'); @@ -315,6 +421,7 @@ foot.append(mosaic(p.activity.map((n) => Math.max(0, Number(n) || 0))), el('span', 'k', `${Math.round(p.activity.length / 7)} weeks`)); card.append(foot); } + if (steerable(it)) card.append(fleetControl(it)); return card; } function paintOverview() { @@ -329,9 +436,18 @@ const full = count('blocked') + count('waiting'); const partly = all.filter((it) => it.x.pulse?.blocking === 'partial' && stateOf(it) !== 'blocked').length; const head = el('div', 'ov-head'); - head.append(el('h1', '', !all.length ? 'No projects running' : full ? `${plural(full, 'project')} fully blocked on you` : stuck ? `${plural(stuck, 'project')} ${stuck === 1 ? 'needs' : 'need'} a look` : 'Everything is moving')); - const parts = [plural(all.length, 'project'), count('building') + count('moving') ? `${count('building') + count('moving')} moving` : null, partly ? `${partly} partly blocked` : null, full && stuck ? `${stuck} stalled` : null, owed ? `${plural(owed, 'answer')} owed` : null].filter(Boolean); + head.append(el('h1', '', !all.length ? 'No projects running' : full ? `${plural(full, 'project')} fully blocked on you` : stuck ? `${plural(stuck, 'project')} ${stuck === 1 ? 'needs' : 'need'} a look` : all.every((it) => ['parked', 'idle'].includes(stateOf(it))) ? (count('parked') ? 'Everything is parked or idle' : 'Everything is idle') : 'Everything is moving')); + const parts = [plural(all.length, 'project'), count('building') + count('moving') ? `${count('building') + count('moving')} moving` : null, partly ? `${partly} partly blocked` : null, full && stuck ? `${stuck} stalled` : null, count('parked') ? `${count('parked')} parked` : null, owed ? `${plural(owed, 'answer')} owed` : null].filter(Boolean); head.append(el('span', 'sum', parts.join(' · '))); + // every fleet at once: Park all (confirmed), and Resume all while any is held + const can = all.filter(steerable); + const acts = el('div', 'acts'); + const held_ = can.filter((it) => modeOf(it) !== 'run'); + const running = can.filter((it) => modeOf(it) !== 'park'); + const anyBusy = can.some((it) => fleetBusy.get(it.x.root)?.asking); + if (held_.length) { const b = el('button', '', `Resume all (${held_.length})`); b.title = 'Undo every park and drain'; b.disabled = anyBusy; b.onclick = () => setFleet(held_.map((it) => it.x.root), 'run'); acts.append(b); } + if (running.length) { const b = el('button', isConfirming('all:park') ? 'confirm' : '', isConfirming('all:park') ? `Confirm: park ${plural(running.length, 'project')}` : 'Park all'); b.title = 'Stop every session in every project where it is'; b.disabled = anyBusy; b.onclick = () => { if (confirmed('all:park')) setFleet(running.map((it) => it.x.root), 'park'); }; acts.append(b); } + if (acts.childElementCount) head.append(acts); const grid = el('div', 'ov-grid'); for (const it of all) grid.append(overviewCard(it, newest)); ovEl.replaceChildren(head, ...(all.length ? [grid] : [el('p', 'ov-none', 'Start a dashboard in a devteam project and it shows up here.')])); diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index d7f9978c..0b2796e6 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -5593,6 +5593,31 @@

${k.plain ? esc(k.plain) : linkify(k.statement)}

}); if (VIEWER) viewerBoot(); else { loadSchemaDocs(); load(); setInterval(load, 10000); } +// The hub's Overview: Run, Drain and Park for this project, from the hub page that frames this one. +// The hub never posts an owner action itself (it only lists); it asks this page, and this page +// posts it to its own origin as a click here would. Who asks: only this window's parent, and only +// when the parent is not this origin. frame-ancestors lets only this origin and the hub frame this +// page, so a parent on another origin is the hub. Three ops, each once per nonce; the answer goes +// back to that parent alone. +{ + const HUB_OPS = { park: 'park', drain: 'drain', run: 'resume' }; + const seen = new Set(); + window.addEventListener('message', async (ev) => { + if (window.parent === window || ev.source !== window.parent || ev.origin === location.origin || ev.origin === 'null') return; + const m = ev.data; + if (!m || typeof m !== 'object' || m.devteam !== 'hub-fleet' || !Object.hasOwn(HUB_OPS, m.mode) || typeof m.nonce !== 'string' || !/^[a-z0-9]{8,40}$/.test(m.nonce)) return; + const reply = (o) => { try { ev.source.postMessage({ devteam: 'hub-fleet-done', nonce: m.nonce, ...o }, ev.origin); } catch {} }; + if (seen.has(m.nonce)) return; + seen.add(m.nonce); + try { + const r = await fetch('/api/fleet', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op: HUB_OPS[m.mode], ...(m.mode === 'run' ? {} : { reason: m.all ? `${m.mode === 'park' ? 'parked' : 'drained'} with every project, from the hub's Overview` : `${m.mode === 'park' ? 'parked' : 'drained'} from the hub's Overview` }) }) }); + const j = await r.json().catch(() => ({})); + // "nothing to resume" is the fleet already running: what was asked for holds + if (r.ok || (m.mode === 'run' && /nothing to resume/.test(String(j.error ?? '')))) { reply({ ok: true }); if (typeof loadFleet === 'function') loadFleet(); } + else reply({ ok: false, error: String(j.error ?? `the dashboard answered ${r.status}`).slice(0, 300) }); + } catch (e) { reply({ ok: false, error: String(e?.message ?? e).slice(0, 300) }); } + }); +} diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index d7f78d80..8fdf2a0f 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -16454,7 +16454,12 @@ function helloPulse() { incidents ? `${incidents} open production incident${incidents === 1 ? '' : 's'}` : null, mode === 'drain' ? 'Draining: workers finish what they have, nothing new starts' : null, ].filter(Boolean); - return { state, blocking, blocked_tickets: held.size, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; + // what it is working on: the approved goal first in priority, and its tickets merged of planned + const goals = logObjects('goal').filter((g) => g.status === 'ready').sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity)); + const top = goals[0] ?? null; + const topTs = top ? ts.filter((t) => ticketGoals(t).some((g) => g.id === top.id)) : []; + const goal = top ? { title: String(top.title ?? top.id).slice(0, 120), done: topTs.filter((t) => mergeShaFor(t.id)).length, total: topTs.length, mode: goalMode(top) } : null; + return { state, blocking, blocked_tickets: held.size, goal, goals_open: goals.length, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; } function cmdHello() { const s = loadSettings(); diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 45bf3cc2..0ca7df73 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -24,6 +24,8 @@ test('hello carries a pulse: idle, moving, fully and partly blocked, building', ok(old, ['feature', 'add', 'bill', '--json', JSON.stringify({ title: 'bill', goals: [G], stories: [{ id: 'BILL-S1', text: 'it works', release: 'v1.0.0' }] })], SUP); ok(old, ['ticket', 'add', 'app-01-bill', '--json', JSON.stringify({ title: 'Billing page', feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: [], touches: ['src/bill/**'], done_when: ['its test passes in CI'] })], SUP); p = pulse(); + assert.equal(p.goal, null, 'a draft goal is not what it is working on'); + assert.equal(p.goals_open, 0); assert.equal(p.open_tickets, 1); assert.equal(p.done_tickets, 0); assert.equal(p.state, 'moving', 'open work, and the fleet wrote just now'); @@ -60,5 +62,10 @@ test('hello carries a pulse: idle, moving, fully and partly blocked, building', assert.equal(p.state, 'building'); assert.equal(p.blocking, 'partial'); + + // the owner approves the goal: the card names it, with its own tickets merged of planned + fs.appendFileSync(path.join(old, 'docs/devteam/run/log', `owner-${ym}.jsonl`), line({ id: G, type: 'goal', op: 'set', status: 'ready', at: new Date().toISOString(), by: 'owner', channel: 'cli' })); + p = pulse(); + assert.deepEqual([p.goal?.title, p.goal?.done, p.goal?.total, p.goals_open], ['Billing', 0, 3, 1]); } finally { F.done(); } }); From 9af633a3725ac31cdae4b771ce21be722a4968f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:48:29 -0500 Subject: [PATCH 04/10] hello: every ref in one git call, not two per open ticket The pulse asks whether each ticket has merged. Without the read cache that was two rev-parse spawns per unmerged ticket: 2.2 s for 200 open tickets, every 10 s, per project. With it, 0.18 s (0.20 s at 1000). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/scripts/devteam.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 8fdf2a0f..c95a9955 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -16387,8 +16387,9 @@ function boxAlert() { } catch { return null; } } const helloAlert = () => [accountsState()?.supervisor_limit ? 'The supervisor stopped at a usage limit: type "continue" in its terminal' : null, boxAlert()].filter(Boolean).join(' · ') || null; -// The hub's Overview: is this project moving, and if not, why. From the log in one pass (no git -// beyond the merge facts the fold already keeps), so hello stays cheap at every 10 s. +// The hub's Overview: is this project moving, and if not, why. From the log in one pass; from git, +// only whether each ticket has merged (main's log and the refs, one call each under cmdHello's +// read cache, never one per ticket), so hello stays cheap at every 10 s. // Blocked on the owner, per open ticket: named in the --blocks of an open question with no default, // or depending on one that is, however far down. blocking: 'full' when every // open ticket is, 'partial' when some are and the rest can move. @@ -16462,6 +16463,8 @@ function helloPulse() { return { state, blocking, blocked_tickets: held.size, goal, goals_open: goals.length, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; } function cmdHello() { + // read-only, and the pulse asks whether each open ticket has merged: every ref in one git call + gitReadCache = new Map(); const s = loadSettings(); // the Inbox as the dashboard counts it: open questions and the lessons sent to the owner to keep const inbox = questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go').length + logObjects('knowledge').filter(forOwner).length; From 0b99745720a26e226d0f3d81f04ff38df2216213 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:50:04 -0500 Subject: [PATCH 05/10] Hub Overview pulse: an approved goal with nothing planned is not idle; a parked goal's tickets are not stalled Two wrong reads, both reproduced: - an approved goal with no tickets yet read "idle" (the card said "Waiting for a new goal"), however long nobody had planned it. It is open work: moving, then stalled. - one goal parked by the owner (Upkeep still running, so the fleet is not parked) read "moving", then a red "stalled" after 2 h. Its tickets wait by choice: they no longer count as work that could move, nor as what a question blocks; the card says they are on hold. Also: the goal named is a running one before a parked one; a question that holds no ticket (an incident, unplanned work) is counted apart from the ones that do; the hub's "Waiting on you" state, which nothing sent, is now sent for an unplanned goal held by a question with no default. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/scripts/devteam.mjs | 57 +++++++++++++++++++++----------- test/hub-overview.test.mjs | 38 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index c95a9955..3613f030 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -16390,13 +16390,14 @@ const helloAlert = () => [accountsState()?.supervisor_limit ? 'The supervisor st // The hub's Overview: is this project moving, and if not, why. From the log in one pass; from git, // only whether each ticket has merged (main's log and the refs, one call each under cmdHello's // read cache, never one per ticket), so hello stays cheap at every 10 s. -// Blocked on the owner, per open ticket: named in the --blocks of an open question with no default, -// or depending on one that is, however far down. blocking: 'full' when every -// open ticket is, 'partial' when some are and the rest can move. +// Blocked on the owner, per open ticket that could move (its goal not parked or let go): named in +// the --blocks of an open question with no default, or depending on one that is, however far down. +// blocking: 'full' when every such ticket is, 'partial' when some are and the rest can move. // state: parked (every goal held), blocked (fully: nothing can move until the owner answers), +// waiting (an approved goal with nothing planned yet, and a question with no default open on it), // building (a worker mid-ticket and the fleet wrote in the last hour), stalled (open work and -// nothing written by the fleet for 2 h), idle (no open work), moving (anything else: the -// supervisor planning, reviewing, merging). activity: the fleet's log lines per day, oldest first, +// nothing written by the fleet for 2 h), idle (no open work: no ticket that could move, no +// approved goal still to plan), moving (anything else: the supervisor planning, reviewing, merging). activity: the fleet's log lines per day, oldest first, // the last 84 days ending today (UTC). const PULSE_DAYS = 84; function helloPulse() { @@ -16426,41 +16427,57 @@ function helloPulse() { const silent = working.filter((t) => now - (lastBy.get(t.id) ?? 0) > 3600000); const qs = questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go'); const holdingQs = qs.filter((q) => (q.blocks ?? []).length && q.default == null); - const holding = holdingQs.length; const named = new Set(holdingQs.flatMap((q) => q.blocks)); const openTs = ts.filter((t) => !mergeShaFor(t.id)); - const openIds = new Set(openTs.map((t) => t.id)); - const held = new Set(openTs.filter((t) => named.has(t.id)).map((t) => t.id)); + // what could move: a ticket whose goal the owner parked or let go waits by choice, so it is + // neither stalled nor blocked, and it isn't what a question holds up + const live = openTs.filter((t) => !['park', 'let-go'].includes(ticketMode(t))); + const liveIds = new Set(live.map((t) => t.id)); + const held = new Set(live.filter((t) => named.has(t.id)).map((t) => t.id)); // and whatever depends on a held ticket, however far down for (let grew = true; grew;) { grew = false; - for (const t of openTs) if (!held.has(t.id) && (t.depends_on ?? []).some((d) => held.has(d) && openIds.has(d))) { held.add(t.id); grew = true; } + for (const t of live) if (!held.has(t.id) && (t.depends_on ?? []).some((d) => held.has(d))) { held.add(t.id); grew = true; } } - const blocking = !held.size ? null : held.size === openTs.length ? 'full' : 'partial'; + // the questions that hold tickets, and the ones that hold something else (an incident, work not + // planned yet) + const holding = holdingQs.filter((q) => q.blocks.some((b) => liveIds.has(b))).length; + const holdingElse = holdingQs.length - holding; + const blocking = !held.size ? null : held.size === live.length ? 'full' : 'partial'; const mode = fleetMode() ?? 'run'; + // what it is working on: the approved goal first in priority (one that is running before one on + // hold), and its tickets merged of planned + const goals = logObjects('goal').filter((g) => g.status === 'ready').sort((a, b) => (goalMode(a) === 'park') - (goalMode(b) === 'park') || (a.priority ?? Infinity) - (b.priority ?? Infinity)); + const goalTs = (g) => ts.filter((t) => ticketGoals(t).some((x) => x.id === g.id)); + // an approved goal, running, with no ticket yet: planning is owed, so the project is not idle + const unplanned = goals.filter((g) => goalMode(g) !== 'park' && !goalTs(g).length).length; + const work = live.length + unplanned; const state = mode === 'park' ? 'parked' : blocking === 'full' ? 'blocked' + : !live.length && unplanned && holdingElse ? 'waiting' : working.length > silent.length && quietMin !== null && quietMin < 60 ? 'building' - : open && (quietMin === null || quietMin >= 120) ? 'stalled' - : !open ? 'idle' + : work && (quietMin === null || quietMin >= 120) ? 'stalled' + : !work ? 'idle' : 'moving'; const ago = (m) => (m < 120 ? `${m} min` : m < 2880 ? `${Math.round(m / 60)} h` : `${Math.round(m / 1440)} days`); const incidents = (() => { try { return incidentList().filter((p) => p.status !== 'resolved').length; } catch { return 0; } })(); + const answers = (n) => (n === 1 ? 'your answer to 1 question' : `your answers to ${n} questions`); + const onHold = openTs.length - live.length; const attention = [ - blocking === 'full' ? `Fully blocked: all ${open} open ticket${open === 1 ? '' : 's'} wait on ${holding === 1 ? 'your answer to 1 question' : `your answers to ${holding} questions`}` - : blocking === 'partial' ? `Partly blocked: ${held.size} of ${open} open tickets wait on ${holding === 1 ? 'your answer to 1 question' : `your answers to ${holding} questions`}; the rest can move` - : holding ? `${holding} question${holding === 1 ? '' : 's'} with no default ${holding === 1 ? 'is' : 'are'} holding up work not planned into tickets yet` : null, - state === 'stalled' ? (quietMin === null ? 'Open work, and the fleet has never written to the log' : `Open work, and nothing has moved in ${ago(quietMin)}`) : null, + blocking === 'full' ? `Fully blocked: all ${live.length} open ticket${live.length === 1 ? '' : 's'} wait on ${answers(holding)}` + : blocking === 'partial' ? `Partly blocked: ${held.size} of ${live.length} open tickets wait on ${answers(holding)}; the rest can move` + : null, + holdingElse ? `${holdingElse} question${holdingElse === 1 ? '' : 's'} with no default ${holdingElse === 1 ? 'is' : 'are'} holding up work that is not an open ticket` : null, + state === 'stalled' ? (quietMin === null ? 'Open work, and the fleet has never written to the log' : `${live.length ? 'Open work' : 'An approved goal with no tickets'}, and nothing has moved in ${ago(quietMin)}`) : null, silent.length ? `${silent.length} worker${silent.length === 1 ? ' has' : 's have'} been silent for over an hour` : null, incidents ? `${incidents} open production incident${incidents === 1 ? '' : 's'}` : null, mode === 'drain' ? 'Draining: workers finish what they have, nothing new starts' : null, + onHold && mode !== 'park' ? `${onHold} open ticket${onHold === 1 ? ' is' : 's are'} on hold with ${onHold === 1 ? 'its' : 'their'} goal` : null, ].filter(Boolean); - // what it is working on: the approved goal first in priority, and its tickets merged of planned - const goals = logObjects('goal').filter((g) => g.status === 'ready').sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity)); const top = goals[0] ?? null; - const topTs = top ? ts.filter((t) => ticketGoals(t).some((g) => g.id === top.id)) : []; + const topTs = top ? goalTs(top) : []; const goal = top ? { title: String(top.title ?? top.id).slice(0, 120), done: topTs.filter((t) => mergeShaFor(t.id)).length, total: topTs.length, mode: goalMode(top) } : null; - return { state, blocking, blocked_tickets: held.size, goal, goals_open: goals.length, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding, attention, activity }; + return { state, blocking, blocked_tickets: held.size, goal, goals_open: goals.length, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding: holdingQs.length, attention, activity }; } function cmdHello() { // read-only, and the pulse asks whether each open ticket has merged: every ref in one git call diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 0ca7df73..9680bbc5 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -69,3 +69,41 @@ test('hello carries a pulse: idle, moving, fully and partly blocked, building', assert.deepEqual([p.goal?.title, p.goal?.done, p.goal?.total, p.goals_open], ['Billing', 0, 3, 1]); } finally { F.done(); } }); + +test('the pulse: an approved goal with nothing planned is not idle; a goal the owner parked is neither moving nor stalled', () => { + const F = fleetFixture('hub-pulse-goals'); + const { old, ok } = F; + const pulse = () => json(ok(old, ['hello', '--json', '--no-kit'])).pulse; + const dir = path.join(old, 'docs/devteam/run/log'); + // every line written so far, as if written that long ago + const age = (ms) => { for (const f of fs.readdirSync(dir)) fs.writeFileSync(path.join(dir, f), fs.readFileSync(path.join(dir, f), 'utf8').replace(/"at":"([^"]+)"/g, (all, t) => `"at":"${new Date(Date.parse(t) - ms).toISOString()}"`)); }; + try { + const G = ok(old, ['goal', 'add', '--title', 'Billing', '--outcome', 'it works', '--priority', '1'], SUP); + fs.appendFileSync(path.join(dir, `owner-${new Date().toISOString().slice(0, 7)}.jsonl`), line({ id: G, type: 'goal', op: 'set', status: 'ready', at: new Date().toISOString(), by: 'owner', channel: 'cli' })); + let p = pulse(); + assert.equal(p.state, 'moving', 'approved and not planned yet: planning is owed'); + assert.equal(p.open_tickets, 0); + age(3 * 3600000); + p = pulse(); + assert.equal(p.state, 'stalled', 'and nobody planning it for hours is a stall, not "waiting for a new goal"'); + assert.match(p.attention.join('\n'), /An approved goal with no tickets, and nothing has moved in 3 h/); + + ok(old, ['feature', 'add', 'bill', '--json', JSON.stringify({ title: 'bill', goals: [G], stories: [{ id: 'BILL-S1', text: 'it works', release: 'v1.0.0' }] })], SUP); + ok(old, ['ticket', 'add', 'app-01-bill', '--json', JSON.stringify({ title: 'Billing page', feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: [], touches: ['src/bill/**'], done_when: ['its test passes in CI'] })], SUP); + assert.equal(pulse().state, 'moving'); + // the owner parks that one goal: Upkeep still runs, so the fleet is not parked + ok(old, ['goal', 'hold', G, 'park', '--reason', 'later', '--owner'], { DEVTEAM_CHANNEL: 'dashboard' }); + age(3 * 86400000); + p = pulse(); + assert.equal(json(ok(old, ['hello', '--json', '--no-kit'])).fleet.mode, 'run'); + assert.equal(p.state, 'idle', 'its ticket waits by the owner\'s choice: nothing is stalled'); + assert.equal(p.open_tickets, 1, 'still counted as not merged'); + assert.deepEqual(p.attention, ['1 open ticket is on hold with its goal']); + assert.equal(p.goal.mode, 'park'); + // a question on it blocks nothing that could move + ok(old, ['ask', '--question', 'Charge per seat or per team?', '--blocks', 'app-01-bill', '--recommendation', 'Per team'], SUP); + p = pulse(); + assert.equal(p.blocking, null); + assert.notEqual(p.state, 'blocked'); + } finally { F.done(); } +}); From df58accde5e2e66aeed8442bcf6473384e7d5704 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:57:57 -0500 Subject: [PATCH 06/10] Dashboard: the hub's fleet bridge is not installed in the viewer, nor under anything but the top page index.html is also the page at /view/. On a box the gateway lets the owner's wrapper on the public name frame a granted viewer, so there "a parent on another origin" is not only the hub. The wrapper runs no script today; the bridge no longer rests on that. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/dashboard/index.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 0b2796e6..f82ec1aa 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -5597,9 +5597,11 @@

${k.plain ? esc(k.plain) : linkify(k.statement)}

// The hub never posts an owner action itself (it only lists); it asks this page, and this page // posts it to its own origin as a click here would. Who asks: only this window's parent, and only // when the parent is not this origin. frame-ancestors lets only this origin and the hub frame this -// page, so a parent on another origin is the hub. Three ops, each once per nonce; the answer goes -// back to that parent alone. -{ +// page, so a parent on another origin is the hub. Never in the viewer: on a box its parent may be +// the owner's wrapper on the public name, which is not the hub (it runs no script, and this does +// not rest on that). And only straight under the top page, as the hub holds it. Three ops, each +// once per nonce; the answer goes back to that parent alone. +if (!VIEWER && window.parent === window.top) { const HUB_OPS = { park: 'park', drain: 'drain', run: 'resume' }; const seen = new Set(); window.addEventListener('message', async (ev) => { From 53099b19b05d0a056e815a4af9946736b6e22fa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:57:57 -0500 Subject: [PATCH 07/10] Hub Overview: Confirm park by keyboard, the mode shown once set, no dashboards left running unseen - The page is rebuilt on every press and every 10 s, and focus went with it: Park then Confirm park could not be done from the keyboard. Whatever had focus keeps it. - After a park that worked the card went on showing Run for up to 10 s (a hello's age), with Park enabled again. The card shows what its dashboard said it set until the hello agrees; the dashboard reads its hello again after every owner action. - Park all loaded every project's dashboard in a hidden frame and left it there, each reading /api/data every 10 s. A frame made only to ask is dropped when it answers. - A hello that isn't shaped like one (attention not a list) threw in the middle of the paint and took the whole Overview with it: now its own card says so. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/dashboard/hub.html | 44 ++++++++++++++++++++++++++------ docs/devteam/scripts/devteam.mjs | 6 ++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/devteam/dashboard/hub.html b/docs/devteam/dashboard/hub.html index 4b9930f7..88bf7d98 100644 --- a/docs/devteam/dashboard/hub.html +++ b/docs/devteam/dashboard/hub.html @@ -239,6 +239,9 @@ // message from that frame alone; nothing else is listened to. const fleetWait = new Map(); // nonce -> { resolve, origin, win } const fleetBusy = new Map(); // root -> what is being asked, or the last error +// root -> the mode its dashboard just said it set: a hello is up to 10 s old, and until it agrees +// (or 30 s pass) the card shows what was done, not what the old hello says +const fleetSet = new Map(); window.addEventListener('message', (ev) => { const m = ev.data; if (!m || typeof m !== 'object' || m.devteam !== 'hub-fleet-done' || typeof m.nonce !== 'string') return; @@ -248,6 +251,9 @@ w.resolve({ ok: m.ok === true, error: typeof m.error === 'string' ? m.error : null }); }); async function fleetAsk(it, mode, all = false) { + // a frame made only to ask is dropped once it has answered, unless its tab was chosen meanwhile: + // a dashboard reads its data every 10 s for as long as it is open, seen or not + const made = !it.frame; const frame = frameFor(it); await Promise.race([it.loaded, new Promise((r) => setTimeout(r, 15000))]); const origin = new URL(urlOf(it.x)).origin; @@ -260,6 +266,7 @@ const r = await Promise.race([answer, new Promise((r_) => setTimeout(() => r_({ ok: false, error: 'its dashboard didn\'t answer (an older kit can\'t be parked from here)' }), 20000))]); clearInterval(again); fleetWait.delete(nonce); + if (made && it.frame === frame && selected !== it.x.root) { frame.remove(); it.frame = null; it.loaded = null; } return r; } async function setFleet(roots, mode) { @@ -269,7 +276,7 @@ await Promise.all(roots.map(async (r) => { const it = items.get(r); const res = it ? await fleetAsk(it, mode, roots.length > 1) : { ok: false, error: 'gone' }; - if (res.ok) fleetBusy.delete(r); else fleetBusy.set(r, { error: res.error ?? 'it didn\'t work' }); + if (res.ok) { fleetBusy.delete(r); fleetSet.set(r, { mode, until: Date.now() + 30000 }); } else fleetBusy.set(r, { error: res.error ?? 'it didn\'t work' }); })); await draw(); paintOverview(); @@ -289,7 +296,14 @@ starting: { label: 'Starting', tone: '', rank: 5 }, unknown: { label: 'Running', tone: 'blue', rank: 3 }, }; -const stateOf = (it) => (it.goneAt ? 'gone' : it.x.inbox === null || it.x.inbox === undefined ? 'starting' : STATES[it.x.pulse?.state] ? it.x.pulse.state : 'unknown'); +const stateOf = (it) => { + if (it.goneAt) return 'gone'; + if (it.x.inbox === null || it.x.inbox === undefined) return 'starting'; + const st = Object.hasOwn(STATES, it.x.pulse?.state) ? it.x.pulse.state : 'unknown'; + // just parked or resumed from here, and its hello hasn't caught up + if (it.x.pulse && fleetSet.has(it.x.root)) { const m = modeOf(it); if (m === 'park') return 'parked'; if (st === 'parked') return 'unknown'; } + return st; +}; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = String(text); return e; }; const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; function agoText(iso) { @@ -325,7 +339,14 @@ const confirming = new Map(); // key -> until const confirmed = (key) => { const until = confirming.get(key) ?? 0; if (until > Date.now()) { confirming.delete(key); return true; } confirming.set(key, Date.now() + 5000); setTimeout(paintOverview, 5100); paintOverview(); return false; }; const isConfirming = (key) => (confirming.get(key) ?? 0) > Date.now(); -const modeOf = (it) => (['park', 'drain'].includes(it.x.fleet?.mode) ? it.x.fleet.mode : 'run'); +const modeOf = (it) => { + const said = ['park', 'drain'].includes(it.x.fleet?.mode) ? it.x.fleet.mode : 'run'; + const set = fleetSet.get(it.x.root); + if (!set) return said; + if (set.mode !== said && Date.now() < set.until) return set.mode; + fleetSet.delete(it.x.root); + return said; +}; // what the Overview can steer: a dashboard answering, on a kit that sends a pulse const steerable = (it) => !it.goneAt && Boolean(it.x.pulse); function fleetControl(it) { @@ -340,6 +361,7 @@ for (const [mode, label, why] of opts) { const key = `${root}:${mode}`; const b = el('button', isConfirming(key) ? 'confirm' : '', isConfirming(key) ? 'Confirm park' : label); + b.dataset.k = key; b.title = why; b.setAttribute('aria-pressed', String(now === mode)); b.disabled = Boolean(busy?.asking) || now === mode; @@ -359,6 +381,7 @@ const card = el('article', `ov-card${S.tone === 'red' ? ' bad' : S.tone === 'amber' ? ' warn' : ''}`); const top = el('div', 'ov-top'); const name = el('button', 'ov-name', x.name); + name.dataset.k = `${x.root}:name`; name.title = `Open ${x.name}'s dashboard\n${x.root}`; name.onclick = () => select(x.root); const pills = el('span', 'ov-pills'); @@ -369,7 +392,7 @@ card.append(top); const stats = el('div', 'ov-stats'); - const stat = (k, v, cls = '', onclick = null) => { const s_ = el('div', 'ov-stat'); s_.append(el('span', 'k', k)); const vv = el(onclick ? 'button' : 'span', `v ${cls}`.trim(), v); if (onclick) { vv.onclick = onclick; vv.title = `Open ${x.name}'s Inbox`; } s_.append(vv); stats.append(s_); }; + const stat = (k, v, cls = '', onclick = null) => { const s_ = el('div', 'ov-stat'); s_.append(el('span', 'k', k)); const vv = el(onclick ? 'button' : 'span', `v ${cls}`.trim(), v); if (onclick) { vv.onclick = onclick; vv.dataset.k = `${x.root}:inbox`; vv.title = `Open ${x.name}'s dashboard, where its Inbox is`; } s_.append(vv); stats.append(s_); }; const kit = x.kit ?? '?'; const nums = relNums(kit); const behind = newest && nums && relCmp(nums, newest) < 0; @@ -408,7 +431,7 @@ card.append(prog); } - const notes = [...(x.alert ? [{ t: String(x.alert), red: true }] : []), ...(it.goneAt ? [{ t: 'Its dashboard stopped answering', red: true }] : []), ...((p?.attention ?? []).map((t) => ({ t: String(t), red: (st === 'stalled' && /moved/.test(t)) || /^Fully blocked/.test(t) })))]; + const notes = [...(x.alert ? [{ t: String(x.alert), red: true }] : []), ...(it.goneAt ? [{ t: 'Its dashboard stopped answering', red: true }] : []), ...((Array.isArray(p?.attention) ? p.attention : []).map((t) => ({ t: String(t), red: (st === 'stalled' && /moved/.test(t)) || /^Fully blocked/.test(t) })))]; if (notes.length) { const ul = el('ul', 'ov-att'); for (const n of notes.slice(0, 5)) ul.append(el('li', n.red ? 'red' : '', n.t)); @@ -445,12 +468,17 @@ const held_ = can.filter((it) => modeOf(it) !== 'run'); const running = can.filter((it) => modeOf(it) !== 'park'); const anyBusy = can.some((it) => fleetBusy.get(it.x.root)?.asking); - if (held_.length) { const b = el('button', '', `Resume all (${held_.length})`); b.title = 'Undo every park and drain'; b.disabled = anyBusy; b.onclick = () => setFleet(held_.map((it) => it.x.root), 'run'); acts.append(b); } - if (running.length) { const b = el('button', isConfirming('all:park') ? 'confirm' : '', isConfirming('all:park') ? `Confirm: park ${plural(running.length, 'project')}` : 'Park all'); b.title = 'Stop every session in every project where it is'; b.disabled = anyBusy; b.onclick = () => { if (confirmed('all:park')) setFleet(running.map((it) => it.x.root), 'park'); }; acts.append(b); } + if (held_.length) { const b = el('button', '', `Resume all (${held_.length})`); b.dataset.k = 'all:run'; b.title = 'Undo every park and drain'; b.disabled = anyBusy; b.onclick = () => setFleet(held_.map((it) => it.x.root), 'run'); acts.append(b); } + if (running.length) { const b = el('button', isConfirming('all:park') ? 'confirm' : '', isConfirming('all:park') ? `Confirm: park ${plural(running.length, 'project')}` : 'Park all'); b.dataset.k = 'all:park'; b.title = 'Stop every session in every project where it is'; b.disabled = anyBusy; b.onclick = () => { if (confirmed('all:park')) setFleet(running.map((it) => it.x.root), 'park'); }; acts.append(b); } if (acts.childElementCount) head.append(acts); const grid = el('div', 'ov-grid'); - for (const it of all) grid.append(overviewCard(it, newest)); + // one answer in the block that isn't shaped like a hello costs its own card, not the page + for (const it of all) { try { grid.append(overviewCard(it, newest)); } catch { const c = el('article', 'ov-card bad'); c.append(el('div', 'ov-name', it.x.name), el('div', 'ov-none', 'Its dashboard sent something this page can\'t read.')); grid.append(c); } } + // the page is redrawn whole every 10 s and on each press: whatever had the keyboard keeps it, so + // Park and then Confirm park are two presses of one key + const at = ovEl.contains(document.activeElement) ? document.activeElement.dataset.k : null; ovEl.replaceChildren(head, ...(all.length ? [grid] : [el('p', 'ov-none', 'Start a dashboard in a devteam project and it shows up here.')])); + if (at) [...ovEl.querySelectorAll('[data-k]')].find((e) => e.dataset.k === at && !e.disabled)?.focus({ preventScroll: true }); } function paintTab(it) { const { x, tab } = it; diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 3613f030..bb5014f1 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -11394,7 +11394,9 @@ async function pickDashboardPort({ skip = [] } = {}) { // instance in the block by asking each port /api/hello: a tab per instance, with that instance's // own dashboard in a sandboxed frame on its own address. It owns no state and does nothing for the // owner: GET only, no proxy, its own Host only, a page that fetches only from itself and frames only -// the block. The others retry, so the hub moves when its dashboard stops. Something +// the block. The page's Overview can run, drain and park a fleet, but not through this server: it +// asks that project's dashboard, in its frame, and the dashboard posts to itself (index.html, "The +// hub's Overview"). The others retry, so the hub moves when its dashboard stops. Something // that isn't a devteam hub on the port: logged once, and this dashboard stops trying. function startHub(log) { const page = path.join(kitDir, 'dashboard', 'hub.html'); @@ -13548,6 +13550,8 @@ async function cmdDashboard() { if (args && args.some((a) => typeof a !== 'string' || a.includes('\0'))) args = null; if (!args) return send(res, 400, 'application/json', JSON.stringify({ error: 'missing or invalid fields' })); const r = await runAction(args, { timeout: ACTION_MS[name] ?? 0 }); + // what the hub lists (the Inbox count, the fleet's mode) follows an owner action at once + if (r.ok) refreshHello(); send(res, r.ok ? 200 : 400, 'application/json', JSON.stringify(r.ok ? { ok: true, out: r.stdout.trim() } : { error: r.stderr.split('\n').filter((l) => l && !l.startsWith('devteam: warning:')).join(' ').replace(/^devteam: /, '') })); }); }); From 9f7478f344837e500e9896d6ac5300df9828c0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:57:57 -0500 Subject: [PATCH 08/10] Test the Overview's bridge in Chrome: who can park a fleet, and who can't Park by keyboard, Park all, Resume all; then a dashboard's own page, the viewer under the hub, another local page and a dashboard in its own tab each ask and are not heard. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- test/hub-overview.test.mjs | 104 ++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs index 9680bbc5..09368b15 100644 --- a/test/hub-overview.test.mjs +++ b/test/hub-overview.test.mjs @@ -3,8 +3,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; import path from 'node:path'; -import { fleetFixture, json, line } from './lib/kit.mjs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { PORTS, bareProject, fleetFixture, json, line, startDashboard, until } from './lib/kit.mjs'; +import { canDrive, openChrome } from './lib/chrome.mjs'; const SUP = { DEVTEAM_ROLE: 'supervisor' }; @@ -107,3 +111,101 @@ test('the pulse: an approved goal with nothing planned is not idle; a goal the o assert.notEqual(p.state, 'blocked'); } finally { F.done(); } }); + +// The hub can't post an owner action. Its Overview asks the project's dashboard, in its frame, and +// that page posts to itself: only for its parent, only when that parent is another origin (the hub, +// by frame-ancestors), never in the viewer, never opened on its own. +test('the Overview in Chrome: Park by keyboard, Park all and Resume all through each dashboard; nothing but the hub above a dashboard is heard', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 240000 }, async (t) => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-hub-ov-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + const HUB = PORTS + 60; + const P = PORTS + 61; + const env = { ...process.env, DEVTEAM_HUB_PORT: String(HUB), DEVTEAM_PORT_BASE: String(P), DEVTEAM_HUB_RETRY_MS: '500', CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; + const A = bareProject(base, 'alpha'); + const B = bareProject(base, 'beta'); + for (const r of [A, B]) execFileSync('sh', ['-c', 'git add -A && git -c user.email=t@t -c user.name=t commit -qm init'], { cwd: r }); + // the fleet's mode as the project itself says it now (the hub's list is up to 10 s behind) + const mode = (root) => { const r = spawnSync(process.execPath, [path.join(root, 'docs/devteam/scripts/devteam.mjs'), 'hello', '--json', '--no-kit'], { cwd: root, env, encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); return json(r.stdout).fleet.mode; }; + const a = startDashboard(A, env); + t.after(() => a.stop()); + await until(() => a.out().includes(`127.0.0.1:${P}`), "alpha's dashboard"); + const b = startDashboard(B, env); + t.after(() => b.stop()); + await until(async () => { try { const l = (await (await fetch(`http://127.0.0.1:${HUB}/instances.json`)).json()).instances; return l.length === 2 && l.every((x) => x.pulse); } catch { return false; } }, 'both projects listed with a pulse'); + const chrome = await openChrome(); + t.after(() => chrome.close()); + const p = await chrome.newPage(); + t.after(() => p.close()); + await p.send('Page.enable'); + await p.send('Runtime.enable'); + await p.send('Page.navigate', { url: `http://127.0.0.1:${HUB}/` }); + const page = (what, expr) => until(() => p.ev(expr).catch(() => false), what); + const card = (name) => `[...document.querySelectorAll('.ov-card')].find((c) => c.querySelector('.ov-name')?.textContent === '${name}')`; + const seg = (name, label) => `[...${card(name)}.querySelectorAll('.seg button')].find((x) => x.textContent === '${label}')`; + await page('a card each', `document.querySelectorAll('.ov-card').length === 2 && document.querySelectorAll('.seg').length === 2`); + assert.equal(await p.ev(`document.querySelector('[role=tab][aria-selected=true]').textContent`), 'Overview', 'the page opens on the Overview'); + assert.equal(await p.ev(`location.hash`), '#overview'); + assert.equal(await p.ev(`document.querySelectorAll('iframe').length`), 0, 'no dashboard is loaded until one is opened or asked'); + + // Park, by keyboard: two presses of the space bar on one button, across the redraw between them + await p.ev(`${seg('alpha', 'Park')}.focus()`); + await p.key(' '); + await page('the press asking to be confirmed', `document.activeElement?.textContent === 'Confirm park'`); + assert.equal(mode(A), 'run', 'one press parks nothing'); + await p.key(' '); + await until(() => mode(A) === 'park', 'alpha parked'); + assert.equal(mode(B), 'run', 'and only alpha'); + // the card says so as soon as alpha's dashboard answers, not a hello later + await page('the card showing Park pressed', `${seg('alpha', 'Park')}?.getAttribute('aria-pressed') === 'true' && ${card('alpha')}.querySelector('.pill:last-child').textContent === 'Parked'`); + await page('the frame made to ask, gone again', `document.querySelectorAll('iframe').length === 0`); + const held = spawnSync(process.execPath, [path.join(A, 'docs/devteam/scripts/devteam.mjs'), 'fleet', 'status'], { cwd: A, env, encoding: 'utf8' }).stdout; + assert.match(held, /parked from the hub's Overview/, 'the reason says where it came from'); + + // every project at once, and back + await page('Park all offered', `[...document.querySelectorAll('.ov-head .acts button')].some((x) => x.textContent === 'Park all' && !x.disabled)`); + await p.ev(`[...document.querySelectorAll('.ov-head .acts button')].find((x) => x.textContent === 'Park all').click()`); + assert.equal(mode(B), 'run', 'Park all asks first'); + await p.ev(`[...document.querySelectorAll('.ov-head .acts button')].find((x) => /^Confirm: park 1 project$/.test(x.textContent)).click()`); + await until(() => mode(B) === 'park', 'beta parked'); + await page('Resume all offered for both', `[...document.querySelectorAll('.ov-head .acts button')].some((x) => x.textContent === 'Resume all (2)' && !x.disabled)`); + await p.ev(`[...document.querySelectorAll('.ov-head .acts button')].find((x) => x.textContent === 'Resume all (2)').click()`); + await until(() => mode(A) === 'run' && mode(B) === 'run', 'both running again'); + + // a project's tab after all that: its dashboard, shown + await p.ev(`[...document.querySelectorAll('[role=tab]')].find((x) => x.textContent.startsWith('alpha')).click()`); + await page("alpha's dashboard in its frame", `document.querySelectorAll('iframe:not([hidden])').length === 1 && document.getElementById('overview').hidden`); + await until(() => p.evIn(`http://127.0.0.1:${P}`, `document.readyState === 'complete' && typeof loadFleet === 'function'`).catch(() => false), "alpha's page running"); + + // Who is not heard. Each is given 2 s to park beta, which would take well under one. + const ask = (nonce) => JSON.stringify({ devteam: 'hub-fleet', mode: 'park', nonce }); + const quiet = async (why) => { await new Promise((r) => setTimeout(r, 2000)); assert.equal(mode(B), 'run', why); }; + // the dashboard's own page, to itself (its parent is itself: any script of its own origin) + await p.evIn(`http://127.0.0.1:${P}`, `window.postMessage(${ask('aaaaaaaa01')}, '*')`); + // the viewer under the hub: the same page, but never the hub's to steer + await p.ev(`(() => { const f = document.createElement('iframe'); f.id = 'viewer'; f.src = 'http://127.0.0.1:${P + 1}/view/artifact/some-page'; document.getElementById('frames').append(f); return new Promise((r) => f.addEventListener('load', r)); })()`); + await until(() => p.evIn(`http://127.0.0.1:${P + 1}`, `document.readyState === 'complete' && typeof VIEWER === 'object' && VIEWER !== null`).catch(() => false), "beta's viewer running"); + await p.ev(`document.getElementById('viewer').contentWindow.postMessage(${ask('aaaaaaaa02')}, '*')`); + await quiet('the viewer does not listen'); + // another page on this machine can't hold a dashboard at all, so it has no frame to ask + const other = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end(``); }); + await new Promise((r) => other.listen(PORTS + 90, '127.0.0.1', r)); + t.after(() => other.close()); + const q = await chrome.newPage(); + t.after(() => q.close()); + await q.send('Page.enable'); + await q.send('Runtime.enable'); + await q.send('Page.navigate', { url: `http://127.0.0.1:${PORTS + 90}/` }); + await until(() => q.ev(`document.getElementById('d') !== null`).catch(() => false), 'the other page'); + await quiet('a page that is not the hub frames no dashboard'); + // and a dashboard in a tab of its own hears nobody: it has no parent + await q.send('Page.navigate', { url: `http://127.0.0.1:${P + 1}/` }); + await until(() => q.ev(`location.port === '${P + 1}' && document.readyState === 'complete' && typeof loadFleet === 'function'`).catch(() => false), "beta's dashboard on its own"); + await q.ev(`window.postMessage(${ask('aaaaaaaa04')}, '*')`); + await quiet('a dashboard opened on its own hears nobody'); + // the hub itself still is: the same ask from the page above beta's dashboard parks it + await p.ev(`[...document.querySelectorAll('[role=tab]')].find((x) => x.textContent.startsWith('beta')).click()`); + await until(() => p.evIn(`http://127.0.0.1:${P + 1}`, `location.pathname === '/' && document.readyState === 'complete' && typeof loadFleet === 'function'`).catch(() => false), "beta's dashboard under the hub"); + await p.ev(`[...document.querySelectorAll('iframe')].find((f) => f.title === 'beta').contentWindow.postMessage(${ask('aaaaaaaa05')}, 'http://127.0.0.1:${P + 1}')`); + await until(() => mode(B) === 'park', 'beta parked by its parent, the hub'); +}); From 7cffe97e79ed42b3d4c4f67f85165ab95099266d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:57:57 -0500 Subject: [PATCH 09/10] README: the hub's Overview, and that it can run, drain and park every fleet Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8862ffa1..dbf4b924 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ docs/devteam/ **What matters most.** The supervisor judges each goal's impact against the project's strategy and rates every question it asks (how much rides on the answer, how soon, and which other questions an answer would settle). The kit counts the rest (tickets held up, a default) and orders the Inbox from it, with the next best step on top and the reason on every card; `devteam.mjs next` prints the same. The order is computed on every read and stored nowhere. The owner corrects a goal's impact with their own value, and it stands. -**Many projects, one machine.** Each project has its own tmux session (`devteam--<6 hex of its path>`, saved in `run/tmux-session`) and its own dashboard port: the next free one in 4321–4345, saved in `run/dashboard-port` and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. The hub keeps no state and takes no action: each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub. A dashboard answers only on the address it serves (`127.0.0.1` and `localhost`, or the `--host` it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read. +**Many projects, one machine.** Each project has its own tmux session (`devteam--<6 hex of its path>`, saved in `run/tmux-session`) and its own dashboard port: the next free one in 4321–4345, saved in `run/dashboard-port` and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. It opens on its **Overview** tab: a card per project saying whether it is building, moving, fully blocked on your answers (or partly: some tickets wait, the rest can move), stalled, idle or parked, with its kit version (amber when behind the newest one running here), Inbox count, workers, the goal it is on, tickets merged of planned, what needs you, and twelve weeks of activity. Each card has **Run**, **Drain** and **Park** for that project's fleet, and the page has **Park all** and **Resume all**; Park asks twice. The hub's server keeps no state and takes no action: for Run, Drain and Park the page asks that project's dashboard, in its frame, and the dashboard does it as a click there would, for its parent page only. Each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub, so whatever is served at the hub's address can steer every fleet on the machine: an owner who finds something else on port 4320 should not open it (a dashboard notes it in `run/dashboard.log`). A dashboard answers only on the address it serves (`127.0.0.1` and `localhost`, or the `--host` it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read. **Artifacts.** Any session can draw an artifact, a page that shows the owner an idea instead of describing it: a UI mock, a flow, a mock docs site, a 3D model. It lives in `project/artifacts//` and lands with the PR it was drawn in; the dashboard's **Library** lists landed ones and live drafts from every worktree beside the link registry, and opens each in a centered viewer or its own tab. An artifact loads its own files and pinned libraries from cdnjs, jsdelivr and unpkg, runs sandboxed with no other network and no reach into the dashboard, and sends nothing back: a decision on one is a question that links it, by name and inline (`[[name]]`). The owner can ask for one ("draw me a mock docs site"), and planners draw them for any goal with UI or a flow. `devteam.mjs artifact new|check`, `artifacts`. On a cloud box the owner can also publish a landed artifact to anyone, at `public.box.example.com///` (below). @@ -163,7 +163,7 @@ Install [claude-swap](https://github.com/realiti4/claude-swap) and add your acco Setting up a fleet needs none of this, and a project never copies it: `cloud/` sits outside `docs/devteam/`. It is for an owner who wants their dev box in the cloud: one container (Render, fly.io or any Docker host with a disk) holding the repos, the tmux server where every supervisor and worker runs, and the hub, reached at a URL of the owner's (say `box.example.com`, plus `*.box.example.com`, since each dashboard keeps an origin of its own on a subdomain, and `public.box.example.com` serves what the owner publishes). `cloud/Dockerfile` says how to deploy it. - **Claiming it.** On first boot the log prints a claim link, good for a day. Opening it makes a passkey (1Password or the browser saves it), and from then on only a passkey registered on the box opens it; more can be added from the hub's Passkeys link. Signing in is at `box.example.com/auth`, and passkeys belong to `box.example.com`: pick the box's name before claiming, since changing it invalidates them. Lost every one: `devteam-reclaim` in a shell on the box prints a new link. A box claimed before sign-in moved from `auth.box.example.com` to `/auth` needs that once too: its passkeys belong to the old name (the gateway's log says so). -- **The hub** is the box's home page, with a **Terminal** tab first (`term.box.example.com`): a terminal on the box's tmux server, where the owner logs in to `claude` and `gh`, clones a repo and starts its supervisor (the notes it prints say how), and switches between every session (`C-b s`). Each repo's dashboard is a tab, as on a laptop. The image carries its own copy of the hub page and of the terminal's page, because every agent can change a checkout or take a port: pages on the box's name and the `term.` name are only ever the gateway's, and an agent's page can exist only on an instance's name. +- **The hub** is the box's home page, with a **Terminal** tab first (`term.box.example.com`): a terminal on the box's tmux server, where the owner logs in to `claude` and `gh`, clones a repo and starts its supervisor (the notes it prints say how), and switches between every session (`C-b s`). The **Overview** tab comes next (every project on one page, with Run, Drain and Park for each fleet and for all of them), then each repo's dashboard as a tab, as on a laptop. The image carries its own copy of the hub page and of the terminal's page, because every agent can change a checkout or take a port: pages on the box's name and the `term.` name are only ever the gateway's, and an agent's page can exist only on an instance's name. - **Notifications.** The gateway watches every Inbox itself and sends the hub's notifications by Web Push, so they arrive with no page open. Turn them on with the hub's "Notify me of new Inbox items", once per browser; tap that text afterwards to send a test notification to that device. On an iPhone (iOS 16.4 or later), open the hub in Safari, Share > Add to Home Screen, open it from there and sign in (it doesn't share Safari's sign-in), then turn them on. They reach the Lock Screen. Removing a passkey or running `devteam-reclaim` ends every device's notifications. - **Dropping files on the terminal.** Drag files from your desktop onto the Terminal tab and each is uploaded to `/data/uploads/-/`, then its path is pasted at the focused pane, as a native terminal does, so the operator or supervisor there picks it up; press Enter when you've said what to do with it. An image arrives as an attachment (Safari sends a HEIC photo as a JPEG; elsewhere it arrives as a path). If the terminal has lost its connection, the path stays on screen and pastes when it's back. Up to 512 MB a file and 4 GB in all, kept for 7 days. The name is reduced to letters, digits, `.`, `-` and `_`. Only the gateway writes there; agents can read it. - **It's a general-purpose dev machine,** with no container per project. Everything outside `/data` resets on a deploy, so `$HOME` is the machine: languages and tools install per project with `mise` (it reads `.tool-versions`, and `.ruby-version`, `.python-version` and `.nvmrc` too; `asdf` is an alias for it), and global tools are in `~/.config/mise/config.toml`. The image holds only what needs root: build libraries, `ffmpeg`, Chromium, and Postgres 14, 16 and 17 with `pgvector`. `box-pg 16` starts another Postgres (17 is on the default port, the others on 5400 + major); `~/.box/boot.sh` is what starts at boot, the box's `brew services`. There is no Docker on the box. Expo works without it: `npx expo start --tunnel` for your phone, `eas build` for builds. The Heroku CLI is a global tool too, the standalone build pinned by version and checksum, with its own Node and only `heroku` on `PATH` (the npm one runs on whatever Node a repo pins, and refuses one older than 20). Log in once with `heroku login -i`, giving a token from `heroku authorizations:create --expires-in --scope ` on your laptop as the password: the browser login fails, since the browser isn't on the box's IP. The token lands in `~/.netrc`, on the disk, readable by every agent on the box, so give it the narrowest scope and shortest life that does the job. From e23a928661789bf02dcd4f1dc0d042916882414e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 02:00:28 -0500 Subject: [PATCH 10/10] Hub Overview: the mosaic draws at most a year of days, whatever a hello sends Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01T5z3hdiDu7TwHcW5jpP6do --- docs/devteam/dashboard/hub.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/devteam/dashboard/hub.html b/docs/devteam/dashboard/hub.html index 88bf7d98..bed4bd3f 100644 --- a/docs/devteam/dashboard/hub.html +++ b/docs/devteam/dashboard/hub.html @@ -441,7 +441,7 @@ if (Array.isArray(p?.activity) && p.activity.length) { const foot = el('div', 'ov-foot'); - foot.append(mosaic(p.activity.map((n) => Math.max(0, Number(n) || 0))), el('span', 'k', `${Math.round(p.activity.length / 7)} weeks`)); + foot.append(mosaic(p.activity.slice(-371).map((n) => Math.max(0, Number(n) || 0))), el('span', 'k', `${Math.round(Math.min(371, p.activity.length) / 7)} weeks`)); card.append(foot); } if (steerable(it)) card.append(fleetControl(it));