CLAUDE.md@src/grove/core/issueops · git:20260803.49581e6 · 2026-08-03 · sha256 0d7fd70c5647c4bc

CLAUDE.md@src/grove/core/issueops git:20260803.49581e6A

Immutable. This exact content is served forever at /api/v1/blob/0d7fd70c5647c4bc.

# grove.core.issueops — turn issue-comment events into workspace actions

> ↑ [grove.core](../CLAUDE.md) · [root](../../../../CLAUDE.md)

One concern with three faces: a forwarded, normalized issue-comment event in, a workspace action out; the workspace's progress mirrored back onto the ticket as a live sticky comment; and the tracker's own **assignee field as the fleet's work queue**, in both directions. The engine is pure policy over the *existing* lifecycle seams and adds **zero new lifecycle logic** — steering is `manager.send_message` verbatim, creation is the ticket-aware `manager.create` path. Wire shapes live in [contracts/issueops.py](../contracts/CLAUDE.md); the `IssueOpsConfig` submodel cascades with the rest of config in [config.py](../CLAUDE.md#config-cascade); comment I/O belongs to the provider layer in [tickets/](../tickets/CLAUDE.md).

## The atoms (no free helpers)

- **`CommandParser` (parser.py) is pure and takes the trigger as an argument, never a config object** — so it stays a table-testable unit and the engine owns config resolution. Grammar decisions worth not re-litigating: the trigger must be the FIRST token and word-boundary matched (`@grovebot` and a mid-comment mention never fire); a non-verb first word is `prompt` free-text, **never a mistyped verb to second-guess**; a bare trigger or a verb with trailing junk is `usage`; leading `:`/`,` after the trigger is stripped.
- **`IssueOpsEngine` (engine.py) is the routing + gate policy; all I/O is at the edges.** `handle(event)` runs a terminal-drop pipeline in one fixed order: **dedupe → bot → marker → resolve-repo → parse → permission → route**. The early-return count IS the pipeline (PLR0911 silenced) — collapsing the gates behind a flag reads worse.
- **`marker.py` holds TWO constants shared by both faces, split so recovery and the anti-loop guard can't collide.** `SIGNATURE_MARKER` rides *every* Grove comment — engine replies AND the sticky comment — and the engine drops any inbound comment carrying it, so the bot never answers itself. `STICKY_MARKER` (`<!-- grove:issue-ops:status -->`) rides *only* the sticky comment, and the publisher's cold-start recovery scans for **that** one: a signature scan would adopt an old refusal/usage reply and render status over it. Both live in this tiny module because both faces import them and *neither may depend on the other* — the engine must not import render logic, the publisher must not import the router. HTML comments, invisible in rendered markdown, substring-matched.

## The inbound contract (converged defaults, and the forge floors)

Binding on anything added to this package; distilled from a prior-art sweep across the comparable products plus Gitea source verification.

- **Inbound defaults worth copying rather than reinventing:** gate on the commenter's live repo write access with a separately-named opt-in widening knob; ignore bot actors and anything carrying our own comment signature; listen only to comment *created* events (**an `edited` trigger is a silent re-fire hole**); reactions (👀 receipt, 🚀/👎 outcome) are the universal cheap ack; a bad command gets a reply, never silence. **A bare `/` prefix collides with GitHub's native markdown slash commands** — use a mention-style token, configurable as data never code.
- **The CI forwarder must stay a stateless dumb pipe.** No prior art routes a follow-up comment into a running task — everyone else re-runs fresh per comment. The new-vs-steer decision belongs to the daemon (it owns workspace-by-ticket state); the workflow forwards every trigger comment identically. Live steering plus a session-lifetime todo comment are Grove differentiators precisely because everyone else's executor dies with the CI job.
- **Gitea Actions capability floors:** `issue_comment` fires for PR comments only on 1.21.6+; issue-event workflows always load from the DEFAULT branch (GitHub parity); `permissions:` blocks are parsed-but-ignored until 1.26.0, where Restricted-mode instances 403 an undeclared `issues: write`; private cross-repo `workflow_call` needs 1.26.0 collaborative owners — sidestepped by shipping logic as a composite action referenced via Gitea's absolute-URL `uses:` extension; `.gitea/workflows/` wins precedence over `.github/workflows/` (both scanned); fork-PR runs clamp the token to read-only; the payload is GitHub-schema-compatible but not byte-identical (`issue.pull_request` presence is the PR-vs-issue discriminator — parse defensively).
- **Runner topology is the sharp constraint, not code:** a default docker act_runner job runs on an isolated bridge and cannot reach host loopback, and **`host.docker.internal` does not resolve on Linux**, so it is not a route back either. The supported shape is a dedicated `:host`-labeled runner on the daemon host (GitHub: a same-host self-hosted runner) — which is exactly what keeps the daemon's loopback-bind invariant untouched.

## Load-bearing decisions (engine)

- **`IssueOpsConfig` lives in `config.py`, NOT `issueops/config.py`.** Putting it under `issueops/` forces `config.py` to `import grove.core.issueops.config`, which runs `issueops/__init__.py` → engine → `manager` + `config` mid-import → **cycle**. So the submodel sits with its siblings and this package holds engine/parser/marker/publisher only.
- **Repo resolution is always repo+ticket, never a fleet-wide ticket scan** — a bare ticket id is ambiguous across repos. `_resolve_manager` matches the event's `owner`/`repo` against each known root's `tickets.<provider>` config, then `find_by_ticket` runs on THAT manager only. The match is **generic over the provider**: `event.provider` is exactly a `TicketsConfig` field name, so one `getattr` reaches the right submodel with no per-provider branching (Linear has no owner/repo, so it resolves nothing — issue-ops targets the numeric forges). The resolved repo's own cascade then supplies the trigger, permission policy, agent and prompt template, so every knob cascades per-repo.
- **The engine is injected the `RepoRegistry` and reads `mgr.config.issueops` per event — no separate config injection.** One daemon-wide engine serves every repo; per-repo policy rides the registry's cascade. Tests duck-type a fake registry → fake manager (holding a REAL `GroveConfig`) + capturing fake provider; `make lint` only mypy-checks `src`, so a structural test fake needs no ABC.
- **Permission default is strict; the allowlist only ever WIDENS.** `_permitted` = forwarder-asserted write-or-above (`{write,admin,maintain,owner}`, case-insensitive) OR an `issueops.allowed_actors` entry. It never narrows what write access already grants.
- **Routing "running" means a LIVE session, and the paused case is refused, not auto-resumed.** Free-text prompt: hit + live → `send_message`; hit + not-live → refuse `workspace_not_running` with a reply telling the user to `resume` first (resume is an explicit verb — no compound lifecycle op here); miss → `create`.
- **Every ack/reply write is best-effort and swallowed (and TESTED).** `_reply` catches `GroveError` (covering the provider-not-configured / provider-error / comments-unsupported family) and logs; a failed reply never re-raises into routing. Same for the `status` seam. The **reactions** are the CI action's job — it holds the forge event token — so the engine only names the outcome.
- **The outcome vocab is a closed literal the CI action maps to a reaction:** terminal verbs carry no code, while `refused` and `ignored` each carry one naming the gate that fired. `workspace_id` is set whenever a concrete workspace was touched.
- **Dedupe is a bounded, thread-safe LRU on `(provider, owner, repo, comment_id)`.** CI retries deliver at-least-once, and the daemon dispatches `handle` into an executor, so `_RecentKeys.check_and_add` is atomic under a `threading.Lock` — concurrent threads must not both treat one comment as fresh. **Mark-first** (record at the top of the pipeline) so a retried comment can't double-act even when the first pass dropped it as bot/marker.
- **`StatusPublisher` seam:** a `Protocol` with `publish(event, manager)` and a no-op default, invoked best-effort only on the `status` verb. The real publisher is injected at daemon wiring ([daemon](../../daemon/CLAUDE.md) owns the route and the lifespan); it must not import the engine.

## The status publisher (`publisher.py`)

`TicketStatusPublisher` is the activity bus's **third subscriber** (alongside `_SseHub` and `NotificationBroker`) — a live sticky comment mirroring a workspace's blended state + todo checklist + branch/commit onto every ticket it names (the issue and the PR that resolves it).

- **It rides the activity bus but deliberately NOT the notification broker — wrong granularity, right discipline.** The broker is an edge-triggered, *debounced* push for human attention; the publisher needs the *opposite* — the continuous WORKING-state todo churn that never crosses an attention edge. So it binds the same bus and copies the broker's *mechanics* wholesale (pure decision / I-O dispatch split, single-worker `ThreadPoolExecutor`, best-effort per-delivery isolation) and never its edge/debounce *policy*. See [notifications](../notifications/CLAUDE.md).
- **Coalescing, not debouncing — the load-bearing difference.** The broker drops flapping edges; the publisher *folds* every render-relevant change into per-workspace state and flushes the merged result **at most once per window** (`update_window_seconds`, default 5 s), because forges apply secondary rate limits to same-comment PATCH storms. `dirty_since` is stamped on the clean→dirty edge and **not** refreshed while dirty, so the window measures from the *first* unflushed change and staleness is bounded to one window rather than deferred indefinitely by a busy workspace.
- **`observe` (fold, poll thread) / `flush_pending` (clock-driven) / `dispatch` (I/O) is the whole design, and it is what makes it testable with no threads.** `observe` never blocks the poll. `flush_pending(now)` is the seam the coalescing test drives with a fake clock; in production a single self-arming `threading.Timer` calls it. **The timer only arms while bound** (`_scheduling`): unbound — every test — runs each step inline on the caller's thread, so no real timer fires mid-test.
- **The whole body is rebuilt from live state on every edit — never a diff-patch of rendered markdown, and never sharing a field with human-authored task text.** The forge holds the last render; we replace it wholesale. A `terminal` snapshot swaps the live status + checklist for a final summary and the workspace latches `done` — no further publishes.
- **"Terminal" is one axis of mechanism, no policy baked in.** The always-on terminal is the lifecycle `killed` delta (the workspace is gone — render the finale from the last cached row, since the store record is already deleted). A second, **caller-supplied** `terminal_states` frozenset (default *empty*) lets a deployment mark an agent state terminal without the code deciding that ERROR means done.
- **Render-relevance is a custom fingerprint, NOT the poll's — and that distinction is what makes a member's ABSENCE a bug rather than an optimization.** Gating dirty on `_render_fingerprint` (state/current_task/tool_calls/replies/branch/latest-commit-sha/**phase+note**, **excluding** diff/dirty counts) stops a pure `git add` from scheduling a redundant PATCH. The poll's own fingerprint feeds the SSE stream; this one ALONE decides whether a forge PATCH happens. **`phase` is the member whose absence is invisible:** every other member is a by-product of the agent *working*, so a phase set from the CLI, MCP or HTTP moves none of them and the comment silently never updates — masked while an agent is mid-run, breaking only in the two quiet cases the axis exists for (*reported, then stopped*, and *a human corrected it from outside*). Only `(phase, note)` is folded, never the whole `PhaseReport`: `updated_at` is the phase file's mtime, so an agent rewriting an identical phase would move the key and buy a redundant PATCH. **`tool_calls`/replies stand in for todo progress** — a todo can only advance by the agent calling its own todo tool, which moves `tool_calls` in the same tick, so the counts are omitted as *redundant*, not unavailable. **When adding anything to the rendered body, ask what already moves with it; if the honest answer is "nothing", it belongs in this key.**
- **One rendered field CANNOT join that key, and it is stated here rather than left silent: the enriched ticket status.** The fingerprint is computed on the poll thread purely over the row; a ticket's live status is a forge GET, and putting network I/O behind a per-tick key is exactly what the observe/dispatch split exists to prevent. So a pull request merging while nothing else moves does not itself schedule a PATCH — it rides the next flush any other change causes, or `@grove status` immediately. Contrast `phase`, whose absence from the key was *invisible*; this omission is bounded, named, and has a remedy a human can reach for.
- **Todo, phase and the ticket refs are all resolved at DISPATCH time, never observed on the poll thread**, through injected `TodoResolver` / `PhaseResolver` callables over `WorkspaceManager.latest_todo` / `.phase` plus `_enrich` over the provider, best-effort (`None` or the ref untouched on a miss, e.g. a killed workspace whose dir is gone). **A `None` phase renders as nothing** — no placeholder line, no empty section — matching the "absence is not a state" rule the todo field follows. `_phase_caption` renders a dot-progress bar (`●●●○○○ Verifying · 4 of 6`) rather than the state glyph+label pair, because a phase is a *position* on an ordered axis, not a state to badge; dots read at ticket-list scan depth in every forge's markdown with no HTML. It is the summary table's **Phase row**, so the row label supplies the word and the caption supplies dots, name and position — **the agent's note is deliberately not in it**: a note runs to 200 characters, a table cell that long wrecks the column, and under the diagram it explains there is room for prose. Phase renders in BOTH the live and terminal bodies, the terminal keeping the last claimed phase as context for how far the agent believed it got.
- **Refs are persisted BARE by design, and nothing was re-resolving them — so a render branch gated on an optional field NOTHING in the system populates read as a feature and was dead code.** `attach_ticket` stores provider + id + kind only (display enrichment is meant to be an on-demand fetch, never stale persisted state), so the tracking block's link branch never fired on any normal path and the `status` suffix could not render **at all**. `_enrich` now resolves each ref at dispatch alongside todo and phase, best-effort. **The general lesson is the review one: an optional field with no producer is invisible in a diff and in a green suite — ask who writes it before believing a branch that reads it.** (What enrichment now BUYS is the entry's `title`; the tracking block renders neither the url nor the status — see below. A PR still reads from `get_pull_request` rather than the issues endpoint, which calls a merged PR `closed`: no longer load-bearing, kept because it is the same one GET and the honest source.)
- **Enrichment is TTL-memoized ACROSS workspaces, keyed by ticket rather than by workspace, and the argument is the RATE, not correctness.** A flush is per-window (~5 s) and a forge budget is per-hour, so an unmemoized read spends thousands of calls an hour *per workspace* on a title that never moves and a state that changes on a human timescale; two workspaces naming one issue share the read. Same reader-side memo shape as `ContainerLiveness`, swept on every miss so a long-lived daemon holds no entry per ticket it has ever seen.
- **`phase == "done"` deliberately does NOT touch the publisher's `done` latch, and the reason is structural.** The latch answers "has the WORKSPACE's own lifecycle ended" — a fact Grove observes. A reported phase is the opposite: an unverified CLAIM, and `grove.core.phase` never enforces monotonicity, so an agent can report `done` and legitimately regress to `planning`. Latching on it would let an agent's own claim silence its sticky comment while it keeps working. Enforced by construction, not by a check: phase is resolved in `dispatch` and never reaches `observe`/`_record`/`_is_terminal_state` at all.
- **All forge writes go through the `TicketProvider` comment I/O (`list_comments`/`post_comment`/`edit_comment`, no direct `httpx`), and every call is swallowed at THIS call site** — logged and dropped, never re-raised into the activity poll path (tested). On a publish failure the failing target's sticky id is *forgotten* so the next flush re-scans that thread, which also recreates a comment a human deleted.
- **Sticky identity is an in-memory id plus `STICKY_MARKER` cold-start recovery, and the id is per TARGET.** It needs no persistence because the *forge thread itself* holds the recovery key: with no id, scan `list_comments` for the sticky marker and adopt that comment before posting a fresh one, so a daemon restart re-adopts rather than duplicates. `_WsPub.comment_ids` is keyed by `(provider NAME, ticket id)` — one scalar id shared by two targets has them editing each other's comments on alternate flushes, and keying on the provider *object* would silently lose the id whenever a repo's registry is rebuilt and post a duplicate.
- **Routing is MULTI-TARGET, and eligibility is `provider.can_comment` — never mere resolution.** `_route` returns EVERY ref whose provider can actually carry a comment (typically the issue *and* the PR that resolves it), deduped by target key so an attach on top of an identical branch-parsed ref can't post twice into one thread. A first-match rule carries a real bug this closes: the registry only refuses a provider it considers *disabled*, so an enabled-but-tokenless Gitea — or Linear, which backs no comment I/O at all — resolves fine, is taken as *the* target, and **permanently shadows every later ref**, leaving the workspace mirroring onto nothing, silently and forever. **A PR target needs zero new API code:** both numeric forges build `/repos/{o}/{r}/issues/{id}/comments` and interpolate the id raw (verified live against both), so a PR number is an ordinary ticket id and this is routing + identity, never I/O.
- **One body goes to every target, and `TicketRef.kind` reaches the render ONLY through the whole ref list.** A per-target body could only change a header word — state, phase, checklist, commit and deep link answer for both readers. The one genuinely per-target line a reader wants is a *cross-link* to the other target, which needs the sibling's identity a per-target `kind` cannot supply: the **Tracking** block carries the whole `ticket_refs` list with `kind` spelling each entry's noun, so one identical body gives the issue reader the PR and the PR reader the issue for free. Each entry is one prose line ENDING in its reference, so the eye lands on the click target; issues sort ahead of pull requests (stable, so the engine's order survives within a kind) because that is the order the work happened in.
- **The tracking entry renders neither `kind` nor `status`, and the status half is a signal deliberately GIVEN UP — not delegated to the forge.** The tempting justification is false and was measured false: an open issue, a closed issue and a merged pull request all render with the identical `ref-issue` class and colour, no strikethrough, no tooltip, server-side *or* client-side. So dropping the column drops that signal for every reader, including a human in a browser. **The trade actually taken:** this comment's job is to say where the WORKSPACE is; a ticket state rendered here is only ever as fresh as the last flush, while the ticket's own page is one click away and never stale. This supersedes the earlier rule that `status` must pass through verbatim so a merged PR reads `merged` — that rule was about not CORRUPTING the value while rendering it, and the resolution now is not to render it. `kind` goes for a cheaper reason: a title says what an entry is, and both kinds reach the same place.
- **A reference is written BARE (`#42`), never as `[#42](url)`, and that is a mechanism rather than a saving.** A forge turns a bare `#N` into a live reference to its own thread; a markdown link renders as ordinary link text. Verified on this Gitea through `/api/v1/markdown` in `comment` mode, INCLUDING inside a `<details>` body and inside a table cell — which matters because the enriched `url` sits right there on the ref and reads as an unused field to anyone tidying.
- **The branch row names its repository in a trailing bracket** (`` [`main`](…) (owner/repo) ``), from the provider's own `context`, falling back to the repo directory's name. The link already encodes it and only a hovering reader sees it — and this comment is read on a *tracker*, the one place where several projects' branches all called `main` land in front of one person.
- **The Agent row names the profile and the kind, and NEVER the model.** A model id dates the comment and invites conclusions it cannot support; the profile renders only where it differs from the kind's label. It is deliberately absent from `_render_fingerprint`, and the reasoning that licenses that is *not* "it never changes" — a constant is exactly what silently fails to appear when the first render predates it. It is safe because `create()` persists both before any activity row exists. **Ask "is it there at the first render", never "does it move".**

### The finale outlives its workspace, so it must not point at one

- **The workspace deep link is the LIVE body's alone.** `kill` deletes the store record (the only `_store.delete` on the verb path), so `/w/<id>` resolves to nothing the moment the finale is written — and the finale is the render a reader meets months later, which is when a dead link costs the most. `pause` is NOT this case and deliberately changes nothing: it keeps the record, so the link still resolves and no terminal render fires.
- **The finale points at the TRANSCRIPT instead, and only where a reader could actually open it.** `SessionLink.url` is built solely when an injected `TranscriptProbe` says the session resolves; otherwise the id renders as plain text plus *"not reachable from here"*, because "the transcript exists but this deployment cannot serve it" is a fact a reader acts on where a bare id is noise. The probe is **injected by the daemon from the same memoized catalog `/sessions/{id}/turns` resolves against**, so "we linked it" and "a reader can open it" cannot drift; `core` never learns what a catalog is.
- **Bytes surviving and a transcript being REACHABLE are different facts, and the gap is exactly one runtime.** Measured on the reference host: 148 of 590 catalog rows are sessions whose worktree no longer exists — killed HOST workspaces, still discoverable, because the transcript lives under the user's own config dir. A CONTAINER workspace writes under `agent_workspace_config_dir(<ws id>)`, which `kill` deliberately preserves but which no scan walks (`discover_all` reads the ambient config-dir cascade only) — 0 of 6 such transcripts were in the catalog. **Making them discoverable is not a render change**: the host had 8,136 of those directories, and walking them would break the catalog's own "bounded head reads, no index" cost guarantee. **A second, independent reason means even a widened scan would not be enough:** the session surface resolves by the cwd the transcript RECORDED, matched as an exact string (a trailing slash 404s, verified), and a container's agent records its own namespace's path (`/workspaces/repo`) — a string that names nothing on the host. So for that runtime there may be no reachable URL to build at all, which is precisely why the render asks a probe instead of composing one.
- **Everything the finale needs was already captured before teardown, and that is why it works.** `_WsPub.last_row` caches a whole `WorkspaceActivity` on every fold, and it carries each session's id, `adapter_kind` and `transcript_path` plus the state's `agent_cwd` — the exact `(kind, cwd, session_id)` coordinate the session surface resolves by. **A lookup written against a live workspace passes every test and returns nothing in production**, where the record is deleted first; the cached row is the only honest source.
- **"Session ended" is a fact about a WORKSPACE; the reader is asking about the TICKET, and the two come apart.** A `HolderResolver` (`find_by_ticket`, which by construction can never see a killed record) answers whether another live workspace still holds the refs, asked only on the finale — while the workspace lives, the answer is "this one", which the comment already says. Unanswerable renders NOTHING, the phase rule again. The sentence names **Grove** deliberately: a human may well be working the ticket, which is not a thing this comment can see.
- **The `🧾 Other sessions` section is scoped to THIS workspace's own sessions, and the scope is stated in the render's docstring rather than implied.** Nothing on disk ties a session to a ticket once its workspace is gone — the association lived in the deleted record — so "every session that ever worked this ticket" would need durable state Grove does not keep, and an incomplete list rendered as a history is worse than an honest partial one. Sub-agent sessions are excluded (unbounded, and not something a reader opens).

### What the rendered body owes its reader

These rules decide the shape, and they should settle future changes rather than be re-derived from the current layout.

- **If an element names something with a destination it must REACH it; if it has no destination it must not be dressed as one.** Commit, branch, ticket and workspace are all links (`_link` is the single site: text plus a url, or bare text); the phase caption names a *position*, not a place, so it is correctly not a link. The half nobody writes down is the second one — a link that lands nowhere spends the reader's click and is strictly worse than the plain text it replaced, which is why `commit_url`/`branch_url` answering `None` is a normal outcome rather than a degradation to paper over. Free text entering a table cell is escaped where it enters (`_cell`): an unescaped `|` shears the row, a newline ends it — the value-becomes-syntax class again.
- **The body is ONE fixed hierarchy; only the CONTENT varies.** An `##` heading naming the agent state, the title, an at-a-glance table, then four sections whose titles are always the same four strings in the same order — `🧭 Progress`, `💬 Latest activity`, `☑️ Checklist`, `🔗 Tracking`. A section title's job here is RECOGNITION: a reader meets this comment on many tickets and learns the shape once, so a title that moves between renders cannot be recognized at a glance — no count, no state, no timestamp is allowed into one. That is what forces every varying number into the table instead (the checklist's progress, the phase's position), and the table being the overview is also what lets a section fold without hiding anything a scanner needed. **The live body carries no Status row** — the heading already answers it, and the loudest signal in the comment is stated once — while the terminal body *does* carry `🚦 Final state`, because its heading says "Session ended" rather than naming a state, so nothing else there would say it.
- **Structured state renders as FIELDS: bounded content stays open, unbounded content collapses.** Every scalar the publisher holds is a row of the table directly under the heading, where the eye lands, and a row exists only for a fact that exists. Progress and Tracking are `<details open>` — a diagram bounded at six nodes, and a cross-link bounded by the refs a workspace names *and* the one thing a reader of the other thread cannot get anywhere else. **Progress is open in the terminal body too, and that is pinned by its own test rather than left to the bounded/unbounded one**: the phase is what a reader wants without a click, and collapsing it is a one-character edit in a shared helper. Latest activity and the Checklist are plain `<details>`, both growing without bound as work proceeds. Verified on a real render rather than assumed: `open` survives Gitea's comment sanitizer. The visible timestamp is a table row, not a footer line, so one fact is not rendered at both ends of the comment.
- **The icon vocabulary EXTENDS the two the comment already had** — the state glyphs and the phase dots — rather than introducing a competing one: one icon per concept, and a table row shares its icon with the section it summarizes, so a reader who folded a section can still find its overview. The icon LEADS its label and never replaces it; an icon that replaces a word has no readable fallback, the same rule the container statusline's ASCII vocabulary follows. **This is an accessibility gain rather than a cost, and it is measurable rather than asserted:** Gitea renders every emoji as `<span class="emoji" aria-label="…">`, so a screen reader announces the concept's name where a bare cell announced only the label.
- **`current_task` is stripped of tag-shaped markup, and the reason is not taste.** The excerpt carries whatever harness-internal markup the agent's own protocol uses; both forges' sanitizers drop an unknown tag anyway, so leaving it in only made the stored body disagree with what a reader sees — and an unbalanced `<` swallows the text after it. The strip is deliberately SHAPE-based (a tag-looking construct), never a list of a vendor's tag names: normalizing shape is the provider-boundary rule, and a tag vocabulary is exactly the semantics an adapter must not learn. **The excerpt is WHOLE, and getting it whole was an engine change rather than a render one**: `AgentActivity.current_task` is capped at 500 chars by every adapter because it rides the ~1 Hz delta for every workspace on the host, so the publisher reads the uncapped per-request seam (`WorkspaceManager.latest_task`, see [core](../CLAUDE.md#registry--activity)) and falls back to the capped field only when the session cannot be resolved. The publisher still adds no cut of its own.
- **The activity excerpt is a FENCED block whose fence length is COMPUTED from the text**, and that is three reasons in one. Agent-written text cannot become markup. The block is hard-wrapped at 88 columns, because a fenced block scrolls sideways rather than reflowing, so an unwrapped paragraph makes the reader drag a scrollbar to read a sentence — wrapping is a readability decision the render owns, not a bound on the text. And the `[updated …]` line gives the excerpt the timestamp it otherwise loses once it is behind a fold. The computed fence is the value-becomes-syntax class again: agent text carrying its own ``` fence would close ours and escape the rest of the message into the comment as markup.
- **What was verified against a LIVE Gitea render rather than assumed**, because the plan listed these as premises: `<details>`/`<summary>` survive the comment sanitizer; markdown *inside* a details body renders (lists, task lists, tables, and a fenced code block) provided blank lines surround it, without which the whole block is treated as raw HTML; a bare `#N` auto-links in a comment — re-verified since inside a `<details>` body and inside a table cell, which is what lets the tracking entries be written bare — so the checklist needed no link work and none was added; **what it does NOT do is decorate that reference with the ticket's live state** (measured: identical class and colour for open, closed and merged, no strikethrough, no tooltip); and an escaped `|` renders as a literal pipe inside one cell instead of shearing the row. **The method is the reusable part:** Gitea's own `/api/v1/markdown` in `comment` mode is the same renderer the thread uses, so a render question is answerable in one call without posting anything.

### The sticky comment's mermaid phase diagram

- **Both target forges render ```` ```mermaid ```` inside an issue COMMENT, verified by execution rather than inferred.** Gitea's built-in renderer runs for *every* `.markup` container (not just READMEs) and `MERMAID_MAX_SOURCE_CHARACTERS` defaults to **50000**, roughly 80× this payload. **Gitea renders each diagram inside an `<iframe>`, so nothing on the host page styles it** — which is precisely why every colour must be stated in the source.
- **Every node carries an explicit fill AND an explicit label colour; that is what makes the chart theme-independent.** An unfilled node inherits the viewer's page background — white in light mode, near-black in dark — and no single label colour is legible on both. Contrast is then solved rather than judged per node: **every fill in `DARK_PHASE_HEX` is light**, so one dark ink (`#111111`) clears WCAG AA against all six, worst case 5.6:1. White is the trap it looks like the answer to, reaching only 3.4:1 there and 3.1:1 on the muted `done` gray — failing on exactly the two fills a reader would assume needed it.
- **The three states take the palette member that already MEANS them — no invented hexes.** Completed → `done`'s muted gray; current → its OWN ramp entry; remaining → the ramp's palest anchor. **Two fills would collide by construction, and the two collisions are NOT treated alike.** At `scoping` the current node matches the remaining ones and the collision stands: the thick dark ring separates it, and a ring survives a reader who cannot distinguish the hues at all. At `done` it would match the COMPLETED ones, and there it does not stand — that is where a workspace ends, so the final render is the one a reader meets forever after, and a current node wearing the completed gray leaves that comment saying nothing about where the work stopped except through a ring. The current node takes the ramp's deepest live entry (`delivering`) instead, still a palette member, with the ring kept on top. **This knowingly overrules the earlier "a fourth colour is not worth it" rule for the `done` end only** — the ring was never wrong, it was insufficient at the one phase that lasts. The branch is keyed on the FILLS BEING EQUAL rather than on `phase == "done"`, so a palette that later moves another entry onto the gray is covered by the same line. Contrast is checked, never assumed: `#5f9c0f` against the one dark ink is **5.6:1** (AA), where white on it reaches 3.4:1 — which is why the ink does not move with the fill.
- **A workspace that has reported NO phase draws nothing — an all-remaining diagram would be a claim Grove cannot make.** Six pale blocks assert "nothing is done yet"; the agent may be nearly finished and simply not reporting. Absence of a report is a fact about the AGENT, not a position on the task axis, and drawing step zero destroys exactly the "has not reported" vs "is scoping" distinction in the one place a human acts on it.
- **The diagram ADDS to the dot-bar caption rather than replacing it, and the redundancy is the point** — a surface that renders no mermaid is never worse off, and a screen reader gets the position in one line. The node's copy of the note is capped at 48 chars because a horizontal flowchart is as wide as its widest node, so an uncapped note stretches the chart past any comment column. **Each of the three facts renders exactly once in its own right place:** the caption (dots, name, position) in the summary table's Phase row, the diagram in the Progress section, and the agent's note as a blockquote directly under the diagram it explains. A fourth rendering of any of them is what a wall of comment is made of.
- **The agent's note is the one untrusted-ish value reaching the diagram, and a single `"` does not degrade the chart — it replaces the whole thing with a parse error.** Reproduced on a real render and fixed by escaping: this is the value-becomes-syntax class, and escaping IS the fix. `str.maketrans` is what makes it one pass — each source character is substituted exactly once, so the `#` the table emits is never re-escaped, which is the whole reason `#` can sit in the table beside the entities that begin with it. **Truncate BEFORE translating**, or a cut lands mid-entity and leaves a literal `#12` on screen. The `<br>` Grove itself emits is markup and stays raw; only the note is escaped.

## The assignee work queue (`pickup.py` / `poller.py` / `handover.py`)

The tracker already has a first-class "who is working this" field. Outbound, Grove puts its own account on every ticket a live workspace holds, so a Grove-managed issue is findable with the tracker's own filters by people who never open Grove. Inbound, **the assignee IS the queue**: a human assigns the bot, a workspace appears. Both halves default OFF (`issueops.assign_bot` / `issueops.pickup_enabled`) — between them they write to somebody else's tracker and spawn real agents, which is not a default-on blast radius.

- **This routes around the CI blocker, and that is why it exists.** The `@grove <verb>` path needs a workflow **plus a `:host`-labeled runner**, because a dockerized runner cannot reach the daemon's loopback bind and `host.docker.internal` does not resolve on Linux — so on a deployment with zero runners that path can never fire. Polling is a read the daemon already has the credentials to make. It is the one place in this tree that owns a timer with no upstream edge to subscribe to, and the doctrine's remaining requirements are met by the two off-by-default gates.
- **THE design risk is the self-trigger loop, and it is closed by a marker that is not derived from current state.** Grove assigns the bot → the poll sees a bot-assigned issue → it starts another workspace, forever. "Has never been handed over" must therefore be durable AND independent of the tracker, because the tracker is exactly what Grove mutates; a prior assignment a human later REMOVED must still count as handed over.
- **The sticky comment's `STICKY_MARKER` was evaluated as that marker and REJECTED, and the reason is the failure mode's shape.** It is on the thread and it is durable — but it only exists where `issueops.enabled` turned the publisher on AND a flush actually reached the forge, so a deployment running pickup with the publisher off would find no marker anywhere and re-pick up every ticket on every tick. **A guard whose failure mode is an unbounded loop must not depend on a feature that can be switched off underneath it.** The marker is instead `handover.json` under the state dir, `write_atomic` + `exclusive_lock` like `JsonWorkspaceStore`, keyed by `(provider, owner, repo, ticket id)` and **never evicted** (an evicted row is a ticket that can be picked up twice).
- **The log fails CLOSED in both directions.** A corrupt or unreadable file **raises** rather than reading as empty, because an empty read is indistinguishable from "nothing was ever handed over" — the loop again — and the poller skips the whole tick and says so.
- **Claim BEFORE the create, never after.** Claiming first can at worst lose one pickup (a crash in the window between, which a human re-triggers); creating first loops without bound on any crash in that same window. Cheap failure over unbounded failure — the same ordering argument the first-turn brief's marker makes. `claim` returning `False` for an existing row is also the race arbiter, so two writers cannot both take one ticket.
- **Hand-back keeps the marker.** Dropping it would let the very next tick re-take a ticket a human just took away, and any remaining assignee keeps that running. It also leaves the workspace alone: stopping work is `kill`'s job, and a hand-back that killed a running agent would destroy uncommitted work to change a field on a tracker.
- **The ceiling counts LIVE work, not starts per tick** (`pickup_max_active`, default 3, host-wide), because what must not run away is the fleet. The count is free — the same `find_by_ticket` that decides eligibility answers it — and the overflow is **deferred and named in the log**, never dropped: a user who assigned forty issues must be able to tell "Grove is pacing itself" from "Grove lost my ticket". `PickupEngine.plan` is pure so the rule is testable with no forge, store or clock.
- **Backoff is per PROVIDER, not per ticket**, because a 403 or a 429 is a statement about the credential or the budget rather than about whichever issue happened to be asked for. It does not compound; the next successful poll clears it.
- **Outbound assignment is reconciled ON THE TICK rather than inline at create, and that is forced.** The ticket layer's own rule is that a provider method doing network must not become reachable from a lifecycle path — that is what keeps `create`/`attach_ticket` deterministic and offline-safe. So the tick sweeps live workspaces' refs (issues only; a PR already records its author), memoized per ticket for the process, and a freshly attached ticket is marked within one interval. `grove tickets handover` assigns on the spot for the human who wants it now.
- **One `prompt_template`, extended rather than duplicated.** `{comments}` renders the whole thread in order, and `{command_text}` is what distinguishes the two create paths — the human's words, or a sentence saying the ticket was assigned. That is why the placeholder needed no rename: it always answered "how were you engaged". The comment path now reads the thread too (one GET, best-effort, create-only), so the placeholder cannot mean two different things on two paths.
- **`PickupEngine` is ONE path for the poll and the CLI**, deliberately: a command handing a ticket over by a different route would claim the marker differently, render a different prompt or skip the assignment, and the divergence would only ever surface in production. `grove tickets handover` / `owned` / `handback` are thin shells over it.

### Wiring

- **One canonical `IssueOpsConfig` in `grove.core.config`**, holding the outbound publisher knobs (`enabled` / `update_window_seconds` / `deep_link_base_url`), the inbound comment ones (`trigger` / `allowed_actors` / `agent` / `prompt_template`) and the assignee-queue ones (`assign_bot` / `pickup_*`). **Three independent opt-ins, not one:** `enabled` gates only the status mirror, `assign_bot` only the outbound assignment, `pickup_enabled` only the inbound poll — a deployment picks any subset, and the poller is built when EITHER assignee half is on. **`enabled` gates ONLY the outbound status mirror** — inbound routing has no on/off flag, its opt-in being the CI workflow plus an enabled ticket provider. It is deliberately not re-exported from this package.
- **The daemon injects the SAME publisher instance into the activity bus and into the engine's `StatusPublisher` seam**, so one object both mirrors continuous progress and answers `@grove status` — which maps ticket → workspace via `find_by_ticket` and `flush_now`s that workspace's latest coalesced row, bypassing the window. The two classes stay decoupled by structural typing: the publisher takes only the wire `IssueOpsEvent` + the already-resolved `WorkspaceManager` (both under `TYPE_CHECKING`) and never imports the engine module.