diff --git a/AGENTS.md b/AGENTS.md index 6e9cd0f..4808fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -470,6 +470,20 @@ step narrates in the ticker; a merge conflict aborts cleanly and the card stays put. Cards without work move silently, and hand-moves on disk are never intercepted — the board only asks when you act through it. +That work takes as long as it takes, so **the card wears it** rather than +sitting there looking idle while its branch is disassembled: from the +first step to the last it takes the accent border and a breathing +`completing` pill — the same vocabulary as an agent working, because that +is what is happening — and carries the latest narrated step on its +activity line ("parking the drive", "merged task/29-… into main", +"cleaned up: worktree and local branch removed"). While it does, it has +no hover actions, no drive or command chips and cannot be dragged, and a +second `complete` for the same card is refused rather than started. The +claim lives in this board's memory and is released on every exit — +merged, conflicted or crashed — so a failure gives the card straight back +and a board restarted mid-completion leaves nothing stuck. Other replicas +see the card unchanged until the move itself arrives. + With `BOARD_SYNC` on the merge is made **on origin** instead: the board runs `gh pr merge` on the card's PR, cleans up and moves the card, and local `main` fast-forwards to the result on the next beat. Replicas diff --git a/manager/core/board.html b/manager/core/board.html index 1e45357..9fb67cc 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -161,6 +161,15 @@ /* the last run on this card died: the same terracotta, worn until the next launch replaces it or the card moves stage */ .card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))} + /* merge & clean up is running on this card: the working vocabulary, + because that is what is happening — the board is disassembling its + branch. No new colour, no new animation, and nothing to grab: the card + is not draggable and carries no actions until it lands in done/. */ + .card.completing{ + border-color:color-mix(in oklab, var(--accent) 55%, var(--border));cursor:default; + } + .card.completing:hover{transform:none;border-color:color-mix(in oklab, var(--accent) 55%, var(--border))} + .card .pill.status.breathing{animation:breathe 2.4s ease-in-out infinite} /* tool chips: destinations, not statuses — they live in the card's footer, never squeezed into the author row */ .chiprow{ @@ -817,6 +826,11 @@ function connectStream() { } else if (msg.type === 'board_event') { S.state?.boardEvents.push(msg.event); scheduleRender(); + } else if (msg.type === 'completing') { + // the cards this board is merging and cleaning up, and which step + // each is on — whole map, so it can never go stale in pieces + if (S.state) S.state.completing = msg.completing; + scheduleRender(); } }; } @@ -968,13 +982,17 @@ function cardFor(task) { const working = agent && agent.mode !== 'review'; const verdict = task.stage === 'review' ? prVerdict(task) : null; const failure = failedRun(task); + // the server holds this, not the tab that clicked: while merge & clean up + // runs, the card is mid-disassembly and hands back none of its actions + const completing = (S.state.completing || {})[task.file]; el.className = 'card' + (S.selected && S.selected.file === task.file ? ' selected' : '') + (working ? ' running' : '') + (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '') + (failure ? ' run-failed' : '') + + (completing ? ' completing' : '') + (task.stage === 'done' ? ' done-dim' : ''); - el.draggable = true; + el.draggable = !completing; let tint = working ? 'var(--accent)' : STAGE_TINT[task.stage]; let pill = agent && agent.mode === 'review' @@ -994,14 +1012,21 @@ function cardFor(task) { title: failure.excerpt }; tint = 'var(--alarm)'; } + if (completing) { + // live work outranks every settled reading: the branch behind an + // 'approved' pill is being merged away as you look at it + pill = { text: 'completing', tint: 'var(--accent)', bg: mix('var(--accent)', 16), + title: 'merge & clean up is running — the card comes back when it lands in done/' }; + tint = 'var(--accent)'; + } const high = (task.priority || '').toLowerCase() === 'high'; const top = [ - ``, + ``, `${task.number ? '#' + esc(task.number) : esc(task.file.slice(0, 10))}`, '', high ? 'HIGH' : '', task.statusMismatch ? `drift` : '', - `${pill.text}`, ]; @@ -1010,7 +1035,10 @@ function cardFor(task) { const stillTrue = { glyph: '◔', label: 'still true?', confirm: 'check it?', busy: 'checking…', title: 'A read-only agent checks this task is still true of the codebase', run: () => fireAgent(task, '/api/agent/review') }; - if (agent) { + if (completing) { + // none, deliberately: an action that looks available and does nothing + // is the same lie as a card that looks idle while its branch is deleted + } else if (agent) { actions.push({ glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…', title: 'Stop this agent — nothing is lost', run: () => stopAgent(agent.id) }); @@ -1088,7 +1116,12 @@ function cardFor(task) { if (task.type) extras.push(esc(task.type.toLowerCase())); let liveLine = ''; - if (agent && agent.session) { + if (completing) { + // the steps are narrated as board events against this file; the card + // wears the latest one instead of leaving the ticker to tell the story + liveLine = `