From 11763d424bfdd037641b85918ffcc1555619a24f Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Mon, 26 Oct 2026 18:13:27 -0700 Subject: [PATCH 01/19] docs(web): design for the dashboard and tabbed page redesign Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf --- .../specs/2026-10-26-web-redesign-design.md | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-26-web-redesign-design.md diff --git a/docs/superpowers/specs/2026-10-26-web-redesign-design.md b/docs/superpowers/specs/2026-10-26-web-redesign-design.md new file mode 100644 index 0000000..544b913 --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-web-redesign-design.md @@ -0,0 +1,366 @@ +# Web page redesign + +Status: draft for owner review · 2026-10-26 + +## Intent + +The status page (`src/web.html`, served by `src/web.ts`) shows everything on one screen: a main +column (Now, Pipeline, Feed) and a right sidebar of six panels (Phase, Owner, Needs you, Waiting +on, Tick, Recent). The owner looks at the header, the control buttons, Now, Pipeline and Feed. Of +the right sidebar, only Needs you is checked regularly; the rest is noise on the screen they open +most. + +The redesign keeps the screen the owner uses as a clean **Dashboard** and moves everything else +behind a left navigation sidebar with four more tabs: **Needs you**, **Waiting on**, **Board** and +**Session**. It also modernises the look (more room per item, larger type, cards) and gives each +foreman instance its own color theme so that several instances open in several browser tabs are +easy to tell apart. + +## Goals + +- A Dashboard that answers "what is the foreman doing right now" at a glance: daemon state, + controls, the running session, the in-flight pipeline and the live feed. +- Needs you as its own tab with a red count badge, grouped by the action the owner takes. +- A whole-board overview (every open issue, grouped by phase epic) with the per-task model and + effort pickers. +- A Session tab with accumulated spend and per-task cost history, filterable by date. +- A per-instance color theme, with the instance name prominent at the top of the sidebar. +- Readable on a laptop and usable at phone width. + +## Non-goals + +- No new foreman behaviour. Every action the page can take today it can still take, with the same + confirmation text and the same server-side allowlists; no new kind of GitHub write. +- No change to the daemon loop, the scheduler, the ledger grammar or the roles. +- No frontend framework, bundler or build step for the page. +- No multi-instance overview page. Each instance keeps its own page on its own port. + +## Anti-goals + +- A page that looks modern but hides something the owner needs when the foreman is stuck. Every + reason the foreman is idle (STOP file, failed preflight, daily cap, paused phase, stalled + session, orphaned child, unfinished bookkeeping) must stay visible on the Dashboard. +- More GitHub API spend. Everything new is computed from the snapshot the tick already fetches or + from local logs. +- A write that skips its confirmation, or an endpoint that accepts an issue the page is not + offering. + +## Constraints + +- One file, `src/web.html`, with inline CSS and vanilla JS. `pnpm build` keeps copying it to + `dist/`. Fonts come from Google Fonts with system fallbacks (the current page already loads IBM + Plex this way): Inter for text, JetBrains Mono for code-like values. +- Tabs are client-side, selected by `location.hash` (`#dashboard`, `#needs-you`, `#waiting`, + `#board`, `#session`) so a reload keeps the tab and a tab can be linked. Default `#dashboard`. +- One `/api/status` poll every 3 s, as today. The Session tab fetches its own endpoint (below) + only while it is open. +- Every new `StatusReport` / `Board` field is optional or defaulted, so existing tests and an older + `state.json` keep working (CLAUDE.md: every optional parameter defaults to today's behaviour). +- Nothing repository-specific in `src/`: no project names, no package managers in sample text. +- `foreman.json` is never served or written by the page (it may hold `webAuth` and a Slack + webhook). Page-set preferences go to `state.json` through `store.patch`, like the model and cap + overrides already do (`src/cli/daemon.ts`, `setModel` / `setCap`). + +## Visual reference + +The approved mockups are on the owner's Mac mini (not in this public repository, because they +contain real project data): +`/Users/matthewsmith/claude-out/foreman-redesign/.superpowers/brainstorm/85817-1793061231/content/` + +| File | Approved for | +|---|---| +| `dashboard-v4.html` | Dashboard layout, card style, Pipeline dot track, Feed density, narrow layout | +| `colors-v1.html` | The four color schemes (dark), with a light-mode strip each | +| `sidebar-v1.html` | Option **A**: name with an initial tile; theme picker in the footer | +| `session-tab-v1.html` | Session tab, including the Daemon card | +| `needs-you-v2.html` | Needs you: group order and button colors | +| `needs-waiting-v1.html` | Waiting on (second half of the page) | +| `board-v1.html` | Board tab | + +Where this document and a mockup disagree, this document wins. + +## Design + +### Shell + +- **Layout:** a 232 px left sidebar and a main column. The old right sidebar is gone. +- **Sidebar header (option A):** a 40 px rounded tile in the theme's primary color holding the + instance name's first letter, the instance name at 19 px bold, `foreman · ` under it in + muted text, then the daemon pill: `Running · up 12d` (green), `Stopped` (amber), `Crashed` + (red), `Unknown` (amber). STOP present adds `· STOP file` to the pill. +- **Navigation:** Dashboard, Needs you, Waiting on, Board, Session, each with an icon. The active + tab uses the theme's soft accent background. +- **Badges:** + + | Tab | Badge counts | Style | + |---|---|---| + | Needs you | rows in Approve a plan + Sign off a phase + Unblock + Answer or close (Ready to plan is not counted) | red when > 0, grey 0 | + | Waiting on | rows shown in its groups (banners not counted) | grey | + | Others | none | | + +- **Sidebar footer:** pid, and the theme picker: four color swatches, the current one ringed. +- **Feedback:** the result message of every action (today's `#msg`) becomes a toast in the + bottom-right corner for about 5 s; errors stay until dismissed. +- **Narrow screens:** below about 760 px of page width the sidebar becomes a horizontal strip of + icon tabs at the top (badges kept, the active tab shows its name), the header controls wrap to + their own row, and stat tiles go 2 × 2. Below about 460 px only the active tab shows its label. + Use container queries so the breakpoints follow the content width. + +### Themes + +Four schemes, each with a dark and a light token set. Dark values (from `colors-v1.html`): + +| Theme | bg | surface | sunk | line | accent (text) | accent-soft | primary (buttons) | on-primary | +|---|---|---|---|---|---|---|---|---| +| indigo | #0E1015 | #161A21 | #1D222B | #262C36 | #8EA6FF | #1E2645 | #4263EB | #FFFFFF | +| violet | #0E0D13 | #16151E | #1E1C29 | #2A2736 | #B4A4FF | #2A2250 | #6E56CF | #FFFFFF | +| teal | #0B1114 | #121A1E | #182328 | #223036 | #5FD4E4 | #0F3239 | #22B8CF | #04161A | +| mono | #0A0A0B | #141416 | #1C1C1F | #27272B | #EDEDEF | #2A2A2F | #EDEDEF | #0A0A0B | + +Ink for all dark themes follows the indigo row's pattern (`ink` ≈ #ECEEF2, `ink-2` ≈ #A3AAB5, +`ink-3` ≈ #6E7581), tinted toward each theme as in the mockup. Light primaries: indigo #3B5BDB, +violet #6E56CF, teal #0C8599 (white text), mono #111113 (white text); the light ground and soft +accent values are on each theme's strip in the mockup. The builder derives the remaining light +tokens in the same structure. + +Status colors are shared by every theme and keep one meaning each: green done/ok (#5CCB8D dark), +amber warning (#F0B44C), red failed (#FF8787), each with a soft background variant. In mono, the +active-stage dot and the session progress bar use a muted blue (#5B8DEF) because its primary is +near-white. + +The page follows `prefers-color-scheme` as today; the theme choice selects which pair of token +sets applies. + +Every solid button meets 4.5:1 text contrast. The pale accent is for text, the active nav item and +tool pills only; it is never a button fill. (This fixes the current Go button, which is too light +on a dark background.) + +**Choosing a theme:** the swatches in the sidebar footer call `POST /api/theme`. With no choice +saved, the theme is derived from the instance name (a stable hash over the four themes), so +instances differ before anyone picks. + +### Dashboard + +From top to bottom: + +1. **Header row:** the page title `Dashboard`, then the controls in two groups: `Go` (solid + primary), `Refresh project`, `Compute next`; then `Stop` and `Abort` (red text, armed + two-click confirm as today). +2. **Banners**, only when present, each one line with its fix: STOP file present (amber, "Go + resumes"), preflight failed (red, with the reason), daily cap reached (amber, "N of N + sessions today", with a link to the Session tab's cap control), a paused phase (amber), a + stalled session (red, "no output for Nm"), an orphaned child (red), unfinished bookkeeping + (red, "run ctl abort"), a pending stop/abort (amber), and "no state.json: restart the daemon" + when the daemon state is UNKNOWN. +3. **Compute next output**, when requested: a dismissable card under the banners with the same + text the `
` shows today.
+4. **Now card.** While a session runs: a role chip (`REVIEWER`), round chip, effort chip when not
+   high; the title `# · ` at 20 px; a meta line with the PR, the model and the
+   branch; the elapsed-time bar with start, `Nm of Mm` and deadline; four stat tiles (turns,
+   tokens, 5 h window, 7 d window); and the latest tool call with its age, plus the latest
+   assistant text in italics when present. With no session, the card is compact: the phase label
+   (`idle`, `between ticks`, `ticking`, `parked`) and its one-line detail, as today.
+5. **Pipeline.** One card per in-flight issue: `#<issue> <title>` with a sub-line (PR, claim host,
+   role and round, fix round); then the stage track; then tags (model when pinned, `<level>
+   effort` when not high, `blocked`, `CI pending`, `mergeable`, `fix round N`). The track is
+   five dots (Build, Review, Validate, CI, Merge) joined by a line, labels under the dots: green
+   filled for done, a blue ring for active, red filled for failed, hollow grey for pending,
+   dashed for skipped. The track column has a fixed width and the tag column a fixed width, so
+   every card's dots line up, with about 32 px between the track and the tags. When the pipeline
+   is narrower than about 760 px, the track moves to its own full-width row under the title,
+   separated by a hairline, and the tags stay top-right.
+6. **Feed.** A card of compact rows (about 26 px each): time, a kind pill (tool name in the
+   accent pill, red pill for a tool error, plain text for `init`/`text`/`result`), the summary
+   with ellipsis, assistant text wrapping in italics. Newest last, auto-scroll as today, the
+   newest row highlighted. It shows the current session; after "View feed" on the Session tab it
+   shows that session, with a "Back to live" chip in its header.
+
+The Recent list, the Tick panel, the Phase card and the Owner card are no longer on the
+Dashboard; see "Where everything moved".
+
+### Needs you
+
+Grouped by the action the owner takes, in this order, oldest first within a group. Each row shows
+the wait time large on the left (`51d`, amber once over 30 days), the issue number and title, a
+sub-line, and its action on the right.
+
+1. **Approve a plan** · epics offering `approve_plan`. Sub-line: when the plan was drafted and a
+   "read the plan" link. Button: **Approve plan**, solid theme primary.
+2. **Sign off a phase** · epics offering `sign_off`. Sub-line: a small green progress bar with
+   `done/total tasks`, the phase spend, and a link to its review issue. Button: **Sign off**,
+   solid green (#2B8A3E, white text).
+3. **Ready to plan** · epics offering `start_planning`. Not counted in the badge. Sub-line notes a
+   planner session costs about $5. Button: **Start planning**, amber tint (amber-soft
+   background, amber text and border).
+4. **Unblock** · `needsYou` items labelled `blocked`, with the reason from the waiting detail.
+   Button: **Unblock**, neutral.
+5. **Answer or close** · the remaining `needsYou` items (`decision`, `needs-owner`). Label tags
+   and, for an old phase review whose epic is no longer open, "epic already closed". Button:
+   **Open ↗** (link to the issue), ghost style.
+
+Each group title has a small dot in its button's color. Every button keeps today's
+`window.confirm` text.
+
+A phase review issue (`Phase <N> review: <epic title>`, created by the phase-closer with
+`needs-owner`) is shown on its epic's Sign off row, not as its own row. Match it by the epic's
+title appearing in the review title, not by the `phase:<N>` label alone, because several epics can
+share a phase number (10a, 10b, 10c). A review issue with no open matching epic goes to Answer or
+close.
+
+Pause and Resume are no longer on this tab; they live on the Board's epic headers.
+
+### Waiting on
+
+- A status line at the top: green "Nothing stops new claims: …" when no global blocker is active;
+  otherwise the same banners as the Dashboard (STOP, preflight, cap, paused).
+- Then groups by wait kind with counts: CI, Review cycle, Dependencies, Another Mac
+  (`other_host`), Phase gate, and Fix on GitHub (the `human` waits that are not on Needs you, such
+  as "agent-ready but Status Backlog" or "no Parent epic line", and "phase N held").
+- Each row: a kind icon, the subject and detail, a sub-line with context (the issue title, the
+  state of each dependency), and `since` when known.
+- Dependency rows for the same issue merge into one (`#270 waits on #268 and #269`).
+- Any `human` wait whose subject issue or epic is a row on Needs you is not repeated; a dashed
+  link row at the bottom points to Needs you with its count.
+
+### Board
+
+Every open issue on the project board, grouped under its phase epic.
+
+- **Top bar:** title with a count line (`N open issues in M phases · read from the last tick,
+  HH:MMZ`), a search box (number or title), a Status filter (All, Ready, In progress, In review,
+  Backlog, Blocked, each with a count) and a "Show done" checkbox.
+- **Epic card header:** `PHASE <N>` eyebrow, `#<epic> <title>`, a state chip (Planning / Plan
+  waiting / Building / Sign-off waiting / Not started, plus Paused), and **Pause** or **Resume**.
+  A facts line: green progress bar `done of total`, spend and session count, median claim→merge
+  (from today's phase progress), or "no tasks yet" for an epic still being planned.
+- **Task rows** in a table: issue and title with a sub-line of labels (`blocked` in red, `size:*`,
+  `area:*`) and a hint (`waits on #268, #269`, `next to claim`, `validator failed · fix round 2`);
+  Status as a colored dot and word (Backlog grey, Ready blue, In progress amber, In review violet,
+  Done green); PR; a **model** picker; an **effort** picker.
+- **Pickers** are compact dropdowns. A value that differs from the default is highlighted (model
+  in the accent color, effort in amber). The effort menu lists low, medium, high (marked
+  default), xhigh, max; the model menu lists `StatusReport.model.choices` and `default`. Choosing
+  a value shows the same confirm text as today and calls `/api/task-model` or
+  `/api/task-effort`.
+- **Done tasks** collapse into one "▸ N done" row per epic. Closed issues are not in the snapshot,
+  so the expanded list shows numbers (linked) without titles.
+- **Issues whose epic is closed** (a signed-off phase with leftovers) group under `Phase <N>` from
+  their `phase:<N>` label, slightly dimmed, with no Pause button. The closed epic's title is not
+  in the snapshot, so the header reads `Phase <N> · epic closed`. Issues with no epic and no
+  phase label group under "No phase".
+
+### Session
+
+- **Range filter:** a segmented control: Today, 7 days, 30 days, All (the whole `sessions.log`),
+  Custom (a from/to date pair). Days are local days, the same boundary `todayStats` uses for the
+  daily cap.
+- **Totals:** four tiles: spend, sessions, tasks and epics touched, spend per session.
+- **Daily spend:** a single-series bar chart, one bar per day in range, theme accent bars with
+  4 px rounded tops and a 2 px gap, a recessive grid with `$` labels, the peak bar labelled, and a
+  hover tooltip per bar (date, cost, session count). One series, so no legend.
+- **By role:** horizontal bars per role, sorted by spend, value at the end.
+- **Cost by task:** a table, most expensive first: issue and title, role chips, session count,
+  last run, cost with an inline bar. The first ten rows show, then "Show all N". Clicking a row
+  expands its sessions: time, role, model, turns, outcome (green/amber/red), cost, and **View
+  feed**, which switches to the Dashboard with that session pinned in the Feed.
+- **Daemon card** (what today's Tick panel holds): model for new sessions (buttons, current one
+  highlighted, override note), daily session cap (today's count and spend, cap buttons, override
+  note), GitHub budget (remaining of limit, reset time, last tick's read attribution), and a line
+  with last tick and preflight, next tick, backoff, and launchd. Same endpoints and confirm text
+  as today.
+
+### Where everything moved
+
+| Today | Redesign |
+|---|---|
+| Header: daemon badge, pid, uptime | Sidebar pill (state, uptime); pid in sidebar footer |
+| Header: STOP present, stalled, "no state.json" | Dashboard banners |
+| Header buttons, Compute next output | Dashboard header row; output card under the banners |
+| Now, Pipeline, Feed | Dashboard |
+| Phase card (progress, spend, cycle time) | Board epic headers; Sign off rows on Needs you |
+| Phase card task model pins / effort buttons | Board task pickers |
+| Owner card: Sign off, Approve plan, Start planning | Needs you groups |
+| Owner card: Pause / Resume | Board epic headers |
+| Needs you card | Needs you tab |
+| Waiting on card | Waiting on tab |
+| Tick card | Session tab, Daemon card; blockers also as Dashboard banners |
+| Recent card | Session tab, Cost by task (expanded rows) |
+
+## Data and API changes
+
+All computed from the existing snapshot or local files; no new GitHub reads.
+
+1. **`StatusReport.instance`** (string): the instance name, for the sidebar header and the theme
+   hash. Optional in the type so old fixtures parse; the daemon always sets it.
+2. **`StatusReport.theme`**: `{ current, source: "override" | "derived", choices }`. Stored as
+   `theme` in `state.json` via `store.patch`. **`POST /api/theme`** `{ theme: <name> | "default" }`,
+   allowlisted to the four names plus `default` (which clears the override).
+3. **`OwnerItem.since`** (ISO or null): when the epic started waiting, for the wait-time column.
+   `src/waiting.ts` already computes this for the same epics.
+4. **`Board.issues[]`**, built in `describeBoard` from `Snapshot.issues`, `prs` and `epics`: for
+   every open non-epic issue, `{ issue, title, status, labels (display subset), epic, phase, pr,
+   dependsOn, openDependsOn, blocked, model, effort }`. The epic is the open epic whose
+   `taskNumbers` include the issue; else the `phase:<N>` label; else none.
+5. **`Board.epics[]`** or an extension of `phases`, so the Board can show every open epic
+   (including ones being planned) with its state, not only approved phases.
+6. **Needs-you pairing**: the review-issue-to-epic match described above, computed server-side so
+   the badge count and the rows agree.
+7. **Allowlist for the pickers**: `/api/task-model` and `/api/task-effort` accept any issue in
+   `Board.issues` (today: only tasks under an approved phase). Still the board, still no arbitrary
+   issue number.
+8. **`GET /api/sessions?from=YYYY-MM-DD&to=YYYY-MM-DD`** (both optional; absent means the whole
+   log): `{ totals, daily[], byRole[], byIssue[] }`, each `byIssue` row with its entries. Computed
+   from `readSessions`. Sessions are counted by distinct `sessionId`; cost is summed the same way
+   `todayStats` sums it, so the Session tab and the cap counter agree. Same host check / auth as
+   every other route. The page fetches it when the Session tab opens, when the range changes, and
+   when `recent[0].sessionId` changes in the status poll.
+9. **Titles in the cost history**: see open question 1.
+
+## Calls made during design (owner may override)
+
+- Theme choice is stored in `state.json` through `store.patch`, not in `foreman.json` as first
+  said in conversation: the daemon reads `foreman.json` only at start and the page never writes
+  it; `state.json` is the existing home for page-set overrides and is per instance.
+- With no saved choice, the theme is derived from the instance name.
+- The sidebar shows the instance name (what `foreman -p` uses), not the repository name.
+- A phase review issue shows on its epic's Sign off row.
+- Ready to plan is not counted in the Needs you badge.
+- Waits over 30 days turn amber.
+- The tab is labelled "Session" (the conversation's "Session info").
+- Session days are local days, matching the daily cap.
+- Session tab "View feed" switches to the Dashboard with that session pinned.
+- Board pickers are offered for every open issue on the board, widening the allowlist from
+  approved-phase tasks.
+
+## Open questions
+
+1. **Titles for closed issues in the cost history.** `sessions.log` records the issue number but
+   not its title, and closed issues are not in the snapshot, so most history rows would show only
+   `#N`. Options: (a) add an optional `title` to each new `sessions.log` entry, so rows from now on
+   have titles and older rows show `#N` (or a title from any later entry for the same issue); (b)
+   look titles up on GitHub when the Session tab opens, cached in the instance directory, which
+   spends API budget the page avoids today; (c) both.
+
+## Left to the builder
+
+- Exact spacing, radii and type sizes beyond what the mockups show; icons (inline SVG, as in the
+  mockups).
+- The full light-mode token sets, following the dark structure and the strip values.
+- The hash function for the derived theme.
+- Whether `Board.epics` is a new field or an extension of `phases`.
+- How the page keeps a tab's scroll position and an open picker across the 3 s re-render (today's
+  page re-renders whole sections; the Board pickers and the Session expansions must survive a
+  poll, the way `openPhases` survives today).
+- Toast behaviour details.
+
+## Testing
+
+- TDD for every new pure function: `Board.issues` and epic grouping, needs-you grouping and review
+  pairing, the Waiting on de-duplication and badge counts, session aggregation by range, the theme
+  derivation.
+- `src/web.test.ts`: new routes (`/api/theme`, `/api/sessions`, the widened picker allowlist)
+  with the existing request-level tests; the `web.html` string-contains tests at the end of the
+  file are rewritten for the new markup (confirm strings, endpoint paths, element ids).
+- `pnpm lint && pnpm typecheck && pnpm test` green; `pnpm build`; then dogfood with `npm link` on
+  a live instance and check every tab at laptop and phone width, in both color schemes.
+- `README.md`: update the section that describes the page.

From d671445b12230e8c14a70bcbdf7d5442dd813778 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:23:15 -0700
Subject: [PATCH 02/19] docs(web): settle builder-check points in the redesign
 spec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 .../specs/2026-10-26-web-redesign-design.md   | 48 +++++++++++++------
 1 file changed, 34 insertions(+), 14 deletions(-)

diff --git a/docs/superpowers/specs/2026-10-26-web-redesign-design.md b/docs/superpowers/specs/2026-10-26-web-redesign-design.md
index 544b913..3196b6b 100644
--- a/docs/superpowers/specs/2026-10-26-web-redesign-design.md
+++ b/docs/superpowers/specs/2026-10-26-web-redesign-design.md
@@ -119,7 +119,8 @@ Four schemes, each with a dark and a light token set. Dark values (from `colors-
 
 Ink for all dark themes follows the indigo row's pattern (`ink` ≈ #ECEEF2, `ink-2` ≈ #A3AAB5,
 `ink-3` ≈ #6E7581), tinted toward each theme as in the mockup. Light primaries: indigo #3B5BDB,
-violet #6E56CF, teal #0C8599 (white text), mono #111113 (white text); the light ground and soft
+violet #6E56CF, teal #0B7285 (white text; darker than the mockup's #0C8599, which fails 4.5:1),
+mono #111113 (white text); the light ground and soft
 accent values are on each theme's strip in the mockup. The builder derives the remaining light
 tokens in the same structure.
 
@@ -136,8 +137,10 @@ tool pills only; it is never a button fill. (This fixes the current Go button, w
 on a dark background.)
 
 **Choosing a theme:** the swatches in the sidebar footer call `POST /api/theme`. With no choice
-saved, the theme is derived from the instance name (a stable hash over the four themes), so
-instances differ before anyone picks.
+saved, the daemon picks one at start and saves it, so it never changes on its own afterwards: the
+theme least used by the other instances on this Mac (read from their `state.json` files via
+`listInstances`), ties broken by a stable hash of the instance name. Up to four instances
+therefore start with four different themes; a fifth repeats one.
 
 ### Dashboard
 
@@ -188,7 +191,7 @@ sub-line, and its action on the right.
    "read the plan" link. Button: **Approve plan**, solid theme primary.
 2. **Sign off a phase** · epics offering `sign_off`. Sub-line: a small green progress bar with
    `done/total tasks`, the phase spend, and a link to its review issue. Button: **Sign off**,
-   solid green (#2B8A3E, white text).
+   solid green (#287A3A, white text, 5.3:1).
 3. **Ready to plan** · epics offering `start_planning`. Not counted in the badge. Sub-line notes a
    planner session costs about $5. Button: **Start planning**, amber tint (amber-soft
    background, amber text and border).
@@ -202,10 +205,12 @@ Each group title has a small dot in its button's color. Every button keeps today
 `window.confirm` text.
 
 A phase review issue (`Phase <N> review: <epic title>`, created by the phase-closer with
-`needs-owner`) is shown on its epic's Sign off row, not as its own row. Match it by the epic's
-title appearing in the review title, not by the `phase:<N>` label alone, because several epics can
-share a phase number (10a, 10b, 10c). A review issue with no open matching epic goes to Answer or
-close.
+`needs-owner`) is shown on its epic's Sign off row, not as its own row. Pair them in this order:
+(1) the epic's own comment `Phase review: #<review>`, which `roles/phase-closer.md` has the
+phase-closer post and which is already in the snapshot's comments; (2) otherwise, the epic's title
+with its leading `Phase <X>:` removed appearing in the review's title. Never pair by the
+`phase:<N>` label alone, because several epics can share a phase number. A review issue with no
+open matching epic goes to Answer or close.
 
 Pause and Resume are no longer on this tab; they live on the Board's epic headers.
 
@@ -299,8 +304,12 @@ All computed from the existing snapshot or local files; no new GitHub reads.
    `src/waiting.ts` already computes this for the same epics.
 4. **`Board.issues[]`**, built in `describeBoard` from `Snapshot.issues`, `prs` and `epics`: for
    every open non-epic issue, `{ issue, title, status, labels (display subset), epic, phase, pr,
-   dependsOn, openDependsOn, blocked, model, effort }`. The epic is the open epic whose
-   `taskNumbers` include the issue; else the `phase:<N>` label; else none.
+   dependsOn, openDependsOn, blocked, model, effort }`. Grouping, in order: (1) the open epic
+   whose `taskNumbersOf` set includes the issue (sub-issues plus the body's `Parent epic: #N`
+   line, the same set the phase progress uses); (2) else, by its `phase:<N>` label, the one open
+   epic with that phase when exactly one exists; (3) else, when several open epics share that
+   phase, a `Phase <N> · not linked to an epic` group; (4) else, when no open epic has that phase,
+   `Phase <N> · epic closed`; (5) else "No phase".
 5. **`Board.epics[]`** or an extension of `phases`, so the Board can show every open epic
    (including ones being planned) with its state, not only approved phases.
 6. **Needs-you pairing**: the review-issue-to-epic match described above, computed server-side so
@@ -310,8 +319,8 @@ All computed from the existing snapshot or local files; no new GitHub reads.
    issue number.
 8. **`GET /api/sessions?from=YYYY-MM-DD&to=YYYY-MM-DD`** (both optional; absent means the whole
    log): `{ totals, daily[], byRole[], byIssue[] }`, each `byIssue` row with its entries. Computed
-   from `readSessions`. Sessions are counted by distinct `sessionId`; cost is summed the same way
-   `todayStats` sums it, so the Session tab and the cap counter agree. Same host check / auth as
+   from `readSessions`. Sessions and cost are counted per log line, the same way `todayStats`
+   counts them, so the Session tab and the cap counter agree. Same host check / auth as
    every other route. The page fetches it when the Session tab opens, when the range changes, and
    when `recent[0].sessionId` changes in the status poll.
 9. **Titles in the cost history**: see open question 1.
@@ -321,7 +330,12 @@ All computed from the existing snapshot or local files; no new GitHub reads.
 - Theme choice is stored in `state.json` through `store.patch`, not in `foreman.json` as first
   said in conversation: the daemon reads `foreman.json` only at start and the page never writes
   it; `state.json` is the existing home for page-set overrides and is per instance.
-- With no saved choice, the theme is derived from the instance name.
+- With no saved choice, the daemon saves the theme least used by the other instances on this Mac,
+  so instances start distinct and a theme never shifts on its own.
+- Review pairing uses the epic's `Phase review: #<review>` comment first, then a title match.
+- Board grouping uses `taskNumbersOf`, then a unique open epic for the phase label.
+- Session counts follow `todayStats` (per log line).
+- Sign off green is #287A3A and light teal is #0B7285, to meet 4.5:1.
 - The sidebar shows the instance name (what `foreman -p` uses), not the repository name.
 - A phase review issue shows on its epic's Sign off row.
 - Ready to plan is not counted in the Needs you badge.
@@ -339,7 +353,13 @@ All computed from the existing snapshot or local files; no new GitHub reads.
    `#N`. Options: (a) add an optional `title` to each new `sessions.log` entry, so rows from now on
    have titles and older rows show `#N` (or a title from any later entry for the same issue); (b)
    look titles up on GitHub when the Session tab opens, cached in the instance directory, which
-   spends API budget the page avoids today; (c) both.
+   spends API budget the page avoids today; (c) both; (d) neither: titles only for issues still
+   open (from the snapshot), `#N` for the rest. (a) touches `appendSession` in `src/loop.ts`,
+   which the non-goals otherwise leave alone; (b) adds a `gh` read path; (d) is page-only.
+2. **Issues that are open but not on the project board.** Some repositories have open issues with
+   no board Status (phase review issues, follow-ups filed by hand). Show them on the Board under
+   an "Off board" status, or leave them off? Showing them adds an "Off board" filter and widens
+   the picker allowlist to every open issue the snapshot holds.
 
 ## Left to the builder
 

From 796a23983444bcaf6880da543d068a9f03b41b4b Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:28:55 -0700
Subject: [PATCH 03/19] docs(web): record owner decisions on history titles and
 off-board issues

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 .../specs/2026-10-26-web-redesign-design.md   | 37 +++++++++----------
 1 file changed, 18 insertions(+), 19 deletions(-)

diff --git a/docs/superpowers/specs/2026-10-26-web-redesign-design.md b/docs/superpowers/specs/2026-10-26-web-redesign-design.md
index 3196b6b..15836ed 100644
--- a/docs/superpowers/specs/2026-10-26-web-redesign-design.md
+++ b/docs/superpowers/specs/2026-10-26-web-redesign-design.md
@@ -1,6 +1,6 @@
 # Web page redesign
 
-Status: draft for owner review · 2026-10-26
+Status: final draft, open questions answered · 2026-10-26
 
 ## Intent
 
@@ -31,7 +31,8 @@ easy to tell apart.
 
 - No new foreman behaviour. Every action the page can take today it can still take, with the same
   confirmation text and the same server-side allowlists; no new kind of GitHub write.
-- No change to the daemon loop, the scheduler, the ledger grammar or the roles.
+- No change to the daemon loop, the scheduler, the ledger grammar or the roles, except recording
+  the issue title in each `sessions.log` entry (see "Owner decisions").
 - No frontend framework, bundler or build step for the page.
 - No multi-instance overview page. Each instance keeps its own page on its own port.
 
@@ -233,7 +234,7 @@ Every open issue on the project board, grouped under its phase epic.
 
 - **Top bar:** title with a count line (`N open issues in M phases · read from the last tick,
   HH:MMZ`), a search box (number or title), a Status filter (All, Ready, In progress, In review,
-  Backlog, Blocked, each with a count) and a "Show done" checkbox.
+  Backlog, Blocked, Off board, each with a count) and a "Show done" checkbox.
 - **Epic card header:** `PHASE <N>` eyebrow, `#<epic> <title>`, a state chip (Planning / Plan
   waiting / Building / Sign-off waiting / Not started, plus Paused), and **Pause** or **Resume**.
   A facts line: green progress bar `done of total`, spend and session count, median claim→merge
@@ -303,7 +304,7 @@ All computed from the existing snapshot or local files; no new GitHub reads.
 3. **`OwnerItem.since`** (ISO or null): when the epic started waiting, for the wait-time column.
    `src/waiting.ts` already computes this for the same epics.
 4. **`Board.issues[]`**, built in `describeBoard` from `Snapshot.issues`, `prs` and `epics`: for
-   every open non-epic issue, `{ issue, title, status, labels (display subset), epic, phase, pr,
+   every open non-epic issue, on the board or not (`status` null shows as "Off board"), `{ issue, title, status, labels (display subset), epic, phase, pr,
    dependsOn, openDependsOn, blocked, model, effort }`. Grouping, in order: (1) the open epic
    whose `taskNumbersOf` set includes the issue (sub-issues plus the body's `Parent epic: #N`
    line, the same set the phase progress uses); (2) else, by its `phase:<N>` label, the one open
@@ -323,7 +324,8 @@ All computed from the existing snapshot or local files; no new GitHub reads.
    counts them, so the Session tab and the cap counter agree. Same host check / auth as
    every other route. The page fetches it when the Session tab opens, when the range changes, and
    when `recent[0].sessionId` changes in the status poll.
-9. **Titles in the cost history**: see open question 1.
+9. **`SessionLogEntry.title`** (optional string): see "Owner decisions" 1. `readSessions` keeps
+   reading old lines without it.
 
 ## Calls made during design (owner may override)
 
@@ -346,20 +348,17 @@ All computed from the existing snapshot or local files; no new GitHub reads.
 - Board pickers are offered for every open issue on the board, widening the allowlist from
   approved-phase tasks.
 
-## Open questions
-
-1. **Titles for closed issues in the cost history.** `sessions.log` records the issue number but
-   not its title, and closed issues are not in the snapshot, so most history rows would show only
-   `#N`. Options: (a) add an optional `title` to each new `sessions.log` entry, so rows from now on
-   have titles and older rows show `#N` (or a title from any later entry for the same issue); (b)
-   look titles up on GitHub when the Session tab opens, cached in the instance directory, which
-   spends API budget the page avoids today; (c) both; (d) neither: titles only for issues still
-   open (from the snapshot), `#N` for the rest. (a) touches `appendSession` in `src/loop.ts`,
-   which the non-goals otherwise leave alone; (b) adds a `gh` read path; (d) is page-only.
-2. **Issues that are open but not on the project board.** Some repositories have open issues with
-   no board Status (phase review issues, follow-ups filed by hand). Show them on the Board under
-   an "Off board" status, or leave them off? Showing them adds an "Off board" filter and widens
-   the picker allowlist to every open issue the snapshot holds.
+## Owner decisions after review
+
+1. **Titles in the cost history: record them from now on.** `SessionLogEntry` gains an optional
+   `title`, filled from the issue the session ran on wherever `appendSession` is called in
+   `src/loop.ts`. This is the one change to the loop, and it is additive. On the Session tab, a
+   row's title comes from the newest log entry for that issue that has one, else from the
+   snapshot if the issue is still open, else `#N`. No GitHub lookup.
+2. **Open issues not on the project board are shown.** They appear on the Board with the status
+   "Off board" (a hollow grey dot), grouped like any other issue, and the Status filter gains an
+   "Off board" bucket with its count. The model and effort pickers work on them, since
+   `Board.issues` is the picker allowlist.
 
 ## Left to the builder
 

From 2e23b4ec2afc925eff87361cd113e20a80cfd1c9 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:33:40 -0700
Subject: [PATCH 04/19] docs(web): implementation plan for the page redesign

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 .../plans/2026-10-26-web-redesign.md          | 512 ++++++++++++++++++
 1 file changed, 512 insertions(+)
 create mode 100644 docs/superpowers/plans/2026-10-26-web-redesign.md

diff --git a/docs/superpowers/plans/2026-10-26-web-redesign.md b/docs/superpowers/plans/2026-10-26-web-redesign.md
new file mode 100644
index 0000000..ebe5fb5
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-26-web-redesign.md
@@ -0,0 +1,512 @@
+# Web Page Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the one-screen status page with a Dashboard plus four tabs (Needs you, Waiting on, Board, Session), per-instance color themes and a modern card layout, backed by the data the new tabs need.
+
+**Architecture:** Server first: every new fact the page shows is computed in TypeScript from the snapshot the tick already has or from local logs, stored on `Board` / `StatusReport` (or served by one new `GET /api/sessions`), and unit-tested. Then `src/web.html` is rewritten tab by tab as one vanilla HTML/CSS/JS file with hash routing, rendering those fields.
+
+**Tech Stack:** TypeScript (ESM, Node), zod, vitest, Biome; vanilla HTML/CSS/JS with container queries; Google Fonts (Inter, JetBrains Mono).
+
+**Spec:** `docs/superpowers/specs/2026-10-26-web-redesign-design.md`. Mockups (visual reference only, local to the owner's Mac mini): `/Users/matthewsmith/claude-out/foreman-redesign/.superpowers/brainstorm/85817-1793061231/content/` — `dashboard-v4.html`, `colors-v1.html`, `sidebar-v1.html` (option A), `session-tab-v1.html`, `needs-you-v2.html`, `needs-waiting-v1.html` (Waiting on half), `board-v1.html`. Where spec and mockup disagree, the spec wins.
+
+## Global Constraints
+
+- One file, `src/web.html`, inline CSS and vanilla JS; no framework, bundler or build step. `pnpm build` keeps copying it to `dist/`.
+- Fonts: Inter for text, JetBrains Mono for code-like values, from Google Fonts, with system fallbacks.
+- Tabs selected by `location.hash`: `#dashboard`, `#needs-you`, `#waiting`, `#board`, `#session`; default `#dashboard`.
+- One `/api/status` poll every 3 s. `/api/sessions` is fetched only while the Session tab is open.
+- Every new `StatusReport` / `Board` / `ForemanState` / `SessionLogEntry` field is optional or defaulted, so existing tests and older `state.json` / `sessions.log` files keep working.
+- Nothing repository-specific in `src/`: `grep -rniE "pnpm|supabase|docker|playwright" src roles settings` must still return only `src/repo-config.ts`. No project names in sample text.
+- `foreman.json` is never served or written by the page. Page-set preferences go to `state.json` through `store.patch`.
+- No new GitHub reads. `gh` stays the only GitHub client; every write keeps going through `GitHub` and keeps its server-side allowlist.
+- Every write button keeps today's `window.confirm` text (Stop/Abort keep the armed two-click confirm).
+- Every value interpolated into HTML goes through `esc()`.
+- Contrast: every solid button ≥ 4.5:1 text contrast. Sign off green `#287A3A`; light teal primary `#0B7285`.
+- Style: ESM, `.ts` import extensions, double quotes, semicolons, 100-column Biome, `noUncheckedIndexedAccess`; tests next to code as `*.test.ts`; commit messages `type(scope): summary`.
+- Before every commit: `pnpm lint && pnpm typecheck && pnpm test` green.
+
+## Review Focus
+
+- **No state, no log, no board** (a fresh instance, or a daemon that has never ticked): every tab renders an empty state instead of throwing; `/api/sessions` returns zeros. Pinned by tests in Tasks 6 and 7.
+- **An older `state.json`** written before this change (no `theme`, `issues`, `groups`, owner `since`/`review`): `readState` still parses it and the page still renders. Pinned in Tasks 2, 4 and 6.
+- **A bad custom date range** (`from` after `to`, `2026-13-01`, `../etc`): `/api/sessions` answers 400, never 500 and never a partial result. Pinned in Task 7.
+- **An issue title containing HTML** (`<img src=x onerror=…>`): shown as text on every tab. Pinned by the `esc(` checks in Tasks 8–11 and the dogfood step in Task 12.
+- **The 3 s re-render while the owner is mid-action**: an open Board picker, an expanded Session row, a pinned feed or a scrolled tab must survive a poll. Pinned by the state-set checks in Tasks 10 and 11 and the dogfood step in Task 12.
+
+---
+
+### Task 1: Record the issue title in `sessions.log`
+
+**Files:**
+- Modify: `src/sessions.ts` (`SessionLogEntry`, `readSessions`)
+- Modify: `src/loop.ts:533-543` and `src/loop.ts:574-584` (the two `appendSession` calls)
+- Test: `src/sessions.test.ts`, `src/loop.test.ts`
+
+**Interfaces:**
+- Produces: `SessionLogEntry.title?: string` — the issue's (or, for a planner/phase-closer, the epic's) title when the session ran. Absent on older lines.
+
+- [ ] **Step 1: Write the failing tests**
+
+In `src/sessions.test.ts`:
+```ts
+it("reads a title when present and leaves it undefined on older lines", () => {
+  // write two lines: one with "title":"feat: a grid", one without
+  const [withTitle, without] = readSessions(dir);
+  expect(withTitle?.title).toBe("feat: a grid");
+  expect(without?.title).toBeUndefined();
+});
+```
+In `src/loop.test.ts`, extend "sessions.log records the model, turns, duration, denials and subtype" (or add a sibling test) so the issue is `issue({ number: 1, title: "feat: a grid" })` and the parsed line `toMatchObject({ title: "feat: a grid" })`.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `pnpm test -- src/sessions.test.ts src/loop.test.ts`
+Expected: FAIL — `title` undefined.
+
+- [ ] **Step 3: Implement**
+
+Add `title?: string` to `SessionLogEntry` with a doc comment ("the issue title at the time; absent on lines written before titles were recorded"). In `readSessions`, set `title` only when `typeof j.title === "string"`. In both `appendSession` calls in `src/loop.ts`, add `title: r.issue.title`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `pnpm test -- src/sessions.test.ts src/loop.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/sessions.ts src/sessions.test.ts src/loop.ts src/loop.test.ts
+git commit -m "feat(sessions): record the issue title on each sessions.log line"
+```
+
+---
+
+### Task 2: `Board.issues` and `Board.groups` for the Board tab
+
+**Files:**
+- Create: `src/board-issues.ts`, `src/board-issues.test.ts`
+- Modify: `src/state-file.ts` (new schemas; `BoardSchema` fields)
+- Modify: `src/board.ts` (`describeBoard` fills them)
+- Modify: `src/status.ts` (`describeStatus` carries them into the merged board)
+- Test: `src/board.test.ts`, `src/status.test.ts`, `src/state-file.test.ts`
+
+**Interfaces:**
+- Consumes: `taskNumbersOf(e: Epic, issues: Issue[]): Set<number>` (`src/phase-progress.ts`), `parseDependsOn(body)`, `phaseOf(labels)` (`src/github.ts`), `MODEL_LABEL_PREFIX`, `EFFORT_LABEL_PREFIX`.
+- Produces (in `src/state-file.ts`):
+  ```ts
+  export const BoardIssueSchema = z.object({
+    issue: z.number().int(),
+    title: z.string(),
+    status: z.string().nullable(),            // null = not on the project board ("Off board")
+    labels: z.array(z.string()),              // display subset: "blocked", "size:*", "area:*"
+    group: z.string(),                        // BoardGroup.key this issue is listed under
+    pr: z.number().int().nullable(),          // open non-draft PR whose `issue` is this one
+    dependsOn: z.array(z.number().int()),
+    openDependsOn: z.array(z.number().int()), // the subset still open in the snapshot
+    blocked: z.boolean(),
+    model: z.string().nullable(),             // `model:<name>` label value
+    effort: z.string().nullable(),            // `effort:<level>` label value
+  });
+  export const BoardGroupSchema = z.object({
+    key: z.string(),                          // "epic:<n>" | "phase:<n>:unlinked" | "phase:<n>:closed" | "none"
+    kind: z.enum(["epic", "unlinked", "closed", "none"]),
+    epic: z.number().int().nullable(),
+    phase: z.number().nullable(),
+    title: z.string(),
+    state: z.enum(["planning", "plan_waiting", "building", "signoff_waiting", "not_started"]).nullable(),
+    paused: z.boolean(),
+    doneTasks: z.array(z.number().int()),     // closed task numbers (taskNumbersOf minus open ones)
+  });
+  // BoardSchema gains:
+  issues: z.array(BoardIssueSchema).default([]),
+  groups: z.array(BoardGroupSchema).default([]),
+  ```
+  and `export function describeBoardIssues(s: Snapshot): { issues: BoardIssue[]; groups: BoardGroup[] }` in `src/board-issues.ts`.
+
+Rules (from the spec, "Data and API changes" 4 and "Board"):
+- `issues`: every open non-epic issue in `s.issues`, on the board or not.
+- Grouping, first match wins: (1) the open epic whose `taskNumbersOf` set has the issue → `epic:<n>`; (2) else the one open epic whose `phase` equals the issue's `phase:<N>` label, when exactly one exists → `epic:<n>`; (3) several open epics share that phase → `phase:<N>:unlinked`, title `Phase <N> · not linked to an epic`; (4) no open epic has that phase → `phase:<N>:closed`, title `Phase <N> · epic closed`; (5) else `none`, title `No phase`.
+- Every open epic gets a group, even with no open issues. Epic title from its issue.
+- `state` from epic labels: `needs-owner` + `plan-approved` → `signoff_waiting`; `needs-owner` alone → `plan_waiting`; `plan-approved` → `building`; `agent-ready` → `planning`; else `not_started`. `paused` = `foreman:pause`. Non-epic groups: `state` null, `paused` false.
+- Group order: epic groups by `phase` then epic number; then `unlinked` and `closed` groups by phase; `none` last. Issues ordered by number within a group.
+
+- [ ] **Step 1: Write the failing tests** in `src/board-issues.test.ts` (use `issue`, `epic`, `snapshot`, `pr` from `test/helpers.ts`):
+  - `"lists open non-epic issues, off-board ones with status null"` — an issue with `status: null` appears with `status: null`.
+  - `"groups by taskNumbersOf, including a Parent epic line"` — epic 10 with `taskNumbers: []`, issue body `Parent epic: #10` → `group: "epic:10"`.
+  - `"falls back to the single open epic with the issue's phase label"` — one open epic `phase:7`, issue labelled `phase:7` with no parent → `epic:<that epic>`.
+  - `"puts a shared-phase issue in an unlinked group"` — two open epics with `phase: 10`, issue `phase:10` unlinked → group `phase:10:unlinked`, title `"Phase 10 · not linked to an epic"`.
+  - `"puts an issue whose phase has no open epic in a closed group"` → `phase:4:closed`, `"Phase 4 · epic closed"`.
+  - `"puts an issue with neither in No phase"` → `none`, `"No phase"`.
+  - `"gives every open epic a group with its state, even with no issues"` — labels → each of the five `state` values and `paused: true` for `foreman:pause`.
+  - `"fills pr, dependsOn, openDependsOn, blocked, model, effort and the display labels"` — PR `pr({ number: 276, issue: 268 })`; body `## Depends on\n- #269\n- #250` with 269 open, 250 absent → `openDependsOn: [269]`; labels `["blocked","size:L","area:web","agent-ready","model:sonnet","effort:xhigh","phase:7"]` → `labels: ["blocked","size:L","area:web"]`, `model: "sonnet"`, `effort: "xhigh"`.
+  - `"lists closed task numbers as doneTasks"` — epic `taskNumbers: [1,2,3]`, only 3 open → `doneTasks: [1,2]`.
+
+  In `src/board.test.ts`: `describeBoard` output has `issues` and `groups` equal to `describeBoardIssues(s)`. In `src/status.test.ts`: a stored board with `issues`/`groups` comes back on `report.board`. In `src/state-file.test.ts`: a `state.json` board without `issues`/`groups` parses with both `[]`.
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pnpm test -- src/board-issues.test.ts src/board.test.ts src/status.test.ts src/state-file.test.ts`
+Expected: FAIL — module / fields missing.
+
+- [ ] **Step 3: Implement** the schemas, `describeBoardIssues`, the two fields in `describeBoard`, and `issues: stored?.issues ?? [], groups: stored?.groups ?? []` in the merged board in `describeStatus`. Fix any test fixture that builds a full `Board` literal by adding `issues: [], groups: []`.
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `pnpm test`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/board-issues.ts src/board-issues.test.ts src/state-file.ts src/board.ts src/status.ts src/*.test.ts
+git commit -m "feat(board): every open issue grouped under its phase epic"
+```
+
+---
+
+### Task 3: Board issues are the model/effort picker allowlist
+
+**Files:**
+- Modify: `src/task-model.ts` (`phaseTasks` → `pickerTasks`, `applyTaskModelToBoard`), `src/task-effort.ts` (`applyTaskEffortToBoard`), `src/web.ts` (`WebDeps`), `src/cli/daemon.ts`
+- Test: `src/task-model.test.ts`, `src/task-effort.test.ts`, `src/web.test.ts`
+
+**Interfaces:**
+- Consumes: `Board.issues` (Task 2).
+- Produces: `export function pickerTasks(board: Board | null): { issue: number; model: string | null; effort: string | null }[]` in `src/task-model.ts` — `board.issues` when non-empty, else today's open phase tasks (an older board before its first new tick). `WebDeps.phaseTasks` is renamed `WebDeps.pickerTasks` with that return type.
+
+- [ ] **Step 1: Write the failing tests**
+  - `src/task-model.test.ts`: `"pickerTasks lists every board issue, off-board included"`; `"pickerTasks falls back to open phase tasks when issues is empty"`; `"applyTaskModelToBoard updates board.issues too"`.
+  - `src/task-effort.test.ts`: `"applyTaskEffortToBoard updates board.issues too"`.
+  - `src/web.test.ts`: `/api/task-model` and `/api/task-effort` accept an issue present only in `pickerTasks()` (an off-board issue) and still answer 400 `"#999 is not a task on the board"` for one that is absent.
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pnpm test -- src/task-model.test.ts src/task-effort.test.ts src/web.test.ts`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement** `pickerTasks`; map `issues` in both `apply…ToBoard` functions; rename the dep in `src/web.ts` and its two allowlist checks; in `src/cli/daemon.ts` use `pickerTasks(store.get().board)` for the allowlist and for `current` in `setTaskModel` / `setTaskEffort`.
+
+- [ ] **Step 4: Run to verify pass** — `pnpm test` → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/task-model.ts src/task-effort.ts src/web.ts src/cli/daemon.ts src/*.test.ts
+git commit -m "feat(web): model and effort pickers for every issue on the board"
+```
+
+---
+
+### Task 4: Owner wait time and phase-review pairing
+
+**Files:**
+- Create: `src/review-pairing.ts`, `src/review-pairing.test.ts`
+- Modify: `src/state-file.ts` (`OwnerItemSchema`, `NeedsYouItemSchema`), `src/owner.ts` (`ownerItems`), `src/needs-you.ts` (`needsYouItems`)
+- Test: `src/owner.test.ts`, `src/needs-you.test.ts`, `src/state-file.test.ts`
+
+**Interfaces:**
+- Produces:
+  ```ts
+  // OwnerItemSchema gains
+  since: z.string().nullable().default(null),     // the epic issue's updatedAt
+  review: z.number().int().nullable().default(null), // paired phase review issue, sign_off epics only
+  // NeedsYouItemSchema gains
+  reviewOf: z.number().int().nullable().default(null), // epic this review issue is paired with
+  phaseReview: z.boolean().default(false),         // title matches /^Phase .+ review:/
+  ```
+  `export function pairReviews(s: Snapshot): Map<number, number>` in `src/review-pairing.ts` — review issue number → open epic number.
+
+Pairing rule (spec, Needs you): (1) an open epic's own comment matching `/^Phase review: #(\d+)/m` pairs that issue; (2) otherwise, a needs-owner issue whose title matches `/^Phase .+ review: (.+)$/` pairs with the open epic whose title, with a leading `/^Phase [^:]+:\s*/` removed, appears in the review's title. Never by `phase:<N>` alone. Comment wins over title.
+
+- [ ] **Step 1: Write the failing tests** in `src/review-pairing.test.ts`:
+  - `"pairs by the epic's Phase review comment"` — epic 514 comment `Phase review: #571` → `get(571) === 514`.
+  - `"pairs by title with the epic's Phase prefix removed"` — epic `Phase 10b: Services, tuition and enrollment`, review `Phase 10b review: Services, tuition and enrollment` → paired.
+  - `"pairs a review whose title repeats the epic's full title"` — epic `Phase 10c: Platform subscriptions`, review `Phase 10c review: Phase 10c: Platform subscriptions` → paired.
+  - `"does not pair two epics that share a phase number by label"` — epics 10a/10b both `phase:10`, review titled for 10b → paired only with 10b.
+  - `"leaves a review unpaired when no open epic matches"`.
+
+  In `src/owner.test.ts`: `ownerItems` sets `since` to the epic issue's `updatedAt` and `review` on a sign_off epic. In `src/needs-you.test.ts`: `reviewOf` set for a paired review, `phaseReview: true` with `reviewOf: null` for an unpaired one. In `src/state-file.test.ts`: an older board's owner/needsYou rows parse with the defaults.
+
+- [ ] **Step 2: Run to verify failure** — `pnpm test -- src/review-pairing.test.ts src/owner.test.ts src/needs-you.test.ts src/state-file.test.ts` → FAIL.
+
+- [ ] **Step 3: Implement** `pairReviews`; call it from `ownerItems` and `needsYouItems`.
+
+- [ ] **Step 4: Run to verify pass** — `pnpm test` → PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/review-pairing.ts src/review-pairing.test.ts src/state-file.ts src/owner.ts src/needs-you.ts src/*.test.ts
+git commit -m "feat(needs-you): owner wait time and phase review pairing"
+```
+
+---
+
+### Task 5: Attention: badge counts, banners and Waiting on rows
+
+**Files:**
+- Create: `src/attention.ts`, `src/attention.test.ts`
+- Modify: `src/status.ts` (`StatusReport.attention`)
+- Test: `src/status.test.ts`
+
+**Interfaces:**
+- Consumes: `Board` with `owner`, `needsYou` (Task 4 fields), `waiting`.
+- Produces:
+  ```ts
+  export const BANNER_KINDS = ["stop", "preflight", "cap", "paused"] as const;
+  export interface Attention {
+    needsYou: number;      // Needs you badge
+    waiting: number;       // Waiting on badge (= waitRows.length)
+    banners: WaitItem[];   // BANNER_KINDS items, in WAIT_KINDS order
+    waitRows: WaitItem[];  // what the Waiting on tab lists
+  }
+  export function describeAttention(board: Board | null): Attention;
+  // StatusReport gains: attention: Attention
+  ```
+
+Rules (spec, Shell badges and Waiting on):
+- `needsYou` = owner items offering `approve_plan` or `sign_off` + `needsYou` items with `reviewOf === null`. `start_planning` not counted.
+- `waitRows` = every waiting item except banner kinds, except a `human` item whose subject (`#N` or `epic #N`) names an issue or epic that is a Needs you row (any owner item with `approve_plan`, `sign_off` or `start_planning`, or any `needsYou` issue), with `dependency` items for the same subject merged into one: detail `#270 waits on #268 and #269` (three or more: `#a, #b and #c`), `since` the earliest non-null.
+
+- [ ] **Step 1: Write the failing tests** in `src/attention.test.ts`:
+  - `"counts approve and sign-off epics and unpaired needs-you issues, not start_planning"` — owner items: one `["approve_plan","pause"]`, one `["sign_off","pause"]`, one `["start_planning","pause"]`, one `["pause"]`; needsYou: three items, one with `reviewOf: 514` → `needsYou: 4`.
+  - `"moves stop, preflight, cap and paused into banners"`.
+  - `"drops human waits whose subject is on Needs you and keeps the rest"` — `epic #93 awaits owner sign-off` dropped; `#41 is agent-ready but Status Backlog` kept.
+  - `"merges dependency rows for one issue"` — two items for `#270` → one with detail `"#270 waits on #268 and #269"`.
+  - `"is all zeros and empty for a null board"`.
+
+  In `src/status.test.ts`: `describeStatus` returns `attention` computed from the merged board (live `stop` wait appears in `banners`).
+
+- [ ] **Step 2: Run to verify failure** — `pnpm test -- src/attention.test.ts src/status.test.ts` → FAIL.
+- [ ] **Step 3: Implement** `describeAttention`; set `attention: describeAttention(board)` in `describeStatus`.
+- [ ] **Step 4: Run to verify pass** — `pnpm test` → PASS.
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/attention.ts src/attention.test.ts src/status.ts src/status.test.ts
+git commit -m "feat(status): badge counts, banners and de-duplicated waiting rows"
+```
+
+---
+
+### Task 6: Per-instance theme and instance name
+
+**Files:**
+- Create: `src/theme.ts`, `src/theme.test.ts`
+- Modify: `src/state-file.ts` (`ForemanStateSchema.theme`, `initialState`), `src/status.ts` (`StatusInput.instance`, `StatusReport.instance`, `StatusReport.theme`), `src/web.ts` (`POST /api/theme`, `WebDeps.setTheme`), `src/cli/daemon.ts`
+- Test: `src/state-file.test.ts`, `src/status.test.ts`, `src/web.test.ts`
+
+**Interfaces:**
+- Produces:
+  ```ts
+  export const THEMES = ["indigo", "violet", "teal", "mono"] as const;
+  export const ThemeSchema = z.enum(THEMES);
+  export type ThemeName = z.infer<typeof ThemeSchema>;
+  export function nameHash(name: string): number;          // stable 32-bit (FNV-1a)
+  export function pickTheme(name: string, taken: readonly ThemeName[]): ThemeName;
+  // ForemanStateSchema gains: theme: ThemeSchema.nullable().default(null)
+  // initialState(o) accepts theme?: ThemeName | null
+  // StatusInput gains: instance?: string   (default "")
+  // StatusReport gains: instance: string; theme: { current: ThemeName; choices: readonly ThemeName[] }
+  // WebDeps gains: setTheme: (theme: ThemeName) => Promise<string>
+  ```
+- `pickTheme`: the themes used least often in `taken`; among those, `candidates[nameHash(name) % candidates.length]`.
+- `StatusReport.theme.current` = `state.theme ?? pickTheme(instance, [])` (a daemon that has never run still gets a stable color).
+- Daemon start: `theme: previous?.theme ?? pickTheme(instance.name, others)`, where `others` are the non-null `readState(i.dir)?.theme` of every other entry in `listInstances()`. Saved immediately (it is part of `initialState`), so it never shifts on its own.
+- `POST /api/theme` body `{ theme: ThemeName }`; anything else → 400 `"bad theme"`; non-POST → 405. Handler: `store.patch({ theme })`, returns `"theme is now <name>"`. (The spec's `"default"` value is dropped: a theme is always saved, so there is no override to clear.)
+
+- [ ] **Step 1: Write the failing tests**
+  - `src/theme.test.ts`: `"pickTheme is stable for a name"`; `"pickTheme takes the only unused theme"` (`taken = ["indigo","violet","teal"]` → `"mono"`); `"four instances in turn get four different themes"` (fold `pickTheme` over four names, accumulating `taken`); `"a fifth instance repeats one"`.
+  - `src/state-file.test.ts`: older state parses with `theme: null`; `initialState({ …, theme: "teal" }).theme === "teal"`.
+  - `src/status.test.ts`: `instance` echoed; `theme.current` from state; with `state: null`, `theme.current === pickTheme(instance, [])`.
+  - `src/web.test.ts`: `POST /api/theme {"theme":"violet"}` → 200 and `setTheme` called with `"violet"`; `{"theme":"pink"}` → 400; `GET` → 405.
+
+- [ ] **Step 2: Run to verify failure** — `pnpm test -- src/theme.test.ts src/state-file.test.ts src/status.test.ts src/web.test.ts` → FAIL.
+- [ ] **Step 3: Implement** the above; pass `instance: instance.name` into `describeStatus` in the daemon.
+- [ ] **Step 4: Run to verify pass** — `pnpm test` → PASS.
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/theme.ts src/theme.test.ts src/state-file.ts src/status.ts src/web.ts src/cli/daemon.ts src/*.test.ts
+git commit -m "feat(web): per-instance color theme, distinct by default"
+```
+
+---
+
+### Task 7: Session history endpoint
+
+**Files:**
+- Create: `src/session-history.ts`, `src/session-history.test.ts`
+- Modify: `src/web.ts` (`GET /api/sessions`, `WebDeps.sessions`), `src/cli/daemon.ts`
+- Test: `src/web.test.ts`
+
+**Interfaces:**
+- Consumes: `SessionLogEntry.title` (Task 1), `Board.issues` (Task 2), `localDate` (`src/preflight.ts`).
+- Produces:
+  ```ts
+  export interface SessionRange { from: string | null; to: string | null } // YYYY-MM-DD, local, inclusive
+  export interface SessionHistory {
+    from: string | null; to: string | null;
+    totals: { spendUsd: number; sessions: number; issues: number };
+    daily: { date: string; spendUsd: number; sessions: number }[];
+    byRole: { role: string; spendUsd: number; sessions: number }[];
+    byIssue: {
+      issue: number; title: string; roles: string[]; sessions: number;
+      lastAt: string; costUsd: number; entries: SessionLogEntry[];
+    }[];
+  }
+  export function describeSessionHistory(
+    entries: SessionLogEntry[], range: SessionRange, openTitles: ReadonlyMap<number, string>,
+  ): SessionHistory;
+  export function boardTitles(board: Board | null): Map<number, string>; // issues, owner, needsYou, pipeline
+  export function parseRange(from: string | null, to: string | null): SessionRange | null; // null = invalid
+  // WebDeps gains: sessions: (range: SessionRange) => SessionHistory
+  ```
+
+Rules (spec, Session and Data 8):
+- Filter by `localDate(new Date(e.t))` within `[from, to]`; a null bound is open.
+- Count per log line, cost summed per line (same as `todayStats`).
+- `daily`: one row per local date from `from ?? first entry's date` to `to ?? last entry's date`, zero rows filled; `[]` when no entries in range.
+- `byRole` sorted by `spendUsd` desc; `byIssue` sorted by `costUsd` desc, `entries` newest first, `roles` in first-seen order.
+- Title: the newest entry in the whole log (not only the range) for that issue with a `title`; else `openTitles`; else `#N`.
+- `parseRange`: each present bound must match `/^\d{4}-\d{2}-\d{2}$/` and be a real date; `from <= to`; otherwise `null`.
+- Route: `GET /api/sessions?from=&to=` → `parseRange` null → 400 `"bad range"`; else 200 with `d.sessions(range)`. Same auth/host check as every route.
+- Daemon: `sessions: (range) => describeSessionHistory(readSessions(STATE_DIR), range, boardTitles(store.get().board))`.
+
+- [ ] **Step 1: Write the failing tests** in `src/session-history.test.ts`:
+  - `"totals, daily, byRole and byIssue for the whole log"` (three entries over two days, two roles, two issues; assert exact numbers).
+  - `"fills zero days between the first and last entry"`.
+  - `"filters by an inclusive local-date range"`.
+  - `"takes a title from the newest titled line, then open titles, then #N"`.
+  - `"is zeros and empty arrays for no entries"`.
+  - `"parseRange rejects a malformed date, a non-date and from after to"` (`"2026-13-01"`, `"../etc"`, `from: "2026-10-27", to: "2026-10-01"` → `null`).
+
+  In `src/web.test.ts`: `GET /api/sessions` → 200 with `sessions` called with `{ from: null, to: null }`; `?from=2026-10-01&to=2026-10-26` passes the range through; `?from=nope` → 400.
+
+- [ ] **Step 2: Run to verify failure** — `pnpm test -- src/session-history.test.ts src/web.test.ts` → FAIL.
+- [ ] **Step 3: Implement** the module, the route and the daemon dep.
+- [ ] **Step 4: Run to verify pass** — `pnpm test` → PASS.
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/session-history.ts src/session-history.test.ts src/web.ts src/cli/daemon.ts src/web.test.ts
+git commit -m "feat(web): session cost history endpoint"
+```
+
+---
+
+### Task 8: Page shell, themes and the Dashboard
+
+**Files:**
+- Modify (rewrite): `src/web.html`
+- Test: `src/web.test.ts` (the `describe("web.html", …)` block)
+
+**Interfaces:**
+- Consumes: `StatusReport` fields `instance`, `theme`, `attention`, `current`, `board.pipeline`, `nowPhase`, `tick`, `stopPresent`, `orphan`, `unfinished`, `stopping`, `daemon`, `uptimeMinutes`, `pid`, `lastPlan`; `POST /api/theme`; existing `/api/go|stop|abort|refresh|next|feed`.
+- Produces (for Tasks 9–11, in the page script): `const $ = …`, `const esc = …`, `function route()` reading `location.hash`, `function toast(message, isError)`, one `<section data-tab="…">` per tab, `let feedSession`, `let feedPinned`, a `function pinFeed(sessionId)` that switches to `#dashboard` and pins the Feed, and a `function renderAll(r)` that each later task extends with its tab's renderer.
+
+Spec sections to implement: Shell (layout, sidebar header option A, nav with icons, badges from `r.attention`, footer with pid and theme swatches, toast, narrow layout via container queries at about 760 px and 460 px), Themes (token table; light sets; `prefers-color-scheme`; status colors; mono active-stage `#5B8DEF`; pale accent never a button fill), Dashboard (header row and button groups, banners from `r.attention.banners` plus stalled / orphan / unfinished / stopping / UNKNOWN, Compute next card, Now card, Pipeline cards with the five-dot track in a fixed-width column and fixed-width tag column, Feed compact rows with "Back to live" when pinned).
+
+- [ ] **Step 1: Rewrite the failing `web.html` tests.** Replace the existing string checks with:
+  - each tab: `expect(html).toContain('data-tab="dashboard"')` … for all five, and `location.hash`.
+  - themes: contains `data-theme="indigo"` handling and each value `#4263EB`, `#6E56CF`, `#22B8CF`, `#EDEDEF`, `#3B5BDB`, `#0B7285`, `#5B8DEF`; `prefers-color-scheme`; `@container`.
+  - `fetch("/api/theme"`, `fetch("/api/next")`, `fetch("/api/refresh"`, `/api/feed?session=`, `const esc =`, `r.attention`, `Back to live`, `no state.json: restart the daemon`, `run ctl abort`, `Confirm ${cmd}?`.
+  - gone: `expect(html).not.toContain('id="tick"')`, same for `id="recent"`, `id="phases"`, `id="owner"`.
+- [ ] **Step 2: Run to verify failure** — `pnpm test -- src/web.test.ts` → FAIL.
+- [ ] **Step 3: Write the page** shell and Dashboard. Other tabs render a placeholder section for now. Keep the Stop/Abort `armed()` behaviour and the feed auto-scroll rule as they are today.
+- [ ] **Step 4: Run to verify pass** — `pnpm test -- src/web.test.ts` → PASS; then `pnpm lint && pnpm typecheck && pnpm test`.
+- [ ] **Step 5: Check it renders.** `pnpm build`, then serve `dist/web.html` against a recorded status (or a dry-run daemon) and look at it at 1400 px and 400 px wide; dots line up across cards, track drops to its own row when narrow, Go button readable in dark mode.
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/web.html src/web.test.ts
+git commit -m "feat(web): dashboard, navigation sidebar and per-instance themes"
+```
+
+---
+
+### Task 9: Needs you and Waiting on tabs
+
+**Files:**
+- Modify: `src/web.html`, `src/web.test.ts`
+
+**Interfaces:**
+- Consumes: `board.owner` (`since`, `review`, `actions`), `board.needsYou` (`reviewOf`, `phaseReview`, `labels`, `since`), `board.phases` (progress for Sign off rows), `r.attention`; `POST /api/owner`, `POST /api/unblock`.
+
+Spec sections: Needs you (group order Approve a plan → Sign off a phase → Ready to plan → Unblock → Answer or close; wait time large, amber over 30 days; sub-lines; group dots; button classes: Approve solid theme primary, Sign off solid `#287A3A`, Start planning amber tint, Unblock neutral, Open ↗ ghost link; a paired review shows as a link on its epic's Sign off row and is not its own row; unpaired `phaseReview` rows say "epic already closed"); Waiting on (green "Nothing stops new claims" line or the banners; groups CI, Review cycle, Dependencies, Another Mac, Phase gate, Fix on GitHub from `r.attention.waitRows`; link row to Needs you with its count).
+
+- [ ] **Step 1: Write the failing tests:** the five group titles appear in order (`indexOf` strictly increasing); `Nothing stops new claims`; `Fix on GitHub`; `epic already closed`; `#287A3A`; today's confirm texts are kept: `` `Sign off #${n}?` ``, `Approve the plan on #`, `` `Start planning #${n}?` ``, `` `Unblock #${issue}?` ``; `fetch("/api/owner"`, `fetch("/api/unblock"`.
+- [ ] **Step 2: Run to verify failure** — `pnpm test -- src/web.test.ts` → FAIL.
+- [ ] **Step 3: Implement** both tabs.
+- [ ] **Step 4: Run to verify pass**, then the full gate.
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/web.html src/web.test.ts
+git commit -m "feat(web): Needs you and Waiting on tabs"
+```
+
+---
+
+### Task 10: Board tab
+
+**Files:**
+- Modify: `src/web.html`, `src/web.test.ts`
+
+**Interfaces:**
+- Consumes: `board.groups`, `board.issues`, `board.phases` (progress facts by epic), `board.pipeline` (hints such as `validator failed · fix round 2`), `r.lastPlan` (first `claim#N` → "next to claim"), `r.model.choices`, `r.effort.levels`; `POST /api/task-model`, `POST /api/task-effort`, `POST /api/owner` (`pause` / `unpause`).
+
+Spec section: Board (top bar with count line, search, Status filter including Off board with counts, Show done; epic card header with eyebrow, title, state chip, Pause/Resume; facts line; task table with labels sub-line and hint, Status dot colors, PR, model and effort dropdown pickers highlighting non-default values, effort menu marking `high` as default; `▸ N done` row listing linked numbers; dimmed `closed` groups without Pause).
+
+- [ ] **Step 1: Write the failing tests:** `Off board`; `Show done`; `esc(g.title)` (group titles come from the server and are escaped); `fetch("/api/task-model"`, `fetch("/api/task-effort"`; confirm texts kept: `Run every foreman session on #` and `A session running now keeps the effort it started with`; `` `Pause #${n}?` ``; the UI-state sets survive polls: `const openPicker` and `const openDone` (or the names chosen) declared at top level, not inside the renderer.
+- [ ] **Step 2: Run to verify failure** — FAIL.
+- [ ] **Step 3: Implement** the tab. Search and filter state live in top-level variables so a poll re-render keeps them; an open picker stays open across a re-render.
+- [ ] **Step 4: Run to verify pass**, then the full gate.
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/web.html src/web.test.ts
+git commit -m "feat(web): Board tab with every open issue and per-task pickers"
+```
+
+---
+
+### Task 11: Session tab
+
+**Files:**
+- Modify: `src/web.html`, `src/web.test.ts`
+
+**Interfaces:**
+- Consumes: `GET /api/sessions` (Task 7), `pinFeed(sessionId)` (Task 8), `r.model`, `r.cap`, `r.today`, `r.budget`, `r.tick`, `r.launchdInstalled`, `r.recent[0].sessionId`; `POST /api/model`, `POST /api/cap`.
+
+Spec section: Session (range segmented control Today / 7 days / 30 days / All / Custom with local dates; four totals tiles with spend per session computed page-side; Daily spend single-series bar chart — accent bars, 4 px rounded tops, 2 px gap, recessive `$` grid, peak label, per-bar hover tooltip with date, cost and session count, no legend; By role bars; Cost by task table, first ten rows then "Show all N", expandable rows with View feed → `pinFeed`; Daemon card with today's model and cap controls and their confirm texts, GitHub budget, last/next tick, backoff, launchd). Fetch `/api/sessions` on tab open, on range change, and when `r.recent[0]?.sessionId` changes.
+
+- [ ] **Step 1: Write the failing tests:** `` fetch(`/api/sessions ``; the five range labels; `View feed`; `Show all`; kept confirm texts `` `Run foreman sessions as ${model}?` `` and `Set the daily session cap to`; `fetch("/api/model"`, `fetch("/api/cap"`; an expanded-rows set declared at top level.
+- [ ] **Step 2: Run to verify failure** — FAIL.
+- [ ] **Step 3: Implement** the tab.
+- [ ] **Step 4: Run to verify pass**, then the full gate.
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/web.html src/web.test.ts
+git commit -m "feat(web): Session tab with spend history and daemon controls"
+```
+
+---
+
+### Task 12: README and live check
+
+**Files:**
+- Modify: `README.md` (the section describing the status page)
+
+- [ ] **Step 1: Update `README.md`**: describe the Dashboard and the four tabs, the theme picker, `GET /api/sessions` and `POST /api/theme`, and that the Tick/Recent/Phase/Owner panels moved.
+- [ ] **Step 2: Full gate** — `pnpm lint && pnpm typecheck && pnpm test && pnpm build` → all green; `grep -rniE "pnpm|supabase|docker|playwright" src roles settings` → only `src/repo-config.ts`.
+- [ ] **Step 3: Ask the owner before touching a live daemon.** Restarting an instance's daemon is outward-facing; get a yes and which instance. Then `npm link` (once), `pnpm build`, `foreman -p <name> stop`, `foreman -p <name> start`.
+- [ ] **Step 4: Look at every tab** on the live page (`http://mini:<webPort>`, laptop and phone width, dark and light): banners appear for a STOP file (`foreman -p <name> stop` then check, then `go`); an issue title with `<b>` renders as text; open a Board picker and expand a Session row, wait two polls, both still open; pick a theme swatch and reload.
+- [ ] **Step 5: Commit**
+
+```bash
+git add README.md
+git commit -m "docs(readme): the redesigned status page"
+```

From ee5486ef6cf71de163d7722decaac08d2162a517 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:50:36 -0700
Subject: [PATCH 05/19] feat(sessions): record the issue title on each
 sessions.log line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/loop.test.ts     | 6 ++++--
 src/loop.ts          | 2 ++
 src/sessions.test.ts | 9 +++++++++
 src/sessions.ts      | 6 ++++++
 4 files changed, 21 insertions(+), 2 deletions(-)

diff --git a/src/loop.test.ts b/src/loop.test.ts
index aed8f61..01b5281 100644
--- a/src/loop.test.ts
+++ b/src/loop.test.ts
@@ -1160,7 +1160,7 @@ describe("operator interrupt", () => {
     expect(st.get().unfinished).toBeNull();
   });
   it("abort: releases, unassigns, resets Ready; sessions.log records aborted", async () => {
-    const i = issue({ number: 1, status: "In Progress" });
+    const i = issue({ number: 1, title: "feat: a grid", status: "In Progress" });
     const { gh, calls } = fakeGh([i]);
     const ac = new AbortController();
     ac.abort();
@@ -1180,6 +1180,7 @@ describe("operator interrupt", () => {
     expect(calls).toContain("setStatus PVTI_1 Ready");
     const line = readFileSync(join(dir, "sessions.log"), "utf8").trim();
     expect(JSON.parse(line)).toMatchObject({
+      title: "feat: a grid",
       outcome: "aborted",
       // Recorded on an interrupt too, so an aborted session's spend is still comparable.
       model: "opus",
@@ -1188,7 +1189,7 @@ describe("operator interrupt", () => {
     });
   });
   it("sessions.log records the model, turns, duration, denials and subtype", async () => {
-    const i = issue({ number: 1 });
+    const i = issue({ number: 1, title: "feat: a grid" });
     const { gh } = fakeGh([i]);
     const dir = mkdtempSync(join(tmpdir(), "tt-loop-"));
     await execute(
@@ -1207,6 +1208,7 @@ describe("operator interrupt", () => {
     );
     // No stream events reach the fake spawner, so `model` falls back to the dispatched name.
     expect(JSON.parse(readFileSync(join(dir, "sessions.log"), "utf8").trim())).toMatchObject({
+      title: "feat: a grid",
       model: "opus",
       turns: 2,
       durationMinutes: 3.7,
diff --git a/src/loop.ts b/src/loop.ts
index 48d268c..13d1ff8 100644
--- a/src/loop.ts
+++ b/src/loop.ts
@@ -539,6 +539,7 @@ async function runRole(ctx: Ctx, r: RunRole): Promise<void> {
       attempt: current.attempt,
       costUsd: result.costUsd,
       outcome: mode === "abort" ? "aborted" : "interrupted",
+      title: r.issue.title,
       ...sessionMetrics(result, current, model),
     });
     try {
@@ -580,6 +581,7 @@ async function runRole(ctx: Ctx, r: RunRole): Promise<void> {
     attempt: req.attempt,
     costUsd: result.costUsd,
     outcome: result.outcome?.outcome ?? result.subtype,
+    title: r.issue.title,
     ...sessionMetrics(result, current, model),
   });
   await gh.comment(
diff --git a/src/sessions.test.ts b/src/sessions.test.ts
index 11c4fe1..bb62f9e 100644
--- a/src/sessions.test.ts
+++ b/src/sessions.test.ts
@@ -29,6 +29,15 @@ const entry = (over: Partial<SessionLogEntry> = {}): SessionLogEntry => ({
 });
 
 describe("appendSession", () => {
+  it("reads a title when present and leaves it undefined on older lines", () => {
+    const dir = mkdtempSync(join(tmpdir(), "tt-"));
+    appendSession(dir, entry({ title: "feat: a grid" }));
+    appendSession(dir, entry({ sessionId: "t" }));
+    const [withTitle, without] = readSessions(dir);
+    expect(withTitle?.title).toBe("feat: a grid");
+    expect(without?.title).toBeUndefined();
+  });
+
   it("appends one JSON line per session", () => {
     const dir = mkdtempSync(join(tmpdir(), "tt-"));
     appendSession(dir, entry());
diff --git a/src/sessions.ts b/src/sessions.ts
index dee4d44..5d9359b 100644
--- a/src/sessions.ts
+++ b/src/sessions.ts
@@ -19,6 +19,11 @@ export interface SessionLogEntry {
   denials: number;
   /** The CLI's own result subtype (`success`, `error_max_turns`, …), kept next to the outcome. */
   subtype: string;
+  /**
+   * The issue's title when the session ran, so the page's cost history can name a task after it
+   * has closed and left the snapshot. Absent on lines written before titles were recorded.
+   */
+  title?: string;
 }
 
 /**
@@ -64,6 +69,7 @@ export function readSessions(stateDir: string): SessionLogEntry[] {
           durationMinutes: Number(j.durationMinutes ?? 0),
           denials: Number(j.denials ?? 0),
           subtype: String(j.subtype ?? ""),
+          ...(typeof j.title === "string" ? { title: j.title } : {}),
         });
     } catch {
       /* skip a torn line */

From da5aca9a95421930d1ab84d1d471ba131066f1a3 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:53:40 -0700
Subject: [PATCH 06/19] feat(board): every open issue grouped under its phase
 epic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/board-issues.test.ts | 165 +++++++++++++++++++++++++++++++++++++++
 src/board-issues.ts      | 114 +++++++++++++++++++++++++++
 src/board.test.ts        |   4 +
 src/board.ts             |   2 +
 src/ctl.test.ts          |   2 +
 src/needs-you.test.ts    |   2 +
 src/owner.test.ts        |   2 +
 src/state-file.test.ts   |   3 +
 src/state-file.ts        |  54 +++++++++++++
 src/status.test.ts       |  54 +++++++++++++
 src/status.ts            |   2 +
 src/task-effort.test.ts  |   2 +
 src/task-model.test.ts   |   2 +
 13 files changed, 408 insertions(+)
 create mode 100644 src/board-issues.test.ts
 create mode 100644 src/board-issues.ts

diff --git a/src/board-issues.test.ts b/src/board-issues.test.ts
new file mode 100644
index 0000000..bd08fc5
--- /dev/null
+++ b/src/board-issues.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from "vitest";
+import { epic, issue, pr, snapshot } from "../test/helpers.ts";
+import { describeBoardIssues } from "./board-issues.ts";
+
+// An epic as the snapshot carries it twice: once as an Epic, once as its open issue (the title).
+const withEpic = (e: ReturnType<typeof epic>, title = `Phase ${e.phase}: work`) =>
+  issue({ number: e.number, title, labels: e.labels, body: "" });
+
+const task = (over: Parameters<typeof issue>[0] = {}) =>
+  issue({ body: "## Depends on\nNone", labels: ["agent-ready"], ...over });
+
+describe("describeBoardIssues", () => {
+  it("lists open non-epic issues, off-board ones with status null", () => {
+    const e = epic({ number: 10, phase: 1, taskNumbers: [11, 12] });
+    const s = snapshot({
+      epics: [e],
+      issues: [withEpic(e), task({ number: 11 }), task({ number: 12, status: null, itemId: null })],
+    });
+    const { issues } = describeBoardIssues(s);
+    expect(issues.map((i) => i.issue)).toEqual([11, 12]);
+    expect(issues.find((i) => i.issue === 12)?.status).toBeNull();
+  });
+
+  it("groups by taskNumbersOf, including a Parent epic line", () => {
+    const e = epic({ number: 10, phase: 1, taskNumbers: [] });
+    const s = snapshot({
+      epics: [e],
+      issues: [withEpic(e), task({ number: 11, body: "Parent epic: #10" })],
+    });
+    expect(describeBoardIssues(s).issues[0]?.group).toBe("epic:10");
+  });
+
+  it("falls back to the single open epic with the issue's phase label", () => {
+    const e = epic({ number: 10, phase: 7, labels: ["epic", "phase:7", "plan-approved"] });
+    const s = snapshot({
+      epics: [e],
+      issues: [withEpic(e), task({ number: 11, labels: ["phase:7"] })],
+    });
+    expect(describeBoardIssues(s).issues[0]?.group).toBe("epic:10");
+  });
+
+  it("puts a shared-phase issue in an unlinked group", () => {
+    const a = epic({ number: 10, phase: 10, labels: ["epic", "phase:10"] });
+    const b = epic({ number: 20, phase: 10, labels: ["epic", "phase:10"] });
+    const s = snapshot({
+      epics: [a, b],
+      issues: [withEpic(a), withEpic(b), task({ number: 11, labels: ["phase:10"] })],
+    });
+    const { issues, groups } = describeBoardIssues(s);
+    expect(issues[0]?.group).toBe("phase:10:unlinked");
+    expect(groups.find((g) => g.key === "phase:10:unlinked")).toMatchObject({
+      kind: "unlinked",
+      epic: null,
+      phase: 10,
+      title: "Phase 10 · not linked to an epic",
+    });
+  });
+
+  it("puts an issue whose phase has no open epic in a closed group", () => {
+    const s = snapshot({ epics: [], issues: [task({ number: 11, labels: ["phase:4"] })] });
+    const { issues, groups } = describeBoardIssues(s);
+    expect(issues[0]?.group).toBe("phase:4:closed");
+    expect(groups).toEqual([
+      expect.objectContaining({
+        key: "phase:4:closed",
+        kind: "closed",
+        title: "Phase 4 · epic closed",
+      }),
+    ]);
+  });
+
+  it("puts an issue with neither in No phase", () => {
+    const s = snapshot({ epics: [], issues: [task({ number: 11, labels: [] })] });
+    const { issues, groups } = describeBoardIssues(s);
+    expect(issues[0]?.group).toBe("none");
+    expect(groups).toEqual([
+      expect.objectContaining({ key: "none", kind: "none", title: "No phase" }),
+    ]);
+  });
+
+  it("gives every open epic a group with its state, even with no issues", () => {
+    const mk = (number: number, extra: string[]) =>
+      epic({ number, phase: number, labels: ["epic", `phase:${number}`, ...extra] });
+    const epics = [
+      mk(1, ["needs-owner", "plan-approved"]),
+      mk(2, ["needs-owner"]),
+      mk(3, ["plan-approved", "foreman:pause"]),
+      mk(4, ["agent-ready"]),
+      mk(5, []),
+    ];
+    const s = snapshot({ epics, issues: epics.map((e) => withEpic(e, `Epic ${e.number}`)) });
+    const { groups } = describeBoardIssues(s);
+    expect(groups.map((g) => [g.key, g.title, g.state, g.paused])).toEqual([
+      ["epic:1", "Epic 1", "signoff_waiting", false],
+      ["epic:2", "Epic 2", "plan_waiting", false],
+      ["epic:3", "Epic 3", "building", true],
+      ["epic:4", "Epic 4", "planning", false],
+      ["epic:5", "Epic 5", "not_started", false],
+    ]);
+  });
+
+  it("orders epic groups by phase, then unlinked and closed groups, then No phase", () => {
+    const a = epic({ number: 30, phase: 9, labels: ["epic", "phase:9"] });
+    const b = epic({ number: 20, phase: 7, labels: ["epic", "phase:7"] });
+    const s = snapshot({
+      epics: [a, b],
+      issues: [
+        withEpic(a),
+        withEpic(b),
+        task({ number: 41, labels: [] }),
+        task({ number: 42, labels: ["phase:4"] }),
+      ],
+    });
+    expect(describeBoardIssues(s).groups.map((g) => g.key)).toEqual([
+      "epic:20",
+      "epic:30",
+      "phase:4:closed",
+      "none",
+    ]);
+  });
+
+  it("fills pr, dependsOn, openDependsOn, blocked, model, effort and the display labels", () => {
+    const e = epic({ number: 10, phase: 7, taskNumbers: [268, 269] });
+    const s = snapshot({
+      epics: [e],
+      prs: [pr({ number: 276, issue: 268 })],
+      issues: [
+        withEpic(e),
+        task({
+          number: 268,
+          body: "## Depends on\n- #269\n- #250",
+          labels: [
+            "blocked",
+            "size:L",
+            "area:web",
+            "agent-ready",
+            "model:sonnet",
+            "effort:xhigh",
+            "phase:7",
+          ],
+        }),
+        task({ number: 269 }),
+      ],
+    });
+    expect(describeBoardIssues(s).issues[0]).toEqual({
+      issue: 268,
+      title: "Task 268",
+      status: "Ready",
+      labels: ["blocked", "size:L", "area:web"],
+      group: "epic:10",
+      pr: 276,
+      dependsOn: [269, 250],
+      openDependsOn: [269],
+      blocked: true,
+      model: "sonnet",
+      effort: "xhigh",
+    });
+  });
+
+  it("lists closed task numbers as doneTasks", () => {
+    const e = epic({ number: 10, phase: 1, taskNumbers: [1, 2, 3] });
+    const s = snapshot({ epics: [e], issues: [withEpic(e), task({ number: 3 })] });
+    expect(describeBoardIssues(s).groups[0]?.doneTasks).toEqual([1, 2]);
+  });
+});
diff --git a/src/board-issues.ts b/src/board-issues.ts
new file mode 100644
index 0000000..1e28d72
--- /dev/null
+++ b/src/board-issues.ts
@@ -0,0 +1,114 @@
+import { parseDependsOn, phaseOf } from "./github.ts";
+import { taskNumbersOf } from "./phase-progress.ts";
+import {
+  type BoardGroup,
+  type BoardIssue,
+  EFFORT_LABEL_PREFIX,
+  MODEL_LABEL_PREFIX,
+} from "./state-file.ts";
+import type { Epic, Snapshot } from "./types.ts";
+
+const SHOWN_LABEL = /^(blocked|size:.+|area:.+)$/;
+
+function epicState(e: Epic): BoardGroup["state"] {
+  const has = (l: string) => e.labels.includes(l);
+  // needs-owner means two things; plan-approved tells them apart, as on the Owner gates.
+  if (has("needs-owner")) return has("plan-approved") ? "signoff_waiting" : "plan_waiting";
+  if (has("plan-approved")) return "building";
+  if (has("agent-ready")) return "planning";
+  return "not_started";
+}
+
+const labelValue = (labels: string[], prefix: string) =>
+  labels.find((l) => l.startsWith(prefix))?.slice(prefix.length) ?? null;
+
+/**
+ * Every open issue on the Board tab, grouped under the epic it belongs to. The epic is the open
+ * one whose task set (sub-issues plus `Parent epic:` lines) includes the issue; failing that, the
+ * only open epic with the issue's `phase:<N>` label. Issues neither rule places are grouped by
+ * phase — `unlinked` when several open epics share it, `closed` when none is open — or "No phase".
+ * Computed from the snapshot the tick already has, so the tab costs no GitHub reads.
+ */
+export function describeBoardIssues(s: Snapshot): { issues: BoardIssue[]; groups: BoardGroup[] } {
+  const open = new Set(s.issues.map((i) => i.number));
+  const epics = s.epics
+    .filter((e) => e.state === "OPEN")
+    .sort((a, b) => a.phase - b.phase || a.number - b.number);
+  const tasksOf = new Map(epics.map((e) => [e.number, taskNumbersOf(e, s.issues)]));
+  const titleOf = (n: number) => s.issues.find((i) => i.number === n)?.title ?? `#${n}`;
+
+  const groups = new Map<string, BoardGroup>();
+  for (const e of epics) {
+    groups.set(`epic:${e.number}`, {
+      key: `epic:${e.number}`,
+      kind: "epic",
+      epic: e.number,
+      phase: e.phase,
+      title: titleOf(e.number),
+      state: epicState(e),
+      paused: e.labels.includes("foreman:pause"),
+      doneTasks: [...(tasksOf.get(e.number) ?? [])]
+        .filter((n) => !open.has(n))
+        .sort((a, b) => a - b),
+    });
+  }
+  const extra = (key: string, kind: BoardGroup["kind"], phase: number | null, title: string) => {
+    if (!groups.has(key))
+      groups.set(key, {
+        key,
+        kind,
+        epic: null,
+        phase,
+        title,
+        state: null,
+        paused: false,
+        doneTasks: [],
+      });
+    return key;
+  };
+  const groupOf = (n: number, labels: string[]): string => {
+    const owner = epics.find((e) => tasksOf.get(e.number)?.has(n));
+    if (owner) return `epic:${owner.number}`;
+    const phase = phaseOf(labels);
+    if (phase === null) return extra("none", "none", null, "No phase");
+    const same = epics.filter((e) => e.phase === phase);
+    if (same.length === 1) return `epic:${(same[0] as Epic).number}`;
+    return same.length > 1
+      ? extra(
+          `phase:${phase}:unlinked`,
+          "unlinked",
+          phase,
+          `Phase ${phase} · not linked to an epic`,
+        )
+      : extra(`phase:${phase}:closed`, "closed", phase, `Phase ${phase} · epic closed`);
+  };
+
+  const issues: BoardIssue[] = s.issues
+    .filter((i) => i.state === "OPEN" && !i.labels.includes("epic"))
+    .sort((a, b) => a.number - b.number)
+    .map((i) => {
+      const dependsOn = parseDependsOn(i.body);
+      return {
+        issue: i.number,
+        title: i.title,
+        status: i.status,
+        labels: i.labels.filter((l) => SHOWN_LABEL.test(l)),
+        group: groupOf(i.number, i.labels),
+        pr: s.prs.find((p) => p.issue === i.number)?.number ?? null,
+        dependsOn,
+        openDependsOn: dependsOn.filter((d) => open.has(d)),
+        blocked: i.labels.includes("blocked"),
+        model: labelValue(i.labels, MODEL_LABEL_PREFIX),
+        effort: labelValue(i.labels, EFFORT_LABEL_PREFIX),
+      };
+    });
+
+  const rank = (g: BoardGroup) => (g.kind === "epic" ? 0 : g.kind === "none" ? 2 : 1);
+  const ordered = [...groups.values()].sort(
+    (a, b) =>
+      rank(a) - rank(b) ||
+      // Epic groups keep the epic order above; the phase groups sort by phase.
+      (rank(a) === 1 ? (a.phase ?? 0) - (b.phase ?? 0) : 0),
+  );
+  return { issues, groups: ordered };
+}
diff --git a/src/board.test.ts b/src/board.test.ts
index 3c56879..304700d 100644
--- a/src/board.test.ts
+++ b/src/board.test.ts
@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest";
 import { epic, hoursAgo, issue, pr, snapshot } from "../test/helpers.ts";
 import { describeBoard, overlayCurrent } from "./board.ts";
+import { describeBoardIssues } from "./board-issues.ts";
 import type { Board, CurrentSession } from "./state-file.ts";
 
 describe("describeBoard", () => {
@@ -23,6 +24,7 @@ describe("describeBoard", () => {
     expect(b.explain.length).toBeGreaterThan(0);
     // No logs passed: the phase card still lists the approved epic, at zero spend.
     expect(b.phases).toMatchObject([{ tasksTotal: 0, spendUsd: 0, medianMergeMinutes: null }]);
+    expect({ issues: b.issues, groups: b.groups }).toEqual(describeBoardIssues(s));
   });
 
   it("folds the local session and merge logs into the phase card", () => {
@@ -106,6 +108,8 @@ describe("overlayCurrent", () => {
     owner: [],
     needsYou: [],
     phases: [],
+    issues: [],
+    groups: [],
     explain: [],
     prs: [],
   };
diff --git a/src/board.ts b/src/board.ts
index 90b8ade..57f118d 100644
--- a/src/board.ts
+++ b/src/board.ts
@@ -1,3 +1,4 @@
+import { describeBoardIssues } from "./board-issues.ts";
 import { needsYouItems } from "./needs-you.ts";
 import { describeNext } from "./next.ts";
 import { ownerItems } from "./owner.ts";
@@ -30,6 +31,7 @@ export function describeBoard(
     owner: ownerItems(s),
     needsYou: needsYouItems(s),
     phases: phaseProgress(s, logs.sessions, logs.merges),
+    ...describeBoardIssues(s),
     explain: next.explain,
     prs: next.prs,
   };
diff --git a/src/ctl.test.ts b/src/ctl.test.ts
index 10d3d57..b69e6a1 100644
--- a/src/ctl.test.ts
+++ b/src/ctl.test.ts
@@ -168,6 +168,8 @@ describe("ctlModel", () => {
         pipeline: [],
         owner: [],
         needsYou: [],
+        issues: [],
+        groups: [],
         explain: [],
         prs: [],
         phases: [
diff --git a/src/needs-you.test.ts b/src/needs-you.test.ts
index 9373015..b699def 100644
--- a/src/needs-you.test.ts
+++ b/src/needs-you.test.ts
@@ -10,6 +10,8 @@ const board = (over: Partial<Board> = {}): Board => ({
   owner: [],
   needsYou: [],
   phases: [],
+  issues: [],
+  groups: [],
   explain: [],
   prs: [],
   ...over,
diff --git a/src/owner.test.ts b/src/owner.test.ts
index 649a099..646220c 100644
--- a/src/owner.test.ts
+++ b/src/owner.test.ts
@@ -129,6 +129,8 @@ describe("applyOwnerActionToBoard", () => {
     ],
     needsYou: [],
     phases: [],
+    issues: [],
+    groups: [],
     explain: [],
     prs: [],
   };
diff --git a/src/state-file.test.ts b/src/state-file.test.ts
index df7b92a..b0124dd 100644
--- a/src/state-file.test.ts
+++ b/src/state-file.test.ts
@@ -88,6 +88,9 @@ describe("state file", () => {
     expect(s).not.toBeNull();
     expect(s?.board?.pipeline[0]?.effort).toBeNull();
     expect(s?.board?.phases[0]?.tasks[0]?.effort).toBeNull();
+    // Written before the Board tab: no issues or groups, which read as empty.
+    expect(s?.board?.issues).toEqual([]);
+    expect(s?.board?.groups).toEqual([]);
   });
   it("StateStore.patch merges and persists", () => {
     const dir = mkdtempSync(join(tmpdir(), "tt-state-"));
diff --git a/src/state-file.ts b/src/state-file.ts
index 94d58db..35f5779 100644
--- a/src/state-file.ts
+++ b/src/state-file.ts
@@ -202,6 +202,57 @@ export const PhaseProgressSchema = z.object({
 });
 export type PhaseProgress = z.infer<typeof PhaseProgressSchema>;
 
+/**
+ * One open issue as the Board tab lists it: every open non-epic issue in the snapshot, on the
+ * project board or not (`status` null is "Off board"). Computed in the tick, so no extra reads.
+ */
+export const BoardIssueSchema = z.object({
+  issue: z.number().int(),
+  title: z.string(),
+  status: z.string().nullable(),
+  /** The labels worth showing on the row: `blocked`, `size:*`, `area:*`. */
+  labels: z.array(z.string()),
+  /** The `BoardGroup.key` this issue is listed under. */
+  group: z.string(),
+  /** The open non-draft PR that closes this issue, if any. */
+  pr: z.number().int().nullable(),
+  dependsOn: z.array(z.number().int()),
+  /** The subset of `dependsOn` still open in the snapshot. */
+  openDependsOn: z.array(z.number().int()),
+  blocked: z.boolean(),
+  /** The issue's `model:<name>` label, or null when the global model applies. */
+  model: z.string().nullable(),
+  /** The issue's `effort:<level>` label, or null when the default effort applies. */
+  effort: z.string().nullable(),
+});
+export type BoardIssue = z.infer<typeof BoardIssueSchema>;
+
+export const BOARD_GROUP_STATES = [
+  "planning",
+  "plan_waiting",
+  "building",
+  "signoff_waiting",
+  "not_started",
+] as const;
+
+/**
+ * A heading on the Board tab: an open epic, or — for issues no open epic claims — their phase
+ * (`unlinked` when several open epics share it, `closed` when none is open), or "No phase".
+ */
+export const BoardGroupSchema = z.object({
+  key: z.string(),
+  kind: z.enum(["epic", "unlinked", "closed", "none"]),
+  epic: z.number().int().nullable(),
+  phase: z.number().nullable(),
+  title: z.string(),
+  /** Where an epic stands, from its labels; null for the non-epic groups. */
+  state: z.enum(BOARD_GROUP_STATES).nullable(),
+  paused: z.boolean(),
+  /** Closed task numbers: closed issues have left the snapshot, so only their numbers remain. */
+  doneTasks: z.array(z.number().int()),
+});
+export type BoardGroup = z.infer<typeof BoardGroupSchema>;
+
 export const BoardSchema = z.object({
   at: z.string(),
   waiting: z.array(WaitItemSchema),
@@ -212,6 +263,9 @@ export const BoardSchema = z.object({
   needsYou: z.array(NeedsYouItemSchema).default([]),
   // Same.
   phases: z.array(PhaseProgressSchema).default([]),
+  // Same: the Board tab's rows and headings.
+  issues: z.array(BoardIssueSchema).default([]),
+  groups: z.array(BoardGroupSchema).default([]),
   explain: z.array(z.string()),
   prs: z.array(
     z.object({
diff --git a/src/status.test.ts b/src/status.test.ts
index 20c0f20..115043a 100644
--- a/src/status.test.ts
+++ b/src/status.test.ts
@@ -294,6 +294,50 @@ describe("live activity and board", () => {
     );
     expect(describeStatus(input()).tick?.blockedBySession).toBe(false);
   });
+  it("carries the stored board's issues and groups into the report", () => {
+    const groups = [
+      {
+        key: "none",
+        kind: "none" as const,
+        epic: null,
+        phase: null,
+        title: "No phase",
+        state: null,
+        paused: false,
+        doneTasks: [],
+      },
+    ];
+    const issues = [
+      {
+        issue: 7,
+        title: "t",
+        status: null,
+        labels: [],
+        group: "none",
+        pr: null,
+        dependsOn: [],
+        openDependsOn: [],
+        blocked: false,
+        model: null,
+        effort: null,
+      },
+    ];
+    const board = {
+      at: now,
+      waiting: [],
+      pipeline: [],
+      owner: [],
+      needsYou: [],
+      phases: [],
+      issues,
+      groups,
+      explain: [],
+      prs: [],
+    };
+    const r = describeStatus(input({ state: { ...state(), board } }));
+    expect(r.board?.issues).toEqual(issues);
+    expect(r.board?.groups).toEqual(groups);
+  });
   it("layers live STOP/preflight/cap waits over the stored board", () => {
     const board = {
       at: now,
@@ -304,6 +348,8 @@ describe("live activity and board", () => {
       owner: [],
       needsYou: [],
       phases: [],
+      issues: [],
+      groups: [],
       explain: [],
       prs: [],
     };
@@ -338,6 +384,8 @@ describe("live activity and board", () => {
       owner: [],
       needsYou: [],
       phases: [],
+      issues: [],
+      groups: [],
       explain: [],
       prs: [],
     };
@@ -365,6 +413,8 @@ describe("live activity and board", () => {
       owner: [],
       needsYou: [],
       phases: [],
+      issues: [],
+      groups: [],
       explain: [],
       prs: [],
     };
@@ -413,6 +463,8 @@ describe("live activity and board", () => {
         },
       ],
       phases: [],
+      issues: [],
+      groups: [],
       explain: [],
       prs: [],
     };
@@ -443,6 +495,8 @@ describe("live activity and board", () => {
           tasks: [],
         },
       ],
+      issues: [],
+      groups: [],
       explain: [],
       prs: [],
     };
diff --git a/src/status.ts b/src/status.ts
index ac36c05..ecdf380 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -156,6 +156,8 @@ export function describeStatus(i: StatusInput): StatusReport {
           owner: stored?.owner ?? [],
           needsYou: stored?.needsYou ?? [],
           phases: stored?.phases ?? [],
+          issues: stored?.issues ?? [],
+          groups: stored?.groups ?? [],
           explain: stored?.explain ?? [],
           prs: stored?.prs ?? [],
         }
diff --git a/src/task-effort.test.ts b/src/task-effort.test.ts
index ffe1398..4b941e2 100644
--- a/src/task-effort.test.ts
+++ b/src/task-effort.test.ts
@@ -56,6 +56,8 @@ const board = (over: Partial<Board> = {}): Board => ({
   owner: [],
   needsYou: [],
   phases: [phase()],
+  issues: [],
+  groups: [],
   explain: [],
   prs: [],
   ...over,
diff --git a/src/task-model.test.ts b/src/task-model.test.ts
index 405a7fe..e1f3ccb 100644
--- a/src/task-model.test.ts
+++ b/src/task-model.test.ts
@@ -64,6 +64,8 @@ const board = (over: Partial<Board> = {}): Board => ({
   owner: [],
   needsYou: [],
   phases: [phase()],
+  issues: [],
+  groups: [],
   explain: [],
   prs: [],
   ...over,

From 31495f12e671a8d8ac75e2c97c7dea015bdd5ffd Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:55:31 -0700
Subject: [PATCH 07/19] feat(web): model and effort pickers for every issue on
 the board

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/cli/daemon.ts       |  8 ++++----
 src/task-effort.test.ts | 18 +++++++++++++++++
 src/task-effort.ts      |  1 +
 src/task-model.test.ts  | 43 +++++++++++++++++++++++++++++++++++++++--
 src/task-model.ts       | 18 +++++++++++++++++
 src/web.test.ts         | 29 ++++++++++++++-------------
 src/web.ts              | 14 +++++++-------
 7 files changed, 103 insertions(+), 28 deletions(-)

diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts
index 1a8e3b3..229e2db 100644
--- a/src/cli/daemon.ts
+++ b/src/cli/daemon.ts
@@ -19,7 +19,7 @@ import { readSessions } from "../sessions.ts";
 import { initialState, MODEL_DEFAULT, readState, StateStore } from "../state-file.ts";
 import { describeStatus } from "../status.ts";
 import { applyTaskEffort, applyTaskEffortToBoard } from "../task-effort.ts";
-import { applyTaskModel, applyTaskModelToBoard, phaseTasks } from "../task-model.ts";
+import { applyTaskModel, applyTaskModelToBoard, pickerTasks } from "../task-model.ts";
 import { startWebServer } from "../web.ts";
 import { launchdLabel } from "./launchd.ts";
 
@@ -235,12 +235,12 @@ export async function runDaemon(
       return "project re-read; tick requested";
     },
     ownerItems: () => store.get().board?.owner ?? [],
-    phaseTasks: () => phaseTasks(store.get().board),
+    pickerTasks: () => pickerTasks(store.get().board),
     setTaskModel: async (issue, model) => {
       // The stored task row says which label is on the issue now, so the swap costs at most two
       // label edits and no GitHub read. Read per dispatch by runRole's modelFor(), so it applies
       // to the issue's next session and never to one already running.
-      const current = phaseTasks(store.get().board).find((t) => t.issue === issue)?.model ?? null;
+      const current = pickerTasks(store.get().board).find((t) => t.issue === issue)?.model ?? null;
       const message = await applyTaskModel(gh, { issue, current }, model);
       log("info", "task model set", { issue, model, was: current });
       const board = store.get().board;
@@ -251,7 +251,7 @@ export async function runDaemon(
     setTaskEffort: async (issue, effort) => {
       // Same shape as setTaskModel: the stored row names the label on the issue now, and
       // runRole's effortFor() reads the label per dispatch, so a running session keeps its effort.
-      const current = phaseTasks(store.get().board).find((t) => t.issue === issue)?.effort ?? null;
+      const current = pickerTasks(store.get().board).find((t) => t.issue === issue)?.effort ?? null;
       const message = await applyTaskEffort(gh, { issue, current }, effort);
       log("info", "task effort set", { issue, effort, was: current });
       const board = store.get().board;
diff --git a/src/task-effort.test.ts b/src/task-effort.test.ts
index 4b941e2..afe792b 100644
--- a/src/task-effort.test.ts
+++ b/src/task-effort.test.ts
@@ -112,6 +112,24 @@ describe("applyTaskEffortToBoard", () => {
     expect(b.pipeline[0]?.effort).toBe("max");
     expect(b.phases[0]?.tasks.find((t) => t.issue === 101)?.effort).toBeNull();
   });
+  it("updates board.issues too", () => {
+    const issue = {
+      issue: 201,
+      title: "Off-board follow-up",
+      status: null,
+      labels: [],
+      group: "none",
+      pr: null,
+      dependsOn: [],
+      openDependsOn: [],
+      blocked: false,
+      model: null,
+      effort: null,
+    };
+    const b = applyTaskEffortToBoard(board({ issues: [issue] }), 201, "xhigh");
+    expect(b.issues[0]?.effort).toBe("xhigh");
+    expect(applyTaskEffortToBoard(b, 201, "default").issues[0]?.effort).toBeNull();
+  });
   it("high clears the label too, since no label means high", () => {
     const b = applyTaskEffortToBoard(board(), 102, "high");
     expect(b.phases[0]?.tasks.find((t) => t.issue === 102)?.effort).toBeNull();
diff --git a/src/task-effort.ts b/src/task-effort.ts
index c48128e..721e70e 100644
--- a/src/task-effort.ts
+++ b/src/task-effort.ts
@@ -39,5 +39,6 @@ export function applyTaskEffortToBoard(board: Board, issue: number, effort: Effo
       tasks: p.tasks.map((t) => (t.issue === issue ? { ...t, effort: next } : t)),
     })),
     pipeline: board.pipeline.map((r) => (r.issue === issue ? { ...r, effort: next } : r)),
+    issues: board.issues.map((i) => (i.issue === issue ? { ...i, effort: next } : i)),
   };
 }
diff --git a/src/task-model.test.ts b/src/task-model.test.ts
index e1f3ccb..6679d36 100644
--- a/src/task-model.test.ts
+++ b/src/task-model.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vitest";
-import type { Board, PhaseProgress, PipelineRow } from "./state-file.ts";
-import { applyTaskModel, applyTaskModelToBoard, phaseTasks } from "./task-model.ts";
+import type { Board, BoardIssue, PhaseProgress, PipelineRow } from "./state-file.ts";
+import { applyTaskModel, applyTaskModelToBoard, phaseTasks, pickerTasks } from "./task-model.ts";
 
 function fakeGh() {
   const calls: string[] = [];
@@ -71,6 +71,40 @@ const board = (over: Partial<Board> = {}): Board => ({
   ...over,
 });
 
+const boardIssue = (over: Partial<BoardIssue> = {}): BoardIssue => ({
+  issue: 201,
+  title: "Off-board follow-up",
+  status: null,
+  labels: [],
+  group: "none",
+  pr: null,
+  dependsOn: [],
+  openDependsOn: [],
+  blocked: false,
+  model: null,
+  effort: null,
+  ...over,
+});
+
+describe("pickerTasks", () => {
+  it("lists every board issue, off-board included", () => {
+    const b = board({
+      issues: [boardIssue(), boardIssue({ issue: 101, status: "Ready", model: "haiku" })],
+    });
+    expect(pickerTasks(b)).toEqual([
+      { issue: 201, model: null, effort: null },
+      { issue: 101, model: "haiku", effort: null },
+    ]);
+  });
+  it("falls back to open phase tasks when issues is empty", () => {
+    expect(pickerTasks(board())).toEqual([
+      { issue: 101, model: null, effort: null },
+      { issue: 102, model: "sonnet", effort: null },
+    ]);
+    expect(pickerTasks(null)).toEqual([]);
+  });
+});
+
 describe("phaseTasks", () => {
   it("flattens the open tasks of every phase on the board; a closed task takes no label", () => {
     expect(phaseTasks(board()).map((t) => t.issue)).toEqual([101, 102]);
@@ -112,6 +146,11 @@ describe("applyTaskModelToBoard", () => {
     // Other rows are untouched.
     expect(b.phases[0]?.tasks.find((t) => t.issue === 101)?.model).toBeNull();
   });
+  it("updates board.issues too", () => {
+    const b = applyTaskModelToBoard(board({ issues: [boardIssue()] }), 201, "haiku");
+    expect(b.issues[0]?.model).toBe("haiku");
+    expect(applyTaskModelToBoard(b, 201, "default").issues[0]?.model).toBeNull();
+  });
   it("`default` clears the override", () => {
     const b = applyTaskModelToBoard(board(), 102, "default");
     expect(b.phases[0]?.tasks.find((t) => t.issue === 102)?.model).toBeNull();
diff --git a/src/task-model.ts b/src/task-model.ts
index 215efbc..1e0ac57 100644
--- a/src/task-model.ts
+++ b/src/task-model.ts
@@ -26,6 +26,23 @@ export function phaseTasks(board: Board | null): PhaseTask[] {
  * then the new one lands; `default` only removes. `current` is what the stored board saw, which
  * is what the page showed the owner when they clicked — the tick's snapshot is the truth we have.
  */
+/** What a model/effort picker needs to know about an issue it may relabel. */
+export interface PickerTask {
+  issue: number;
+  model: string | null;
+  effort: string | null;
+}
+
+/**
+ * The issues the page may relabel with `model:` / `effort:` — every open issue the Board tab
+ * lists, on the project board or not. A board stored before the Board tab existed has no
+ * `issues` until its next tick, so it falls back to the open phase tasks it does have.
+ */
+export function pickerTasks(board: Board | null): PickerTask[] {
+  const rows = board?.issues.length ? board.issues : phaseTasks(board);
+  return rows.map(({ issue, model, effort }) => ({ issue, model, effort }));
+}
+
 export async function applyTaskModel(
   gh: TaskModelGh,
   task: { issue: number; current: string | null },
@@ -51,5 +68,6 @@ export function applyTaskModelToBoard(board: Board, issue: number, model: TaskMo
       tasks: p.tasks.map((t) => (t.issue === issue ? { ...t, model: next } : t)),
     })),
     pipeline: board.pipeline.map((r) => (r.issue === issue ? { ...r, model: next } : r)),
+    issues: board.issues.map((i) => (i.issue === issue ? { ...i, model: next } : i)),
   };
 }
diff --git a/src/web.test.ts b/src/web.test.ts
index b73a751..125b7ac 100644
--- a/src/web.test.ts
+++ b/src/web.test.ts
@@ -112,16 +112,11 @@ describe("web server", () => {
       refreshes += 1;
       return "project re-read; tick requested";
     },
-    phaseTasks: () => [
-      { issue: 101, title: "One", status: "Ready", closed: false, model: null, effort: null },
-      {
-        issue: 102,
-        title: "Two",
-        status: "In Progress",
-        closed: false,
-        model: "sonnet",
-        effort: null,
-      },
+    pickerTasks: () => [
+      { issue: 101, model: null, effort: null },
+      { issue: 102, model: "sonnet", effort: null },
+      // On the Board tab but not on the project board: still a picker target.
+      { issue: 103, model: null, effort: null },
     ],
     setTaskModel: async (issue, model) => {
       taskModels.push(`${issue} ${model}`);
@@ -264,15 +259,18 @@ describe("web server", () => {
     expect(ok.status).toBe(200);
     expect(await ok.json()).toEqual({ ok: true, message: "#101 runs as sonnet" });
     expect((await post({ issue: 102, model: "default" })).status).toBe(200);
+    expect((await post({ issue: 103, model: "haiku" })).status).toBe(200);
     // A label has to exist in the repo, so unlike /api/model only the one-click names pass.
     expect((await post({ issue: 101, model: "claude-haiku-4-5-20251001" })).status).toBe(400);
     expect((await post({ issue: 101, model: "--dangerously-skip-permissions" })).status).toBe(400);
     // Not a task of any phase on the board: refused before it reaches GitHub.
-    expect((await post({ issue: 999, model: "sonnet" })).status).toBe(400);
+    const absent = await post({ issue: 999, model: "sonnet" });
+    expect(absent.status).toBe(400);
+    expect(await absent.json()).toEqual({ ok: false, error: "#999 is not a task on the board" });
     expect((await post({ issue: "101", model: "sonnet" })).status).toBe(400);
     expect((await post({ model: "sonnet" })).status).toBe(400);
     expect((await fetch(`${base}/api/task-model`)).status).toBe(405);
-    expect(taskModels).toEqual(["101 sonnet", "102 default"]);
+    expect(taskModels).toEqual(["101 sonnet", "102 default", "103 haiku"]);
   });
   it("sets a task's effort when the board lists the task, and only Claude Code's levels", async () => {
     const post = (body: unknown) =>
@@ -281,6 +279,7 @@ describe("web server", () => {
     expect(ok.status).toBe(200);
     expect(await ok.json()).toEqual({ ok: true, message: "#101 runs at xhigh effort" });
     expect((await post({ issue: 102, effort: "default" })).status).toBe(200);
+    expect((await post({ issue: 103, effort: "low" })).status).toBe(200);
     // The effort reaches `claude` as argv: anything but a level is refused.
     expect((await post({ issue: 101, effort: "ultra" })).status).toBe(400);
     expect((await post({ issue: 101, effort: "--dangerously-skip-permissions" })).status).toBe(400);
@@ -288,7 +287,7 @@ describe("web server", () => {
     expect((await post({ issue: 999, effort: "low" })).status).toBe(400);
     expect((await post({ effort: "low" })).status).toBe(400);
     expect((await fetch(`${base}/api/task-effort`)).status).toBe(405);
-    expect(taskEfforts).toEqual(["101 xhigh", "102 default"]);
+    expect(taskEfforts).toEqual(["101 xhigh", "102 default", "103 low"]);
   });
   it("unblocks an issue the board lists as blocked", async () => {
     const r = await fetch(`${base}/api/unblock`, {
@@ -336,7 +335,7 @@ describe("web server with webAuth", () => {
     setModel: async () => "",
     setCap: async () => "",
     refresh: async () => "",
-    phaseTasks: () => [],
+    pickerTasks: () => [],
     setTaskModel: async () => "",
     modelChoices: () => [],
     setTaskEffort: async () => "",
@@ -481,7 +480,7 @@ describe("web server with webHosts", () => {
     setModel: async () => "",
     setCap: async () => "",
     refresh: async () => "",
-    phaseTasks: () => [],
+    pickerTasks: () => [],
     setTaskModel: async () => "",
     modelChoices: () => [],
     setTaskEffort: async () => "",
diff --git a/src/web.ts b/src/web.ts
index 3e6318f..cf46b62 100644
--- a/src/web.ts
+++ b/src/web.ts
@@ -18,11 +18,11 @@ import {
   type OwnerAction,
   OwnerActionSchema,
   type OwnerItem,
-  type PhaseTask,
   type TaskModelChoice,
   TaskModelChoiceSchema,
 } from "./state-file.ts";
 import type { StatusReport } from "./status.ts";
+import type { PickerTask } from "./task-model.ts";
 
 export type WebCommand = "stop" | "abort" | "go";
 
@@ -56,13 +56,13 @@ export interface WebDeps {
   setCap: (maxSessionsPerDay: number | null) => Promise<string>;
   /** Drops the cached project board and wakes the loop, so the page shows the board as it is now. */
   refresh: () => Promise<string>;
-  /** The open tasks of every phase on the board, as of the last tick; the task-model allowlist. */
-  phaseTasks: () => PhaseTask[];
-  /** Swaps the issue's `model:<name>` label; only called for a task `phaseTasks` lists. */
+  /** Every open issue the Board tab lists, as of the last tick; the model/effort allowlist. */
+  pickerTasks: () => PickerTask[];
+  /** Swaps the issue's `model:<name>` label; only called for an issue `pickerTasks` lists. */
   setTaskModel: (issue: number, model: TaskModelChoice) => Promise<string>;
   /** The repo's configured `models`; the task-model allowlist beyond `MODEL_DEFAULT`. */
   modelChoices: () => string[];
-  /** Swaps the issue's `effort:<level>` label; only called for a task `phaseTasks` lists. */
+  /** Swaps the issue's `effort:<level>` label; only called for an issue `pickerTasks` lists. */
   setTaskEffort: (issue: number, effort: EffortChoice) => Promise<string>;
   /** Page HTML; defaults to src/web.html. Injectable for tests. */
   html?: string;
@@ -227,7 +227,7 @@ export function createWebServer(d: WebDeps): http.Server {
         if (!parsed.success) return json(res, 400, { ok: false, error: "bad request" });
         const { issue, model } = parsed.data;
         // Same allowlist pattern as /api/owner: only a task the board lists under a phase.
-        if (!d.phaseTasks().some((t) => t.issue === issue))
+        if (!d.pickerTasks().some((t) => t.issue === issue))
           return json(res, 400, { ok: false, error: `#${issue} is not a task on the board` });
         // TaskModelChoiceSchema now accepts any well-formed name; the repo's configured models
         // are the actual allowlist, checked here so a typo'd model does not reach a label.
@@ -241,7 +241,7 @@ export function createWebServer(d: WebDeps): http.Server {
         // An effort reaches `claude` as argv, so only Claude Code's own levels get past here.
         if (!parsed.success) return json(res, 400, { ok: false, error: "bad request" });
         const { issue, effort } = parsed.data;
-        if (!d.phaseTasks().some((t) => t.issue === issue))
+        if (!d.pickerTasks().some((t) => t.issue === issue))
           return json(res, 400, { ok: false, error: `#${issue} is not a task on the board` });
         return json(res, 200, { ok: true, message: await d.setTaskEffort(issue, effort) });
       }

From 770cf6c653b140223b27a134fdde2f12266d961c Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:58:05 -0700
Subject: [PATCH 08/19] feat(needs-you): owner wait time and phase review
 pairing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/needs-you.test.ts      | 51 ++++++++++++++++++++++-
 src/needs-you.ts           |  4 ++
 src/owner.test.ts          | 26 +++++++++++-
 src/owner.ts               |  4 ++
 src/review-pairing.test.ts | 85 ++++++++++++++++++++++++++++++++++++++
 src/review-pairing.ts      | 39 +++++++++++++++++
 src/state-file.test.ts     | 13 ++++++
 src/state-file.ts          |  8 ++++
 src/status.test.ts         |  4 ++
 src/web.test.ts            | 14 ++++++-
 10 files changed, 244 insertions(+), 4 deletions(-)
 create mode 100644 src/review-pairing.test.ts
 create mode 100644 src/review-pairing.ts

diff --git a/src/needs-you.test.ts b/src/needs-you.test.ts
index b699def..98944bc 100644
--- a/src/needs-you.test.ts
+++ b/src/needs-you.test.ts
@@ -38,6 +38,7 @@ describe("needsYouItems", () => {
       ],
       epics: [epic({ number: 10, phase: 1, labels: ["epic", "phase:1", "needs-owner"] })],
     });
+    const unpaired = { reviewOf: null, phaseReview: false };
     expect(needsYouItems(s)).toEqual([
       {
         issue: 165,
@@ -45,17 +46,53 @@ describe("needsYouItems", () => {
         labels: ["decision", "needs-owner"],
         since: hoursAgo(30),
         itemId: "PVTI_165",
+        ...unpaired,
+      },
+      {
+        issue: 5,
+        title: "Task 5",
+        labels: ["blocked"],
+        since: hoursAgo(9),
+        itemId: "PVTI_5",
+        ...unpaired,
       },
-      { issue: 5, title: "Task 5", labels: ["blocked"], since: hoursAgo(9), itemId: "PVTI_5" },
       {
         issue: 171,
         title: "Task 171",
         labels: ["needs-owner"],
         since: hoursAgo(2),
         itemId: "PVTI_171",
+        ...unpaired,
       },
     ]);
   });
+  it("marks a phase review paired with an open epic, and an unpaired one", () => {
+    const s = snapshot({
+      epics: [epic({ number: 514, phase: 10, labels: ["epic", "phase:10", "needs-owner"] })],
+      issues: [
+        issue({
+          number: 514,
+          title: "Phase 10a: Families and wards",
+          labels: ["epic", "phase:10", "plan-approved", "needs-owner"],
+        }),
+        issue({
+          number: 571,
+          title: "Phase 10 review: Phase 10a: Families and wards",
+          labels: ["phase:10", "needs-owner"],
+        }),
+        issue({
+          number: 336,
+          title: "Phase 3 review: Lesson creation",
+          labels: ["phase:3", "needs-owner"],
+        }),
+        issue({ number: 599, title: "decide: billing", labels: ["needs-owner"] }),
+      ],
+    });
+    const byIssue = new Map(needsYouItems(s).map((n) => [n.issue, n]));
+    expect(byIssue.get(571)).toMatchObject({ reviewOf: 514, phaseReview: true });
+    expect(byIssue.get(336)).toMatchObject({ reviewOf: null, phaseReview: true });
+    expect(byIssue.get(599)).toMatchObject({ reviewOf: null, phaseReview: false });
+  });
   it("carries a null itemId for an issue that is not on the project board", () => {
     const s = snapshot({ issues: [issue({ number: 8, labels: ["blocked"], itemId: null })] });
     expect(needsYouItems(s)[0]).toMatchObject({ issue: 8, itemId: null });
@@ -105,13 +142,23 @@ describe("applyUnblockToBoard", () => {
   it("drops the row, its blocked wait item and the pipeline flag, and leaves the rest alone", () => {
     const b = board({
       needsYou: [
-        { issue: 5, title: "Task 5", labels: ["blocked"], since: hoursAgo(9), itemId: "PVTI_5" },
+        {
+          issue: 5,
+          title: "Task 5",
+          labels: ["blocked"],
+          since: hoursAgo(9),
+          itemId: "PVTI_5",
+          reviewOf: null,
+          phaseReview: false,
+        },
         {
           issue: 171,
           title: "Task 171",
           labels: ["needs-owner"],
           since: hoursAgo(2),
           itemId: null,
+          reviewOf: null,
+          phaseReview: false,
         },
       ],
       waiting: [
diff --git a/src/needs-you.ts b/src/needs-you.ts
index 0c5cf38..2521a29 100644
--- a/src/needs-you.ts
+++ b/src/needs-you.ts
@@ -1,5 +1,6 @@
 import type { GitHubApi } from "./github.ts";
 import { fmt } from "./ledger.ts";
+import { PHASE_REVIEW_TITLE, pairReviews } from "./review-pairing.ts";
 import { type Board, NEEDS_YOU_LABELS, type NeedsYouItem } from "./state-file.ts";
 import type { Snapshot } from "./types.ts";
 
@@ -9,6 +10,7 @@ import type { Snapshot } from "./types.ts";
  * are excluded here. Oldest first — the point of the card is the issue that has been waiting a week.
  */
 export function needsYouItems(s: Snapshot): NeedsYouItem[] {
+  const pairs = pairReviews(s);
   return s.issues
     .filter((x) => x.state === "OPEN" && !x.labels.includes("epic"))
     .map((x) => ({
@@ -17,6 +19,8 @@ export function needsYouItems(s: Snapshot): NeedsYouItem[] {
       labels: NEEDS_YOU_LABELS.filter((l) => x.labels.includes(l)) as string[],
       since: x.updatedAt,
       itemId: x.itemId,
+      reviewOf: pairs.get(x.number) ?? null,
+      phaseReview: PHASE_REVIEW_TITLE.test(x.title),
     }))
     .filter((x) => x.labels.length > 0)
     .sort((a, b) => a.since.localeCompare(b.since) || a.issue - b.issue);
diff --git a/src/owner.test.ts b/src/owner.test.ts
index 646220c..bdf55b8 100644
--- a/src/owner.test.ts
+++ b/src/owner.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { epic, issue, snapshot } from "../test/helpers.ts";
+import { comment, epic, issue, snapshot } from "../test/helpers.ts";
 import { applyOwnerAction, applyOwnerActionToBoard, ownerItems } from "./owner.ts";
 
 const e = (over = {}) => epic({ number: 200, phase: 2, labels: ["epic", "phase:2"], ...over });
@@ -36,6 +36,26 @@ describe("ownerItems", () => {
     const items = ownerItems(snap([e({ labels: ["epic", "phase:2", "plan-approved"] })]));
     expect(items[0]?.actions).toEqual(["pause"]);
   });
+  it("carries the epic's updatedAt as since, and its paired review on a sign-off", () => {
+    const signOff = e({ labels: ["epic", "phase:2", "plan-approved", "needs-owner"] });
+    const s = snapshot({
+      epics: [signOff],
+      issues: [
+        issue({
+          number: 200,
+          title: "Phase 2: Things",
+          labels: signOff.labels,
+          updatedAt: "2026-09-01T00:00:00Z",
+          comments: [comment("Phase review: #250")],
+        }),
+        issue({ number: 250, title: "Phase 2 review: Things", labels: ["phase:2", "needs-owner"] }),
+      ],
+    });
+    expect(ownerItems(s)[0]).toMatchObject({ since: "2026-09-01T00:00:00Z", review: 250 });
+    // Not a sign-off: no review to show, even if one is paired.
+    const plan = e({ labels: ["epic", "phase:2", "needs-owner"] });
+    expect(ownerItems(snap([plan]))[0]?.review).toBeNull();
+  });
   it("skips closed epics and sorts by phase", () => {
     const items = ownerItems(
       snap([
@@ -118,6 +138,8 @@ describe("applyOwnerActionToBoard", () => {
         phase: 0,
         detail: "d",
         actions: ["sign_off" as const, "pause" as const],
+        since: null,
+        review: null,
       },
       {
         epic: 26,
@@ -125,6 +147,8 @@ describe("applyOwnerActionToBoard", () => {
         phase: 2,
         detail: "d",
         actions: ["start_planning" as const, "pause" as const],
+        since: null,
+        review: null,
       },
     ],
     needsYou: [],
diff --git a/src/owner.ts b/src/owner.ts
index de2c633..61148ae 100644
--- a/src/owner.ts
+++ b/src/owner.ts
@@ -1,3 +1,4 @@
+import { pairReviews } from "./review-pairing.ts";
 import type { Board, OwnerAction, OwnerItem } from "./state-file.ts";
 import type { Snapshot } from "./types.ts";
 
@@ -22,6 +23,7 @@ const DETAIL: Record<OwnerAction, string> = {
  */
 export function ownerItems(s: Snapshot): OwnerItem[] {
   const titleOf = (n: number) => s.issues.find((i) => i.number === n)?.title ?? `#${n}`;
+  const reviewOf = new Map([...pairReviews(s)].map(([review, epic]) => [epic, review]));
   return [...s.epics]
     .filter((e) => e.state === "OPEN")
     .sort((a, b) => a.phase - b.phase)
@@ -38,6 +40,8 @@ export function ownerItems(s: Snapshot): OwnerItem[] {
         phase: e.phase,
         detail: DETAIL[actions[0] as OwnerAction],
         actions,
+        since: s.issues.find((i) => i.number === e.number)?.updatedAt ?? null,
+        review: actions.includes("sign_off") ? (reviewOf.get(e.number) ?? null) : null,
       };
     });
 }
diff --git a/src/review-pairing.test.ts b/src/review-pairing.test.ts
new file mode 100644
index 0000000..185cd30
--- /dev/null
+++ b/src/review-pairing.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { comment, epic, issue, snapshot } from "../test/helpers.ts";
+import { pairReviews } from "./review-pairing.ts";
+
+const epicIssue = (number: number, title: string, phase: number, comments: string[] = []) =>
+  issue({
+    number,
+    title,
+    labels: ["epic", `phase:${phase}`, "plan-approved", "needs-owner"],
+    body: "",
+    comments: comments.map((c) => comment(c)),
+  });
+const review = (number: number, title: string, phase: number) =>
+  issue({ number, title, labels: [`phase:${phase}`, "needs-owner"], body: "" });
+const epicOf = (number: number, phase: number) =>
+  epic({ number, phase, labels: ["epic", `phase:${phase}`, "plan-approved", "needs-owner"] });
+
+describe("pairReviews", () => {
+  it("pairs by the epic's Phase review comment", () => {
+    const s = snapshot({
+      epics: [epicOf(514, 10)],
+      issues: [
+        epicIssue(514, "Phase 10a: Families and wards", 10, ["Phase review: #571"]),
+        review(571, "Something else entirely", 10),
+      ],
+    });
+    expect(pairReviews(s).get(571)).toBe(514);
+  });
+
+  it("pairs by title with the epic's Phase prefix removed", () => {
+    const s = snapshot({
+      epics: [epicOf(525, 10)],
+      issues: [
+        epicIssue(525, "Phase 10b: Services, tuition and enrollment", 10),
+        review(573, "Phase 10b review: Services, tuition and enrollment", 10),
+      ],
+    });
+    expect(pairReviews(s).get(573)).toBe(525);
+  });
+
+  it("pairs a review whose title repeats the epic's full title", () => {
+    const s = snapshot({
+      epics: [epicOf(575, 10)],
+      issues: [
+        epicIssue(575, "Phase 10c: Platform subscriptions", 10),
+        review(594, "Phase 10c review: Phase 10c: Platform subscriptions", 10),
+      ],
+    });
+    expect(pairReviews(s).get(594)).toBe(575);
+  });
+
+  it("does not pair two epics that share a phase number by label", () => {
+    const s = snapshot({
+      epics: [epicOf(514, 10), epicOf(525, 10)],
+      issues: [
+        epicIssue(514, "Phase 10a: Families and wards", 10),
+        epicIssue(525, "Phase 10b: Services, tuition and enrollment", 10),
+        review(573, "Phase 10b review: Services, tuition and enrollment", 10),
+      ],
+    });
+    const pairs = pairReviews(s);
+    expect(pairs.get(573)).toBe(525);
+    expect([...pairs.values()]).not.toContain(514);
+  });
+
+  it("leaves a review unpaired when no open epic matches", () => {
+    const s = snapshot({
+      epics: [],
+      issues: [review(336, "Phase 3 review: Lesson creation and AI organization", 3)],
+    });
+    expect(pairReviews(s).size).toBe(0);
+  });
+
+  it("prefers the comment over a title match", () => {
+    const s = snapshot({
+      epics: [epicOf(514, 10), epicOf(525, 10)],
+      issues: [
+        epicIssue(514, "Phase 10a: Families and wards", 10, ["Phase review: #573"]),
+        epicIssue(525, "Phase 10b: Services, tuition and enrollment", 10),
+        review(573, "Phase 10b review: Services, tuition and enrollment", 10),
+      ],
+    });
+    expect(pairReviews(s).get(573)).toBe(514);
+  });
+});
diff --git a/src/review-pairing.ts b/src/review-pairing.ts
new file mode 100644
index 0000000..eef7175
--- /dev/null
+++ b/src/review-pairing.ts
@@ -0,0 +1,39 @@
+import type { Snapshot } from "./types.ts";
+
+const REVIEW_COMMENT = /^Phase review: #(\d+)/m;
+/** The title `roles/phase-closer.md` gives a phase review: `Phase <N> review: <epic title>`. */
+export const PHASE_REVIEW_TITLE = /^Phase .+ review: (.+)$/;
+const PHASE_PREFIX = /^Phase [^:]+:\s*/;
+
+/**
+ * Which open epic each phase review issue belongs to: review issue number → epic number. The
+ * phase-closer links the review from the epic with a `Phase review: #<n>` comment, which is the
+ * truth when present; otherwise the epic's title (its `Phase <X>:` prefix dropped) inside the
+ * review's title. Never by `phase:<N>` alone — several open epics can share a phase number.
+ */
+export function pairReviews(s: Snapshot): Map<number, number> {
+  const pairs = new Map<number, number>();
+  const epics = s.epics
+    .filter((e) => e.state === "OPEN")
+    .flatMap((e) => {
+      const i = s.issues.find((x) => x.number === e.number);
+      return i ? [i] : [];
+    });
+  for (const e of epics) {
+    for (const c of e.comments) {
+      const m = REVIEW_COMMENT.exec(c.body);
+      if (m) pairs.set(Number(m[1]), e.number);
+    }
+  }
+  for (const r of s.issues) {
+    if (pairs.has(r.number) || r.labels.includes("epic")) continue;
+    const m = PHASE_REVIEW_TITLE.exec(r.title);
+    if (!m) continue;
+    const owner = epics.find((e) => {
+      const name = e.title.replace(PHASE_PREFIX, "").trim();
+      return name.length > 0 && (m[1] ?? "").includes(name);
+    });
+    if (owner) pairs.set(r.number, owner.number);
+  }
+  return pairs;
+}
diff --git a/src/state-file.test.ts b/src/state-file.test.ts
index b0124dd..84e2d19 100644
--- a/src/state-file.test.ts
+++ b/src/state-file.test.ts
@@ -80,6 +80,16 @@ describe("state file", () => {
           tasks: [{ issue: 1, title: "t", status: null, closed: false, model: null }],
         },
       ],
+      owner: [{ epic: 9, title: "p", phase: 1, detail: "", actions: ["pause"] }],
+      needsYou: [
+        {
+          issue: 2,
+          title: "n",
+          labels: ["needs-owner"],
+          since: "2026-09-01T00:00:00Z",
+          itemId: null,
+        },
+      ],
       explain: [],
       prs: [],
     };
@@ -88,6 +98,9 @@ describe("state file", () => {
     expect(s).not.toBeNull();
     expect(s?.board?.pipeline[0]?.effort).toBeNull();
     expect(s?.board?.phases[0]?.tasks[0]?.effort).toBeNull();
+    // Written before the Needs you tab: owner rows without since/review, needs-you rows unpaired.
+    expect(s?.board?.owner[0]).toMatchObject({ since: null, review: null });
+    expect(s?.board?.needsYou[0]).toMatchObject({ reviewOf: null, phaseReview: false });
     // Written before the Board tab: no issues or groups, which read as empty.
     expect(s?.board?.issues).toEqual([]);
     expect(s?.board?.groups).toEqual([]);
diff --git a/src/state-file.ts b/src/state-file.ts
index 35f5779..6f3fc61 100644
--- a/src/state-file.ts
+++ b/src/state-file.ts
@@ -145,6 +145,10 @@ export const OwnerItemSchema = z.object({
   phase: z.number(),
   detail: z.string(),
   actions: z.array(OwnerActionSchema),
+  /** The epic issue's `updatedAt`, so the page can say how long it has waited. */
+  since: z.string().nullable().default(null),
+  /** The phase review issue paired with this epic; set only when it offers `sign_off`. */
+  review: z.number().int().nullable().default(null),
 });
 export type OwnerItem = z.infer<typeof OwnerItemSchema>;
 
@@ -162,6 +166,10 @@ export const NeedsYouItemSchema = z.object({
   since: z.string(),
   /** Project item id, so Unblock can set Status Ready without another read; null when off board. */
   itemId: z.string().nullable(),
+  /** The open epic this phase review belongs to; its Sign off row shows it instead of a row here. */
+  reviewOf: z.number().int().nullable().default(null),
+  /** Titled like a phase review; with `reviewOf` null, its epic is no longer open. */
+  phaseReview: z.boolean().default(false),
 });
 export type NeedsYouItem = z.infer<typeof NeedsYouItemSchema>;
 
diff --git a/src/status.test.ts b/src/status.test.ts
index 115043a..1e49a2d 100644
--- a/src/status.test.ts
+++ b/src/status.test.ts
@@ -453,6 +453,8 @@ describe("live activity and board", () => {
           // A week old: the case the card exists for, and the one a bare clock time cannot say.
           since: "2026-08-28T05:03:00.000Z",
           itemId: "PVTI_165",
+          reviewOf: null,
+          phaseReview: false,
         },
         {
           issue: 5,
@@ -460,6 +462,8 @@ describe("live activity and board", () => {
           labels: ["blocked"],
           since: "2026-09-04T04:00:00.000Z",
           itemId: null,
+          reviewOf: null,
+          phaseReview: false,
         },
       ],
       phases: [],
diff --git a/src/web.test.ts b/src/web.test.ts
index 125b7ac..513c9a3 100644
--- a/src/web.test.ts
+++ b/src/web.test.ts
@@ -78,7 +78,15 @@ describe("web server", () => {
       return `did ${action} on #${epic}`;
     },
     ownerItems: () => [
-      { epic: 124, title: "Phase 0.5", phase: 0, detail: "d", actions: ["sign_off", "pause"] },
+      {
+        epic: 124,
+        title: "Phase 0.5",
+        phase: 0,
+        detail: "d",
+        actions: ["sign_off", "pause"],
+        since: null,
+        review: null,
+      },
     ],
     needsYouItems: () => [
       {
@@ -87,6 +95,8 @@ describe("web server", () => {
         labels: ["blocked"],
         since: "2026-09-03T03:00:00Z",
         itemId: "PVTI_5",
+        reviewOf: null,
+        phaseReview: false,
       },
       {
         issue: 171,
@@ -94,6 +104,8 @@ describe("web server", () => {
         labels: ["needs-owner"],
         since: "2026-09-03T10:00:00Z",
         itemId: null,
+        reviewOf: null,
+        phaseReview: false,
       },
     ],
     unblock: async (issue) => {

From 205b16ab37ef42341c2d909489b43bcc48914734 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 18:59:49 -0700
Subject: [PATCH 09/19] feat(status): badge counts, banners and de-duplicated
 waiting rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/attention.test.ts | 128 ++++++++++++++++++++++++++++++++++++++++++
 src/attention.ts      |  68 ++++++++++++++++++++++
 src/status.test.ts    |   5 ++
 src/status.ts         |   4 ++
 src/web.test.ts       |   1 +
 5 files changed, 206 insertions(+)
 create mode 100644 src/attention.test.ts
 create mode 100644 src/attention.ts

diff --git a/src/attention.test.ts b/src/attention.test.ts
new file mode 100644
index 0000000..14d41ca
--- /dev/null
+++ b/src/attention.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest";
+import { describeAttention } from "./attention.ts";
+import type { Board, NeedsYouItem, OwnerItem, WaitItem } from "./state-file.ts";
+
+const board = (over: Partial<Board> = {}): Board => ({
+  at: "2026-09-05T10:00:00Z",
+  waiting: [],
+  pipeline: [],
+  owner: [],
+  needsYou: [],
+  phases: [],
+  issues: [],
+  groups: [],
+  explain: [],
+  prs: [],
+  ...over,
+});
+
+const owner = (epic: number, actions: OwnerItem["actions"]): OwnerItem => ({
+  epic,
+  title: `Phase ${epic}`,
+  phase: 1,
+  detail: "",
+  actions,
+  since: null,
+  review: null,
+});
+
+const needs = (issue: number, over: Partial<NeedsYouItem> = {}): NeedsYouItem => ({
+  issue,
+  title: `Task ${issue}`,
+  labels: ["needs-owner"],
+  since: "2026-09-01T00:00:00Z",
+  itemId: null,
+  reviewOf: null,
+  phaseReview: false,
+  ...over,
+});
+
+const wait = (
+  kind: WaitItem["kind"],
+  subject: string,
+  detail: string,
+  since: string | null = null,
+) => ({
+  kind,
+  subject,
+  detail,
+  since,
+});
+
+describe("describeAttention", () => {
+  it("counts approve and sign-off epics and unpaired needs-you issues, not start_planning", () => {
+    const b = board({
+      owner: [
+        owner(1, ["approve_plan", "pause"]),
+        owner(2, ["sign_off", "pause"]),
+        owner(3, ["start_planning", "pause"]),
+        owner(4, ["pause"]),
+      ],
+      needsYou: [needs(11), needs(12, { reviewOf: 2 }), needs(13, { labels: ["blocked"] })],
+    });
+    expect(describeAttention(b).needsYou).toBe(4);
+  });
+
+  it("moves stop, preflight, cap and paused into banners", () => {
+    const b = board({
+      waiting: [
+        wait("stop", "STOP", "STOP file present; ctl go to resume"),
+        wait("preflight", "preflight", "gh not authenticated"),
+        wait("cap", "cap", "20 of 20 sessions today"),
+        wait("paused", "phase 3", "phase 3 paused (foreman:pause on #30)"),
+        wait("ci", "PR #276", "PR #276 checks pending"),
+      ],
+    });
+    const a = describeAttention(b);
+    expect(a.banners.map((w) => w.kind)).toEqual(["stop", "preflight", "cap", "paused"]);
+    expect(a.waitRows.map((w) => w.kind)).toEqual(["ci"]);
+    expect(a.waiting).toBe(1);
+  });
+
+  it("drops human waits whose subject is on Needs you and keeps the rest", () => {
+    const b = board({
+      owner: [owner(93, ["sign_off", "pause"]), owner(77, ["start_planning", "pause"])],
+      needsYou: [needs(609, { labels: ["blocked"] })],
+      waiting: [
+        wait("human", "epic #93", "epic #93 awaits owner sign-off"),
+        wait("human", "#609", "#609 blocked: depends on #608"),
+        wait("human", "#41", "#41 is agent-ready but Status Backlog"),
+        wait(
+          "human",
+          "phase 13",
+          "phase 13 held: 1 blocked tasks need you before any new build starts",
+        ),
+      ],
+    });
+    expect(describeAttention(b).waitRows.map((w) => w.subject)).toEqual(["#41", "phase 13"]);
+  });
+
+  it("merges dependency rows for one issue", () => {
+    const b = board({
+      waiting: [
+        wait("dependency", "#270", "#270 waits on #268"),
+        wait("dependency", "#271", "#271 waits on #269"),
+        wait("dependency", "#270", "#270 waits on #269", "2026-09-02T00:00:00Z"),
+      ],
+    });
+    expect(describeAttention(b).waitRows).toEqual([
+      wait("dependency", "#270", "#270 waits on #268 and #269", "2026-09-02T00:00:00Z"),
+      wait("dependency", "#271", "#271 waits on #269"),
+    ]);
+  });
+
+  it("lists three or more dependencies with commas", () => {
+    const b = board({
+      waiting: [
+        wait("dependency", "#9", "#9 waits on #1"),
+        wait("dependency", "#9", "#9 waits on #2"),
+        wait("dependency", "#9", "#9 waits on #3"),
+      ],
+    });
+    expect(describeAttention(b).waitRows[0]?.detail).toBe("#9 waits on #1, #2 and #3");
+  });
+
+  it("is all zeros and empty for a null board", () => {
+    expect(describeAttention(null)).toEqual({ needsYou: 0, waiting: 0, banners: [], waitRows: [] });
+  });
+});
diff --git a/src/attention.ts b/src/attention.ts
new file mode 100644
index 0000000..f0af52e
--- /dev/null
+++ b/src/attention.ts
@@ -0,0 +1,68 @@
+import type { Board, WaitItem } from "./state-file.ts";
+
+/** Waits that stop this Mac taking any new work; the page shows them as banners, not rows. */
+export const BANNER_KINDS = ["stop", "preflight", "cap", "paused"] as const;
+
+/** What the page's navigation badges, banners and Waiting on tab show. */
+export interface Attention {
+  /** The Needs you badge: decisions only the owner can make. */
+  needsYou: number;
+  /** The Waiting on badge; always `waitRows.length`. */
+  waiting: number;
+  banners: WaitItem[];
+  waitRows: WaitItem[];
+}
+
+const numberIn = (subject: string) => {
+  const m = /#(\d+)/.exec(subject);
+  return m ? Number(m[1]) : null;
+};
+
+const list = (ns: string[]) =>
+  ns.length < 2 ? (ns[0] ?? "") : `${ns.slice(0, -1).join(", ")} and ${ns[ns.length - 1]}`;
+
+/**
+ * Turns the board into what the page counts and lists, so the badge and the rows it counts can
+ * never disagree. A `human` wait for an issue or epic already on Needs you is not repeated on
+ * Waiting on, and one issue's dependency waits read as one row.
+ */
+export function describeAttention(board: Board | null): Attention {
+  if (!board) return { needsYou: 0, waiting: 0, banners: [], waitRows: [] };
+  const gated = board.owner.filter((o) =>
+    o.actions.some((a) => a === "approve_plan" || a === "sign_off"),
+  );
+  const needsYou = gated.length + board.needsYou.filter((n) => n.reviewOf === null).length;
+  // Every issue or epic that has a row of its own on Needs you, Ready to plan included.
+  const onNeedsYou = new Set([
+    ...board.owner
+      .filter((o) => o.actions.some((a) => a !== "pause" && a !== "unpause"))
+      .map((o) => o.epic),
+    ...board.needsYou.map((n) => n.issue),
+  ]);
+  const isBanner = (w: WaitItem) => (BANNER_KINDS as readonly string[]).includes(w.kind);
+  const banners = board.waiting.filter(isBanner);
+  const waitRows: WaitItem[] = [];
+  const deps = new Map<string, { row: WaitItem; on: string[] }>();
+  for (const w of board.waiting) {
+    if (isBanner(w)) continue;
+    const n = numberIn(w.subject);
+    if (w.kind === "human" && n !== null && onNeedsYou.has(n)) continue;
+    if (w.kind === "dependency") {
+      const on = w.detail.slice(w.detail.lastIndexOf(" ") + 1);
+      const seen = deps.get(w.subject);
+      if (seen) {
+        seen.on.push(on);
+        const since = [seen.row.since, w.since].filter((x): x is string => x !== null).sort()[0];
+        seen.row.since = since ?? null;
+        seen.row.detail = `${w.subject} waits on ${list(seen.on)}`;
+        continue;
+      }
+      const row = { ...w };
+      deps.set(w.subject, { row, on: [on] });
+      waitRows.push(row);
+      continue;
+    }
+    waitRows.push(w);
+  }
+  return { needsYou, waiting: waitRows.length, banners, waitRows };
+}
diff --git a/src/status.test.ts b/src/status.test.ts
index 1e49a2d..32ab9df 100644
--- a/src/status.test.ts
+++ b/src/status.test.ts
@@ -355,8 +355,13 @@ describe("live activity and board", () => {
     };
     const r = describeStatus(input({ state: { ...state(), board }, stopPresent: true }));
     expect(r.board?.waiting.map((w) => w.kind)).toEqual(["stop", "ci"]);
+    // The page's banners and Waiting on rows come from the same merged board.
+    expect(r.attention.banners.map((w) => w.kind)).toEqual(["stop"]);
+    expect(r.attention.waitRows.map((w) => w.kind)).toEqual(["ci"]);
+    expect(r.attention.waiting).toBe(1);
     const noBoard = describeStatus(input({ stopPresent: true }));
     expect(noBoard.board?.waiting.map((w) => w.kind)).toEqual(["stop"]);
+    expect(describeStatus(input({ state: null })).attention.needsYou).toBe(0);
     const capped = describeStatus(
       input({
         state: {
diff --git a/src/status.ts b/src/status.ts
index ecdf380..47729bd 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -1,3 +1,4 @@
+import { type Attention, describeAttention } from "./attention.ts";
 import { overlayCurrent } from "./board.ts";
 import { formatBudget } from "./budget.ts";
 import { type SessionLogEntry, todayStats } from "./sessions.ts";
@@ -86,6 +87,8 @@ export interface StatusReport {
   lastPlan: string[] | null;
   board: Board | null;
   budget: BudgetReport | null;
+  /** Badge counts, banners and the Waiting on rows, from the merged board. */
+  attention: Attention;
   recent: SessionLogEntry[];
   today: { count: number; cap: number; spendUsd: number };
   /** The daily session cap the next tick will enforce, and the page's one-click choices. */
@@ -222,6 +225,7 @@ export function describeStatus(i: StatusInput): StatusReport {
     lastPlan: s?.lastPlan ?? null,
     board,
     budget: s?.budget ?? null,
+    attention: describeAttention(board),
     recent,
     today: { count: today.count, cap, spendUsd: today.spendUsd },
   };
diff --git a/src/web.test.ts b/src/web.test.ts
index 513c9a3..13a26a8 100644
--- a/src/web.test.ts
+++ b/src/web.test.ts
@@ -37,6 +37,7 @@ const report: StatusReport = {
   lastPlan: ["plan#10"],
   board: null,
   budget: null,
+  attention: { needsYou: 0, waiting: 0, banners: [], waitRows: [] },
   recent: [],
   today: { count: 0, cap: 20, spendUsd: 0 },
 };

From 0e0329410bb38c2506f38865b28ad12a06a5c2ce Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 19:01:43 -0700
Subject: [PATCH 10/19] feat(web): per-instance color theme, distinct by
 default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/cli/daemon.ts      | 15 ++++++++++++++-
 src/state-file.test.ts | 17 +++++++++++++++++
 src/state-file.ts      |  9 +++++++++
 src/status.test.ts     | 13 +++++++++++++
 src/status.ts          |  9 +++++++++
 src/theme.test.ts      | 25 +++++++++++++++++++++++++
 src/theme.ts           | 28 ++++++++++++++++++++++++++++
 src/web.test.ts        | 22 ++++++++++++++++++++++
 src/web.ts             | 11 +++++++++++
 9 files changed, 148 insertions(+), 1 deletion(-)
 create mode 100644 src/theme.test.ts
 create mode 100644 src/theme.ts

diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts
index 229e2db..ad41dd4 100644
--- a/src/cli/daemon.ts
+++ b/src/cli/daemon.ts
@@ -6,7 +6,7 @@ import { realExec } from "../exec.ts";
 import { readFeed } from "../feed.ts";
 import { GitHub } from "../github.ts";
 import { fileHookLog } from "../hooks.ts";
-import type { Instance } from "../instance.ts";
+import { type Instance, listInstances } from "../instance.ts";
 import { launchdInstalled } from "../launchd-status.ts";
 import { log } from "../log.ts";
 import { buildSnapshot, ensureLogin, realCtx, runForever, runOnce } from "../loop.ts";
@@ -20,6 +20,7 @@ import { initialState, MODEL_DEFAULT, readState, StateStore } from "../state-fil
 import { describeStatus } from "../status.ts";
 import { applyTaskEffort, applyTaskEffortToBoard } from "../task-effort.ts";
 import { applyTaskModel, applyTaskModelToBoard, pickerTasks } from "../task-model.ts";
+import { pickTheme, type ThemeName } from "../theme.ts";
 import { startWebServer } from "../web.ts";
 import { launchdLabel } from "./launchd.ts";
 
@@ -139,6 +140,11 @@ export async function runDaemon(
 
   // Long-running daemon: owns state.json, the web page, and the signal handlers.
   const previous = readState(STATE_DIR);
+  // Picked once and saved, so it never shifts: the theme the other instances here use least.
+  const others = listInstances()
+    .filter((i) => i.name !== instance.name)
+    .map((i) => readState(i.dir)?.theme ?? null)
+    .filter((t): t is ThemeName => t !== null);
   const store = new StateStore(
     STATE_DIR,
     initialState({
@@ -150,6 +156,7 @@ export async function runDaemon(
       // A restart must not silently revert the owner's overrides back to foreman.json.
       model: previous?.model ?? null,
       maxSessionsPerDay: previous?.maxSessionsPerDay ?? null,
+      theme: previous?.theme ?? pickTheme(instance.name, others),
     }),
   );
   const controller = new Controller((mode) => {
@@ -197,6 +204,7 @@ export async function runDaemon(
         repo: cfg.repo,
         configModel: cfg.model,
         modelChoices: repo.models,
+        instance: instance.name,
       }),
     next: () => lock.run(async () => describeNext(await buildSnapshot(ctx), repo)),
     act: async (cmd) => {
@@ -218,6 +226,11 @@ export async function runDaemon(
       log("info", "model set", { model: next, configured: cfg.model });
       return `next session runs ${next ?? cfg.model}`;
     },
+    setTheme: async (theme) => {
+      store.patch({ theme });
+      log("info", "theme set", { theme });
+      return `theme is now ${theme}`;
+    },
     setCap: async (maxSessionsPerDay) => {
       // Read per tick by runOnce's capFor(), so raising it while parked on the cap lets the very
       // next tick run — no restart, and the sleep is cut short so it happens now.
diff --git a/src/state-file.test.ts b/src/state-file.test.ts
index 84e2d19..82d166b 100644
--- a/src/state-file.test.ts
+++ b/src/state-file.test.ts
@@ -105,6 +105,23 @@ describe("state file", () => {
     expect(s?.board?.issues).toEqual([]);
     expect(s?.board?.groups).toEqual([]);
   });
+  it("a state file written before themes parses with theme null; initialState carries one", () => {
+    const dir = mkdtempSync(join(tmpdir(), "tt-state-"));
+    const { theme: _absent, ...old } = base();
+    writeFileSync(statePath(dir), JSON.stringify(old));
+    expect(readState(dir)?.theme).toBeNull();
+    expect(base().theme).toBeNull();
+    expect(
+      initialState({
+        pid: 1,
+        host: "h",
+        configPath: "/c",
+        dryRun: false,
+        startedAt: "2026-09-03T12:00:00.000Z",
+        theme: "teal",
+      }).theme,
+    ).toBe("teal");
+  });
   it("StateStore.patch merges and persists", () => {
     const dir = mkdtempSync(join(tmpdir(), "tt-state-"));
     const store = new StateStore(dir, base());
diff --git a/src/state-file.ts b/src/state-file.ts
index 6f3fc61..a2ed29a 100644
--- a/src/state-file.ts
+++ b/src/state-file.ts
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
 import { join } from "node:path";
 import { z } from "zod";
 import { ActivitySchema } from "./stream.ts";
+import { type ThemeName, ThemeSchema } from "./theme.ts";
 
 export const StopModeSchema = z.enum(["stop", "abort"]);
 export type StopMode = z.infer<typeof StopModeSchema>;
@@ -335,6 +336,11 @@ export const ForemanStateSchema = z.object({
    *. Older state.json files predate this field; the default keeps them readable.
    */
   maxSessionsPerDay: z.number().int().nullable().default(null),
+  /**
+   * The page's color theme, picked once when the daemon first starts and changed from the page.
+   * Older state.json files predate this field; the default keeps them readable.
+   */
+  theme: ThemeSchema.nullable().default(null),
 });
 export type ForemanState = z.infer<typeof ForemanStateSchema>;
 
@@ -352,6 +358,8 @@ export function initialState(o: {
   model?: string | null;
   /** Carried over from the previous state.json, like `model`, so a restart keeps the override. */
   maxSessionsPerDay?: number | null;
+  /** Carried over from the previous state.json, or picked at first start; never reset. */
+  theme?: ThemeName | null;
 }): ForemanState {
   return {
     version: 1,
@@ -359,6 +367,7 @@ export function initialState(o: {
     // After the spread: an omitted override arrives as undefined, which the schema rejects.
     model: o.model ?? null,
     maxSessionsPerDay: o.maxSessionsPerDay ?? null,
+    theme: o.theme ?? null,
     exitedAt: null,
     lastTickAt: null,
     nextTickAt: null,
diff --git a/src/status.test.ts b/src/status.test.ts
index 32ab9df..6f08715 100644
--- a/src/status.test.ts
+++ b/src/status.test.ts
@@ -3,6 +3,7 @@ import type { SessionLogEntry } from "./sessions.ts";
 import { CAP_CHOICES, initialState, MODEL_CHOICES } from "./state-file.ts";
 import { describeStatus, formatStatus, type StatusInput } from "./status.ts";
 import { emptyActivity } from "./stream.ts";
+import { pickTheme, THEMES } from "./theme.ts";
 
 const now = "2026-09-04T05:10:00.000Z";
 const session = (over: Partial<SessionLogEntry> = {}): SessionLogEntry => ({
@@ -64,6 +65,18 @@ function input(over: Partial<StatusInput> = {}): StatusInput {
   };
 }
 
+describe("describeStatus theme", () => {
+  it("echoes the instance and the saved theme", () => {
+    const r = describeStatus(input({ instance: "litart", state: { ...state(), theme: "violet" } }));
+    expect(r.instance).toBe("litart");
+    expect(r.theme).toEqual({ current: "violet", choices: THEMES });
+  });
+  it("derives a stable theme when there is no state to read one from", () => {
+    const r = describeStatus(input({ instance: "litart", state: null }));
+    expect(r.theme.current).toBe(pickTheme("litart", []));
+  });
+});
+
 describe("describeStatus", () => {
   it("RUNNING with uptime and a current session", () => {
     const r = describeStatus(input({ state: { ...state(), current } }));
diff --git a/src/status.ts b/src/status.ts
index 47729bd..8646c4d 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -13,6 +13,7 @@ import {
   type ForemanState,
   type StopMode,
 } from "./state-file.ts";
+import { pickTheme, THEMES, type ThemeName } from "./theme.ts";
 import { liveWaits } from "./waiting.ts";
 
 export type DaemonState = "RUNNING" | "STOPPED" | "CRASHED" | "UNKNOWN";
@@ -43,6 +44,8 @@ export interface StatusInput {
   configModel: string;
   /** The repo's `models`; the page and `ctl status` offer these as one-click choices. */
   modelChoices: readonly string[];
+  /** The instance name (`foreman -p <name>`), for the page header and the derived theme. */
+  instance?: string;
 }
 
 export interface StatusReport {
@@ -89,6 +92,10 @@ export interface StatusReport {
   budget: BudgetReport | null;
   /** Badge counts, banners and the Waiting on rows, from the merged board. */
   attention: Attention;
+  /** The instance name the page shows at the top of its sidebar. */
+  instance: string;
+  /** The page's color theme for this instance, and the ones it can switch to. */
+  theme: { current: ThemeName; choices: readonly ThemeName[] };
   recent: SessionLogEntry[];
   today: { count: number; cap: number; spendUsd: number };
   /** The daily session cap the next tick will enforce, and the page's one-click choices. */
@@ -226,6 +233,8 @@ export function describeStatus(i: StatusInput): StatusReport {
     board,
     budget: s?.budget ?? null,
     attention: describeAttention(board),
+    instance: i.instance ?? "",
+    theme: { current: s?.theme ?? pickTheme(i.instance ?? "", []), choices: THEMES },
     recent,
     today: { count: today.count, cap, spendUsd: today.spendUsd },
   };
diff --git a/src/theme.test.ts b/src/theme.test.ts
new file mode 100644
index 0000000..f43d591
--- /dev/null
+++ b/src/theme.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { pickTheme, THEMES, type ThemeName } from "./theme.ts";
+
+describe("pickTheme", () => {
+  it("is stable for a name", () => {
+    expect(pickTheme("litart", [])).toBe(pickTheme("litart", []));
+    expect(THEMES).toContain(pickTheme("litart", []));
+  });
+
+  it("takes the only unused theme", () => {
+    expect(pickTheme("anything", ["indigo", "violet", "teal"])).toBe("mono");
+    expect(pickTheme("else", ["mono", "violet", "teal", "violet"])).toBe("indigo");
+  });
+
+  it("four instances in turn get four different themes", () => {
+    const taken: ThemeName[] = [];
+    for (const name of ["apptrade", "fishcity", "litart", "tone_tonic"])
+      taken.push(pickTheme(name, taken));
+    expect(new Set(taken).size).toBe(4);
+  });
+
+  it("a fifth instance repeats one", () => {
+    expect(THEMES).toContain(pickTheme("fifth", ["indigo", "violet", "teal", "mono"]));
+  });
+});
diff --git a/src/theme.ts b/src/theme.ts
new file mode 100644
index 0000000..22ca7e0
--- /dev/null
+++ b/src/theme.ts
@@ -0,0 +1,28 @@
+import { z } from "zod";
+
+/** The page's color schemes; each instance shows one, so several open tabs are easy to tell apart. */
+export const THEMES = ["indigo", "violet", "teal", "mono"] as const;
+export const ThemeSchema = z.enum(THEMES);
+export type ThemeName = z.infer<typeof ThemeSchema>;
+
+/** FNV-1a, 32-bit: a stable number per instance name, the same on every Mac and every run. */
+export function nameHash(name: string): number {
+  let h = 0x811c9dc5;
+  for (let i = 0; i < name.length; i++) {
+    h ^= name.charCodeAt(i);
+    h = Math.imul(h, 0x01000193) >>> 0;
+  }
+  return h;
+}
+
+/**
+ * The theme a new instance starts with: one the other instances on this Mac use least, so up to
+ * four start with four different themes; among equals, the name's hash decides. The daemon saves
+ * the result, so it never shifts when another instance is added later.
+ */
+export function pickTheme(name: string, taken: readonly ThemeName[]): ThemeName {
+  const uses = (t: ThemeName) => taken.filter((x) => x === t).length;
+  const least = Math.min(...THEMES.map(uses));
+  const candidates = THEMES.filter((t) => uses(t) === least);
+  return candidates[nameHash(name) % candidates.length] as ThemeName;
+}
diff --git a/src/web.test.ts b/src/web.test.ts
index 13a26a8..104f57a 100644
--- a/src/web.test.ts
+++ b/src/web.test.ts
@@ -38,6 +38,8 @@ const report: StatusReport = {
   board: null,
   budget: null,
   attention: { needsYou: 0, waiting: 0, banners: [], waitRows: [] },
+  instance: "widgets",
+  theme: { current: "indigo", choices: ["indigo", "violet", "teal", "mono"] },
   recent: [],
   today: { count: 0, cap: 20, spendUsd: 0 },
 };
@@ -56,6 +58,7 @@ describe("web server", () => {
   const unblocked: number[] = [];
   const taskModels: string[] = [];
   const taskEfforts: string[] = [];
+  const themes: string[] = [];
   let refreshes = 0;
   const sid = "144ba520-6c0f-4195-ae18-c67de1443b31";
   const entries: FeedEntry[] = Array.from({ length: 5 }, (_, i) => ({
@@ -117,6 +120,10 @@ describe("web server", () => {
       models.push(model);
       return `next session runs ${model}`;
     },
+    setTheme: async (theme) => {
+      themes.push(theme);
+      return `theme is now ${theme}`;
+    },
     setCap: async (cap) => {
       caps.push(cap);
       return `cap is now ${cap ?? "the configured value"}`;
@@ -248,6 +255,19 @@ describe("web server", () => {
     expect((await fetch(`${base}/api/model`)).status).toBe(405);
     expect(models).toEqual(["sonnet", "claude-haiku-4-5-20251001"]);
   });
+  it("sets the page theme to one of the four, and nothing else", async () => {
+    const post = (body: unknown) =>
+      fetch(`${base}/api/theme`, { method: "POST", body: JSON.stringify(body) });
+    const ok = await post({ theme: "violet" });
+    expect(ok.status).toBe(200);
+    expect(await ok.json()).toEqual({ ok: true, message: "theme is now violet" });
+    const bad = await post({ theme: "pink" });
+    expect(bad.status).toBe(400);
+    expect(await bad.json()).toEqual({ ok: false, error: "bad theme" });
+    expect((await post({})).status).toBe(400);
+    expect((await fetch(`${base}/api/theme`)).status).toBe(405);
+    expect(themes).toEqual(["violet"]);
+  });
   it("raises the daily cap, and refuses anything that is not a sane session count", async () => {
     const post = (body: unknown) =>
       fetch(`${base}/api/cap`, { method: "POST", body: JSON.stringify(body) });
@@ -346,6 +366,7 @@ describe("web server with webAuth", () => {
     needsYouItems: () => [],
     unblock: async () => "",
     setModel: async () => "",
+    setTheme: async () => "",
     setCap: async () => "",
     refresh: async () => "",
     pickerTasks: () => [],
@@ -491,6 +512,7 @@ describe("web server with webHosts", () => {
     needsYouItems: () => [],
     unblock: async () => "",
     setModel: async () => "",
+    setTheme: async () => "",
     setCap: async () => "",
     refresh: async () => "",
     pickerTasks: () => [],
diff --git a/src/web.ts b/src/web.ts
index cf46b62..994e2e1 100644
--- a/src/web.ts
+++ b/src/web.ts
@@ -23,6 +23,7 @@ import {
 } from "./state-file.ts";
 import type { StatusReport } from "./status.ts";
 import type { PickerTask } from "./task-model.ts";
+import { type ThemeName, ThemeSchema } from "./theme.ts";
 
 export type WebCommand = "stop" | "abort" | "go";
 
@@ -52,6 +53,8 @@ export interface WebDeps {
   unblock: (issue: number) => Promise<string>;
   /** Sets the model the next dispatch uses; the name has already passed `ModelSchema`. */
   setModel: (model: string) => Promise<string>;
+  /** Saves the page's color theme for this instance; the name has already passed `ThemeSchema`. */
+  setTheme: (theme: ThemeName) => Promise<string>;
   /** Sets the daily session cap the next tick enforces; null clears the override. */
   setCap: (maxSessionsPerDay: number | null) => Promise<string>;
   /** Drops the cached project board and wakes the loop, so the page shows the board as it is now. */
@@ -133,6 +136,8 @@ const TaskEffortRequestSchema = z.object({
 // fat-fingered 100000 cannot quietly uncap the Mac's daily spend.
 const CapRequestSchema = z.object({ maxSessionsPerDay: CapSchema.nullable() });
 
+const ThemeRequestSchema = z.object({ theme: ThemeSchema });
+
 /** Reads a small JSON body; anything unparsable (or over 8 KB) resolves to null. */
 async function readJson(req: http.IncomingMessage): Promise<unknown> {
   const chunks: Buffer[] = [];
@@ -245,6 +250,12 @@ export function createWebServer(d: WebDeps): http.Server {
           return json(res, 400, { ok: false, error: `#${issue} is not a task on the board` });
         return json(res, 200, { ok: true, message: await d.setTaskEffort(issue, effort) });
       }
+      if (url === "/api/theme") {
+        if (req.method !== "POST") return json(res, 405, { ok: false, error: "POST only" });
+        const parsed = ThemeRequestSchema.safeParse(await readJson(req));
+        if (!parsed.success) return json(res, 400, { ok: false, error: "bad theme" });
+        return json(res, 200, { ok: true, message: await d.setTheme(parsed.data.theme) });
+      }
       if (url === "/api/cap") {
         if (req.method !== "POST") return json(res, 405, { ok: false, error: "POST only" });
         const parsed = CapRequestSchema.safeParse(await readJson(req));

From 4ebbf0926f83a153b41f4eeb3eb492ab8431f971 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 19:03:09 -0700
Subject: [PATCH 11/19] feat(web): session cost history endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/cli/daemon.ts           |   3 +
 src/session-history.test.ts | 130 ++++++++++++++++++++++++++++++++++++
 src/session-history.ts      | 124 ++++++++++++++++++++++++++++++++++
 src/web.test.ts             |  42 ++++++++++++
 src/web.ts                  |   8 +++
 5 files changed, 307 insertions(+)
 create mode 100644 src/session-history.test.ts
 create mode 100644 src/session-history.ts

diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts
index ad41dd4..960ae35 100644
--- a/src/cli/daemon.ts
+++ b/src/cli/daemon.ts
@@ -15,6 +15,7 @@ import { describeNext } from "../next.ts";
 import { applyOwnerAction, applyOwnerActionToBoard } from "../owner.ts";
 import { checkEnv, preflight } from "../preflight.ts";
 import { loadRepoConfigSafe } from "../repo-config.ts";
+import { boardTitles, describeSessionHistory } from "../session-history.ts";
 import { readSessions } from "../sessions.ts";
 import { initialState, MODEL_DEFAULT, readState, StateStore } from "../state-file.ts";
 import { describeStatus } from "../status.ts";
@@ -226,6 +227,8 @@ export async function runDaemon(
       log("info", "model set", { model: next, configured: cfg.model });
       return `next session runs ${next ?? cfg.model}`;
     },
+    sessions: (range) =>
+      describeSessionHistory(readSessions(STATE_DIR), range, boardTitles(store.get().board)),
     setTheme: async (theme) => {
       store.patch({ theme });
       log("info", "theme set", { theme });
diff --git a/src/session-history.test.ts b/src/session-history.test.ts
new file mode 100644
index 0000000..4e80b8f
--- /dev/null
+++ b/src/session-history.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { describeSessionHistory, parseRange } from "./session-history.ts";
+import type { SessionLogEntry } from "./sessions.ts";
+
+// Midday UTC, so the local date is the same day in every timezone the tests might run in.
+const entry = (over: Partial<SessionLogEntry> = {}): SessionLogEntry => ({
+  t: "2026-10-07T12:00:00.000Z",
+  host: "h",
+  role: "builder",
+  issue: 1,
+  sessionId: "s",
+  attempt: 1,
+  costUsd: 1,
+  outcome: "pr_opened",
+  model: "claude-opus-5-5",
+  turns: 10,
+  durationMinutes: 2,
+  denials: 0,
+  subtype: "success",
+  ...over,
+});
+const all = { from: null, to: null };
+
+describe("describeSessionHistory", () => {
+  it("totals, daily, byRole and byIssue for the whole log", () => {
+    const entries = [
+      entry({ issue: 1, role: "builder", costUsd: 4, title: "One" }),
+      entry({ issue: 1, role: "reviewer", costUsd: 1, t: "2026-10-08T12:00:00.000Z" }),
+      entry({ issue: 2, role: "builder", costUsd: 2, t: "2026-10-08T13:00:00.000Z" }),
+    ];
+    const h = describeSessionHistory(entries, all, new Map());
+    expect(h.totals).toEqual({ spendUsd: 7, sessions: 3, issues: 2 });
+    expect(h.daily).toEqual([
+      { date: "2026-10-07", spendUsd: 4, sessions: 1 },
+      { date: "2026-10-08", spendUsd: 3, sessions: 2 },
+    ]);
+    expect(h.byRole).toEqual([
+      { role: "builder", spendUsd: 6, sessions: 2 },
+      { role: "reviewer", spendUsd: 1, sessions: 1 },
+    ]);
+    expect(
+      h.byIssue.map((r) => [r.issue, r.title, r.roles, r.sessions, r.costUsd, r.lastAt]),
+    ).toEqual([
+      [1, "One", ["builder", "reviewer"], 2, 5, "2026-10-08T12:00:00.000Z"],
+      [2, "#2", ["builder"], 1, 2, "2026-10-08T13:00:00.000Z"],
+    ]);
+    // Newest first inside a row.
+    expect(h.byIssue[0]?.entries.map((e) => e.role)).toEqual(["reviewer", "builder"]);
+  });
+
+  it("fills zero days between the first and last entry", () => {
+    const h = describeSessionHistory(
+      [entry(), entry({ t: "2026-10-10T12:00:00.000Z" })],
+      all,
+      new Map(),
+    );
+    expect(h.daily.map((d) => [d.date, d.sessions])).toEqual([
+      ["2026-10-07", 1],
+      ["2026-10-08", 0],
+      ["2026-10-09", 0],
+      ["2026-10-10", 1],
+    ]);
+  });
+
+  it("filters by an inclusive local-date range", () => {
+    const entries = [
+      entry({ t: "2026-10-06T12:00:00.000Z", costUsd: 1 }),
+      entry({ t: "2026-10-07T12:00:00.000Z", costUsd: 2 }),
+      entry({ t: "2026-10-09T12:00:00.000Z", costUsd: 4 }),
+    ];
+    const h = describeSessionHistory(entries, { from: "2026-10-07", to: "2026-10-09" }, new Map());
+    expect(h.totals.spendUsd).toBe(6);
+    expect(h.daily.map((d) => d.date)).toEqual(["2026-10-07", "2026-10-08", "2026-10-09"]);
+    expect(
+      describeSessionHistory(entries, { from: "2026-10-08", to: null }, new Map()).totals.sessions,
+    ).toBe(1);
+  });
+
+  it("takes a title from the newest titled line, then open titles, then #N", () => {
+    const entries = [
+      entry({ issue: 1, title: "Old name" }),
+      entry({ issue: 1, title: "New name", t: "2026-10-09T12:00:00.000Z" }),
+      entry({ issue: 2 }),
+      entry({ issue: 3 }),
+    ];
+    // The range excludes the newest titled line; the title still comes from the whole log.
+    const h = describeSessionHistory(
+      entries,
+      { from: null, to: "2026-10-08" },
+      new Map([[2, "Open two"]]),
+    );
+    const titles = new Map(h.byIssue.map((r) => [r.issue, r.title]));
+    expect(titles.get(1)).toBe("New name");
+    expect(titles.get(2)).toBe("Open two");
+    expect(titles.get(3)).toBe("#3");
+  });
+
+  it("is zeros and empty arrays for no entries", () => {
+    expect(describeSessionHistory([], all, new Map())).toEqual({
+      from: null,
+      to: null,
+      totals: { spendUsd: 0, sessions: 0, issues: 0 },
+      daily: [],
+      byRole: [],
+      byIssue: [],
+    });
+  });
+});
+
+describe("parseRange", () => {
+  it("accepts absent bounds and real dates in order", () => {
+    expect(parseRange(null, null)).toEqual({ from: null, to: null });
+    expect(parseRange("2026-10-01", "2026-10-26")).toEqual({
+      from: "2026-10-01",
+      to: "2026-10-26",
+    });
+    expect(parseRange("2026-10-26", "2026-10-26")).toEqual({
+      from: "2026-10-26",
+      to: "2026-10-26",
+    });
+  });
+
+  it("rejects a malformed date, a non-date and from after to", () => {
+    expect(parseRange("2026-13-01", null)).toBeNull();
+    expect(parseRange("2026-02-30", null)).toBeNull();
+    expect(parseRange("../etc", null)).toBeNull();
+    expect(parseRange(null, "yesterday")).toBeNull();
+    expect(parseRange("2026-10-27", "2026-10-01")).toBeNull();
+  });
+});
diff --git a/src/session-history.ts b/src/session-history.ts
new file mode 100644
index 0000000..4fd04c9
--- /dev/null
+++ b/src/session-history.ts
@@ -0,0 +1,124 @@
+import { localDate } from "./preflight.ts";
+import type { SessionLogEntry } from "./sessions.ts";
+import type { Board } from "./state-file.ts";
+
+/** Local dates (`YYYY-MM-DD`), both ends inclusive; null is open on that side. */
+export interface SessionRange {
+  from: string | null;
+  to: string | null;
+}
+
+/** What the Session tab shows for one range of `sessions.log`. */
+export interface SessionHistory {
+  from: string | null;
+  to: string | null;
+  totals: { spendUsd: number; sessions: number; issues: number };
+  daily: { date: string; spendUsd: number; sessions: number }[];
+  byRole: { role: string; spendUsd: number; sessions: number }[];
+  byIssue: {
+    issue: number;
+    title: string;
+    roles: string[];
+    sessions: number;
+    lastAt: string;
+    costUsd: number;
+    entries: SessionLogEntry[];
+  }[];
+}
+
+const DATE = /^\d{4}-\d{2}-\d{2}$/;
+
+/** A real calendar date in `YYYY-MM-DD` form; `2026-02-30` is not one. */
+function isDate(s: string): boolean {
+  if (!DATE.test(s)) return false;
+  const d = new Date(`${s}T00:00:00Z`);
+  return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
+}
+
+/** The query string's range, or null when either bound is not a date or they are out of order. */
+export function parseRange(from: string | null, to: string | null): SessionRange | null {
+  if ((from !== null && !isDate(from)) || (to !== null && !isDate(to))) return null;
+  if (from !== null && to !== null && from > to) return null;
+  return { from, to };
+}
+
+/** Every date from `a` to `b` inclusive, stepping in UTC so a DST change cannot skip a day. */
+function datesBetween(a: string, b: string): string[] {
+  const out: string[] = [];
+  for (let d = new Date(`${a}T00:00:00Z`); d.toISOString().slice(0, 10) <= b; ) {
+    out.push(d.toISOString().slice(0, 10));
+    d = new Date(d.getTime() + 86_400_000);
+  }
+  return out;
+}
+
+/** Titles the board knows for open issues and epics, for history rows logged without one. */
+export function boardTitles(board: Board | null): Map<number, string> {
+  const titles = new Map<number, string>();
+  if (!board) return titles;
+  for (const r of board.pipeline) titles.set(r.issue, r.title);
+  for (const n of board.needsYou) titles.set(n.issue, n.title);
+  for (const o of board.owner) titles.set(o.epic, o.title);
+  for (const i of board.issues) titles.set(i.issue, i.title);
+  return titles;
+}
+
+/**
+ * Spend and sessions over a range of the log, counted per line the way `todayStats` counts them
+ * for the daily cap, so the two figures on the page agree. A row's title comes from the newest
+ * line for that issue that recorded one (anywhere in the log), else the board, else `#N`.
+ */
+export function describeSessionHistory(
+  entries: SessionLogEntry[],
+  range: SessionRange,
+  openTitles: ReadonlyMap<number, string>,
+): SessionHistory {
+  const dayOf = (e: SessionLogEntry) => localDate(new Date(e.t));
+  const inRange = entries
+    .filter((e) => {
+      const d = dayOf(e);
+      return (range.from === null || d >= range.from) && (range.to === null || d <= range.to);
+    })
+    .sort((a, b) => a.t.localeCompare(b.t));
+  const logged = new Map<number, string>();
+  for (const e of [...entries].sort((a, b) => a.t.localeCompare(b.t)))
+    if (e.title) logged.set(e.issue, e.title);
+
+  const spend = (xs: SessionLogEntry[]) => xs.reduce((s, e) => s + e.costUsd, 0);
+  const first = inRange[0];
+  const last = inRange[inRange.length - 1];
+  const daily =
+    first && last
+      ? datesBetween(range.from ?? dayOf(first), range.to ?? dayOf(last)).map((date) => {
+          const day = inRange.filter((e) => dayOf(e) === date);
+          return { date, spendUsd: spend(day), sessions: day.length };
+        })
+      : [];
+
+  const roles = new Map<string, SessionLogEntry[]>();
+  const issues = new Map<number, SessionLogEntry[]>();
+  for (const e of inRange) {
+    roles.set(e.role, [...(roles.get(e.role) ?? []), e]);
+    issues.set(e.issue, [...(issues.get(e.issue) ?? []), e]);
+  }
+  return {
+    from: range.from,
+    to: range.to,
+    totals: { spendUsd: spend(inRange), sessions: inRange.length, issues: issues.size },
+    daily,
+    byRole: [...roles]
+      .map(([role, xs]) => ({ role, spendUsd: spend(xs), sessions: xs.length }))
+      .sort((a, b) => b.spendUsd - a.spendUsd),
+    byIssue: [...issues]
+      .map(([issue, xs]) => ({
+        issue,
+        title: logged.get(issue) ?? openTitles.get(issue) ?? `#${issue}`,
+        roles: [...new Set(xs.map((e) => e.role))],
+        sessions: xs.length,
+        lastAt: (xs[xs.length - 1] as SessionLogEntry).t,
+        costUsd: spend(xs),
+        entries: [...xs].reverse(),
+      }))
+      .sort((a, b) => b.costUsd - a.costUsd),
+  };
+}
diff --git a/src/web.test.ts b/src/web.test.ts
index 104f57a..c8b9984 100644
--- a/src/web.test.ts
+++ b/src/web.test.ts
@@ -5,6 +5,7 @@ import { join } from "node:path";
 import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
 import type { FeedEntry } from "./feed.ts";
 import type { NextReport } from "./next.ts";
+import type { SessionRange } from "./session-history.ts";
 import { CAP_CHOICES, EFFORT_LEVELS, MODEL_CHOICES } from "./state-file.ts";
 import type { StatusReport } from "./status.ts";
 import {
@@ -59,6 +60,7 @@ describe("web server", () => {
   const taskModels: string[] = [];
   const taskEfforts: string[] = [];
   const themes: string[] = [];
+  const ranges: SessionRange[] = [];
   let refreshes = 0;
   const sid = "144ba520-6c0f-4195-ae18-c67de1443b31";
   const entries: FeedEntry[] = Array.from({ length: 5 }, (_, i) => ({
@@ -120,6 +122,16 @@ describe("web server", () => {
       models.push(model);
       return `next session runs ${model}`;
     },
+    sessions: (range) => {
+      ranges.push(range);
+      return {
+        ...range,
+        totals: { spendUsd: 0, sessions: 0, issues: 0 },
+        daily: [],
+        byRole: [],
+        byIssue: [],
+      };
+    },
     setTheme: async (theme) => {
       themes.push(theme);
       return `theme is now ${theme}`;
@@ -255,6 +267,20 @@ describe("web server", () => {
     expect((await fetch(`${base}/api/model`)).status).toBe(405);
     expect(models).toEqual(["sonnet", "claude-haiku-4-5-20251001"]);
   });
+  it("serves the session history for a valid range and refuses a bad one", async () => {
+    const all = await fetch(`${base}/api/sessions`);
+    expect(all.status).toBe(200);
+    expect((await all.json()).totals).toEqual({ spendUsd: 0, sessions: 0, issues: 0 });
+    expect((await fetch(`${base}/api/sessions?from=2026-10-01&to=2026-10-26`)).status).toBe(200);
+    const bad = await fetch(`${base}/api/sessions?from=nope`);
+    expect(bad.status).toBe(400);
+    expect(await bad.json()).toEqual({ ok: false, error: "bad range" });
+    expect((await fetch(`${base}/api/sessions?from=2026-10-27&to=2026-10-01`)).status).toBe(400);
+    expect(ranges).toEqual([
+      { from: null, to: null },
+      { from: "2026-10-01", to: "2026-10-26" },
+    ]);
+  });
   it("sets the page theme to one of the four, and nothing else", async () => {
     const post = (body: unknown) =>
       fetch(`${base}/api/theme`, { method: "POST", body: JSON.stringify(body) });
@@ -366,6 +392,14 @@ describe("web server with webAuth", () => {
     needsYouItems: () => [],
     unblock: async () => "",
     setModel: async () => "",
+    sessions: () => ({
+      from: null,
+      to: null,
+      totals: { spendUsd: 0, sessions: 0, issues: 0 },
+      daily: [],
+      byRole: [],
+      byIssue: [],
+    }),
     setTheme: async () => "",
     setCap: async () => "",
     refresh: async () => "",
@@ -512,6 +546,14 @@ describe("web server with webHosts", () => {
     needsYouItems: () => [],
     unblock: async () => "",
     setModel: async () => "",
+    sessions: () => ({
+      from: null,
+      to: null,
+      totals: { spendUsd: 0, sessions: 0, issues: 0 },
+      daily: [],
+      byRole: [],
+      byIssue: [],
+    }),
     setTheme: async () => "",
     setCap: async () => "",
     refresh: async () => "",
diff --git a/src/web.ts b/src/web.ts
index 994e2e1..b1e8554 100644
--- a/src/web.ts
+++ b/src/web.ts
@@ -8,6 +8,7 @@ import type { WebAuth } from "./config.ts";
 import { FEED_LIMIT_DEFAULT, type FeedEntry, isSessionId } from "./feed.ts";
 import { log } from "./log.ts";
 import type { NextReport } from "./next.ts";
+import { parseRange, type SessionHistory, type SessionRange } from "./session-history.ts";
 import {
   CapSchema,
   type EffortChoice,
@@ -53,6 +54,8 @@ export interface WebDeps {
   unblock: (issue: number) => Promise<string>;
   /** Sets the model the next dispatch uses; the name has already passed `ModelSchema`. */
   setModel: (model: string) => Promise<string>;
+  /** Spend and sessions from `sessions.log` over a range already checked by `parseRange`. */
+  sessions: (range: SessionRange) => SessionHistory;
   /** Saves the page's color theme for this instance; the name has already passed `ThemeSchema`. */
   setTheme: (theme: ThemeName) => Promise<string>;
   /** Sets the daily session cap the next tick enforces; null clears the override. */
@@ -194,6 +197,11 @@ export function createWebServer(d: WebDeps): http.Server {
           Number(u.searchParams.get("limit") ?? FEED_LIMIT_DEFAULT) || FEED_LIMIT_DEFAULT;
         return json(res, 200, { session, entries: d.feed(session, limit) });
       }
+      if (req.method === "GET" && url === "/api/sessions") {
+        const range = parseRange(u.searchParams.get("from"), u.searchParams.get("to"));
+        if (!range) return json(res, 400, { ok: false, error: "bad range" });
+        return json(res, 200, d.sessions(range));
+      }
       if (url === "/api/owner") {
         if (req.method !== "POST") return json(res, 405, { ok: false, error: "POST only" });
         const parsed = OwnerRequestSchema.safeParse(await readJson(req));

From 5442faf70d4743be974d9f0fc01af99213844a17 Mon Sep 17 00:00:00 2001
From: Matthew Smith <matthew.smith@apptreesoftware.com>
Date: Mon, 26 Oct 2026 19:11:55 -0700
Subject: [PATCH 12/19] feat(web): dashboard, navigation sidebar and
 per-instance themes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf
---
 src/web.html    | 800 ++++++++++++++++++++++--------------------------
 src/web.test.ts | 102 +++---
 2 files changed, 410 insertions(+), 492 deletions(-)

diff --git a/src/web.html b/src/web.html
index ccdefbf..688e100 100644
--- a/src/web.html
+++ b/src/web.html
@@ -1,509 +1,457 @@
 <!doctype html>
-<html lang="en">
+<html lang="en" data-theme="indigo">
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Foreman
-
+
 
 
 
-
-
-

foreman

- … - - - - -
- - - - - - -
-
- -
-
-

Now

-

Pipeline in-flight issues, build → review → validate → CI → merge

IssueTitleStagesClaimPR
-

Feed

+
+
+ +
+
+
+

Dashboard

+
+
+
+
+
+
+ +
+

Pipeline

+

Feed

+
+ + + + +
+
+
diff --git a/src/web.test.ts b/src/web.test.ts index c8b9984..70484a3 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -626,75 +626,45 @@ describe("web server with webHosts", () => { }); describe("web.html", () => { - it("has the five sections and escapes through esc()", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); - for (const id of [ - "now", - "waiting", - "owner", - "needsYou", - "phases", - "pipeline", - "feed", - "recent", - "tick", - ]) - expect(html).toContain(`id="${id}"`); + const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); + + it("routes five tabs by location.hash", () => { + for (const tab of ["dashboard", "needs-you", "waiting", "board", "session"]) { + expect(html).toContain(`data-tab="${tab}"`); + expect(html).toContain(`href="#${tab}"`); + } + expect(html).toContain("location.hash"); + }); + it("carries the four themes, light and dark, with readable buttons", () => { + for (const t of ["indigo", "violet", "teal", "mono"]) + expect(html).toContain(`[data-theme="${t}"]`); + for (const hex of ["#4263EB", "#6E56CF", "#22B8CF", "#EDEDEF", "#3B5BDB", "#0B7285", "#5B8DEF"]) + expect(html).toContain(hex); + expect(html).toContain("prefers-color-scheme"); + expect(html).toContain("@container"); + expect(html).toContain('post("/api/theme"'); + }); + it("renders the dashboard from the status report and escapes through esc()", () => { expect(html).toContain("const esc ="); - expect(html).toContain("/api/feed?session="); - }); - it("shows phase progress and puts model and turns next to each session's cost", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); - expect(html).toContain("renderPhases(r.board?.phases ?? [])"); - expect(html).toContain("claim→merge"); - expect(html).toContain(">no approved phase in flight<"); - expect(html).toContain("esc(family(e.model))"); - expect(html).toContain("e.turns ?? 0"); - }); - it("offers the model choices and confirms before POSTing one", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); - expect(html).toContain('rows.push(["model", modelControl(r.model)])'); - expect(html).toContain("window.confirm(`Run foreman sessions as "); - expect(html).toContain('data-model="'); - expect(html).toContain('fetch("/api/model"'); - }); - it("lists each phase's tasks with model buttons and confirms before POSTing one", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); - expect(html).toContain("renderTasks("); - expect(html).toContain('data-task-model="'); - expect(html).toContain("window.confirm(`Run every foreman session on #"); - expect(html).toContain('fetch("/api/task-model"'); - // Pipeline rows show the override when one is set. - expect(html).toContain("p.model ?"); - }); - it("lists each phase's tasks with effort buttons and confirms before POSTing one", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); - expect(html).toContain('data-task-effort="'); - expect(html).toContain("A session running now keeps the effort it started with"); - expect(html).toContain('fetch("/api/task-effort"'); - expect(html).toContain("p.effort ?"); - }); - it("renders the Now card from nowPhase, not from the absence of a session", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); + expect(html).toContain("r.attention"); + expect(html).toContain("r.instance"); expect(html).toContain('r.nowPhase === "between_ticks"'); expect(html).toContain('label: "between ticks"'); - expect(html).toContain(".tag.phase_gate"); - }); - it("lists the Needs you rows and confirms before POSTing an unblock", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); - expect(html).toContain("renderNeedsYou(r.board?.needsYou ?? [])"); - expect(html).toContain("window.confirm(`Unblock #"); - expect(html).toContain('data-unblock="'); - expect(html).toContain('fetch("/api/unblock"'); - expect(html).toContain(">nothing<"); - }); - it("names the parked reason and offers the cap next to today's count", () => { - const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); expect(html).toContain('r.nowPhase === "parked"'); - expect(html).toContain('label: "parked"'); - expect(html).toContain("preflight?.reason"); - expect(html).toContain("capControl(r.cap)"); - expect(html).toContain('data-cap="'); - expect(html).toContain('fetch("/api/cap"'); + expect(html).toContain("/api/feed?session="); + expect(html).toContain('fetch("/api/next")'); + expect(html).toContain('post("/api/refresh")'); + expect(html).toContain(`Confirm $${"{cmd}"}?`); + expect(html).toContain("Back to live"); + }); + it("puts every reason the foreman is idle in a banner", () => { + expect(html).toContain("no state.json: restart the daemon"); + expect(html).toContain("run ctl abort"); + expect(html).toContain("no output for"); + expect(html).toContain("r.attention?.banners"); + }); + it("no longer has the old right-hand cards", () => { + for (const id of ["tick", "recent", "phases", "owner"]) + expect(html).not.toContain(`id="${id}"`); }); }); From b8345f132636e131ab717498dce025ccad302a07 Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Mon, 26 Oct 2026 19:14:06 -0700 Subject: [PATCH 13/19] feat(web): Needs you and Waiting on tabs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf --- src/web.html | 113 +++++++++++++++++++++++++++++++++++++++++++++++- src/web.test.ts | 30 +++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/src/web.html b/src/web.html index 688e100..9626085 100644 --- a/src/web.html +++ b/src/web.html @@ -160,6 +160,35 @@ .fr:last-child { background: var(--accent-soft); } .chip { font: 600 12px/1 var(--sans); padding: 5px 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); color: var(--accent); cursor: pointer; } + /* Needs you and Waiting on: groups of rows, oldest first, the wait time large on the left. */ + .grp { margin-top: 20px; } + .grp:first-child { margin-top: 4px; } + .gh { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-weight: 600; font-size: 14.5px; flex-wrap: wrap; } + .gh .c { font: 600 11.5px/1 var(--sans); padding: 3px 7px; border-radius: 999px; background: var(--sunk); color: var(--ink-2); } + .gh small { color: var(--ink-3); font-weight: 500; font-size: 12.5px; } + .gh .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } + .list { overflow: hidden; } + .it { display: grid; grid-template-columns: 58px minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 11px 16px; } + .it + .it { border-top: 1px solid var(--line); } + .age { font: 650 17px/1 var(--sans); font-variant-numeric: tabular-nums; color: var(--ink-2); } + .age small { display: block; font: 500 11px var(--sans); color: var(--ink-3); margin-top: 3px; } + .age.old { color: var(--warn); } + .it .t, .wt .t { font-weight: 600; } .it .t .num, .wt .t .num { color: var(--ink-3); font-weight: 500; margin-right: 6px; } + .it .s, .wt .s { color: var(--ink-2); font-size: 13px; margin-top: 2px; display: flex; gap: 4px 8px; flex-wrap: wrap; align-items: center; } + .it .acts { display: flex; gap: 6px; margin-left: 0; } + .mini { display: inline-flex; align-items: center; gap: 6px; } + .mini .b { width: 60px; height: 5px; background: var(--sunk); border-radius: 99px; overflow: hidden; } + .mini .b i { display: block; height: 100%; background: var(--ok); border-radius: 99px; } + button.approve { background: var(--primary); border-color: var(--primary); color: var(--on-primary); } + button.signoff { background: var(--signoff); border-color: var(--signoff); color: #fff; } + button.plan { background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 45%, transparent); color: var(--warn); } + .wt { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 11px 16px; } + .wt + .wt { border-top: 1px solid var(--line); } + .wt .k { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } + .wt .since { color: var(--ink-3); font-size: 12.5px; white-space: nowrap; } + .linkrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 16px; border-radius: 12px; border: 1px dashed var(--line); color: var(--ink-2); margin-top: 20px; flex-wrap: wrap; } + .linkrow a { font-weight: 600; } + /* Toasts */ #toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 20; max-width: min(420px, calc(100vw - 36px)); } .toast { background: var(--surface); border: 1px solid var(--line); border-left: 4px solid var(--ok); border-radius: 10px; padding: 10px 14px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); font-size: 13.5px; display: flex; gap: 10px; align-items: start; } @@ -429,9 +458,89 @@

${link("issues", c.issue)} · ${esc(c.title)}

$("nextText").textContent = [`plan ${lines.join(", ")}`, ...j.explain.map((e) => ` ${e}`), "", ...j.prs.map((p) => `PR #${p.pr} → ${p.issue == null ? "?" : `#${p.issue}`} status ${p.status} checks ${p.checks} labels [${p.labels.join(",")}] ${p.reason === "eligible to merge" ? p.reason : `not eligible: ${p.reason}`}`)].join("\n"); }; + // ---- Needs you: grouped by the action the owner takes ---- + const waitDays = (iso) => (iso ? Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / 86_400_000)) : null); + const subjectLink = (subject) => { const m = /^(PR |epic )?#(\d+)$/.exec(subject); return m ? link(m[1] === "PR " ? "pull" : "issues", m[2], subject) : esc(subject); }; + const oldestFirst = (a, b) => (a.since ?? "9999").localeCompare(b.since ?? "9999"); + // Label gates a human owns. The confirm text spells out the exact label change, because a + // mis-click here closes a phase or starts a $5 planning session. + const OWNER = { + sign_off: { label: "Sign off", cls: "signoff", ask: (n) => `Sign off #${n}?\n\nAdds signed-off, removes needs-owner, and closes the epic.` }, + approve_plan: { label: "Approve plan", cls: "approve", ask: (n) => `Approve the plan on #${n}?\n\nAdds plan-approved. The foreman then creates the task issues and starts building.` }, + start_planning: { label: "Start planning", cls: "plan", ask: (n) => `Start planning #${n}?\n\nAdds agent-ready. A planner session costs roughly $5.` }, + pause: { label: "Pause", cls: "", ask: (n) => `Pause #${n}?\n\nAdds foreman:pause. No new claims under this phase on any Mac.` }, + unpause: { label: "Resume", cls: "", ask: (n) => `Resume #${n}?\n\nRemoves foreman:pause.` }, + }; + function ownerAct(epic, action) { + if (!window.confirm((OWNER[action]?.ask ?? ((n) => `${action} #${n}?`))(epic))) return; + post("/api/owner", { epic, action }); + } + function unblock(issue) { + if (!window.confirm(`Unblock #${issue}?\n\nRemoves the blocked label, sets Status Ready, and comments on the issue. The foreman may claim it on the next tick — fix whatever blocked it first.`)) return; + post("/api/unblock", { issue }); + } + const ageCell = (since) => { + const d = waitDays(since); + return d === null ? '
–
' : `
${d}dwaiting
`; + }; + const ownerButton = (epic, action) => ``; + function group(title, dot, note, rows) { + if (!rows.length) return ""; + return `
${dot ? `` : ""}${esc(title)} ${rows.length}${note}
${rows.join("")}
`; + } + function renderNeedsYou(r) { + const owner = r.board?.owner ?? []; + const needs = (r.board?.needsYou ?? []).filter((n) => n.reviewOf === null).sort(oldestFirst); + const phases = new Map((r.board?.phases ?? []).map((p) => [p.epic, p])); + const waits = r.board?.waiting ?? []; + const by = (action) => owner.filter((o) => o.actions.includes(action)).sort(oldestFirst); + const row = (age, title, sub, acts) => `
${age}
${title}
${sub ? `
${sub}
` : ""}
${acts}
`; + const approve = by("approve_plan").map((o) => row(ageCell(o.since), `${link("issues", o.epic)}${esc(o.title)}`, `plan drafted · ${link("issues", o.epic, "read the plan")}`, ownerButton(o.epic, "approve_plan"))); + const signOff = by("sign_off").map((o) => { + const p = phases.get(o.epic); + const progress = p ? `${p.tasksDone}/${p.tasksTotal} tasks${usd(p.spendUsd)}` : ""; + return row(ageCell(o.since), `${link("issues", o.epic)}${esc(o.title)}`, `${progress}${o.review ? `review ${link("issues", o.review)}` : ""}`, ownerButton(o.epic, "sign_off")); + }); + const plan = by("start_planning").map((o) => row('
–
', `${link("issues", o.epic)}${esc(o.title)}`, "", ownerButton(o.epic, "start_planning"))); + const tags = (n) => n.labels.map((l) => `${esc(l)}`).join(""); + const blocked = needs.filter((n) => n.labels.includes("blocked")).map((n) => { + const why = waits.find((w) => w.subject === `#${n.issue}` && w.detail.startsWith(`#${n.issue} blocked`))?.detail.replace(`#${n.issue} blocked`, "").replace(/^:\s*/, ""); + return row(ageCell(n.since), `${link("issues", n.issue)}${esc(n.title)}`, `${tags(n)}${why ? `${esc(why)}` : ""}`, ``); + }); + const answer = needs.filter((n) => !n.labels.includes("blocked")).map((n) => row(ageCell(n.since), `${link("issues", n.issue)}${esc(n.title)}`, `${tags(n)}${n.phaseReview ? "epic already closed" : ""}`, repo ? `Open ↗` : "")); + const counted = approve.length + signOff.length + blocked.length + answer.length; + $("needsMeta").textContent = counted ? `${counted} waiting on you · oldest first in each group` : ""; + const html = [ + group("Approve a plan", "var(--primary)", "the planner drafted these; building starts after approval", approve), + group("Sign off a phase", "var(--signoff)", "phase review is done; signing off closes the epic", signOff), + group("Ready to plan", "var(--warn)", "not counted in the badge · a planner session costs about $5", plan), + group("Unblock", "", "fix the cause first; Unblock moves it back to Ready", blocked), + group("Answer or close", "", "decisions and owner tasks; answer on GitHub", answer), + ].join(""); + const el = $("needsBody"); + el.innerHTML = html || '
nothing is waiting on you
'; + for (const b of el.querySelectorAll("button[data-owner]")) b.onclick = () => ownerAct(Number(b.dataset.epic), b.dataset.owner); + for (const b of el.querySelectorAll("button[data-unblock]")) b.onclick = () => unblock(Number(b.dataset.unblock)); + } + + // ---- Waiting on: what the foreman resolves by itself, grouped by kind ---- + const WAIT_GROUPS = [["ci", "CI"], ["review_cycle", "Review cycle"], ["dependency", "Dependencies"], ["other_host", "Another Mac"], ["phase_gate", "Phase gate"], ["human", "Fix on GitHub"]]; + function renderWaiting(r) { + const rows = r.attention?.waitRows ?? []; + const issues = new Map((r.board?.issues ?? []).map((i) => [i.issue, i])); + const banners = bannerRows(r); + const clear = ``; + const depState = (w) => { + const deps = [...w.detail.replace(/^.*waits on /, "").matchAll(/#(\d+)/g)].map((m) => Number(m[1])); + return deps.map((d) => `#${d} ${esc(issues.get(d)?.status ?? (issues.has(d) ? "off board" : "not open"))}`).join(" · "); + }; + const row = (w) => `
${w.detail.startsWith(w.subject) ? `${subjectLink(w.subject)}${esc(w.detail.slice(w.subject.length))}` : esc(w.detail)}
${w.kind === "dependency" ? `
${depState(w)}
` : ""}
${w.since ? `since ${t(w.since)}` : ""}
`; + const groups = WAIT_GROUPS.map(([kind, title]) => group(title, "", "", rows.filter((w) => w.kind === kind).map(row))).join(""); + const needs = r.attention?.needsYou ?? 0; + $("waitBody").innerHTML = `
${banners.length ? bannerHtml(banners) : clear}
${groups || '
nothing else is pending
'}
Items waiting on you are on the Needs you tab, not repeated here.Needs you · ${needs} →
`; + } + // ---- tabs filled in by later sections ---- - function renderNeedsYou(r) {} - function renderWaiting(r) {} function renderBoard(r) {} function loadSessions() {} diff --git a/src/web.test.ts b/src/web.test.ts index 70484a3..bc2e049 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -667,4 +667,34 @@ describe("web.html", () => { for (const id of ["tick", "recent", "phases", "owner"]) expect(html).not.toContain(`id="${id}"`); }); + it("groups Needs you by the action the owner takes, in order", () => { + const at = [ + "Approve a plan", + "Sign off a phase", + "Ready to plan", + "Unblock", + "Answer or close", + ].map((g) => html.indexOf(`"${g}"`)); + expect(at.every((i) => i > 0)).toBe(true); + expect([...at].sort((a, b) => a - b)).toEqual(at); + expect(html).toContain("epic already closed"); + expect(html).toContain("#287A3A"); + expect(html).toContain("button.approve"); + expect(html).toContain("button.signoff"); + expect(html).toContain("button.plan"); + }); + it("keeps every owner gate's confirm text and endpoint", () => { + expect(html).toContain(`Sign off #$${"{n}"}?`); + expect(html).toContain("Approve the plan on #"); + expect(html).toContain(`Start planning #$${"{n}"}?`); + expect(html).toContain("window.confirm(`Unblock #"); + expect(html).toContain('post("/api/owner"'); + expect(html).toContain('post("/api/unblock"'); + }); + it("lists Waiting on rows by kind and points human waits at Needs you", () => { + expect(html).toContain("Nothing stops new claims"); + expect(html).toContain("Fix on GitHub"); + expect(html).toContain("r.attention?.waitRows"); + expect(html).toContain('href="#needs-you"'); + }); }); From 0c0b35ca6dbfb2c7e6dfad0ab5d19a511fd4e4df Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Mon, 26 Oct 2026 19:17:51 -0700 Subject: [PATCH 14/19] feat(web): Board tab with every open issue and per-task pickers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf --- src/web.html | 165 +++++++++++++++++++++++++++++++++++++++++++++++- src/web.test.ts | 25 ++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/src/web.html b/src/web.html index 9626085..04dbe9e 100644 --- a/src/web.html +++ b/src/web.html @@ -189,6 +189,52 @@ .linkrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 16px; border-radius: 12px; border: 1px dashed var(--line); color: var(--ink-2); margin-top: 20px; flex-wrap: wrap; } .linkrow a { font-weight: 600; } + /* Board: one card per group, a table of its open issues with model and effort pickers. */ + .filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; } + .filters input[type="search"] { flex: 0 1 240px; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); font: 13.5px var(--sans); } + .seg { display: inline-flex; background: var(--sunk); border-radius: 9px; padding: 3px; flex-wrap: wrap; } + .seg button { border: 0; background: transparent; padding: 5px 11px; border-radius: 7px; font-size: 12.5px; color: var(--ink-2); } + .seg button.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(0, 0, 0, .15); } + .seg button b { color: var(--ink-3); font-weight: 600; margin-left: 4px; } + .check { margin-left: auto; font-size: 13px; color: var(--ink-2); display: flex; gap: 6px; align-items: center; } + .ep { margin-bottom: 14px; } + .ep.closed { opacity: .8; } + .eph { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; padding: 14px 18px; align-items: center; } + .eph .k { font-size: 12px; font-weight: 600; color: var(--ink-3); text-transform: uppercase; letter-spacing: .05em; } + .eph .t { font-size: 16px; font-weight: 650; } .eph .t .num { color: var(--ink-3); font-weight: 500; margin-right: 6px; } + .eph .facts { display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; color: var(--ink-2); font-size: 13px; grid-column: 1 / -1; } + .eph .right { display: flex; gap: 8px; align-items: center; } + .eph .mini .b { width: 120px; } + .tk { display: grid; grid-template-columns: minmax(0, 1fr) 112px 60px 112px 100px; gap: 14px; align-items: center; padding: 9px 18px; border-top: 1px solid var(--line); } + .tk.head { padding: 8px 18px 6px; font-size: 11.5px; font-weight: 600; color: var(--ink-3); text-transform: uppercase; letter-spacing: .05em; } + .tk .tt { font-weight: 600; font-size: 14px; } .tk .tt .num { color: var(--ink-3); font-weight: 500; margin-right: 6px; } + .tk .sub { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; margin-top: 3px; font-size: 12.5px; color: var(--ink-3); } + .lbl-chip { font: 600 11px/1 var(--sans); padding: 3px 6px; border-radius: 5px; background: var(--sunk); color: var(--ink-2); } + .lbl-chip.bad { background: var(--bad-soft); color: var(--bad); } + .st { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-3); } + .st::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } + .st.ready { color: var(--accent); } .st.progress { color: var(--warn); } .st.review { color: var(--violet-st); } .st.done { color: var(--ok); } + .st.off::before { background: transparent; border: 1.5px solid currentColor; } + .picker { position: relative; } + .picker > button { width: 100%; display: flex; justify-content: space-between; gap: 4px; padding: 4px 8px; font-size: 12.5px; font-weight: 500; color: var(--ink-3); border-radius: 7px; } + .picker > button::after { content: "▾"; font-size: 10px; } + .picker > button.set { border-color: color-mix(in srgb, var(--accent) 55%, transparent); color: var(--accent); background: var(--accent-soft); font-weight: 600; } + .picker > button.set.eff { border-color: color-mix(in srgb, var(--warn) 55%, transparent); color: var(--warn); background: var(--warn-soft); } + .menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: 0 8px 24px rgba(0, 0, 0, .3); padding: 4px; min-width: 150px; display: grid; } + .menu button { border: 0; background: transparent; text-align: left; display: flex; justify-content: space-between; gap: 10px; padding: 6px 10px; border-radius: 6px; font-weight: 500; color: var(--ink-2); } + .menu button:hover { background: var(--sunk); } + .menu button.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + .menu button small { color: var(--ink-3); } + .done-row { padding: 9px 18px; border-top: 1px solid var(--line); color: var(--ink-3); font-size: 13px; } + .done-row button { border: 0; background: transparent; padding: 0; color: var(--ink-3); font-weight: 600; } + .done-row .nums { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 6px; } + @container shell (max-width: 760px) { + .tk { grid-template-columns: minmax(0, 1fr) auto; } + .tk.head { display: none; } + .tk .pr { display: none; } + .tk .picker { grid-column: span 1; } + } + /* Toasts */ #toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 20; max-width: min(420px, calc(100vw - 36px)); } .toast { background: var(--surface); border: 1px solid var(--line); border-left: 4px solid var(--ok); border-radius: 10px; padding: 10px 14px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); font-size: 13.5px; display: flex; gap: 10px; align-items: start; } @@ -252,7 +298,9 @@

Dashboard

- +
@@ -540,8 +588,121 @@

${link("issues", c.issue)} · ${esc(c.title)}

$("waitBody").innerHTML = `
${banners.length ? bannerHtml(banners) : clear}
${groups || '
nothing else is pending
'}
Items waiting on you are on the Needs you tab, not repeated here.Needs you · ${needs} →
`; } + // ---- Board: every open issue, grouped under its epic ---- + // UI state lives outside the renderer, so the 3 s re-render keeps the search, the filter, an + // open picker and an expanded done list exactly as the owner left them. + let boardQuery = ""; + let boardFilter = "all"; + let showDone = false; + const openPicker = { issue: null, kind: null }; + const openDone = new Set(); + const STATUS_CLASS = { Backlog: "", Ready: "ready", "In Progress": "progress", "In Review": "review", Done: "done" }; + const FILTERS = [ + ["all", "All", () => true], + ["Ready", "Ready", (i) => i.status === "Ready"], + ["In Progress", "In progress", (i) => i.status === "In Progress"], + ["In Review", "In review", (i) => i.status === "In Review"], + ["Backlog", "Backlog", (i) => i.status === "Backlog"], + ["blocked", "Blocked", (i) => i.blocked], + ["off", "Off board", (i) => i.status === null], + ]; + const GROUP_STATE = { planning: ["Planning", "warn"], plan_waiting: ["Plan waiting", "warn"], building: ["Building", "acc"], signoff_waiting: ["Sign-off waiting", "ok"], not_started: ["Not started", ""] }; + $("boardSearch").oninput = (e) => { boardQuery = e.target.value.trim().toLowerCase(); if (r) renderBoard(r); }; + $("showDone").onchange = (e) => { showDone = e.target.checked; if (r) renderBoard(r); }; + document.addEventListener("click", (e) => { + if (openPicker.issue !== null && !e.target.closest(".picker")) { openPicker.issue = null; if (r) renderBoard(r); } + }); + // A daemon older than per-task effort reports no `effort`; its sessions run at the default. + const effortDefault = () => r?.effort?.default ?? "high"; + + function setTaskModel(issue, model, current) { + const what = model === "default" ? `the global model${current ? ` (drops ${current})` : ""}` : model; + if (!window.confirm(`Run every foreman session on #${issue} as ${what}?\n\nBuilder, reviewer and validator alike, outranking the model on the Session tab. A session running now keeps the model it started with; the issue's next session uses this.`)) return; + post("/api/task-model", { issue, model }); + } + function setTaskEffort(issue, level, current) { + const what = level === effortDefault() ? `the default ${level} effort${current ? ` (drops ${current})` : ""}` : `${level} effort`; + if (!window.confirm(`Run every foreman session on #${issue} at ${what}?\n\nBuilder, reviewer and validator alike. A session running now keeps the effort it started with; the issue's next session uses this.`)) return; + post("/api/task-effort", { issue, effort: level }); + } + function picker(i, kind) { + const isModel = kind === "model"; + const value = isModel ? i.model ?? "default" : i.effort ?? effortDefault(); + const set = isModel ? i.model !== null : i.effort !== null; + const options = isModel ? [...(r.model?.choices ?? []), "default"] : r.effort?.levels ?? []; + const open = openPicker.issue === i.issue && openPicker.kind === kind; + const menu = open + ? `` + : ""; + return `
${menu}
`; + } + function hintsFor(i, nextClaim, pipe) { + const out = []; + if (i.openDependsOn.length) out.push(`waits on ${i.openDependsOn.map((d) => `#${d}`).join(", ")}`); + if (nextClaim === i.issue) out.push("next to claim"); + const failed = pipe && ["build", "review", "validate"].find((s) => pipe.stages[s] === "failed"); + if (failed) out.push(`${failed === "validate" ? "validator" : failed} failed${pipe.fixRound > 1 ? ` · fix round ${pipe.fixRound}` : ""}`); + return out.map((h) => `${esc(h)}`).join(""); + } + function renderBoard(r) { + const all = r.board?.issues ?? []; + const groups = r.board?.groups ?? []; + const phases = new Map((r.board?.phases ?? []).map((p) => [p.epic, p])); + const pipeline = new Map((r.board?.pipeline ?? []).map((p) => [p.issue, p])); + const claim = (r.lastPlan ?? []).map((a) => /^claim#(\d+)$/.exec(a)).find(Boolean); + const nextClaim = claim ? Number(claim[1]) : null; + const match = (i) => !boardQuery || String(i.issue).includes(boardQuery) || i.title.toLowerCase().includes(boardQuery); + const filterFn = (FILTERS.find(([key]) => key === boardFilter) ?? FILTERS[0])[2]; + const shown = all.filter((i) => match(i) && filterFn(i)); + $("boardSeg").innerHTML = FILTERS.map(([key, label, fn]) => ``).join(""); + for (const b of $("boardSeg").querySelectorAll("button")) b.onclick = () => { boardFilter = b.dataset.filter; renderBoard(r); }; + $("boardMeta").textContent = r.board ? `${all.length} open issues in ${groups.length} groups · read from the last tick, ${hm(r.board.at)}` : ""; + const narrowed = boardQuery || boardFilter !== "all"; + const cards = groups.map((g) => { + const rows = shown.filter((i) => i.group === g.key); + if (narrowed && !rows.length) return ""; + const p = g.epic !== null ? phases.get(g.epic) : null; + const [stateLabel, stateCls] = g.state ? GROUP_STATE[g.state] ?? [g.state, ""] : [null, ""]; + const facts = p + ? `${p.tasksDone} of ${p.tasksTotal} done${usd(p.spendUsd)} · ${p.sessions} sessions${p.medianMergeMinutes === null ? "no merge yet" : `median ${mins(p.medianMergeMinutes)} claim→merge`}` + : rows.length ? `${rows.length} open` : "no tasks yet"; + const head = `
${g.phase !== null ? `
Phase ${esc(g.phase)}
` : ""}
${g.epic !== null ? `${link("issues", g.epic)}` : ""}${esc(g.title)}
${stateLabel ? `${esc(stateLabel)}` : ""}${g.paused ? 'Paused' : ""}${g.kind === "epic" ? ownerButton(g.epic, g.paused ? "unpause" : "pause") : ""}
${facts}
`; + const table = rows.length + ? `
IssueStatusPRModelEffort
${rows.map((i) => { + const labels = i.labels.map((l) => `${esc(l)}`).join(""); + const status = i.status === null ? 'Off board' : `${esc(i.status)}`; + return `
${link("issues", i.issue)}${esc(i.title)}
${labels}${hintsFor(i, nextClaim, pipeline.get(i.issue))}
${status}${i.pr ? link("pull", i.pr) : "–"}${picker(i, "model")}${picker(i, "effort")}
`; + }).join("")}` + : ""; + const done = showDone && g.doneTasks.length + ? `
${openDone.has(g.key) ? `
${g.doneTasks.map((n) => link("issues", n)).join("")}
` : ""}
` + : ""; + return `
${head}${table}${done}
`; + }).join(""); + const el = $("boardBody"); + el.innerHTML = cards || `
${r.board ? "no open issues match" : "the board appears after the first tick"}
`; + for (const b of el.querySelectorAll("button[data-owner]")) b.onclick = () => ownerAct(Number(b.dataset.epic), b.dataset.owner); + for (const b of el.querySelectorAll("button[data-done]")) b.onclick = () => { openDone.has(b.dataset.done) ? openDone.delete(b.dataset.done) : openDone.add(b.dataset.done); renderBoard(r); }; + for (const p of el.querySelectorAll(".picker")) { + const issue = Number(p.dataset.issue); + const kind = p.dataset.kind; + p.querySelector(":scope > button").onclick = () => { + const same = openPicker.issue === issue && openPicker.kind === kind; + openPicker.issue = same ? null : issue; + openPicker.kind = same ? null : kind; + renderBoard(r); + }; + for (const o of p.querySelectorAll("button[data-pick]")) o.onclick = () => { + const row = all.find((i) => i.issue === issue); + openPicker.issue = null; + renderBoard(r); + if (kind === "model") { if (o.dataset.pick !== (row?.model ?? "default")) setTaskModel(issue, o.dataset.pick, row?.model ?? null); } + else if (o.dataset.pick !== (row?.effort ?? effortDefault())) setTaskEffort(issue, o.dataset.pick, row?.effort ?? null); + }; + } + } + // ---- tabs filled in by later sections ---- - function renderBoard(r) {} function loadSessions() {} async function refresh() { diff --git a/src/web.test.ts b/src/web.test.ts index bc2e049..576d7a8 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -697,4 +697,29 @@ describe("web.html", () => { expect(html).toContain("r.attention?.waitRows"); expect(html).toContain('href="#needs-you"'); }); + it("lists every board issue by group with a status filter, off board included", () => { + expect(html).toContain("r.board?.groups"); + expect(html).toContain("r.board?.issues"); + expect(html).toContain("Off board"); + expect(html).toContain("Show done"); + // Group titles come from the server and are escaped like everything else. + expect(html).toContain("esc(g.title)"); + }); + it("offers model and effort pickers that confirm before POSTing", () => { + expect(html).toContain("window.confirm(`Run every foreman session on #"); + expect(html).toContain("A session running now keeps the effort it started with"); + expect(html).toContain('post("/api/task-model"'); + expect(html).toContain('post("/api/task-effort"'); + expect(html).toContain('ownerButton(g.epic, g.paused ? "unpause" : "pause")'); + }); + it("keeps Board UI state across the 3 s re-render", () => { + for (const v of [ + "let boardQuery", + "let boardFilter", + "let showDone", + "const openPicker", + "const openDone", + ]) + expect(html).toContain(v); + }); }); From 44dd50bcbf234d3cfcae2ae590addfda0a33e108 Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Mon, 26 Oct 2026 19:20:54 -0700 Subject: [PATCH 15/19] feat(web): Session tab with spend history and daemon controls Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf --- src/web.html | 182 +++++++++++++++++++++++++++++++++++++++++++++++- src/web.test.ts | 17 +++++ 2 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/web.html b/src/web.html index 04dbe9e..abd6a8f 100644 --- a/src/web.html +++ b/src/web.html @@ -235,6 +235,57 @@ .tk .picker { grid-column: span 1; } } + /* Session: totals, a daily spend chart, spend by role, cost by task, the daemon's controls. */ + .range { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; } + .range input[type="date"] { padding: 5px 8px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); font: 13px var(--sans); } + .kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } + .kpi { padding: 12px 16px; } .kpi b { display: block; font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; } .kpi span { color: var(--ink-3); font-size: 12.5px; } + .two { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 10px; margin-top: 10px; } + .ch { padding: 14px 18px 12px; min-width: 0; } + .ch h3 { margin: 0 0 10px; font-size: 14.5px; font-weight: 600; } .ch h3 small { color: var(--ink-3); font-weight: 500; margin-left: 8px; } + .plot { position: relative; height: 170px; margin-left: 44px; margin-top: 14px; } + .gridline { position: absolute; left: 0; right: 0; border-top: 1px solid var(--line); } + .gridline span { position: absolute; left: -44px; top: -8px; width: 38px; text-align: right; font: 11px var(--mono); color: var(--ink-3); } + .bars { position: absolute; inset: 0; display: flex; align-items: flex-end; gap: 2px; } + .bars i { flex: 1; min-width: 2px; background: var(--act, var(--accent)); border-radius: 4px 4px 0 0; position: relative; } + .bars i:hover { background: var(--ink); } + .bars i.zero { background: transparent; } + .bars i .peak { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 3px; font: 600 11px var(--mono); color: var(--ink-2); white-space: nowrap; } + .xax { display: flex; justify-content: space-between; margin: 6px 0 0 44px; font: 11px var(--mono); color: var(--ink-3); } + .roles { display: grid; gap: 9px; } + .rl { display: grid; grid-template-columns: 92px minmax(0, 1fr) 64px; align-items: center; gap: 10px; font-size: 13px; } + .rl .b, .costc .b { height: 9px; background: var(--sunk); border-radius: 99px; overflow: hidden; } + .rl .b i, .costc .b i { display: block; height: 100%; border-radius: 99px; background: var(--act, var(--accent)); } + .rl .v, .costc .v { text-align: right; font: 12.5px var(--mono); color: var(--ink-2); } + table.costs { width: 100%; border-collapse: collapse; font-size: 13.5px; } + table.costs th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-3); padding: 10px 14px 8px; border-bottom: 1px solid var(--line); } + table.costs td { padding: 9px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; } + table.costs td.n, table.costs th.n { text-align: right; font-variant-numeric: tabular-nums; } + table.costs tr.row { cursor: pointer; } table.costs tr.row:hover td { background: var(--sunk); } + table.costs tr.sub td { background: color-mix(in srgb, var(--sunk) 55%, transparent); font-size: 13px; padding: 6px 14px; } + table.costs tr.sub td:first-child { padding-left: 36px; color: var(--ink-3); font: 12px var(--mono); } + table.costs .num { color: var(--ink-3); margin-right: 6px; } + .costc { display: grid; grid-template-columns: minmax(40px, 1fr) 76px; gap: 10px; align-items: center; } + .oc { font-weight: 600; } .oc.ok { color: var(--ok); } .oc.bad { color: var(--bad); } .oc.warn { color: var(--warn); } + .more { padding: 10px 14px; } + .dm { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .dm > div { padding: 12px 18px; border-right: 1px solid var(--line); min-width: 0; } + .dm > div:nth-child(3) { border-right: 0; } + .dm .k { font-size: 12px; color: var(--ink-3); font-weight: 600; } + .dm .v2 { font-size: 14px; margin-top: 3px; } + .dm .note { font-size: 12px; color: var(--ink-3); margin-top: 3px; } + .opts { display: flex; gap: 4px; margin-top: 7px; flex-wrap: wrap; } + .opts button { padding: 4px 9px; font-size: 12px; border-radius: 6px; color: var(--ink-2); } + .opts button.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } + .dm .row2 { grid-column: 1 / -1; border-top: 1px solid var(--line); display: flex; gap: 6px 24px; padding: 10px 18px; font-size: 13px; color: var(--ink-2); border-right: 0; flex-wrap: wrap; } + .dm .row2 b { color: var(--ink); font-weight: 600; } + @container shell (max-width: 760px) { + .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .two, .dm { grid-template-columns: minmax(0, 1fr); } + .dm > div { border-right: 0; border-bottom: 1px solid var(--line); } + table.costs .roles-col { display: none; } + } + /* Toasts */ #toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 20; max-width: min(420px, calc(100vw - 36px)); } .toast { background: var(--surface); border: 1px solid var(--line); border-left: 4px solid var(--ok); border-radius: 10px; padding: 10px 14px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); font-size: 13.5px; display: flex; gap: 10px; align-items: start; } @@ -301,7 +352,11 @@

Dashboard

- +
@@ -702,8 +757,126 @@

${link("issues", c.issue)} · ${esc(c.title)}

} } - // ---- tabs filled in by later sections ---- - function loadSessions() {} + // ---- Session: spend and per-task cost history from sessions.log ---- + let sessionRange = "all"; + let showAllTasks = false; + let lastSessionKey = null; // the newest session id the history was fetched for + let history = null; + const openSessionRows = new Set(); + // "claude-opus-5" → "opus": the repository's one-click names, from `r.model.choices`. + const family = (model) => (r?.model?.choices ?? []).find((c) => (model ?? "").includes(c)) ?? (model || "–"); + const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + function rangeQuery() { + const today = new Date(); + const back = (n) => localDay(new Date(today.getFullYear(), today.getMonth(), today.getDate() - n)); + if (sessionRange === "today") return { from: localDay(today), to: localDay(today) }; + if (sessionRange === "7") return { from: back(6), to: localDay(today) }; + if (sessionRange === "30") return { from: back(29), to: localDay(today) }; + if (sessionRange === "custom") return { from: $("rangeFrom").value || null, to: $("rangeTo").value || null }; + return { from: null, to: null }; + } + for (const b of $("rangeSeg").querySelectorAll("button")) b.onclick = () => { + sessionRange = b.dataset.range; + for (const x of $("rangeSeg").querySelectorAll("button")) x.classList.toggle("on", x === b); + $("customRange").hidden = sessionRange !== "custom"; + showAllTasks = false; + loadSessions(); + }; + $("rangeFrom").onchange = () => loadSessions(); + $("rangeTo").onchange = () => loadSessions(); + async function loadSessions() { + const { from, to } = rangeQuery(); + const q = new URLSearchParams(); + if (from) q.set("from", from); + if (to) q.set("to", to); + try { + const res = await fetch(`/api/sessions?${q}`); + const j = await res.json(); + if (!res.ok) { $("sessionBody").innerHTML = `
${esc(j.error ?? "could not load the history")}
`; return; } + history = j; + lastSessionKey = r?.recent?.[0]?.sessionId ?? null; + renderSessions(); + } catch { $("sessionBody").innerHTML = '
could not load the history
'; } + } + const outcomeClass = (o) => (/^(pr_opened|approved|passed|phase_closed|plan_drafted)$/.test(o) ? "ok" : /^(blocked|failed|aborted|error_max_turns|interrupted)$/.test(o) ? "bad" : "warn"); + function dailyChart(daily) { + if (!daily.length) return '
no sessions in this range
'; + const max = Math.max(...daily.map((d) => d.spendUsd), 1); + const step = max > 400 ? 200 : max > 100 ? 50 : max > 20 ? 10 : 5; + const top = Math.ceil(max / step) * step; + const peak = daily.reduce((a, b) => (b.spendUsd > a.spendUsd ? b : a)); + const lines = []; + for (let v = 0; v <= top; v += step) lines.push(`
$${v}
`); + const bars = daily.map((d) => `${d === peak && d.spendUsd ? `${usd(d.spendUsd)}` : ""}`).join(""); + const ticks = daily.length > 1 ? [0, Math.floor((daily.length - 1) / 2), daily.length - 1].map((i) => daily[i].date.slice(5)) : [daily[0].date.slice(5)]; + return `
${lines.join("")}
${bars}
${ticks.map((x) => `${esc(x)}`).join("")}
`; + } + function renderSessions() { + const h = history; + if (!h) return; + const per = h.totals.sessions ? h.totals.spendUsd / h.totals.sessions : 0; + const kpi = (v, l) => `
${v}${l}
`; + const maxRole = Math.max(...h.byRole.map((x) => x.spendUsd), 1); + const roles = h.byRole.length + ? h.byRole.map((x) => `
${esc(x.role)}${usd(x.spendUsd)}
`).join("") + : '
none
'; + const maxCost = Math.max(...h.byIssue.map((x) => x.costUsd), 1); + const shown = showAllTasks ? h.byIssue : h.byIssue.slice(0, 10); + const rows = shown.map((x) => { + const open = openSessionRows.has(x.issue); + const head = `${open ? "▾" : "▸"} ${link("issues", x.issue)}${x.title === `#${x.issue}` ? "" : esc(x.title)}${x.roles.map((ro) => `${esc(ro)}`).join(" ")}${x.sessions}${esc(x.lastAt.slice(0, 10))}
${usd(x.costUsd)}
`; + const subs = open + ? x.entries.map((e) => `${esc(e.t.slice(0, 16).replace("T", " "))}${esc(e.role)} · ${esc(family(e.model))} · ${esc(e.turns)} turns${esc(e.outcome)}${usd(e.costUsd)}`).join("") + : ""; + return head + subs; + }).join(""); + $("sessionBody").innerHTML = `
${kpi(usd(h.totals.spendUsd), "spend")}${kpi(h.totals.sessions, "sessions")}${kpi(h.totals.issues, "tasks and epics")}${kpi(usd(per), "per session")}
+

Daily spendlocal days · hover a bar for its sessions

${dailyChart(h.daily)}

By role

${roles}
+

Cost by task

most expensive first · click a row for its sessions
${h.byIssue.length + ? `${rows}
IssueRolesSessionsLast runCost
${h.byIssue.length > 10 ? `
` : ""}` + : '
no sessions in this range
'}
`; + for (const tr of $("sessionBody").querySelectorAll("tr.row")) { + const flip = () => { const n = Number(tr.dataset.row); openSessionRows.has(n) ? openSessionRows.delete(n) : openSessionRows.add(n); renderSessions(); }; + tr.onclick = (e) => { if (!e.target.closest("a")) flip(); }; + tr.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); flip(); } }; + } + for (const b of $("sessionBody").querySelectorAll("button[data-feed]")) b.onclick = (e) => { e.stopPropagation(); pinFeed(b.dataset.feed); }; + const all = $("showAll"); + if (all) all.onclick = () => { showAllTasks = !showAllTasks; renderSessions(); }; + } + function pinFeed(sessionId) { + feedSession = sessionId; + feedPinned = true; + location.hash = "#dashboard"; + loadFeed(); + } + + // The daemon's own controls: what the next session runs as, and how many may run today. Parking + // on the cap is the single most likely reason the foreman is doing nothing, so its count sits + // next to the buttons that raise it. + function setModel(model) { + if (!window.confirm(`Run foreman sessions as ${model}?\n\nA session running now keeps the model it started with; the next dispatch uses ${model}.`)) return; + post("/api/model", { model }); + } + function setCap(cap) { + const what = cap === null ? "the value in foreman.json" : `${cap} sessions a day`; + if (!window.confirm(`Set the daily session cap to ${what}?\n\nEach session costs money; today's spend is on this card. Takes effect on the next tick.`)) return; + post("/api/cap", { maxSessionsPerDay: cap }); + } + function renderDaemon(r) { + const m = r.model; + const c = r.cap; + const modelOpts = m ? (m.source === "override" ? [...m.choices, "default"] : m.choices) : []; + const capOpts = c ? (c.source === "override" ? [...c.choices, "default"] : c.choices) : []; + const b = r.budget; + $("daemonCard").innerHTML = ` +
Model for new sessions
${esc(m?.current ?? "–")}
${m?.source === "override" ? `
override · foreman.json says ${esc(m.configured)}
` : ""}
${modelOpts.map((o) => ``).join("")}
+
Daily session cap
${esc(r.today?.count ?? 0)} of ${esc(c?.current ?? r.today?.cap ?? 0)} today · ${usd(r.today?.spendUsd)}
${c?.source === "override" ? `
override · foreman.json says ${esc(c.configured)}
` : ""}
${capOpts.map((o) => ``).join("")}
+
GitHub budget
${b ? `${b.remaining.toLocaleString("en-US")} of ${b.limit.toLocaleString("en-US")} points` : "–"}
${b ? `
resets ${hm(b.resetAt)} · last tick ${esc(b.tickReads)} reads, ${esc(b.actionSpend)} by sessions, ${esc(b.betweenTicks)} elsewhere
` : ""}
+
Last tick ${t(r.tick?.lastAt)} · ${r.tick?.preflight ? (r.tick.preflight.ok ? "preflight ok" : `preflight failed: ${esc(r.tick.preflight.reason)}`) : "no tick yet"}Next tick ${r.tick?.blockedBySession ? "after the session" : t(r.tick?.nextAt)}Backoff ${esc(r.tick?.consecutiveFailures ?? 0)}launchd ${r.launchdInstalled ? "installed" : "not installed"}${r.lastPlan ? `Plan ${esc(r.lastPlan.join(", "))}` : ""}
`; + for (const x of $("daemonCard").querySelectorAll("button[data-model]")) x.onclick = () => { if (x.dataset.model !== m.current) setModel(x.dataset.model); }; + for (const x of $("daemonCard").querySelectorAll("button[data-cap]")) x.onclick = () => { if (x.dataset.cap !== String(c.current)) setCap(x.dataset.cap === "default" ? null : Number(x.dataset.cap)); }; + } async function refresh() { try { r = await (await fetch("/api/status")).json(); } @@ -716,6 +889,9 @@

${link("issues", c.issue)} · ${esc(c.title)}

renderNeedsYou(r); renderWaiting(r); renderBoard(r); + renderDaemon(r); + // The history only changes when a session finishes, so refetch then, not on every poll. + if (tab === "session" && (r.recent?.[0]?.sessionId ?? null) !== lastSessionKey) loadSessions(); if (!feedPinned) feedSession = r.current?.sessionId ?? r.recent?.[0]?.sessionId ?? null; loadFeed(); } diff --git a/src/web.test.ts b/src/web.test.ts index 576d7a8..22994cd 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -722,4 +722,21 @@ describe("web.html", () => { ]) expect(html).toContain(v); }); + it("loads the Session tab's history by range and offers each session's feed", () => { + expect(html).toContain("fetch(`/api/sessions"); + for (const label of [">Today<", ">7 days<", ">30 days<", ">All<", ">Custom<"]) + expect(html).toContain(label); + expect(html).toContain("View feed"); + expect(html).toContain("pinFeed("); + expect(html).toContain("Show all"); + expect(html).toContain("const openSessionRows"); + }); + it("keeps the model and cap controls, with their confirm texts, on the Session tab", () => { + expect(html).toContain(`window.confirm(\`Run foreman sessions as $${"{model}"}?`); + expect(html).toContain("Set the daily session cap to"); + expect(html).toContain('post("/api/model"'); + expect(html).toContain('post("/api/cap"'); + expect(html).toContain("GitHub budget"); + expect(html).toContain("launchd"); + }); }); From b6b078f5cdfa3c34e32bd036ccbb3d4733268192 Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Mon, 26 Oct 2026 19:21:55 -0700 Subject: [PATCH 16/19] docs(readme): the redesigned status page Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JrFSqqtqyx8Kvf6u5pbAUf --- README.md | 53 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0293392..33ce075 100644 --- a/README.md +++ b/README.md @@ -676,11 +676,30 @@ All of these work from any terminal on the Mac. Inside a configured `repoDir` yo - **Ask what the next tick would do:** `foreman -p widgets next`. The only subcommand that calls GitHub; it explains why each open PR is or is not mergeable, and it ignores the `STOP` file. - **The page:** `foreman -p widgets page` opens `http://127.0.0.1:`, served by the daemon - while it runs and bound to localhost only. It carries the same view plus a pipeline table - (build → review → validate → CI → merge per in-flight issue), a **Phase** card with a progress - bar, a **Needs you** card, an **Owner** card of label gates, and a live event feed per session. - Feeds live in `~/.foreman//activity/.jsonl` and hold tool names and one-line - summaries only, never tool inputs or thinking. + while it runs and bound to localhost only. A sidebar names the instance and switches between + five tabs, each reachable by its own URL fragment (`#dashboard`, `#needs-you`, `#waiting`, + `#board`, `#session`): + - **Dashboard** — the controls, a banner for every reason the foreman is not taking work (STOP, + failed preflight, daily cap, paused phase, stalled session, orphaned child, unfinished + bookkeeping), the running session, the in-flight pipeline (build → review → validate → CI → + merge per issue) and the live event feed. + - **Needs you** — every decision only the owner can make, grouped by the action: approve a + plan, sign off a phase, ready to plan, unblock, answer or close. The sidebar badge counts them, + Ready to plan excepted. + - **Waiting on** — what the foreman is waiting for by itself: CI, review rounds, dependencies, + another Mac's claim, the phase gate, and setup problems to fix on GitHub. + - **Board** — every open issue, on the project board or not, grouped under its phase epic, with + each epic's progress and Pause/Resume, and a model and an effort picker per issue. + - **Session** — spend and sessions from `sessions.log` for today, 7 days, 30 days, the whole log + or a custom range: a daily chart, spend by role, cost per task with each session's feed, and + the daemon's model, daily cap, GitHub budget and tick. + + Each instance gets its own color theme so that several open tabs are easy to tell apart. The + first start picks the theme the other instances on this Mac use least; the swatches in the + sidebar footer change it (`POST /api/theme`, saved in `state.json`). Feeds live in + `~/.foreman//activity/.jsonl` and hold tool names and one-line summaries only, + never tool inputs or thinking. The Session tab reads `GET /api/sessions?from=&to=` (local dates, + both optional). - **Show the board as it is now:** the page's **Refresh project** button. It drops the cached board and wakes the loop, so a Status you set in the GitHub UI shows up within a tick rather than at the end of the poll interval. `foreman go` does the waking half only. @@ -705,7 +724,7 @@ All of these work from any terminal on the Mac. Inside a configured `repoDir` yo - **Raise the daily cap:** `foreman -p widgets cap` prints the cap the next tick will enforce and where it came from; `foreman -p widgets cap 50` raises it; `cap default` hands control back to `foreman.json`. The cap is read per tick, so raising it un-parks a capped daemon immediately. - The override survives a restart. The **Tick** card offers the same, confirm-first. + The override survives a restart. The page's **Session** tab offers the same, confirm-first. - **Change the model:** `foreman -p widgets model` prints what the next session will run as; `foreman -p widgets model sonnet` changes it. With the daemon running the change goes through the page, which owns `state.json`, and applies at the next dispatch — the session running now @@ -715,21 +734,22 @@ All of these work from any terminal on the Mac. Inside a configured `repoDir` yo builder, reviewer, validator, fix rounds — as that model, outranking both the live override and `foreman.json`. The label is the source of truth, so it works from `gh issue edit` too. - **Change one task's effort:** every session runs at `high` effort. To run a task at a different - level, give it a label: `foreman -p widgets effort xhigh --issue 12`, or use the effort buttons - under the task on the page's **Phases** card. This covers builder, reviewer, validator and fix + level, give it a label: `foreman -p widgets effort xhigh --issue 12`, or use the issue's effort + picker on the page's **Board** tab. This covers builder, reviewer, validator and fix rounds. The levels are Claude Code's own: `low`, `medium`, `high`, `xhigh`, `max`. Choosing `high` or `default` removes the label. There is no global effort setting. `foreman -p widgets effort` prints the default and every task with an effort label; `effort --issue 12` prints that task's effort. With the daemon running, a change goes through - the page, which only accepts tasks listed on the board. With no daemon, it edits the label on + the page, which only accepts issues its Board tab lists. With no daemon, it edits the label on GitHub directly. A session already running keeps its effort; the issue's next session uses the new one. `gh issue edit 12 --add-label effort:max` works the same way. -- **Owner gates, from the page:** the **Owner** card lists every open epic with the label gates - only a human can open — **Sign off**, **Approve plan**, **Start planning**, **Pause**/**Resume**. - Each button confirms the exact label change and wakes the daemon. The same gates are just +- **Owner gates, from the page:** the **Needs you** tab offers **Approve plan**, **Sign off** and + **Start planning** on the epics waiting for them, and the **Board** tab's epic headers offer + **Pause**/**Resume**. A phase review issue is shown on its epic's Sign off row. Each button + confirms the exact label change and wakes the daemon. The same gates are just labels, so `gh issue edit --add-label