diff --git a/AGENTS.md b/AGENTS.md index 9f5f7f4..bc8d652 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -293,7 +293,7 @@ an agent is working; a blinking caret means output is still arriving. Night theme by default; the header button switches to Daylight. Tokens live at the top of `manager/board.html`. -The board has three views (header switcher): +The board has four views (header switcher): - **Board** — the kanban, live, and only what you are holding yourself: a phase's members are drawn elsewhere, with the phase card standing for @@ -306,6 +306,10 @@ The board has three views (header switcher): appears — when sync stops converging, saying whether it is behind (origin unreachable, self-healing) or stalled on something only a human can settle. +- **Phases** — a swimlane each: where the members the Board stopped drawing + actually live (see "The Phases view: a swimlane each"). The switcher + itself carries how many phases are running and a mark when one has + halted, so a halt is learned from whichever view you are on. - **Sessions** — a flight recorder per session: a chronological timeline of reads, edits, test runs, commits and card moves, with filters and expandable output. Sessions persist to `local/state/sessions/.jsonl`, so past ones @@ -980,8 +984,62 @@ Focus are about runs, not stages, and a phase member's agent is an agent like any other — it is counted by the header's live chip and by the tab title wherever it is working. -Filtering the board to a phase's cards, and a Focus view for a phase, are -separate cards. +Where they are drawn instead is the next section. Filtering the board to a +phase's cards, and a Focus view for a phase, remain separate cards. + +### The Phases view: a swimlane each + +Where the members went. A fourth view beside Board, Sessions and Focus, +holding **one lane per phase card** — every phase there is, not only the +running ones, because a phase whose cards are all merged and which is +waiting on its own PR still owns them, and a lane that vanished exactly +when it needed merging would be a view you could not trust. Lanes are +ordered so a halt is never below the fold: halted, then running, then the +phases nobody has started, then the ones that are over. With no phase +anywhere the view says so in a sentence rather than drawing an empty grid. + +A lane is a head, five stage columns and the phase's log: + +- **The head** — the phase, its progress (`2 of 3 merged`), the member in + flight on the same activity line a card would use, and its own controls: + **‖ hold**, the phase branch, and the phase card, which opens in the + drawer. Those are all of them. Nothing here ends a phase — merging is a + board move on the phase card, and there is exactly one place where work + leaves the board. +- **Five columns, and the last one is the phase's own.** Four are the + board's stages; the fifth is `Merged in`, not `done/`, because a member + merged into the phase branch is finished as far as the phase is + concerned and is not in `main` yet. A member is drawn in the stage it is + actually in until the phase branch holds it, and then in that last + column whatever its card says. Which members the branch holds is the + runner's last pass while a phase is running, and the phase log once + there are no more passes — the same fact, written down by the same + runner, which is what keeps a phase in `review/` from drawing a lane + where nothing ever landed. +- **The cards are the cards.** `cardFor()` builds them here exactly as it + builds them for the Board: the live agent line, the CI and PR chips, the + `⟶ 2/3` chip that says where each sits in the run, and the same + hover actions. Nothing is shrunk to a token — that fidelity is the whole + advantage a room of its own buys. +- **The phase log, under the lane** — the runner's decisions in the order + it made them, read off the `## Phase log` section of the phase card + itself. It is the only thing that can tell "the phase has not reached + this card" from "it started it and it ended badly", and until this view + it had nowhere to be shown. + +**A halted lane says so at the top of itself**: the reason, the card it +stopped on, and **▸ run again** beneath them. That action lives here and +not on the Board, because clearing a halt should mean having read what +caused it — the Board's halted phase card offers **⟶ phases** in its place, +and **‖ hold**, which needs nothing read. + +The crossing matters more than the lane: a person on the Board has to +learn that a phase halted without being on this view, and a person here +has to be able to get back to the card that owns it. So the switcher +carries the running count and, in `--alarm`, a mark when something has +halted — on every view, holding until the phase is run again or held, +alongside the toast and the ticker line that already fire. And the lane +head's title and its `card ↗` chip open the phase card. An optional **Assignee** line records who holds the card: diff --git a/manager/core/board.html b/manager/core/board.html index 22109a9..67c2327 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -91,6 +91,12 @@ .views button{padding:5px 12px;border:1px solid transparent;border-radius:6px;font-size:12.5px;font-weight:500;color:var(--muted)} .views button:hover{color:var(--text);border-color:transparent} .views button.on{background:var(--surface);border-color:var(--border);color:var(--text)} + /* the crossing: a phase halting is learned from whatever view you are on, + so the switcher carries how many are running and whether one stopped */ + .views button{display:flex;align-items:center;gap:6px} + .views .vcount{font-family:var(--mono);font-size:10.5px;color:var(--dim)} + .views button.on .vcount{color:var(--accent)} + .views .vhalt{width:6px;height:6px;border-radius:99px;background:var(--alarm);flex:none} .livechip{ display:flex;align-items:center;gap:9px;padding:5px 12px 5px 10px;cursor:pointer; background:var(--surface);border:1px solid var(--border);border-radius:99px;font-size:12.5px; @@ -241,14 +247,16 @@ .card.has-acts:has(.hoveracts .armed) .toprow .high, .card.has-acts:has(.hoveracts .busy) .toprow .pill.status, .card.has-acts:has(.hoveracts .busy) .toprow .high{display:none} - .hoveracts button{ + /* the lane head's own controls wear this too: a phase's actions are card + actions, and one look is what says so */ + .lacts button,.hoveracts button{ position:relative;display:flex;align-items:center;gap:4px; padding:4px 10px;min-height:24px; background:var(--raised);border:1px solid var(--border);border-radius:99px; font-size:11px;color:var(--muted); } - .hoveracts button:hover{border-color:var(--accent);color:var(--accent)} - .hoveracts button .g{font-family:var(--mono);font-size:10px} + .lacts button:hover,.hoveracts button:hover{border-color:var(--accent);color:var(--accent)} + .lacts button .g,.hoveracts button .g{font-family:var(--mono);font-size:10px} /* every state's label occupies the same grid cell, so the button is born as wide as its widest state and never reshapes under the cursor */ .actlbl{display:inline-grid;justify-items:center} @@ -258,14 +266,14 @@ button.armed .actlbl .l-arm{visibility:visible} button.busy .actlbl .l-busy{visibility:visible} /* armed = alarm, and the disarm window drains visibly along the bottom */ - .hoveracts button.armed,button.chip2.armed{color:var(--alarm);border-color:var(--alarm)} + .lacts button.armed,.hoveracts button.armed,button.chip2.armed{color:var(--alarm);border-color:var(--alarm)} button.armed::after{ content:"";position:absolute;left:9px;right:9px;bottom:2px;height:2px; border-radius:99px;background:var(--alarm);transform-origin:left; animation:drain 5s linear var(--arm-delay,0s) forwards; } /* busy = the request is away: breathe until the redraw or the timeout */ - .hoveracts button.busy,button.chip2.busy{color:var(--accent);border-color:var(--accent);cursor:default} + .lacts button.busy,.hoveracts button.busy,button.chip2.busy{color:var(--accent);border-color:var(--accent);cursor:default} button.busy .g,button.busy .g2{animation:breathe 2.4s ease-in-out infinite} /* ── the activity bar: log drawer + latest line + archive tray ── */ @@ -455,6 +463,63 @@ .also .card{flex:1 1 240px;cursor:default} .also .card:hover{transform:none;border-color:var(--border-soft)} + /* ── phases: a swimlane each ── */ + /* The room the five columns could never spare. One lane per phase, the + same five stages left to right, and the cards are the cards — full + fidelity is the whole advantage a view buys over a rail or a band. */ + #view-phases{flex-direction:column;overflow-y:auto} + #lanes{display:flex;flex-direction:column;gap:14px;padding:14px 18px;flex:1} + .lane{ + display:flex;flex-direction:column;gap:11px;padding:14px; + background:var(--surface);border:1px solid var(--border);border-radius:12px; + box-shadow:var(--shadow); + } + /* colour only means state, here as everywhere: a run alive, a run stopped */ + .lane.running{border-color:color-mix(in oklab, var(--accent) 45%, var(--border))} + .lane.halted{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))} + .lhead{display:flex;align-items:center;gap:9px;min-width:0;flex-wrap:wrap} + .lhead .mark{width:7px;height:7px;border-radius:2px;flex:none;background:var(--idle)} + .lhead .mark.breathing{animation:breathe 2.4s ease-in-out infinite} + .lhead .lref{font-family:var(--mono);font-size:11.5px;color:var(--dim)} + .lhead .ltitle{ + padding:0;border:none;background:none;border-radius:0; + font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--text); + } + .lhead .ltitle:hover{color:var(--accent);border:none} + .lhead .lprog{font-family:var(--mono);font-size:11.5px;color:var(--muted)} + /* the phase's own controls, in the head that owns them; they wear the + card actions' look because they are card actions in every other way */ + .lacts{display:flex;align-items:center;gap:6px;margin-left:auto} + .lhalt{ + display:flex;align-items:center;gap:10px;padding:9px 11px;background:var(--sunken); + border-left:2px solid var(--alarm);border-radius:0 7px 7px 0;font-size:12.5px; + } + .lhalt .lglyph{color:var(--alarm);font-family:var(--mono);font-size:12px;flex:none} + .lhalt .lwhy{flex:1;min-width:0;color:var(--muted);overflow-wrap:anywhere} + .lhalt .lwhy b{color:var(--alarm);font-weight:500} + .lstages{display:flex;gap:var(--gap);align-items:flex-start;overflow-x:auto} + .lcol{flex:1 1 0;min-width:var(--col-min);display:flex;flex-direction:column;gap:8px} + .lcol > h3{ + margin:0;display:flex;align-items:center;gap:8px;padding:0 4px 2px; + font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted); + } + .lcol > h3 .tickmark{width:6px;height:6px;border-radius:2px;flex:none} + .lcol > h3 .count{font-family:var(--mono);font-size:11px;font-weight:400;color:var(--dim)} + .lcol > h3 .note{margin-left:auto;font-weight:400;letter-spacing:0;text-transform:none;font-size:11px;color:var(--dim)} + .lcards{display:flex;flex-direction:column;gap:var(--gap)} + .lcol .empty{padding:14px 10px;font-size:11.5px} + /* the runner's decisions, in the order it wrote them — the only thing + that can tell "not reached yet" from "started and ended badly" */ + .plog{display:flex;flex-direction:column;gap:4px;padding-top:10px;border-top:1px solid var(--line)} + .plog .phead{display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;color:var(--muted)} + .plog .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)} + .plog .plines{display:flex;flex-direction:column;gap:2px;max-height:160px;overflow-y:auto} + .plog .pline{display:flex;gap:9px;font-family:var(--mono);font-size:11.5px;line-height:1.5} + .plog .pline .stamp{color:var(--dim);flex:none} + .plog .pline .what{color:var(--muted);min-width:0;overflow-wrap:anywhere} + .plog .pline.bad .what{color:var(--alarm)} + .plog .pempty{font-size:12px;color:var(--dim)} + /* ── drawer ── */ #drawer{ position:fixed;top:0;right:0;bottom:0;width:min(460px,92vw);z-index:20;display:none; @@ -581,6 +646,9 @@ @@ -598,6 +666,11 @@
+ +
+
+
+
@@ -1057,6 +1130,28 @@ function renderPhases() { })); } +/* The crossing this feature stands on, seen from the other side: a phase + halts while you are on the Board, and the switcher says so without you + having to go and look. The count is the phases running; the mark is a + halt, in the alarm every other stopped thing wears. A board with no + phase in flight has a switcher that reads exactly as it always did. */ +function renderViews() { + const btn = document.querySelector('#views button[data-view="phases"]'); + if (!btn) return; + const all = Object.values(S.state?.phases || {}); + const running = all.filter(p => p.running).length; + const halted = all.filter(p => p.halted); + btn.innerHTML = 'Phases' + + (running ? `${running}` : '') + + (halted.length ? '' : ''); + btn.title = halted.length + ? halted.map(p => `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` + + `${p.haltedWhy || p.halted}`).join(' · ') + ' — open Phases to read it and run it again' + : running + ? `${running} phase${running > 1 ? 's' : ''} running — a swimlane each` + : 'One swimlane per phase: its cards, its log, its controls'; +} + function setView(view) { // leaving a view drops where you were in it: a hidden view's scrollers // read 0 anyway, and coming back with a remembered offset would be @@ -1079,7 +1174,7 @@ function setView(view) { narrowed to a few characters still shows it. It is the in-flight glyph the CI and copilot chips already use, not an emoji, and a quiet board is the plain title, byte for byte. */ -const VIEW_TITLES = { board: 'bench', flight: 'sessions', focus: 'focus' }; +const VIEW_TITLES = { board: 'bench', phases: 'phases', flight: 'sessions', focus: 'focus' }; const WORKING_MARK = '◌'; function tabTitle(project, view, working) { @@ -1105,14 +1200,23 @@ function render() { renderTitle(); renderChip(); renderPhases(); + renderViews(); renderSync(); - if (S.view === 'board') renderBoard(); - else if (S.view === 'flight') renderFlight(); - else renderFocus(); + if (S.view === 'flight') renderFlight(); + else if (S.view === 'focus') renderFocus(); + else renderCards(); renderBar(); renderLog(); } +/* Two views draw cards, and they draw the same cards: the Board's columns + and a phase lane's. Anything that changes what a card looks like — + selecting one, closing the drawer — redraws whichever of them you are + on, rather than assuming the Board. */ +function renderCards() { + if (S.view === 'phases') renderLanes(); else renderBoard(); +} + /* ── board ────────────────────────────────────────────────────────────── */ /* A phase member is not drawn on the Board view. It keeps its stage, its @@ -1388,17 +1492,22 @@ function cardFor(task) { + 'this board becomes the one that advances it', run: () => runPhase(task, { takeover: true }) } : { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…', - title: ph && ph.halted - ? 'Run it again — the halt is cleared and the phase carries on from ' - + 'where it stopped' + title: ph && ph.stopped + ? 'Run it again — the phase carries on from where it was held' : 'A branch of its own, each card in the list run on it in turn, and ' + 'one PR into main at the end', run: () => runPhase(task) }; - // a halt holds until the phase is run again or held: both are here, - // because a person who has read it and does not want to carry on - // needs a way to say so that is not walking the card backwards + // A halt holds until the phase is run again or held. Holding is here, + // because a person who does not want to carry on needs a way to say + // so that is not walking the card backwards — but running it again is + // not: clearing a halt should mean having read what caused it, and + // the reason is in the lane, under the halt. So this points there. + const room = { glyph: '⟶', label: 'phases', busy: 'opening…', + title: 'Open the Phases view — this phase\'s lane, the halt that stopped it ' + + 'and ▸ run again beneath it', + run: () => { setView('phases'); return true; } }; if (ph && ph.running) actions.push(hold); - else if (ph && ph.halted) actions.push(start, hold); + else if (ph && ph.halted) actions.push(room, hold); else actions.push(start); } else if (task.stage === 'in-progress') { actions.push(held @@ -1965,7 +2074,7 @@ async function addToPhase(task, phase) { function showDetail(task) { const changed = !S.selected || S.selected.file !== task.file; S.selected = task; - renderBoard(); + renderCards(); if (changed) $('#drawerbody').scrollTop = 0; } @@ -2077,7 +2186,7 @@ function renderDrawer() { const member = findTask(row.dataset.member); if (member) showDetail(member); })); - $('#closeDrawer').addEventListener('click', () => { S.selected = null; renderBoard(); }); + $('#closeDrawer').addEventListener('click', () => { S.selected = null; renderCards(); }); } function allRecentEvents() { @@ -2179,6 +2288,291 @@ async function unarchiveLast() { await loadState(); } +/* ── phases: a swimlane each ────────────────────────────────────────────── + + The Board draws the work you are personally holding; this draws what the + phases are holding. One lane per phase card, the same five stages left to + right, and the cards are the cards — the very nodes cardFor() builds for + the Board, agent line, chips and hover actions intact. That fidelity is + the whole reason this is a view and not a rail squeezed into five + columns that could never spare the width. + + Nothing here ends a phase. Merging is a board move on the phase card, so + there is exactly one place where work leaves the board, and it is not + this one. */ + +/* Where a lane sits in the stack: a halt first, because it is the one + thing on this page waiting on a person and it must not be below the + fold; then a run in flight; then the phases nobody has started; then the + ones that are over. Every phase card draws a lane — a phase whose + members are all merged and which is waiting on its own PR still owns + them, and a lane that vanished exactly when it needed merging would be + a view you could not trust. */ +function laneRank(task) { + const snap = (S.state.phases || {})[task.file]; + if (snap && snap.halted) return 0; + if (snap && snap.running) return 1; + return task.stage === 'done' ? 3 : 2; +} + +function lanePhases() { + return allTasks().filter(t => t.isPhase).sort((a, b) => + laneRank(a) - laneRank(b) || + (+a.number || 0) - (+b.number || 0) || + a.file.localeCompare(b.file)); +} + +/* `07`, `7` and `#007` are one card, on this side of the wire too — + taskfiles.canonical_number, in the language the page is written in. */ +function canonicalNumber(number) { + const n = parseInt(number, 10); + return Number.isNaN(n) ? String(number ?? '') : String(n); +} + +/* The phase log: the `-