fix(infra): release builds all 17 registry images; pg_dump backup sidecar (#461)

* fix(infra): release builds all 17 registry images; pg_dump backup sidecar

release.yml was missing roboco-agent-grok-prompter and
roboco-agent-grok-secretary (both FROM the bare local roboco-agent-grok
tag, so agent-grok now builds explicitly ahead of the loop, mirroring
the agent-base special case) — a fresh registry pull could never
succeed. Both compose files gain a backup sidecar on the data network:
pg_dump -Fc on start and every 24h, crash-safe tmp+rename, newest-14
rotation, restore walkthrough in docs/backend/ops/database-backups.md.

* chore(docs): reflow hard-wrapped prose inherited from the six-PR merge train

* chore(foundation): regenerate lifecycle artifacts; reflow inherited prose

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 09:20:57 +02:00
committed by GitHub
co-authored by Renn F
parent 6e57066bd6
commit f7f411e112
11 changed files with 514 additions and 398 deletions
+16 -2
View File
@@ -85,6 +85,16 @@ jobs:
-t roboco-agent-base $(regtags roboco-agent-base) . -t roboco-agent-base $(regtags roboco-agent-base) .
echo "::endgroup::" echo "::endgroup::"
# roboco-agent-grok MUST build next, ahead of the loop: the two
# interactive Grok roles (prompter, secretary) build `FROM
# roboco-agent-grok` — a local tag that has to exist in the daemon
# before they build, and bash associative-array iteration order is
# unspecified, so it can't just be another entry in IMAGES below.
echo "::group::build roboco-agent-grok"
docker build -f docker/agent-grok.Dockerfile \
-t roboco-agent-grok $(regtags roboco-agent-grok) .
echo "::endgroup::"
# Every other image → its Dockerfile. Names mirror docker-compose's # Every other image → its Dockerfile. Names mirror docker-compose's
# `image:` values exactly, so compose can later pull instead of build. # `image:` values exactly, so compose can later pull instead of build.
declare -A IMAGES=( declare -A IMAGES=(
@@ -101,7 +111,8 @@ jobs:
[roboco-agent-prompter]=docker/agent-prompter.Dockerfile [roboco-agent-prompter]=docker/agent-prompter.Dockerfile
[roboco-agent-secretary]=docker/agent-secretary.Dockerfile [roboco-agent-secretary]=docker/agent-secretary.Dockerfile
[roboco-agent-pr-reviewer]=docker/agent-pr-reviewer.Dockerfile [roboco-agent-pr-reviewer]=docker/agent-pr-reviewer.Dockerfile
[roboco-agent-grok]=docker/agent-grok.Dockerfile [roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile
[roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile
) )
for name in "${!IMAGES[@]}"; do for name in "${!IMAGES[@]}"; do
echo "::group::build ${name}" echo "::group::build ${name}"
@@ -114,10 +125,13 @@ jobs:
echo "::group::push roboco-agent-base" echo "::group::push roboco-agent-base"
pushall roboco-agent-base pushall roboco-agent-base
echo "::endgroup::" echo "::endgroup::"
echo "::group::push roboco-agent-grok"
pushall roboco-agent-grok
echo "::endgroup::"
for name in "${!IMAGES[@]}"; do for name in "${!IMAGES[@]}"; do
echo "::group::push ${name}" echo "::group::push ${name}"
pushall "${name}" pushall "${name}"
echo "::endgroup::" echo "::endgroup::"
done done
echo "Published roboco-agent-base + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest" echo "Published roboco-agent-base + roboco-agent-grok + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest"
+25
View File
@@ -66,6 +66,31 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# --------------------------------------------------------------------------
# Backup - periodic pg_dump of the roboco DB (interim; no PITR/WAL yet)
# --------------------------------------------------------------------------
backup:
image: pgvector/pgvector:pg16
container_name: roboco-backup
restart: unless-stopped
# data-only network — pg_dump reaches postgres by container name, same
# as every other data-network consumer; never exposed to the agent mesh.
networks:
- data
environment:
POSTGRES_HOST: roboco-postgres
POSTGRES_PORT: 5432
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
POSTGRES_DB: roboco
entrypoint: ["/bin/bash", "/scripts/backup-entrypoint.sh"]
volumes:
- ./docker/scripts/backup-entrypoint.sh:/scripts/backup-entrypoint.sh:ro
- ${ROBOCO_DATA_DIR:-./data}/backups:/backups
depends_on:
postgres:
condition: service_healthy
ollama: ollama:
image: ollama/ollama:latest image: ollama/ollama:latest
container_name: roboco-ollama container_name: roboco-ollama
+25
View File
@@ -47,6 +47,31 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ==========================================================================
# Backup - periodic pg_dump of the roboco DB (interim; no PITR/WAL yet)
# ==========================================================================
backup:
image: pgvector/pgvector:pg16
container_name: roboco-backup
restart: unless-stopped
# data-only network — pg_dump reaches postgres by container name, same
# as every other data-network consumer; never exposed to the agent mesh.
networks:
- data
environment:
POSTGRES_HOST: roboco-postgres
POSTGRES_PORT: 5432
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
POSTGRES_DB: roboco
entrypoint: ["/bin/bash", "/scripts/backup-entrypoint.sh"]
volumes:
- ./docker/scripts/backup-entrypoint.sh:/scripts/backup-entrypoint.sh:ro
- ${ROBOCO_DATA_DIR:-./data}/backups:/backups
depends_on:
postgres:
condition: service_healthy
# ========================================================================== # ==========================================================================
# MinIO - Object storage for rendered videos (NAS default-on; registry OFF) # MinIO - Object storage for rendered videos (NAS default-on; registry OFF)
# ========================================================================== # ==========================================================================
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Periodic pg_dump of the roboco DB (interim backup — no PITR/WAL archiving).
# Runs once on start, then every BACKUP_INTERVAL_SECONDS (default 24h). A
# failed dump logs and is retried next cycle; the loop itself never exits.
# ponytail: a plain sleep loop, not a cron daemon — good enough for a fixed
# 24h cadence, swap for a scheduler only if finer granularity is ever needed.
set -u
BACKUP_DIR="${BACKUP_DIR:-/backups}"
KEEP="${BACKUP_KEEP:-14}"
INTERVAL_SECONDS="${BACKUP_INTERVAL_SECONDS:-86400}"
# pg_dump reads these natively — no need to pass -h/-p/-U/-d by hand.
export PGHOST="${POSTGRES_HOST:-roboco-postgres}"
export PGPORT="${POSTGRES_PORT:-5432}"
export PGUSER="${POSTGRES_USER:-roboco}"
export PGPASSWORD="${POSTGRES_PASSWORD:-roboco}"
export PGDATABASE="${POSTGRES_DB:-roboco}"
mkdir -p "$BACKUP_DIR"
run_backup() {
local ts dest
ts="$(date -u +%Y%m%dT%H%M%SZ)"
dest="${BACKUP_DIR}/roboco-${ts}.dump"
echo "[backup] $(date -u -Iseconds) starting pg_dump -> ${dest}"
if pg_dump -Fc -f "${dest}.tmp"; then
mv "${dest}.tmp" "${dest}"
echo "[backup] $(date -u -Iseconds) OK: ${dest}"
else
rm -f "${dest}.tmp"
echo "[backup] $(date -u -Iseconds) FAILED — will retry next cycle" >&2
fi
# Prune to the newest $KEEP dumps. Filenames are our own fixed timestamp
# format (no spaces/globs to worry about), so plain `ls -t` is enough.
# shellcheck disable=SC2012
ls -1t "${BACKUP_DIR}"/roboco-*.dump 2>/dev/null | tail -n "+$((KEEP + 1))" | while IFS= read -r old; do
rm -f "$old"
done
}
while true; do
run_backup
sleep "$INTERVAL_SECONDS"
done
+23
View File
@@ -0,0 +1,23 @@
# Database Backups
An interim `backup` sidecar service ships in both `docker-compose.yml` and `docker-compose.registry.yml`: a plain `pgvector/pgvector:pg16` container (the same image the `postgres` service pins) running `docker/scripts/backup-entrypoint.sh` on the `data` network alongside `postgres`, with no feature flag — it is always on, since a default-off backup is pointless.
## What it does
On container start, and then every `BACKUP_INTERVAL_SECONDS` (default `86400`, i.e. 24h), the script runs `pg_dump -Fc` against the `roboco` database over the network (via `PGHOST=roboco-postgres`, the same container-name resolution every other data-network consumer uses) and writes a timestamped custom-format dump to `${ROBOCO_DATA_DIR:-./data}/backups/roboco-<UTC-timestamp>.dump` on the host. The dump is written to a `.tmp` suffix first and only renamed into place on success, so a crash mid-dump never leaves a half-written file that looks complete.
## Retention
After each attempt the script prunes `${ROBOCO_DATA_DIR:-./data}/backups` down to the newest `BACKUP_KEEP` dumps (default `14`, i.e. roughly two weeks at the default 24h cadence) by mtime, deleting the rest. There is no offsite copy and no WAL/PITR archiving — this is a point-in-time `pg_dump` snapshot only, taken once a day.
## Failure behavior
A failed `pg_dump` (network hiccup, postgres briefly unhealthy, disk full) logs to `docker logs roboco-backup` and is retried on the next cycle; the loop itself never exits, so the container never crash-loops on a transient failure. `restart: unless-stopped` covers the rest.
## Restoring a dump
Stop anything writing to the database, then restore into a running (empty or throwaway) `roboco` database with `pg_restore`, for example: `docker exec -i roboco-postgres pg_restore -U roboco -d roboco --clean --if-exists < ./data/backups/roboco-20260711T030000Z.dump` (drop the `.tmp` files if any are present — they are in-progress dumps, not backups). Use `pg_restore -l <dump>` first if you want to inspect or selectively restore a subset of objects rather than the whole database. For a fresh empty database instead of `--clean`, create it first (`createdb -U roboco roboco_restore`) and restore into that.
## Known ceiling
This is an interim measure to close the "zero backups" gap, not a full disaster-recovery story: a single daily snapshot on the same host as the database it's backing up is vulnerable to whole-host loss (disk failure, NAS failure). Copying `${ROBOCO_DATA_DIR:-./data}/backups` offsite periodically, or moving to WAL-based continuous archiving, is the natural next step if that risk matters more than the current simplicity.
+9 -19
View File
@@ -1,8 +1,6 @@
# A2A Conversations Filter Control # A2A Conversations Filter Control
**Location:** `panel/src/components/a2a/` **Location:** `panel/src/components/a2a/` **Design spec:** `docs/ux_ui/design/conversations-filter-control.md` **Related:** A2A page (`panel/src/app/(dashboard)/a2a/page.tsx`)
**Design spec:** `docs/ux_ui/design/conversations-filter-control.md`
**Related:** A2A page (`panel/src/app/(dashboard)/a2a/page.tsx`)
## Overview ## Overview
@@ -207,17 +205,13 @@ function A2APageContent() {
### Filter Functions ### Filter Functions
**`filterConversations(conversations, filters): AdminConversationSummary[]`** **`filterConversations(conversations, filters): AdminConversationSummary[]`** Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list.
Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list.
**`filterPairs(pairs, filters): AdminPairSummary[]`** **`filterPairs(pairs, filters): AdminPairSummary[]`** Applies Agent dimension only to narrow switchboard pair cards.
Applies Agent dimension only to narrow switchboard pair cards.
**`distinctA2AAgents(conversations, pairs): string[]`** **`distinctA2AAgents(conversations, pairs): string[]`** Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a `useMemo` in the parent whenever pairs/conversations change.
Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a `useMemo` in the parent whenever pairs/conversations change.
**`activeA2AFilterCount(filters): number`** **`activeA2AFilterCount(filters): number`** Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date.
Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date.
## Per-View Rules ## Per-View Rules
@@ -303,14 +297,10 @@ The component follows the design spec's accessibility contract:
## Common Questions ## Common Questions
**Q: Why doesn't the fragment search the topic field anymore?** **Q: Why doesn't the fragment search the topic field anymore?** A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed.
A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed.
**Q: Can I make filters persist across page reloads?** **Q: Can I make filters persist across page reloads?** A: Not in this version. To add localStorage persistence, wrap `setFilters` in the parent with `useEffect` to sync to localStorage and restore on mount. This would be a follow-up task.
A: Not in this version. To add localStorage persistence, wrap `setFilters` in the parent with `useEffect` to sync to localStorage and restore on mount. This would be a follow-up task.
**Q: What happens to filtered state when new conversations arrive via WebSocket?** **Q: What happens to filtered state when new conversations arrive via WebSocket?** A: Filters remain active. The `filterConversations` function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters.
A: Filters remain active. The `filterConversations` function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters.
**Q: Can filters be set via URL query params?** **Q: Can filters be set via URL query params?** A: Not currently. The state is ephemeral. To support deep-linking (e.g., `?agents=be-dev-1&status=active`), add a URL param sync layer in the parent using `useSearchParams` / `useRouter` from Next.js. This would be a follow-up task.
A: Not currently. The state is ephemeral. To support deep-linking (e.g., `?agents=be-dev-1&status=active`), add a URL param sync layer in the parent using `useSearchParams` / `useRouter` from Next.js. This would be a follow-up task.
@@ -163,8 +163,7 @@ Roster (col-span-4) | Stream (col-span-8)
The connection badge renders in the pane header, and a dismissable banner appears above the message list when reconnecting or disconnected. The connection badge renders in the pane header, and a dismissable banner appears above the message list when reconnecting or disconnected.
**File:** `panel/src/components/a2a/a2a-connection-badge.tsx` **File:** `panel/src/components/a2a/a2a-connection-badge.tsx` **Utils:** `panel/src/components/a2a/a2a-utils.ts`
**Utils:** `panel/src/components/a2a/a2a-utils.ts`
### `ConnectionState` Type (from `@/lib/websocket/connection`) ### `ConnectionState` Type (from `@/lib/websocket/connection`)
@@ -1,18 +1,10 @@
# Conversation-first layout, agent identity, and live-stream affordances # Conversation-first layout, agent identity, and live-stream affordances
Interaction spec for the pattern that makes a live conversation the primary Interaction spec for the pattern that makes a live conversation the primary surface of a view, rather than a secondary panel bolted onto a data table: a three-region layout, a team-color agent identity scheme that scales to the full 22-agent roster, connection-state visual treatment, a new-message arrival cue, and the loading/empty/error states a conversation panel needs. Written so a frontend developer can implement directly from this document without further design clarification.
surface of a view, rather than a secondary panel bolted onto a data table: a
three-region layout, a team-color agent identity scheme that scales to the
full 22-agent roster, connection-state visual treatment, a new-message
arrival cue, and the loading/empty/error states a conversation panel needs.
Written so a frontend developer can implement directly from this document
without further design clarification.
## Scope and where this lives ## Scope and where this lives
This is a **pattern spec**, not a new page proposal. It extends the one This is a **pattern spec**, not a new page proposal. It extends the one conversation surface RoboCo already ships, `panel/src/app/(dashboard)/a2a/page.tsx`, plus its sub-components:
conversation surface RoboCo already ships,
`panel/src/app/(dashboard)/a2a/page.tsx`, plus its sub-components:
| Piece | Existing file it extends | | Piece | Existing file it extends |
|---|---| |---|---|
@@ -22,20 +14,9 @@ conversation surface RoboCo already ships,
| Agent identity | `panel/src/lib/agent-utils.ts` (`getAgentInitials`, `getAgentDisplayName`) | | Agent identity | `panel/src/lib/agent-utils.ts` (`getAgentInitials`, `getAgentDisplayName`) |
| Connection state | `panel/src/hooks/use-websocket.ts` (`ConnectionState` = `"connecting" \| "connected" \| "reconnecting" \| "disconnected"`), `panel/src/components/layout/connection-status.tsx` | | Connection state | `panel/src/hooks/use-websocket.ts` (`ConnectionState` = `"connecting" \| "connected" \| "reconnecting" \| "disconnected"`), `panel/src/components/layout/connection-status.tsx` |
Nothing here replaces the `/a2a` page's existing behavior (message fetch, Nothing here replaces the `/a2a` page's existing behavior (message fetch, reply composer, switchboard/list toggle) — every section below is additive: a third pane, a color layer on an existing avatar, a refined connection badge, an entrance transition for new rows, and the states around the stream when it has nothing (yet) to show. The same three-region composition and identity/connection/arrival treatment apply to any future conversation surface RoboCo adds (e.g. a unified agent-activity inbox) without re-deriving the pattern.
reply composer, switchboard/list toggle) — every section below is additive:
a third pane, a color layer on an existing avatar, a refined connection
badge, an entrance transition for new rows, and the states around the
stream when it has nothing (yet) to show. The same three-region composition
and identity/connection/arrival treatment apply to any future conversation
surface RoboCo adds (e.g. a unified agent-activity inbox) without
re-deriving the pattern.
**Design bar dial read:** dense product UI (a data-heavy live-ops surface **Design bar dial read:** dense product UI (a data-heavy live-ops surface inside existing panel chrome), not a landing page — variance 2, motion 2-3, density 7, the UX/UI cell's dashboard default. No new radius or shadow tokens; color additions are a bounded, named palette (below), not ad hoc hex values.
inside existing panel chrome), not a landing page — variance 2, motion 2-3,
density 7, the UX/UI cell's dashboard default. No new radius or shadow
tokens; color additions are a bounded, named palette (below), not ad hoc
hex values.
--- ---
@@ -43,8 +24,7 @@ hex values.
### The three regions ### The three regions
A conversation-first surface is composed of three regions, always in this A conversation-first surface is composed of three regions, always in this order left-to-right, with the stream always the widest:
order left-to-right, with the stream always the widest:
``` ```
┌─ Roster (list rail) ─┬───── Stream (primary) ─────┬─ Context (collapsible) ─┐ ┌─ Roster (list rail) ─┬───── Stream (primary) ─────┬─ Context (collapsible) ─┐
@@ -55,20 +35,13 @@ order left-to-right, with the stream always the widest:
└────────────────────────┴───────────────────────────────┴─────────────────────┘ └────────────────────────┴───────────────────────────────┴─────────────────────┘
``` ```
- **Roster** is navigation: "which conversation am I looking at" — today's - **Roster** is navigation: "which conversation am I looking at" — today's `A2ASwitchboard`/`A2AConversationList`.
`A2ASwitchboard`/`A2AConversationList`. - **Stream** is content: "what was said" — today's `A2ATranscript` plus the reply composer beneath it. This region gets the majority of horizontal space at every breakpoint that shows more than one region, because it is the primary surface, not a byproduct of the roster selection.
- **Stream** is content: "what was said" — today's `A2ATranscript` plus the - **Context** is metadata: participant identity detail, the linked task (title, status, a link into `/tasks/{id}`), and any quick actions — a new region, collapsible, not present in the current implementation.
reply composer beneath it. This region gets the majority of horizontal
space at every breakpoint that shows more than one region, because it is
the primary surface, not a byproduct of the roster selection.
- **Context** is metadata: participant identity detail, the linked task
(title, status, a link into `/tasks/{id}`), and any quick actions — a new
region, collapsible, not present in the current implementation.
### Grid and breakpoints ### Grid and breakpoints
Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container (`a2a/page.tsx:260`) with one more breakpoint tier rather than replacing it:
(`a2a/page.tsx:260`) with one more breakpoint tier rather than replacing it:
| Breakpoint | Regions visible | Column split | | Breakpoint | Regions visible | Column split |
|---|---|---| |---|---|---|
@@ -76,31 +49,17 @@ Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container
| `lg` `< xl` | Roster + Stream (today's behavior, unchanged) | Roster `col-span-4`, Stream `col-span-8` | | `lg` `< xl` | Roster + Stream (today's behavior, unchanged) | Roster `col-span-4`, Stream `col-span-8` |
| `xl`+ | Roster + Stream + Context | Roster `col-span-3`, Stream `col-span-6`, Context `col-span-3` | | `xl`+ | Roster + Stream + Context | Roster `col-span-3`, Stream `col-span-6`, Context `col-span-3` |
The context pane is the new addition and is the one that collapses first — The context pane is the new addition and is the one that collapses first — it never appears below `xl`, and even at `xl`+ it is dismissible via a header toggle (a `PanelRightClose`/`PanelRightOpen` icon button, `size="sm" variant="ghost"`, matching the existing switchboard/list toggle buttons at `a2a/page.tsx:275-296`) so a user who wants the stream at full width above `xl` can still get it. Collapsed state persists in `localStorage` (`roboco:conversation-context-open`, boolean), read once at mount — the same persistence idiom already used for panel-width/theme preferences (avoids a new state-management dependency).
it never appears below `xl`, and even at `xl`+ it is dismissible via a
header toggle (a `PanelRightClose`/`PanelRightOpen` icon button, `size="sm"
variant="ghost"`, matching the existing switchboard/list toggle buttons at
`a2a/page.tsx:275-296`) so a user who wants the stream at full width above
`xl` can still get it. Collapsed state persists in `localStorage`
(`roboco:conversation-context-open`, boolean), read once at mount — the same
persistence idiom already used for panel-width/theme preferences (avoids a
new state-management dependency).
### Context pane content ### Context pane content
When open, the context pane shows, top to bottom: When open, the context pane shows, top to bottom:
1. Both participants' identity cards (avatar + name + team badge — see 1. Both participants' identity cards (avatar + name + team badge — see §2), each linking to `/agents/{slug}`.
§2), each linking to `/agents/{slug}`. 2. The linked task, if any: title (truncated to one line), status `Badge` (reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`), and a "View task" link.
2. The linked task, if any: title (truncated to one line), status `Badge` 3. A muted one-line hint when there is no linked task ("This conversation has no linked task"), matching the tone of the existing no-task composer message (`a2a/page.tsx:373-377`).
(reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`),
and a "View task" link.
3. A muted one-line hint when there is no linked task ("This conversation
has no linked task"), matching the tone of the existing no-task composer
message (`a2a/page.tsx:373-377`).
The context pane does not duplicate the reply composer or transcript — it The context pane does not duplicate the reply composer or transcript — it is read-only summary, never a second place to act on the conversation.
is read-only summary, never a second place to act on the conversation.
--- ---
@@ -108,22 +67,11 @@ is read-only summary, never a second place to act on the conversation.
### Why team color, not per-agent color ### Why team color, not per-agent color
With 22 agents in the roster (`AGENT_UUIDS` in `agent-utils.ts`), a unique With 22 agents in the roster (`AGENT_UUIDS` in `agent-utils.ts`), a unique hue per agent is not legible — nobody can hold 22 arbitrary colors in working memory, and two similar hues (e.g. two blues for `be-dev-1` and `fe-dev-1`) would read as "the same agent" at a glance. Colour is scoped to the axis that actually matters for fast scanning — **which cell this agent belongs to** — and individual identity within a team is carried by the existing initials/code, not a second hue. This scales cleanly: adding a 23rd agent to an existing team changes zero colors; adding a whole new team is the only case that needs a new bucket, and the palette below already has headroom.
hue per agent is not legible — nobody can hold 22 arbitrary colors in
working memory, and two similar hues (e.g. two blues for `be-dev-1` and
`fe-dev-1`) would read as "the same agent" at a glance. Colour is scoped to
the axis that actually matters for fast scanning — **which cell this agent
belongs to** — and individual identity within a team is carried by the
existing initials/code, not a second hue. This scales cleanly: adding a
23rd agent to an existing team changes zero colors; adding a whole new team
is the only case that needs a new bucket, and the palette below already has
headroom.
### The six buckets ### The six buckets
A new pure function, `getAgentTeamColor(agentId: string | null | undefined): A new pure function, `getAgentTeamColor(agentId: string | null | undefined): AgentTeamColor`, colocated in `agent-utils.ts` next to `getAgentInitials` (same module — it needs the same slug-resolution logic already there):
AgentTeamColor`, colocated in `agent-utils.ts` next to `getAgentInitials`
(same module — it needs the same slug-resolution logic already there):
```ts ```ts
export type AgentTeamColor = export type AgentTeamColor =
@@ -135,11 +83,7 @@ export type AgentTeamColor =
| "system"; | "system";
``` ```
Derived from the slug prefix (`be-*``backend`, `fe-*``frontend`, Derived from the slug prefix (`be-*``backend`, `fe-*``frontend`, `ux-*``ux_ui`, `main-pm`/`product-owner`/`head-marketing`/`auditor``board`, `ceo`/`CEO``ceo`, `intake-*`/`secretary-*`/`pr-reviewer-*``system`), with the same UUID-to-slug resolution `getAgentInitials` already does via `resolveToSlug`.
`ux-*``ux_ui`, `main-pm`/`product-owner`/`head-marketing`/`auditor`
`board`, `ceo`/`CEO``ceo`, `intake-*`/`secretary-*`/`pr-reviewer-*`
`system`), with the same UUID-to-slug resolution `getAgentInitials` already
does via `resolveToSlug`.
| Bucket | Agents | Token classes (light / dark handled by existing `dark:` pairs already in the codebase's Tailwind v4 setup) | | Bucket | Agents | Token classes (light / dark handled by existing `dark:` pairs already in the codebase's Tailwind v4 setup) |
|---|---|---| |---|---|---|
@@ -150,19 +94,11 @@ does via `resolveToSlug`.
| `ceo` | ceo | `bg-primary/15 border-primary/40 text-primary` (the app's own accent — the one human gets the app's own color, not a team bucket) | | `ceo` | ceo | `bg-primary/15 border-primary/40 text-primary` (the app's own accent — the one human gets the app's own color, not a team bucket) |
| `system` | intake-1, secretary-1, pr-reviewer-1 | `bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400` | | `system` | intake-1, secretary-1, pr-reviewer-1 | `bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400` |
Every value here is an existing Tailwind color family already used Every value here is an existing Tailwind color family already used elsewhere in the codebase for the same semantic weight (`amber` for attention in `release-proposal-card.tsx:181`, `blue`/`violet`/`fuchsia` are Tailwind defaults, no new tokens introduced) at the same `/15` background + `/40` border opacity already established by the pulse-card treatment in `a2a-pair-card.tsx:87`.
elsewhere in the codebase for the same semantic weight (`amber` for
attention in `release-proposal-card.tsx:181`, `blue`/`violet`/`fuchsia` are
Tailwind defaults, no new tokens introduced) at the same `/15` background +
`/40` border opacity already established by the pulse-card treatment in
`a2a-pair-card.tsx:87`.
### Avatar composition ### Avatar composition
Extends the existing avatar circle (`PairAvatar` in `a2a-pair-card.tsx:20-31`, Extends the existing avatar circle (`PairAvatar` in `a2a-pair-card.tsx:20-31`, and the inline avatar in `a2a-transcript.tsx:70-74`) with the team color as `border` + `bg`, keeping the initials as the foreground content — the color becomes a ring around identity, not a replacement for it:
and the inline avatar in `a2a-transcript.tsx:70-74`) with the team color as
`border` + `bg`, keeping the initials as the foreground content — the color
becomes a ring around identity, not a replacement for it:
```tsx ```tsx
<div <div
@@ -178,25 +114,11 @@ becomes a ring around identity, not a replacement for it:
</div> </div>
``` ```
`TEAM_COLOR_CLASSES` is a `Record<AgentTeamColor, string>` map of the class `TEAM_COLOR_CLASSES` is a `Record<AgentTeamColor, string>` map of the class strings from the table above, exported alongside `getAgentTeamColor` so every consumer (transcript rows, pair cards, roster rows, context pane identity cards) applies the identical mapping — one source of truth, no per-component re-derivation.
strings from the table above, exported alongside `getAgentTeamColor` so
every consumer (transcript rows, pair cards, roster rows, context pane
identity cards) applies the identical mapping — one source of truth, no
per-component re-derivation.
### Accessibility ### Accessibility
Color is never the sole differentiator: the `title` attribute always Color is never the sole differentiator: the `title` attribute always carries the full display name (already the case in `PairAvatar`), the initials/code is always visible text inside the circle, and every place an avatar appears the agent's display name renders as adjacent text (already true in the transcript and pair card). A screen reader user gets the name from the text content regardless of the color layer. All six token pairs above meet WCAG AA (4.5:1) for the `text-*-700`/`text-*-400` foreground against a `bg-*-500/15` fill over the app's `background`/`card` surface — verify against the actual rendered surface at implementation time per the design bar's contrast-audit rule, since a `/15` alpha fill's effective contrast depends on what's behind it.
carries the full display name (already the case in `PairAvatar`), the
initials/code is always visible text inside the circle, and every place an
avatar appears the agent's display name renders as adjacent text (already
true in the transcript and pair card). A screen reader user gets the name
from the text content regardless of the color layer. All six token pairs
above meet WCAG AA (4.5:1) for the `text-*-700`/`text-*-400` foreground
against a `bg-*-500/15` fill over the app's `background`/`card` surface —
verify against the actual rendered surface at implementation time per the
design bar's contrast-audit rule, since a `/15` alpha fill's effective
contrast depends on what's behind it.
--- ---
@@ -204,10 +126,7 @@ contrast depends on what's behind it.
### States ### States
`ConnectionState` already has four values (`use-websocket.ts:17-21` via `ConnectionState` already has four values (`use-websocket.ts:17-21` via `lib/websocket/connection.ts`); the spec covers all four, since `"connecting"` (initial handshake) and `"reconnecting"` (recovering after a drop) share one visual family with a different label:
`lib/websocket/connection.ts`); the spec covers all four, since
`"connecting"` (initial handshake) and `"reconnecting"` (recovering after a
drop) share one visual family with a different label:
| State | Dot | Label | Icon (header, inline) | Placement | | State | Dot | Label | Icon (header, inline) | Placement |
|---|---|---|---|---| |---|---|---|---|---|
@@ -216,27 +135,13 @@ drop) share one visual family with a different label:
| `reconnecting` | `bg-amber-500`, `animate-pulse` | "Reconnecting…" | `Loader2` with `animate-spin`, `h-3 w-3` | Inline in the pane header, **plus** a thin dismissable strip directly above the stream pane's message list: `bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-400 text-xs px-3 py-1.5` reading "Reconnecting — messages may be out of date" | | `reconnecting` | `bg-amber-500`, `animate-pulse` | "Reconnecting…" | `Loader2` with `animate-spin`, `h-3 w-3` | Inline in the pane header, **plus** a thin dismissable strip directly above the stream pane's message list: `bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-400 text-xs px-3 py-1.5` reading "Reconnecting — messages may be out of date" |
| `disconnected` | `bg-muted-foreground/40`, static | "Offline" | `WifiOff`, `h-3 w-3`, `text-muted-foreground` | Inline in the pane header, **plus** the same strip pattern as `reconnecting` but `bg-destructive/10 border-destructive/30 text-destructive`, reading "Disconnected — reconnecting automatically" | | `disconnected` | `bg-muted-foreground/40`, static | "Offline" | `WifiOff`, `h-3 w-3`, `text-muted-foreground` | Inline in the pane header, **plus** the same strip pattern as `reconnecting` but `bg-destructive/10 border-destructive/30 text-destructive`, reading "Disconnected — reconnecting automatically" |
The `connected`/`connecting`/`reconnecting` distinction matters because a The `connected`/`connecting`/`reconnecting` distinction matters because a user watching a live conversation needs to know *why* nothing new is arriving: `connected`-but-quiet means the conversation is genuinely idle; `reconnecting`/`disconnected` means the stream itself is the problem, not the conversation. Collapsing all three into one generic "not live" state (as today's binary `isConnected ? "Live" : "Offline"` does) hides that distinction.
user watching a live conversation needs to know *why* nothing new is
arriving: `connected`-but-quiet means the conversation is genuinely idle;
`reconnecting`/`disconnected` means the stream itself is the problem, not
the conversation. Collapsing all three into one generic "not live" state
(as today's binary `isConnected ? "Live" : "Offline"` does) hides that
distinction.
The banner strip is scoped to the stream pane only, not a full-page The banner strip is scoped to the stream pane only, not a full-page takeover — this is a live-connection hint, not an application-down state (that's `OfflineState`, reserved for §5's error case where data can't load at all).
takeover — this is a live-connection hint, not an application-down state
(that's `OfflineState`, reserved for §5's error case where data can't load
at all).
### Motion note ### Motion note
The existing `animate-pulse` dot (`a2a/page.tsx:228`) is a Tailwind The existing `animate-pulse` dot (`a2a/page.tsx:228`) is a Tailwind keyframe that only animates `opacity`, so it already satisfies the "animate transform/opacity only" rule — but it has no `prefers-reduced-motion` guard today. Add one: wrap the pulsing states in `motion-reduce:animate-none`, so a reduced-motion user gets a static dot at full opacity instead of the pulse — the color and label alone still convey the state.
keyframe that only animates `opacity`, so it already satisfies the "animate
transform/opacity only" rule — but it has no `prefers-reduced-motion` guard
today. Add one: wrap the pulsing states in `motion-reduce:animate-none`, so
a reduced-motion user gets a static dot at full opacity instead of the
pulse — the color and label alone still convey the state.
--- ---
@@ -244,11 +149,7 @@ pulse — the color and label alone still convey the state.
### The cue ### The cue
When a new message is appended to the stream (a `a2a.message` frame that When a new message is appended to the stream (a `a2a.message` frame that resolves to a new row after the existing invalidate-on-frame refetch, `a2a/page.tsx:131-140`), the new row enters with a **transform + opacity only** transition — no layout-affecting property, no scroll-listener-driven animation, per the design bar's motion rule:
resolves to a new row after the existing invalidate-on-frame refetch,
`a2a/page.tsx:131-140`), the new row enters with a **transform + opacity
only** transition — no layout-affecting property, no scroll-listener-driven
animation, per the design bar's motion rule:
```tsx ```tsx
className={cn( className={cn(
@@ -257,25 +158,13 @@ className={cn(
)} )}
``` ```
`isNew` is derived the same render-phase way `A2APairCard`'s `isPulsing` `isNew` is derived the same render-phase way `A2APairCard`'s `isPulsing` already is (`a2a-pair-card.tsx:49-60`): compare the incoming message id against a "last seen" set in render, flip to `false` on the next animation frame via `requestAnimationFrame` inside a `useEffect` — no animation library, matching the codebase's existing idiom for this exact kind of one-shot entrance state.
already is (`a2a-pair-card.tsx:49-60`): compare the incoming message id
against a "last seen" set in render, flip to `false` on the next animation
frame via `requestAnimationFrame` inside a `useEffect` — no animation
library, matching the codebase's existing idiom for this exact kind of
one-shot entrance state.
The starting state (`opacity-0 translate-y-1`, i.e. 4px down) is applied The starting state (`opacity-0 translate-y-1`, i.e. 4px down) is applied only for rows that mount already-new (a message arriving while the stream is open); rows present at initial transcript load render straight to `opacity-100 translate-y-0` with no transition, so opening a conversation never shows every existing message animating in at once.
only for rows that mount already-new (a message arriving while the stream
is open); rows present at initial transcript load render straight to
`opacity-100 translate-y-0` with no transition, so opening a conversation
never shows every existing message animating in at once.
### Off-screen arrival (scrolled up) ### Off-screen arrival (scrolled up)
When the user has scrolled up in the stream (not at the bottom) and a new When the user has scrolled up in the stream (not at the bottom) and a new message arrives, do not auto-scroll and do not play the row-entrance transition off-screen. Instead show a small pill anchored to the bottom of the stream pane:
message arrives, do not auto-scroll and do not play the row-entrance
transition off-screen. Instead show a small pill anchored to the bottom of
the stream pane:
```tsx ```tsx
<button <button
@@ -286,31 +175,17 @@ the stream pane:
</button> </button>
``` ```
— same transform/opacity-only constraint, appearing with the same — same transform/opacity-only constraint, appearing with the same fade-and-rise-in treatment as the row cue. Clicking it scrolls to bottom (smooth, CSS `scroll-behavior: smooth` — a browser-native scroll, not a `scroll` event listener) and dismisses the pill.
fade-and-rise-in treatment as the row cue. Clicking it scrolls to bottom
(smooth, CSS `scroll-behavior: smooth` — a browser-native scroll, not a
`scroll` event listener) and dismisses the pill.
### `prefers-reduced-motion` ### `prefers-reduced-motion`
Both cues drop the `translate-y`/`-translate-x` transform and the Both cues drop the `translate-y`/`-translate-x` transform and the `duration-200` transition under `motion-reduce:`, leaving only the `opacity` state change applied instantly (`motion-reduce:transition-none motion-reduce:translate-y-0`) — the row/pill still visually distinguishes "just arrived" via a brief `bg-muted/50` background flash (a non-transform, non-motion cue: a background-color change with its own short `transition-colors duration-150`, exempt from the transform/opacity restriction because a plain color transition is not motion) that fades to the row's normal `bg-card` over 150ms, so reduced-motion users still get an arrival signal without any movement.
`duration-200` transition under `motion-reduce:`, leaving only the
`opacity` state change applied instantly (`motion-reduce:transition-none
motion-reduce:translate-y-0`) — the row/pill still visually distinguishes
"just arrived" via a brief `bg-muted/50` background flash (a
non-transform, non-motion cue: a background-color change with its own
short `transition-colors duration-150`, exempt from the transform/opacity
restriction because a plain color transition is not motion) that fades to
the row's normal `bg-card` over 150ms, so reduced-motion users still get
an arrival signal without any movement.
--- ---
## 5. Loading, empty, and error states ## 5. Loading, empty, and error states
All three states are scoped to the stream pane's content area — the pane All three states are scoped to the stream pane's content area — the pane chrome (header, region title, connection badge) stays mounted and stable across state changes, only the message-list area swaps.
chrome (header, region title, connection badge) stays mounted and stable
across state changes, only the message-list area swaps.
| State | Trigger | Treatment | | State | Trigger | Treatment |
|---|---|---| |---|---|---|
@@ -318,11 +193,7 @@ across state changes, only the message-list area swaps.
| **Empty** | Fetch resolved, zero messages (`A2ATranscript` lines 51-60) | Existing centered icon + one-line text (`MessagesSquare`, opacity-50, `text-sm text-muted-foreground`). Extend the copy to be context-aware: "No messages in this conversation yet" when a conversation is selected (current copy, unchanged) vs. "Select a conversation to view messages" when nothing is selected yet (the roster-selected-nothing case, not currently distinguished) — same icon, same layout, only the string changes based on whether `selectedId`/`peekedPair` is set. | | **Empty** | Fetch resolved, zero messages (`A2ATranscript` lines 51-60) | Existing centered icon + one-line text (`MessagesSquare`, opacity-50, `text-sm text-muted-foreground`). Extend the copy to be context-aware: "No messages in this conversation yet" when a conversation is selected (current copy, unchanged) vs. "Select a conversation to view messages" when nothing is selected yet (the roster-selected-nothing case, not currently distinguished) — same icon, same layout, only the string changes based on whether `selectedId`/`peekedPair` is set. |
| **Error** | The messages fetch itself errors (distinct from the page-level `isOffline` full-page case at `a2a/page.tsx:239-244`, which covers the *conversations list* failing to load) | A scoped inline state inside the stream pane, not a full-page `OfflineState`: centered `AlertTriangle` icon (`h-8 w-8 opacity-50 text-destructive`), "Couldn't load this conversation" text, and a `Button variant="outline" size="sm"` "Retry" that calls the existing `refetchMessages()`. Reuses the same centered-icon-plus-text layout shell as the empty state (same wrapper `div`, different icon/copy/action) so the three states read as one family, not three unrelated designs. | | **Error** | The messages fetch itself errors (distinct from the page-level `isOffline` full-page case at `a2a/page.tsx:239-244`, which covers the *conversations list* failing to load) | A scoped inline state inside the stream pane, not a full-page `OfflineState`: centered `AlertTriangle` icon (`h-8 w-8 opacity-50 text-destructive`), "Couldn't load this conversation" text, and a `Button variant="outline" size="sm"` "Retry" that calls the existing `refetchMessages()`. Reuses the same centered-icon-plus-text layout shell as the empty state (same wrapper `div`, different icon/copy/action) so the three states read as one family, not three unrelated designs. |
The distinction between the page-level `OfflineState` (backend unreachable The distinction between the page-level `OfflineState` (backend unreachable entirely, `a2a/page.tsx:239-244`) and this pane-level error state (this one conversation's message fetch failed, everything else on the page still works) matters: a transient 500 on one conversation's messages should never take over the whole page.
entirely, `a2a/page.tsx:239-244`) and this pane-level error state (this one
conversation's message fetch failed, everything else on the page still
works) matters: a transient 500 on one conversation's messages should never
take over the whole page.
--- ---
+22 -126
View File
@@ -1,39 +1,16 @@
# Filter control for A2A conversations # Filter control for A2A conversations
Design spec for a filter control on the panel's `/a2a` page Design spec for a filter control on the panel's `/a2a` page (`panel/src/app/(dashboard)/a2a/page.tsx`), which is the CEO-facing conversation-first view of agent-to-agent chats. Today the page has no filtering at all: Panel 1 shows either the org-chart **Switchboard** (`A2ASwitchboard`) or the classic **Conversation List** (`A2AConversationList`), capped at the last 100 conversations with no way to narrow them.
(`panel/src/app/(dashboard)/a2a/page.tsx`), which is the CEO-facing
conversation-first view of agent-to-agent chats. Today the page has no
filtering at all: Panel 1 shows either the org-chart **Switchboard**
(`A2ASwitchboard`) or the classic **Conversation List** (`A2AConversationList`),
capped at the last 100 conversations with no way to narrow them.
This spec covers the filter dimensions, their placement, the active-filter This spec covers the filter dimensions, their placement, the active-filter chip representation, and accessibility. It reuses the Popover + Checkbox + Badge-chip pattern already shipped in `panel/src/components/tasks/task-filters.tsx` rather than inventing a new filter idiom, and stays within primitives already in `panel/src/components/ui/` (`popover.tsx`, `checkbox.tsx`, `badge.tsx`, `button.tsx`, `input.tsx`).
chip representation, and accessibility. It reuses the Popover + Checkbox +
Badge-chip pattern already shipped in
`panel/src/components/tasks/task-filters.tsx` rather than inventing a new
filter idiom, and stays within primitives already in
`panel/src/components/ui/` (`popover.tsx`, `checkbox.tsx`, `badge.tsx`,
`button.tsx`, `input.tsx`).
## Design-bar dial read ## Design-bar dial read
This is a dense admin surface (a live operations console), not a marketing This is a dense admin surface (a live operations console), not a marketing page, so per the UX/UI team's design bar defaults: **DESIGN_VARIANCE 2** (the control sits in an already-fixed grid, no asymmetry), **MOTION_INTENSITY 2** (popover open/close and chip enter/exit only, no scroll choreography), **VISUAL_DENSITY 7** (compact chips and a single icon-button trigger, not an airy multi-field form bar like the full-width `TaskFilters` on `/tasks`, because Panel 1 is only 4/12 columns wide at `lg`+).
page, so per the UX/UI team's design bar defaults: **DESIGN_VARIANCE 2**
(the control sits in an already-fixed grid, no asymmetry), **MOTION_INTENSITY
2** (popover open/close and chip enter/exit only, no scroll choreography),
**VISUAL_DENSITY 7** (compact chips and a single icon-button trigger, not an
airy multi-field form bar like the full-width `TaskFilters` on `/tasks`,
because Panel 1 is only 4/12 columns wide at `lg`+).
## 1. Filterable dimensions ## 1. Filterable dimensions
Four dimensions, each backed by a field already on the wire today Four dimensions, each backed by a field already on the wire today (`AdminConversationSummary` / `AdminPairSummary` in `panel/src/lib/api/a2a.ts`). None of these are supported as backend query params yet — `a2aApi.listAdminConversations` only accepts `limit`. The spec below filters **client-side over the already-fetched page** (currently capped at `limit=100`); the "Future work" note calls out the backend seam so a later task can add server-side params without changing this UI contract.
(`AdminConversationSummary` / `AdminPairSummary` in `panel/src/lib/api/a2a.ts`).
None of these are supported as backend query params yet — `a2aApi.listAdminConversations`
only accepts `limit`. The spec below filters **client-side over the already-fetched
page** (currently capped at `limit=100`); the "Future work" note calls out
the backend seam so a later task can add server-side params without changing
this UI contract.
| # | Dimension | Source field(s) | Value domain | Control widget | | # | Dimension | Source field(s) | Value domain | Control widget |
|---|---|---|---|---| |---|---|---|---|---|
@@ -42,31 +19,13 @@ this UI contract.
| 3 | **Status** | `status` (`"active"` \| `"archived"`, the same two values `A2AConversationList`'s `Badge variant` already branches on) | The 2 known statuses | Two-option toggle group (button pair, `aria-pressed`, same idiom as the page's own Switchboard/List view toggle) — a checkbox list would be overkill for 2 values | | 3 | **Status** | `status` (`"active"` \| `"archived"`, the same two values `A2AConversationList`'s `Badge variant` already branches on) | The 2 known statuses | Two-option toggle group (button pair, `aria-pressed`, same idiom as the page's own Switchboard/List view toggle) — a checkbox list would be overkill for 2 values |
| 4 | **Date/time range** | `last_message_at` (falls back to `created_at` when null, matching the list item's own `formatDistanceToNow` fallback) | Any ISO-8601 instant; user picks calendar dates, compared at day granularity in the viewer's local timezone | Two `Input type="date"` fields labeled "From" / "To" — no date-range-picker component exists in `panel/src/components/ui/`, so this stays within already-installed primitives per the ladder (native `<input type="date">` is a browser-native widget, not a new dependency) | | 4 | **Date/time range** | `last_message_at` (falls back to `created_at` when null, matching the list item's own `formatDistanceToNow` fallback) | Any ISO-8601 instant; user picks calendar dates, compared at day granularity in the viewer's local timezone | Two `Input type="date"` fields labeled "From" / "To" — no date-range-picker component exists in `panel/src/components/ui/`, so this stays within already-installed primitives per the ladder (native `<input type="date">` is a browser-native widget, not a new dependency) |
**Per-view applicability.** The Switchboard is org-chart pair cards **Per-view applicability.** The Switchboard is org-chart pair cards (`AdminPairSummary`), not a conversation feed — most pairs have never talked (`conversation_id: null`). Only **Agent** applies there (narrows which pair cards render, same predicate as dimension 1 above); Task/Status/ Date filters have no meaning for a pair with no conversation, so selecting them while in Switchboard view shows a one-line inline note ("Task, Status, and Date filters apply to the Conversation List view") rather than silently hiding pairs. Switching to List view via the existing `LayoutGrid`/`ListIcon` toggle applies all four dimensions.
(`AdminPairSummary`), not a conversation feed — most pairs have never
talked (`conversation_id: null`). Only **Agent** applies there (narrows
which pair cards render, same predicate as dimension 1 above); Task/Status/
Date filters have no meaning for a pair with no conversation, so selecting
them while in Switchboard view shows a one-line inline note ("Task, Status,
and Date filters apply to the Conversation List view") rather than silently
hiding pairs. Switching to List view via the existing `LayoutGrid`/`ListIcon`
toggle applies all four dimensions.
**Future work (not this task):** once conversation volume regularly exceeds **Future work (not this task):** once conversation volume regularly exceeds the `limit=100` page, promote Task/Status/Date to real backend query params on `GET /a2a/chat/admin/conversations` (`agent`, `task_id`, `status`, `from`, `to`) so filtering isn't limited to whatever page happened to load. Client-side filtering as specified here is correct for the current data volume and ships without a backend task.
the `limit=100` page, promote Task/Status/Date to real backend query params
on `GET /a2a/chat/admin/conversations` (`agent`, `task_id`, `status`,
`from`, `to`) so filtering isn't limited to whatever page happened to load.
Client-side filtering as specified here is correct for the current data
volume and ships without a backend task.
## 2. Placement ## 2. Placement
The control must not crowd the conversation-first message stream: it lives The control must not crowd the conversation-first message stream: it lives entirely inside **Panel 1** (the list/switchboard card), never inside **Panel 2** (transcript + composer). Concretely, it is added to the existing Panel-1 header row in `A2APageContent` (`page.tsx` lines ~269-298), which today holds a `Radio` icon, a label, and the Switchboard/List view-toggle buttons:
entirely inside **Panel 1** (the list/switchboard card), never inside
**Panel 2** (transcript + composer). Concretely, it is added to the existing
Panel-1 header row in `A2APageContent` (`page.tsx` lines ~269-298), which
today holds a `Radio` icon, a label, and the Switchboard/List view-toggle
buttons:
``` ```
Collapsed (no active filters) — Panel 1 header, unchanged height: Collapsed (no active filters) — Panel 1 header, unchanged height:
@@ -76,20 +35,9 @@ Collapsed (no active filters) — Panel 1 header, unchanged height:
│ ...pair cards / conversation list... │ │ ...pair cards / conversation list... │
``` ```
The trigger is a single compact `Button variant="outline" size="sm"` reading The trigger is a single compact `Button variant="outline" size="sm"` reading `Filters` (funnel icon, `lucide-react`'s `SlidersHorizontal`), matching the view-toggle buttons' height (`h-7`) so the header row's height never changes — that's what keeps it from crowding the list below. It shows an active-count badge inline (`Filters · 2`) instead of a separate counter chip when >=1 filter is set, same abbreviation `TaskFilters` already uses for its per-dimension triggers (`"${n} statuses"`).
`Filters` (funnel icon, `lucide-react`'s `SlidersHorizontal`), matching the
view-toggle buttons' height (`h-7`) so the header row's height never changes
— that's what keeps it from crowding the list below. It shows an active-count
badge inline (`Filters · 2`) instead of a separate counter chip when >=1
filter is set, same abbreviation `TaskFilters` already uses for its
per-dimension triggers (`"${n} statuses"`).
Clicking the trigger opens **one** `Popover` (not four separate popovers Clicking the trigger opens **one** `Popover` (not four separate popovers like the full-width `/tasks` page — Panel 1 is too narrow at 4/12 columns for a row of triggers) containing all four dimension controls stacked vertically, each in its own labeled section with a small `Clear` link when that dimension has a selection — directly modeled on each section inside `TaskFilters`' existing per-dimension `PopoverContent` blocks:
like the full-width `/tasks` page — Panel 1 is too narrow at 4/12 columns
for a row of triggers) containing all four dimension controls stacked
vertically, each in its own labeled section with a small `Clear` link when
that dimension has a selection — directly modeled on each section inside
`TaskFilters`' existing per-dimension `PopoverContent` blocks:
``` ```
Expanded (popover open), anchored bottom-right of the trigger: Expanded (popover open), anchored bottom-right of the trigger:
@@ -113,25 +61,13 @@ Expanded (popover open), anchored bottom-right of the trigger:
└─────────────────────────┘ └─────────────────────────┘
``` ```
Filters apply live as each control changes (no separate "Apply" button) — Filters apply live as each control changes (no separate "Apply" button) — consistent with `TaskFilters`, whose `onStatusChange`/`onTeamChange` etc. fire immediately. The popover's max height is capped (`max-h-[70vh] overflow-y-auto`, same idea as `TaskFilters`' `max-h-64 overflow-y-auto` project/product lists) so it never grows taller than the viewport on small screens.
consistent with `TaskFilters`, whose `onStatusChange`/`onTeamChange` etc.
fire immediately. The popover's max height is capped (`max-h-[70vh]
overflow-y-auto`, same idea as `TaskFilters`' `max-h-64 overflow-y-auto`
project/product lists) so it never grows taller than the viewport on small
screens.
On mobile (`<lg`, where Panel 1 is the only visible pane per the page's On mobile (`<lg`, where Panel 1 is the only visible pane per the page's existing `onDetailLevel` show/hide split), the trigger and popover behave identically — the popover already clamps to the viewport, so no separate mobile layout is needed.
existing `onDetailLevel` show/hide split), the trigger and popover behave
identically — the popover already clamps to the viewport, so no separate
mobile layout is needed.
## 3. Active-filter chips + clear-all ## 3. Active-filter chips + clear-all
When one or more filters are active, a **chip row** appears directly below When one or more filters are active, a **chip row** appears directly below the Panel-1 header (above the list/switchboard content), pushing the list down by exactly the chip row's own height — it does not overlay content and it collapses to zero height (not rendered at all) when no filters are set, so the empty/default state is pixel-identical to today's layout:
the Panel-1 header (above the list/switchboard content), pushing the list
down by exactly the chip row's own height — it does not overlay content and
it collapses to zero height (not rendered at all) when no filters are set,
so the empty/default state is pixel-identical to today's layout:
``` ```
┌─────────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐
@@ -142,29 +78,16 @@ so the empty/default state is pixel-identical to today's layout:
│ ...filtered pair cards / conversation list...│ │ ...filtered pair cards / conversation list...│
``` ```
Each chip is a `Badge variant="secondary"` with a trailing `X` Each chip is a `Badge variant="secondary"` with a trailing `X` (`lucide-react`) icon button, exactly `TaskFilters`' existing chip markup (`<Badge variant="secondary" className="gap-1">{label}<X className="h-3 w-3 cursor-pointer hover:text-destructive" onClick={...} /></Badge>`). One chip per active value:
(`lucide-react`) icon button, exactly `TaskFilters`' existing chip markup
(`<Badge variant="secondary" className="gap-1">{label}<X className="h-3 w-3
cursor-pointer hover:text-destructive" onClick={...} /></Badge>`). One chip
per active value:
- **Agent**: one chip per selected agent, labeled with `getAgentDisplayName()`. - **Agent**: one chip per selected agent, labeled with `getAgentDisplayName()`.
- **Task**: one chip for the id-fragment text (`Task: <fragment>`) if set, one - **Task**: one chip for the id-fragment text (`Task: <fragment>`) if set, one chip labeled `No linked task` if that toggle is on.
chip labeled `No linked task` if that toggle is on.
- **Status**: one chip per selected status (`Active` / `Archived`). - **Status**: one chip per selected status (`Active` / `Archived`).
- **Date range**: up to two chips, `From <date>` and `To <date>`, each - **Date range**: up to two chips, `From <date>` and `To <date>`, each independently removable.
independently removable.
Clicking a chip's `X` removes only that value (unchecking the matching Clicking a chip's `X` removes only that value (unchecking the matching control inside the popover, same two-way binding `TaskFilters` uses between its checkbox state and its chip `onClick`). The row wraps (`flex flex-wrap gap-2`) rather than truncating or scrolling horizontally, so every active filter stays visible without an extra interaction.
control inside the popover, same two-way binding `TaskFilters` uses between
its checkbox state and its chip `onClick`). The row wraps (`flex flex-wrap
gap-2`) rather than truncating or scrolling horizontally, so every active
filter stays visible without an extra interaction.
**Clear all** is a `Button variant="ghost" size="sm"` at the end of the chip **Clear all** is a `Button variant="ghost" size="sm"` at the end of the chip row, visible only when >=1 filter is active (same condition that gates the whole chip row and mirrors `TaskFilters`' own "Clear all" button), and it resets every one of the four dimensions in a single click.
row, visible only when >=1 filter is active (same condition that gates the
whole chip row and mirrors `TaskFilters`' own "Clear all" button), and it
resets every one of the four dimensions in a single click.
## 4. Accessibility ## 4. Accessibility
@@ -182,12 +105,7 @@ resets every one of the four dimensions in a single click.
### WCAG AA contrast ### WCAG AA contrast
The control introduces zero new colors — every state below reuses the The control introduces zero new colors — every state below reuses the app's existing shadcn/ui tokens (`panel/src/app/globals.css`), which are already in production use across the panel, so this control carries no new contrast risk. Stated minimums (per WCAG 2.1 AA): **4.5:1** for body/label text, **3:1** for large text (≥18px/14px-bold) and for non-text UI components (focus rings, icon-only button boundaries).
app's existing shadcn/ui tokens (`panel/src/app/globals.css`), which are
already in production use across the panel, so this control carries no new
contrast risk. Stated minimums (per WCAG 2.1 AA): **4.5:1** for body/label
text, **3:1** for large text (≥18px/14px-bold) and for non-text UI
components (focus rings, icon-only button boundaries).
| Element | Tokens | Notes | | Element | Tokens | Notes |
|---|---|---| |---|---|---|
@@ -199,32 +117,10 @@ components (focus rings, icon-only button boundaries).
| Status toggle button (selected) | `--primary-foreground` on `--primary` (light: `oklch(0.985 0 0)` on `oklch(0.205 0 0)`) | Near-white on near-black — the app's own primary-button pair | | Status toggle button (selected) | `--primary-foreground` on `--primary` (light: `oklch(0.985 0 0)` on `oklch(0.205 0 0)`) | Near-white on near-black — the app's own primary-button pair |
| Focus-visible ring (all controls) | `--ring` outline, ≥3:1 against `--background` and `--card` | Radix + Tailwind's default `focus-visible:ring` treatment already applied to `Button`/`Checkbox`/`Input` across the panel | | Focus-visible ring (all controls) | `--ring` outline, ≥3:1 against `--background` and `--card` | Radix + Tailwind's default `focus-visible:ring` treatment already applied to `Button`/`Checkbox`/`Input` across the panel |
Because every pair above is an existing, already-shipped token combination Because every pair above is an existing, already-shipped token combination (not a new color introduced by this spec), no new contrast audit tooling is required — QA can spot-check with devtools' contrast inspector against this table rather than measuring from scratch. Dark mode uses the same token names with their dark-mode values (`globals.css` `.dark` block), which preserve the same relative-lightness relationships (e.g. `--secondary` / `--secondary-foreground` stay a light-text-on-darker-chip pair), so no dark-mode-specific override is needed.
(not a new color introduced by this spec), no new contrast audit tooling is
required — QA can spot-check with devtools' contrast inspector against this
table rather than measuring from scratch. Dark mode uses the same token
names with their dark-mode values (`globals.css` `.dark` block), which
preserve the same relative-lightness relationships (e.g. `--secondary` /
`--secondary-foreground` stay a light-text-on-darker-chip pair), so no
dark-mode-specific override is needed.
## 5. Empty and edge states ## 5. Empty and edge states
- **Zero results after filtering**: the list/switchboard content area shows - **Zero results after filtering**: the list/switchboard content area shows the same empty-state idiom the components already use for "no data at all" (`MessagesSquare`/`Radio` icon + one line of `text-muted-foreground`), but with copy that names the cause: `"No conversations match the current filters"` plus an inline `Clear all` link — distinct from today's `"No A2A conversations yet"` (zero data) and `"No allowed A2A pairs configured"` (zero pairs), so the CEO isn't told there's no data when there's just no match.
the same empty-state idiom the components already use for "no data at all" - **Agent list is empty on first load** (conversations/pairs still loading): the Agent checkbox section shows 2 `Skeleton` rows (same `Skeleton` component the list/switchboard already use for their own loading state) instead of an empty list, so the popover doesn't imply there are zero agents.
(`MessagesSquare`/`Radio` icon + one line of `text-muted-foreground`), but - **Filters persist across live updates**: the page already invalidates and refetches conversations on every `a2a.message` WebSocket frame (`page.tsx` lines 131-140); filter *state* is local component state, not derived from the fetch, so an incoming live message does not reset active filters — it's re-evaluated against the refreshed list.
with copy that names the cause: `"No conversations match the current
filters"` plus an inline `Clear all` link — distinct from today's `"No A2A
conversations yet"` (zero data) and `"No allowed A2A pairs configured"`
(zero pairs), so the CEO isn't told there's no data when there's just no
match.
- **Agent list is empty on first load** (conversations/pairs still loading):
the Agent checkbox section shows 2 `Skeleton` rows (same `Skeleton`
component the list/switchboard already use for their own loading state)
instead of an empty list, so the popover doesn't imply there are zero
agents.
- **Filters persist across live updates**: the page already invalidates and
refetches conversations on every `a2a.message` WebSocket frame
(`page.tsx` lines 131-140); filter *state* is local component state, not
derived from the fetch, so an incoming live message does not reset active
filters — it's re-evaluated against the refreshed list.
+2 -2
View File
@@ -32,7 +32,7 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
pnpm install pnpm install
``` ```
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic. This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
3. **Run the quality gate:** 3. **Run the quality gate:**
@@ -44,7 +44,7 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
pnpm build pnpm build
``` ```
All must pass with no errors before committing. All must pass with no errors before committing.
4. **Commit the changes:** 4. **Commit the changes:**
```bash ```bash
+312 -85
View File
@@ -1,19 +1,38 @@
{ {
"claim_rules": { "claim_rules": {
"auditor": [], "auditor": [],
"cell_pm": ["needs_revision", "pending"], "cell_pm": [
"needs_revision",
"pending"
],
"ceo": [], "ceo": [],
"developer": ["needs_revision", "pending"], "developer": [
"documenter": ["awaiting_documentation", "pending"], "needs_revision",
"pending"
],
"documenter": [
"awaiting_documentation",
"pending"
],
"head_marketing": [], "head_marketing": [],
"main_pm": ["needs_revision", "pending"], "main_pm": [
"pr_reviewer": ["awaiting_pr_review", "pending"], "needs_revision",
"pending"
],
"pr_reviewer": [
"awaiting_pr_review",
"pending"
],
"product_owner": [], "product_owner": [],
"qa": ["awaiting_qa"] "qa": [
"awaiting_qa"
]
}, },
"intents": [ "intents": [
{ {
"allowed_roles": ["documenter"], "allowed_roles": [
"documenter"
],
"composes": [], "composes": [],
"description": "Claim awaiting_documentation. Returns evidence inline.", "description": "Claim awaiting_documentation. Returns evidence inline.",
"name": "claim_doc_task", "name": "claim_doc_task",
@@ -21,7 +40,9 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["pr_reviewer"], "allowed_roles": [
"pr_reviewer"
],
"composes": [], "composes": [],
"description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it \u2014 mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.", "description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it \u2014 mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.",
"name": "claim_gate_review", "name": "claim_gate_review",
@@ -29,15 +50,22 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["pr_reviewer"], "allowed_roles": [
"composes": ["claim", "start"], "pr_reviewer"
],
"composes": [
"claim",
"start"
],
"description": "Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress.", "description": "Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress.",
"name": "claim_pr_review", "name": "claim_pr_review",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["qa"], "allowed_roles": [
"qa"
],
"composes": [], "composes": [],
"description": "Claim a task in awaiting_qa for review. Returns evidence inline.", "description": "Claim a task in awaiting_qa for review. Returns evidence inline.",
"name": "claim_review", "name": "claim_review",
@@ -45,15 +73,23 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"composes": ["complete"], "cell_pm",
"main_pm"
],
"composes": [
"complete"
],
"description": "Cell PM merges the PR (leaf into the cell branch, or the gated cell\u2192root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root\u2192master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering \u2014 no trailing pr_merge side_effect is declared here.", "description": "Cell PM merges the PR (leaf into the cell branch, or the gated cell\u2192root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root\u2192master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering \u2014 no trailing pr_merge side_effect is declared here.",
"name": "complete", "name": "complete",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [], "composes": [],
"description": "Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact \u2014 for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them \u2014 never push these into a cell). No status change; the verb body owns ownership + criterion validation.", "description": "Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact \u2014 for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them \u2014 never push these into a cell). No status change; the verb body owns ownership + criterion validation.",
"name": "declare_coverage", "name": "declare_coverage",
@@ -61,23 +97,37 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"composes": ["create_subtask"], "cell_pm",
"main_pm"
],
"composes": [
"create_subtask"
],
"description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable \u2014 the lifecycle auto-creates the doc phase after the code subtask passes QA.", "description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable \u2014 the lifecycle auto-creates the doc phase after the code subtask passes QA.",
"name": "delegate", "name": "delegate",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["head_marketing", "main_pm", "product_owner"], "allowed_roles": [
"composes": ["escalate_to_ceo"], "head_marketing",
"main_pm",
"product_owner"
],
"composes": [
"escalate_to_ceo"
],
"description": "Escalate to CEO with reason. Transitions to awaiting_ceo_approval.", "description": "Escalate to CEO with reason. Transitions to awaiting_ceo_approval.",
"name": "escalate_to_ceo", "name": "escalate_to_ceo",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [], "composes": [],
"description": "Escalate to your role's escalation_target.", "description": "Escalate to your role's escalation_target.",
"name": "escalate_up", "name": "escalate_up",
@@ -85,8 +135,12 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["qa"], "allowed_roles": [
"composes": ["qa_fail"], "qa"
],
"composes": [
"qa_fail"
],
"description": "Fail QA with concrete issues. Transitions to needs_revision.", "description": "Fail QA with concrete issues. Transitions to needs_revision.",
"name": "fail_review", "name": "fail_review",
"pre_side_effects": [], "pre_side_effects": [],
@@ -108,16 +162,27 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["developer", "documenter", "qa"], "allowed_roles": [
"composes": ["block"], "developer",
"documenter",
"qa"
],
"composes": [
"block"
],
"description": "Escalate to PM. Logs a struggle journal entry.", "description": "Escalate to PM. Logs a struggle journal entry.",
"name": "i_am_blocked", "name": "i_am_blocked",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["developer"], "allowed_roles": [
"composes": ["submit_verification", "submit_qa"], "developer"
],
"composes": [
"submit_verification",
"submit_qa"
],
"description": "Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit.", "description": "Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit.",
"name": "i_am_done", "name": "i_am_done",
"pre_side_effects": [], "pre_side_effects": [],
@@ -144,71 +209,111 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["documenter"], "allowed_roles": [
"composes": ["docs_complete"], "documenter"
],
"composes": [
"docs_complete"
],
"description": "Signal docs complete. Transitions to awaiting_pm_review.", "description": "Signal docs complete. Transitions to awaiting_pm_review.",
"name": "i_documented", "name": "i_documented",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"composes": ["claim", "set_plan", "start"], "cell_pm",
"main_pm"
],
"composes": [
"claim",
"set_plan",
"start"
],
"description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.", "description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.",
"name": "i_will_plan", "name": "i_will_plan",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["developer"], "allowed_roles": [
"composes": ["claim", "set_plan", "start"], "developer"
],
"composes": [
"claim",
"set_plan",
"start"
],
"description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.", "description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.",
"name": "i_will_work_on", "name": "i_will_work_on",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["developer"], "allowed_roles": [
"developer"
],
"composes": [], "composes": [],
"description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.", "description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.",
"name": "open_pr", "name": "open_pr",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": ["push_branch", "create_pr"] "side_effects": [
"push_branch",
"create_pr"
]
}, },
{ {
"allowed_roles": ["qa"], "allowed_roles": [
"composes": ["qa_pass"], "qa"
],
"composes": [
"qa_pass"
],
"description": "Pass QA. Transitions awaiting_qa -> awaiting_documentation.", "description": "Pass QA. Transitions awaiting_qa -> awaiting_documentation.",
"name": "pass_review", "name": "pass_review",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["pr_reviewer"], "allowed_roles": [
"composes": ["pr_review_done"], "pr_reviewer"
],
"composes": [
"pr_review_done"
],
"description": "Post one complete change-request to the external PR and finish the review task. in_progress -> completed.", "description": "Post one complete change-request to the external PR and finish the review task. in_progress -> completed.",
"name": "post_pr_review", "name": "post_pr_review",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["pr_reviewer"], "allowed_roles": [
"composes": ["pr_fail"], "pr_reviewer"
],
"composes": [
"pr_fail"
],
"description": "Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_review -> needs_revision, routed back like a QA fail.", "description": "Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_review -> needs_revision, routed back like a QA fail.",
"name": "pr_fail", "name": "pr_fail",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["pr_reviewer"], "allowed_roles": [
"composes": ["pr_pass"], "pr_reviewer"
],
"composes": [
"pr_pass"
],
"description": "Pass the assembled-PR review. Transitions awaiting_pr_review -> awaiting_pm_review so the PM can merge.", "description": "Pass the assembled-PR review. Transitions awaiting_pr_review -> awaiting_pm_review so the PM can merge.",
"name": "pr_pass", "name": "pr_pass",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm"], "allowed_roles": [
"cell_pm"
],
"composes": [], "composes": [],
"description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved \u2014 the new developer continues the work-in-progress. No status change.", "description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved \u2014 the new developer continues the work-in-progress. No status change.",
"name": "reassign", "name": "reassign",
@@ -216,39 +321,66 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"composes": ["request_changes"], "cell_pm",
"main_pm"
],
"composes": [
"request_changes"
],
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.", "description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.",
"name": "request_changes", "name": "request_changes",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "developer", "documenter", "main_pm", "qa"], "allowed_roles": [
"composes": ["resume"], "cell_pm",
"developer",
"documenter",
"main_pm",
"qa"
],
"composes": [
"resume"
],
"description": "Resume a paused task you own. paused -> in_progress.", "description": "Resume a paused task you own. paused -> in_progress.",
"name": "resume", "name": "resume",
"pre_side_effects": [], "pre_side_effects": [],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["main_pm"], "allowed_roles": [
"composes": ["submit_for_review"], "main_pm"
],
"composes": [
"submit_for_review"
],
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.", "description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.",
"name": "submit_root", "name": "submit_root",
"pre_side_effects": ["create_root_pr"], "pre_side_effects": [
"create_root_pr"
],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm"], "allowed_roles": [
"composes": ["submit_for_review"], "cell_pm"
],
"composes": [
"submit_for_review"
],
"description": "Cell PM opens the cell\u2192root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.", "description": "Cell PM opens the cell\u2192root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.",
"name": "submit_up", "name": "submit_up",
"pre_side_effects": ["create_pr"], "pre_side_effects": [
"create_pr"
],
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["developer"], "allowed_roles": [
"developer"
],
"composes": [], "composes": [],
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.", "description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.",
"name": "sync_branch", "name": "sync_branch",
@@ -270,7 +402,9 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["main_pm"], "allowed_roles": [
"main_pm"
],
"composes": [], "composes": [],
"description": "List actionable tasks across all teams (Main PM only).", "description": "List actionable tasks across all teams (Main PM only).",
"name": "triage_all", "name": "triage_all",
@@ -278,8 +412,13 @@
"side_effects": [] "side_effects": []
}, },
{ {
"allowed_roles": ["cell_pm", "main_pm"], "allowed_roles": [
"composes": ["unblock"], "cell_pm",
"main_pm"
],
"composes": [
"unblock"
],
"description": "PM unblocks a blocked task; restores pre-block state.", "description": "PM unblocks a blocked task; restores pre-block state.",
"name": "unblock", "name": "unblock",
"pre_side_effects": [], "pre_side_effects": [],
@@ -304,121 +443,175 @@
"transitions": [ "transitions": [
{ {
"action": "cancel", "action": "cancel",
"roles": ["ceo"], "roles": [
"ceo"
],
"source": "awaiting_ceo_approval", "source": "awaiting_ceo_approval",
"target": "cancelled" "target": "cancelled"
}, },
{ {
"action": "ceo_approve", "action": "ceo_approve",
"roles": ["ceo"], "roles": [
"ceo"
],
"source": "awaiting_ceo_approval", "source": "awaiting_ceo_approval",
"target": "completed" "target": "completed"
}, },
{ {
"action": "ceo_reject", "action": "ceo_reject",
"roles": ["ceo"], "roles": [
"ceo"
],
"source": "awaiting_ceo_approval", "source": "awaiting_ceo_approval",
"target": "needs_revision" "target": "needs_revision"
}, },
{ {
"action": "ceo_reject_to_pool", "action": "ceo_reject_to_pool",
"roles": ["ceo"], "roles": [
"ceo"
],
"source": "awaiting_ceo_approval", "source": "awaiting_ceo_approval",
"target": "pending" "target": "pending"
}, },
{ {
"action": "docs_complete", "action": "docs_complete",
"roles": ["documenter"], "roles": [
"documenter"
],
"source": "awaiting_documentation", "source": "awaiting_documentation",
"target": "awaiting_pm_review" "target": "awaiting_pm_review"
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "awaiting_documentation", "source": "awaiting_documentation",
"target": "cancelled" "target": "cancelled"
}, },
{ {
"action": "claim", "action": "claim",
"roles": ["documenter"], "roles": [
"documenter"
],
"source": "awaiting_documentation", "source": "awaiting_documentation",
"target": "claimed" "target": "claimed"
}, },
{ {
"action": "escalate_to_ceo", "action": "escalate_to_ceo",
"roles": ["head_marketing", "main_pm", "product_owner"], "roles": [
"head_marketing",
"main_pm",
"product_owner"
],
"source": "awaiting_pm_review", "source": "awaiting_pm_review",
"target": "awaiting_ceo_approval" "target": "awaiting_ceo_approval"
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "awaiting_pm_review", "source": "awaiting_pm_review",
"target": "cancelled" "target": "cancelled"
}, },
{ {
"action": "complete", "action": "complete",
"roles": ["cell_pm", "main_pm"], "roles": [
"cell_pm",
"main_pm"
],
"source": "awaiting_pm_review", "source": "awaiting_pm_review",
"target": "completed" "target": "completed"
}, },
{ {
"action": "request_changes", "action": "request_changes",
"roles": ["cell_pm", "main_pm"], "roles": [
"cell_pm",
"main_pm"
],
"source": "awaiting_pm_review", "source": "awaiting_pm_review",
"target": "needs_revision" "target": "needs_revision"
}, },
{ {
"action": "pr_pass", "action": "pr_pass",
"roles": ["pr_reviewer"], "roles": [
"pr_reviewer"
],
"source": "awaiting_pr_review", "source": "awaiting_pr_review",
"target": "awaiting_pm_review" "target": "awaiting_pm_review"
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "awaiting_pr_review", "source": "awaiting_pr_review",
"target": "cancelled" "target": "cancelled"
}, },
{ {
"action": "claim", "action": "claim",
"roles": ["pr_reviewer"], "roles": [
"pr_reviewer"
],
"source": "awaiting_pr_review", "source": "awaiting_pr_review",
"target": "claimed" "target": "claimed"
}, },
{ {
"action": "pr_fail", "action": "pr_fail",
"roles": ["pr_reviewer"], "roles": [
"pr_reviewer"
],
"source": "awaiting_pr_review", "source": "awaiting_pr_review",
"target": "needs_revision" "target": "needs_revision"
}, },
{ {
"action": "qa_pass", "action": "qa_pass",
"roles": ["qa"], "roles": [
"qa"
],
"source": "awaiting_qa", "source": "awaiting_qa",
"target": "awaiting_documentation" "target": "awaiting_documentation"
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "awaiting_qa", "source": "awaiting_qa",
"target": "cancelled" "target": "cancelled"
}, },
{ {
"action": "claim", "action": "claim",
"roles": ["qa"], "roles": [
"qa"
],
"source": "awaiting_qa", "source": "awaiting_qa",
"target": "claimed" "target": "claimed"
}, },
{ {
"action": "qa_fail", "action": "qa_fail",
"roles": ["qa"], "roles": [
"qa"
],
"source": "awaiting_qa", "source": "awaiting_qa",
"target": "needs_revision" "target": "needs_revision"
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "backlog", "source": "backlog",
"target": "cancelled" "target": "cancelled"
}, },
@@ -430,13 +623,21 @@
}, },
{ {
"action": "escalate_to_ceo", "action": "escalate_to_ceo",
"roles": ["head_marketing", "main_pm", "product_owner"], "roles": [
"head_marketing",
"main_pm",
"product_owner"
],
"source": "blocked", "source": "blocked",
"target": "awaiting_ceo_approval" "target": "awaiting_ceo_approval"
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "blocked", "source": "blocked",
"target": "cancelled" "target": "cancelled"
}, },
@@ -454,7 +655,11 @@
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "claimed", "source": "claimed",
"target": "cancelled" "target": "cancelled"
}, },
@@ -484,13 +689,19 @@
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "in_progress", "source": "in_progress",
"target": "cancelled" "target": "cancelled"
}, },
{ {
"action": "pr_review_done", "action": "pr_review_done",
"roles": ["pr_reviewer"], "roles": [
"pr_reviewer"
],
"source": "in_progress", "source": "in_progress",
"target": "completed" "target": "completed"
}, },
@@ -508,7 +719,11 @@
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "needs_revision", "source": "needs_revision",
"target": "cancelled" "target": "cancelled"
}, },
@@ -520,7 +735,11 @@
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "paused", "source": "paused",
"target": "cancelled" "target": "cancelled"
}, },
@@ -532,7 +751,11 @@
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "pending", "source": "pending",
"target": "cancelled" "target": "cancelled"
}, },
@@ -550,7 +773,11 @@
}, },
{ {
"action": "cancel", "action": "cancel",
"roles": ["cell_pm", "ceo", "main_pm"], "roles": [
"cell_pm",
"ceo",
"main_pm"
],
"source": "verifying", "source": "verifying",
"target": "cancelled" "target": "cancelled"
} }