Skip to content

Add Project Update + Project description as agent surfaces (Workstream A)#1213

Open
alicejt wants to merge 6 commits into
cyrusagents:mainfrom
alicejt:project-update-agent-surface
Open

Add Project Update + Project description as agent surfaces (Workstream A)#1213
alicejt wants to merge 6 commits into
cyrusagents:mainfrom
alicejt:project-update-agent-surface

Conversation

@alicejt

@alicejt alicejt commented May 14, 2026

Copy link
Copy Markdown

What this does

Cyrus today only listens for Issue-level Linear events, so the Project surface — the Updates feed and the description body — is invisible to agents. This adds two capabilities:

A1 — Project Updates as a conversation surface

An @<agent> mention inside a Linear Project Update spawns a conversational agent session bound to that project, and the agent replies as a follow-up Project Update. It reuses ChatSessionHandler (the Slack-chat engine), so the session runs in a plain workspace directory — no git worktree — because the surface is discussion, not code. The project is the thread: follow-up Updates on the same project resume the same session.

A2 — Project description as standing context

On a Project webhook whose description changed, the description is cached; when an agent later picks up any issue under that project, the cached description is injected into its system prompt as long-running context. Caching is opt-in via CYRUS_PROJECT_CACHE_URL / CYRUS_PROJECT_CACHE_TOKEN env vars — when unset it's a no-op, so this is inert for installs that don't want it.

Routing — self-filtering, not central routing

ProjectUpdate and Project webhooks are workspace-wide: every installation subscribed to them receives every event (unlike Issue/AgentSession webhooks, which are agent-targeted). So instead of a central router picking an agent, each instance self-filters — it resolves its own Linear identity (from linearAgentId/linearAgentName config, else the viewer query at startup) and acts only when it is the agent @-mentioned. It never replies to its own Updates (loop prevention).

Changes by package

  • coreProjectUpdateWebhook / ProjectWebhook types + isProjectUpdateWebhook / isProjectWebhook / isProjectDescriptionUpdateWebhook guards; optional linearAgentId / linearAgentName config fields.
  • linear-event-transportfetchProject + createProjectUpdate helpers on LinearIssueTrackerService.
  • edge-workerhandleProjectUpdateWebhook / handleProjectWebhook wired into handleWebhook; LinearProjectChatAdapter (a ChatPlatformAdapter for Project Update conversations); ProjectDescriptionCache client; project-mentions.ts (mention parsing + the self-filter gate); self-identity resolution at startup; <project_context> injection into new-session system prompts.
  • Unit tests for the type guards, the mention routing gate, the adapter, and the cache client.

Subscription / config (operator-facing)

To enable, subscribe the Linear app to the ProjectUpdate and Project webhook events. handleWebhook already ignores unknown event types gracefully, so subscribing before/without this change is harmless. A2's cache is off unless the two env vars are set.

Testing

  • New unit tests pass; full core + edge-worker suites show no new failures (pre-existing screenshot-upload-hooks / runner-selection failures on main are unrelated).
  • pnpm build + pnpm typecheck clean across the monorepo.
  • Draft until the end-to-end test plan (direct mention → reply, no-mention quiet, description-as-context, cache invalidation) is exercised against a live workspace.

🤖 Generated with Claude Code

alicejt and others added 3 commits May 14, 2026 12:02
…m A)

Cyrus previously only listened for Issue-level Linear events, so the
Project surface (the Updates feed, the description body) was invisible
to agents. This adds two capabilities:

A1 — Project Updates as a conversation surface. An `@<agent>` mention
inside a Project Update spawns a conversational agent session bound to
that project and the agent replies as a follow-up Project Update.
Reuses ChatSessionHandler, so the session runs in a plain workspace
directory (no git worktree) — the surface is discussion, not code.
The project is the thread: follow-up Updates resume the same session.

A2 — Project description as standing context. On a `Project` webhook
whose description changed, the description is cached; when an agent
picks up any issue under that project, the cached description is
injected into its system prompt as long-running context. Caching is
opt-in via CYRUS_PROJECT_CACHE_URL / CYRUS_PROJECT_CACHE_TOKEN.

Routing note: ProjectUpdate/Project webhooks are workspace-wide — every
subscribed installation receives every event — so handlers self-filter.
Each instance resolves its own Linear identity (config override, else
the `viewer` query) and acts only when it is the agent mentioned, never
on its own Updates (loop prevention).

- core: ProjectUpdateWebhook / ProjectWebhook types + guards;
  optional linearAgentId / linearAgentName config fields.
- linear-event-transport: fetchProject + createProjectUpdate helpers.
- edge-worker: handleProjectUpdateWebhook / handleProjectWebhook wired
  into handleWebhook; LinearProjectChatAdapter; ProjectDescriptionCache
  client; project-mention parsing; self-identity resolution at startup.
- Unit tests for the type guards, mention routing gate, adapter, and
  cache client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A cached empty string would otherwise be treated as a hit, blocking the
Linear back-fill forever — so a row written before a project had a
description (or any stale empty row) could never self-heal. Treat an
empty cached value the same as a miss: fall through to the back-fill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alicejt alicejt marked this pull request as ready for review May 14, 2026 12:16
@alicejt

alicejt commented May 14, 2026

Copy link
Copy Markdown
Author

Validated end-to-end against a live Linear workspace with an isolated test instance:

  • Spike — confirmed Linear encodes an agent @-mention in a Project Update body as plain @<name> text; the self-filter matcher catches it.
  • A1@<agent> in a Project Update → worktree-free chat session → reply posted as a follow-up Project Update. Loop prevention confirmed (the agent's own reply Update is ignored).
  • A1 no-mention — a plain Project Update with no mention is received and ignored, no session spawned.
  • A2 — editing a project's description fires a Project webhook → description cached; a new issue under that project picks it up and the agent's reply reflects it (verified the agent used the description with zero tool calls — it came purely from the injected <project_context>). Re-editing the description invalidates the cache; the next issue picks up the new value.

All four test-plan items pass. Marking ready for review.

alicejt and others added 3 commits May 15, 2026 07:25
… fetch (B1, N1)

B1: mentionsAgent bound against the agent's full Linear viewer name
(`tincture-mara`) so a human typing `@mara` silently never matched. Now
matches both forms by stripping a configurable prefix (env
`CYRUS_AGENT_NAME_PREFIX`, default `tincture-`) before matching. An
optional `linearAgentShortName` config field takes precedence over the
strip. `stripLinearSelfMention` is updated to strip both forms so the
prompt handed to the runner stays clean. EdgeWorker now passes the full
identity (not just `name`) to the adapter; the adapter accepts both
shapes for back-compat.

N1: `resolveSelfLinearIdentity` wraps the Linear `viewer` fetch in a
10-second timeout via `Promise.race`. On cloud, 13 instances hit the
viewer endpoint at boot; a slow Linear API would otherwise hang startup
indefinitely. The existing fallback path handles the degraded case.

Test plan addendum #2 (Ray): added test cases asserting `@mara` matches
against `name: "tincture-mara"`, prefix overrides work, and explicit
`shortName` wins over the strip.

Ref: workstream-a-review.md B1, N1

Co-Authored-By: Bob (Tincture) <noreply@anthropic.com>
…N5, N7, E1, E2)

Several risks Ray flagged on the workspace-wide Project Update fan-out:

B2 (rate limit): on cross-agent ping-pong there was no upper bound. Adds
a per-project rolling-window reply rate-limiter (default 3 replies per
5-minute window per instance; env `CYRUS_PROJECT_REPLY_LIMIT_COUNT` /
`CYRUS_PROJECT_REPLY_LIMIT_WINDOW_MS`). Plus a bonus loop guard: if the
last two updates on a project look agent-authored — `self` user id, or
a Linear user whose name begins with the configured agent prefix —
skip + log.

B3 (team routing gate): fetch the project once per qualified mention,
intersect its team keys with the union of `teamKeys` across this
instance's repositories. No intersection → warn + skip. Re-uses the
same fetchProject call for N7.

N4 (`update` action): only proceed when the mention was newly added.
A typo-fix edit on an existing mention no longer re-fires the handler.

N7 (persona pick): with project teamIds in hand, pick the repo whose
`teamKeys` intersect (via the new `getRepositoryForProject` on
`ChatRepositoryProvider`). Cross-team agents like Iris now reply in the
mode of the team that owns the project, not whichever repo Map iteration
returned first. The resolved project is threaded through the webhook on
an internal `_resolvedProject` field so the adapter doesn't re-fetch.

N2 (size cap on injected description): default 12,000 chars, override
via `CYRUS_PROJECT_CONTEXT_MAX_CHARS`. Truncated bodies get a marker
suffix.

N3 (tag escape): replace `</` with `< /` in user-supplied description /
update bodies before interpolation so reserved closing tags
(`</project_context>`, `</project_description>`, `</recent_updates>`)
inside content can't terminate the wrapper.

N5 / E1: deferred per Ray. Added inline notes documenting the
acceptable cost (concurrent back-fill wastage on first project pickup;
N+1 author hydration in `fetchThreadContext`).

E2 (efficiency): parse mentions once on the no-match debug branch
instead of running the regex twice.

Tests cover B2 cap + bonus loop guard, B3 intersect / non-intersect,
N4 both directions, N7 persona switch, N3 escape on both injection
sites. Also adds a unit test suite for the new
`ProjectReplyRateLimiter`.

Ref: workstream-a-review.md B2, B3, N2, N3, N4, N5, N7, E1, E2

Co-Authored-By: Bob (Tincture) <noreply@anthropic.com>
… E3)

N6: ProjectDescriptionCache.get now returns both the description and the
parsed `updated_at` timestamp from the edge function (which already
emits it). EdgeWorker's `getCachedProjectDescription` treats a cached
row older than the TTL as a miss and back-fills from Linear. Default
TTL is 7 days, override via `CYRUS_PROJECT_CONTEXT_CACHE_TTL_DAYS`.
Covers the case where a `Project` description-update webhook is
dropped, leaving the cache stale-but-non-empty indefinitely.

E3: switch the GET path from string concatenation to `new URL(...)` +
`searchParams.set`, so a future bridge URL carrying a query string
won't produce `?…?…` after the project id is appended.

Tests cover the TTL freshness branch (stale → re-fetch; fresh →
serve from cache), and that the GET path encodes special characters
via searchParams.

Ref: workstream-a-review.md N6, E3

Co-Authored-By: Bob (Tincture) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant