From 7a18e972148eb0a0b7f451dd75e7cf4b1934d97e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 7 May 2026 09:34:47 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20Linear=20surface=20for=20Agen?= =?UTF-8?q?tSession=20mention=20=E2=86=92=20Cloud=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the `src/surfaces/linear/` surface (event types, status, workflow builder, connect guidance) and the canonical wire contract in `src/cloud/api/linear-agent-types.ts`. Wires `ricky connect linear` and `ricky status linear` through the power-user parser and CLI main, and adds Linear connect guidance to the auth provider-connect helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- specs/linear-integration.md | 219 ++++++++++++++++++ src/cloud/api/index.ts | 8 + src/cloud/api/linear-agent-types.ts | 93 ++++++++ src/cloud/auth/provider-connect.ts | 17 ++ src/product/generation/pipeline.test.ts | 20 +- src/product/generation/template-renderer.ts | 3 +- src/product/spec-intake/normalizer.ts | 4 +- src/product/spec-intake/parser.test.ts | 29 +++ src/product/spec-intake/parser.ts | 22 ++ src/surfaces/cli/commands/cli-main.test.ts | 67 ++++++ src/surfaces/cli/commands/cli-main.ts | 36 +++ src/surfaces/cli/flows/power-user-parser.ts | 22 +- src/surfaces/linear/README.md | 21 ++ .../linear-agent-session-event.schema.json | 31 +++ .../linear/__tests__/event-types.test.ts | 61 +++++ src/surfaces/linear/__tests__/index.test.ts | 168 ++++++++++++++ src/surfaces/linear/__tests__/status.test.ts | 24 ++ .../linear/__tests__/workflow-builder.test.ts | 41 ++++ src/surfaces/linear/connect.ts | 34 +++ src/surfaces/linear/event-types.ts | 92 ++++++++ src/surfaces/linear/index.ts | 179 ++++++++++++++ src/surfaces/linear/status.ts | 98 ++++++++ src/surfaces/linear/workflow-builder.ts | 189 +++++++++++++++ 23 files changed, 1468 insertions(+), 10 deletions(-) create mode 100644 specs/linear-integration.md create mode 100644 src/cloud/api/linear-agent-types.ts create mode 100644 src/surfaces/linear/README.md create mode 100644 src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json create mode 100644 src/surfaces/linear/__tests__/event-types.test.ts create mode 100644 src/surfaces/linear/__tests__/index.test.ts create mode 100644 src/surfaces/linear/__tests__/status.test.ts create mode 100644 src/surfaces/linear/__tests__/workflow-builder.test.ts create mode 100644 src/surfaces/linear/connect.ts create mode 100644 src/surfaces/linear/event-types.ts create mode 100644 src/surfaces/linear/index.ts create mode 100644 src/surfaces/linear/status.ts create mode 100644 src/surfaces/linear/workflow-builder.ts diff --git a/specs/linear-integration.md b/specs/linear-integration.md new file mode 100644 index 00000000..711870bd --- /dev/null +++ b/specs/linear-integration.md @@ -0,0 +1,219 @@ +# Spec: Ricky Linear integration — @-mention to Cloud workflow to PR + +## Problem + +Paraglide and similar teams want to triage Linear issues and have a coding agent take a stab at low/mid complexity ones. They've tried wiring Linear → Cursor cloud agents and it doesn't fire reliably; they end up triggering it manually. Ricky already has a Cloud workflow runtime, an auto-fix/diagnose/resume loop, and a Linear provider connection in the integration list — but no surface that listens to Linear events, decides what to do, runs the workflow, and posts the result back. + +This spec adds that surface. + +End state: a user @-mentions the Ricky agent on a Linear issue (or assigns the issue to it). Ricky reads the issue, generates a workflow that uses the agents the user has connected in Cloud, runs it on Cloud, opens a GitHub PR, and posts the PR link back as Linear AgentActivity. + +## Architecture: where things live + +Same split as Sage: +- **Cloud (`AgentWorkforce/cloud`, secret sauce)** — webhook ingress, Nango forwarding, agent registry checks, GitHub-app readiness checks, workflow launcher, AgentActivity egress. +- **Ricky OSS (`AgentWorkforce/ricky`, this repo)** — Linear surface contracts (event types, AgentActivity envelope shape), workflow generation that respects the user's connected agent set, status/connect CLI commands, types consumed by Cloud. + +The Linear additions in Cloud land as a **follow-up PR after [PR #412](https://github.com/AgentWorkforce/cloud/pull/412)** (Ricky agent v2 Slack surface) merges. PR #412 establishes the Ricky-as-Cloud-agent foundation (DB schema, route layout under `app/api/v1/ricky//`, lib layout under `lib/ricky//`, `workflow-launcher.ts`, SSE run events). The Linear surface mirrors that layout file-for-file; merging Slack first keeps the Linear PR small and reviewable against a settled base, and avoids coupling two surfaces in one merge/rollback. + +### Cloud file layout (mirrors PR #412 Slack) + +``` +packages/web/app/api/v1/ricky/linear/ + events/route.ts # Nango-forwarded Linear webhook receiver + oauth/start/route.ts # Linear OAuth Actor app start + oauth/callback/route.ts # OAuth callback → install row + webhook/route.ts # raw webhook (if Nango is bypassed in any env) + +packages/web/lib/ricky/linear/ + ingress.ts # parse + classify AgentSessionEvent / IssueMention + egress.ts # post AgentActivity (thought / action / response / error) + parser.ts # extract @-mention command + issue context + auth.ts # validate webhook signature, resolve workspace from connectionId + blocks.ts # AgentActivity payload builders (connect link, agent-list link, PR link) + dedup.ts # webhook-id idempotency + signature.ts # Linear-LinearSignature HMAC verify + store.ts # Drizzle accessors for ricky_linear_installation + session table + proactive.ts # cross-surface notifications (PR opened → Linear comment) +packages/web/lib/ricky/linear-agent-v2.ts # main entry, parallel to slack-agent-v2.ts +packages/web/drizzle/_ricky_linear_agent_v2.sql # whichever idx is next when this PR opens +integrations/linear/ricky-manifest.json # OAuth Actor app manifest +``` + +### Ricky OSS additions + +``` +src/surfaces/linear/ + index.ts # surface entrypoint (status/connect helpers) + event-types.ts # AgentSessionEvent, AgentActivity types (shared contract w/ cloud) + workflow-builder.ts # issue + connected-agents → workflow spec + status.ts # `ricky status linear` row + connect.ts # `ricky connect linear` guidance (mirrors github connect) + README.md +src/cloud/api/linear-agent-types.ts # request/response types for cloud-bound calls +specs/linear-integration.md # this file +``` + +Ricky OSS does **not** receive webhooks. All Linear traffic terminates in Cloud. Ricky OSS contributes the workflow shape, agent contract, and CLI surfaces. Cloud imports types from Ricky. + +## Behavior we want + +### 1. @-mention ingress + +Linear sends webhooks via Nango → `POST /api/v1/ricky/linear/events`. Body envelope: + +```jsonc +{ + "type": "forward", + "from": "linear", + "connectionId": "", + "providerConfigKey": "linear-ricky", + "payload": { /* raw Linear AgentSessionEvent */ } +} +``` + +Cloud handler steps: +1. Validate envelope shape; verify Linear webhook signature against the configured secret. On mismatch → 401, no body. +2. Look up `ricky_linear_installation` row by `connectionId` + `providerConfigKey`. If none → 200 with no-op (uninstall race). +3. Dedup by Linear `webhookId` for 24h. +4. Classify the event (`ingress.ts`): + - `AgentSessionEvent` of type `created` (mention or assign) → enter the run flow. + - `prompted` (follow-up message in an existing session) → continue an existing session. + - Anything else → ack and ignore. + +### 2. Readiness checks (must run in this order, fail fast) + +Before generating any workflow: + +**a. GitHub app installed for this workspace?** +Query `workspaceIntegrations` for `provider = 'github'` and `status = 'active'`. If missing: +- Post a single `AgentActivity` of kind `response` with body: a short message + a Nango connect link to install the `github-ricky` app for this workspace. Use the same connect-link helper PR #412 uses for Slack `connect` commands; do not roll a fresh one. +- Mark session `ended` with reason `awaiting_github_install`. Do not generate a workflow. + +**b. User has connected agents in Cloud?** +Query the agent registry for the actor (the Linear user who triggered the session) within this workspace. The actor's connected agents are the specialists Ricky may use in the workflow. If the user has zero connected agents: +- Post a `response` AgentActivity with a link to the Cloud dashboard's agent connection page and a short message naming the user. +- End session with reason `awaiting_agent_connect`. Do not generate. + +Both readiness checks live in `packages/web/lib/ricky/linear/auth.ts` (or a sibling `readiness.ts`) and return a typed result so the agent entry can switch on them. + +### 3. Workflow generation + +If both checks pass: +1. Read the Linear issue via Nango proxy: title, description, comments, labels, assignees, project. +2. Resolve the connected agent set for the actor (names + capabilities). Pass these to `src/surfaces/linear/workflow-builder.ts` (Ricky OSS). +3. `workflow-builder` produces a Ricky workflow spec that: + - Treats issue body as the spec input + - Lists the actor's connected agents as the available specialist set + - Picks the appropriate Ricky pattern (`pipeline` / `supervisor` / `dag`) from the existing pattern selector + - Includes a final step that opens a GitHub PR against the repo named in the issue (or the workspace default repo if unambiguous) +4. Post a `thought` AgentActivity describing the chosen pattern and selected agents. + +### 4. Workflow execution + +Reuse the existing workflow launcher (`packages/web/lib/ricky/workflow-launcher.ts` from PR #412). Cloud invokes it with the generated artifact and emits run events on the existing SSE channel. + +While the run executes, mirror notable run events as Linear `AgentActivity`: +- workflow start → `action` (kind: "Generating workflow") +- step start → `action` (kind: step name) +- auto-fix attempt → `thought` ("Step X failed; diagnosing and retrying") +- run complete → see step 5 +- run error after auto-fix budget exhausted → `error` AgentActivity with the final blocker, then `ended` with reason `failed` + +Auto-fix is on by default — that is the literal differentiator vs the Cursor flow Paraglide gave up on. Do not change that default for the Linear surface. + +### 5. PR link reply + +When the workflow's PR-opening step succeeds: +1. Capture the PR URL from the step's run evidence. +2. Post a `response` AgentActivity with the PR link and a one-line summary of what the PR changes. +3. Mark the session `ended` with reason `completed`. + +If the workflow finishes without producing a PR (e.g. no code changes were needed), post `response` describing the conclusion and end with reason `completed_no_changes`. + +## Surface contracts (Ricky OSS) + +### `src/surfaces/linear/event-types.ts` + +Re-export Linear's `AgentSessionEvent` and `AgentActivity` shapes typed so Cloud can `import { AgentSessionEvent, AgentActivity } from "@agentworkforce/ricky/surfaces/linear/event-types"`. Ricky OSS owns this contract; if Linear updates the schema, the bump lands here first. + +### `src/surfaces/linear/workflow-builder.ts` + +```ts +export interface BuildLinearWorkflowInput { + issue: { title: string; description: string; labels: string[]; comments: string[]; project?: string }; + repoTarget: { owner: string; repo: string; defaultBranch: string }; + connectedAgents: ReadonlyArray<{ id: string; name: string; capabilities: string[] }>; + actor: { linearUserId: string; cloudUserId: string }; +} + +export interface BuildLinearWorkflowResult { + artifactPath: string; + artifactContent: string; + pattern: "pipeline" | "supervisor" | "dag"; + selectedAgents: ReadonlyArray; + rationale: string; // surfaced as a Linear `thought` +} + +export function buildLinearWorkflow(input: BuildLinearWorkflowInput): BuildLinearWorkflowResult; +``` + +Implementation reuses `src/product/generation/pattern-selector.ts` and the existing workforce persona writer; do not fork a parallel generator. + +### `src/cloud/api/linear-agent-types.ts` + +The HTTP shapes Cloud uses for `/api/v1/ricky/linear/events` requests and the AgentActivity post payload, exported so Cloud can typecheck imports from this repo. + +The exported wire types are `LinearMentionRequest`, `LinearMentionResponse`, `RickyLinearSession`, and `SessionEndReason`. The `SessionEndReason` values are exactly `completed`, `completed_no_changes`, and `failed`. + +## CLI additions (Ricky OSS) + +- `ricky status linear` — show whether the Linear connection is wired for the user's workspace (delegates to existing readiness check) +- `ricky connect linear` — print the Cloud dashboard URL for the Linear OAuth Actor app install, mirroring the existing `ricky connect github` guidance pattern at `src/cloud/auth/provider-connect.ts` + +These reuse the existing connect-guidance helpers; do not invent a new flow. + +## Telemetry + +Each Linear session run emits one Cloud telemetry record with: +- workspace + actor + linear issue id +- pattern selected +- agents selected +- workflow run id +- PR url (if any) +- end reason +- auto-fix attempts used + +This is the eval surface Paraglide eventually expands into; ship it on day one even if no UI consumes it yet. + +## Out of scope + +- Linear comments that aren't @-mentions (no passive monitoring) +- High-complexity issues — the triage decision lives in the user's Linear setup, not in Ricky. Ricky acts on whatever it's @-mentioned on. +- Multi-issue batching or epic-level orchestration +- Custom per-repo workflow templates +- A Linear-specific dashboard surface (the existing Cloud dashboard surface is enough for v1) + +## Test plan + +OSS (`ricky/`): +- Unit tests for `workflow-builder.ts`: pattern selection respects connected-agent count; rationale references issue labels; missing repo target throws. +- Type test that `event-types.ts` matches Linear's published Agents API schema. +- `ricky status linear` smoke test against a fake readiness fixture. + +Cloud (`cloud/`, in PR #412 follow-on): +- Webhook signature verification rejects bad HMAC. +- Envelope unwrap handles `from: "linear"` and ignores other `from` values. +- Readiness path 1: missing GitHub install → exactly one `response` AgentActivity with a connect link, no workflow generated. +- Readiness path 2: actor has zero connected agents → exactly one `response` with the agent-connect link, no workflow generated. +- Happy path: generates workflow, runs it, posts PR link, ends session `completed`. +- Auto-fix exhaustion: posts `error`, ends `failed`. +- Dedup: same `webhookId` twice → second is no-op. + +Manual: +- Real Linear workspace, install the Ricky Actor app via Nango, @-mention on a throwaway issue, watch AgentActivity post live, end with a real PR link in a sandbox repo. + +## Open questions for the implementor + +1. The actor → Cloud user mapping for the agent-readiness check needs an explicit linkage. PR #412's Slack store maps Slack user IDs to Cloud user IDs through OAuth identity. The Linear surface should do the same — confirm the mapping table extension before implementing the readiness check. +2. "Repo target" resolution: if the issue body doesn't name a repo, fall back to the workspace's default repo binding. Confirm where that default is stored (workspace settings? first-active GitHub installation?) before implementing the workflow builder. diff --git a/src/cloud/api/index.ts b/src/cloud/api/index.ts index 1945dfb4..aff72577 100644 --- a/src/cloud/api/index.ts +++ b/src/cloud/api/index.ts @@ -33,3 +33,11 @@ export type { CloudValidationStatus, CloudWarning, } from './response-types.js'; + +export type { + LinearAgentActivityPostPayload, + LinearMentionRequest, + LinearMentionResponse, + RickyLinearSession, + SessionEndReason, +} from './linear-agent-types.js'; diff --git a/src/cloud/api/linear-agent-types.ts b/src/cloud/api/linear-agent-types.ts new file mode 100644 index 00000000..954d8ec7 --- /dev/null +++ b/src/cloud/api/linear-agent-types.ts @@ -0,0 +1,93 @@ +export type SessionEndReason = 'completed' | 'completed_no_changes' | 'failed'; + +export type AgentSessionEventType = 'created' | 'prompted'; + +export type AgentSessionTrigger = 'issue_mention' | 'comment_mention' | 'assignment'; + +export type AgentActivityKind = 'thought' | 'action' | 'response' | 'error'; + +export interface LinearActor { + linearUserId: string; + cloudUserId?: string; + name?: string; +} + +export interface LinearIssueContext { + id: string; + identifier?: string; + title: string; + description?: string; + labels?: string[]; + comments?: string[]; + project?: string; + url?: string; +} + +export interface AgentSessionEvent { + id: string; + webhookId: string; + type: AgentSessionEventType; + sessionId: string; + organizationId: string; + workspaceId?: string; + trigger: AgentSessionTrigger; + actor: LinearActor; + issue: LinearIssueContext; + prompt?: string; + createdAt: string; +} + +export interface AgentActivity { + id?: string; + sessionId: string; + kind: AgentActivityKind; + body: string; + createdAt: string; + metadata?: Record; +} + +export interface LinearRepoTarget { + owner: string; + repo: string; + defaultBranch: string; +} + +export interface LinearWorkflowSummary { + artifactPath: string; + pattern: 'pipeline' | 'supervisor' | 'dag'; + selectedAgents: ReadonlyArray; + rationale: string; +} + +export interface LinearMentionRequest { + provider: 'linear'; + event: AgentSessionEvent; + repoTarget?: LinearRepoTarget | null; + receivedAt: string; +} + +export interface LinearMentionResponse { + status: 'completed' | 'completed_no_changes' | 'failed' | 'ignored'; + sessionId?: string; + reason: SessionEndReason; + workflow?: LinearWorkflowSummary; + prUrl?: string; +} + +export interface RickyLinearSession { + sessionId: string; + workspaceId: string; + organizationId: string; + actor: LinearActor; + issue: LinearIssueContext; + status: 'running' | 'ended'; + reason?: SessionEndReason; + startedAt: string; + endedAt?: string; + activities: AgentActivity[]; +} + +export interface LinearAgentActivityPostPayload { + sessionId: string; + activity: AgentActivity; +} diff --git a/src/cloud/auth/provider-connect.ts b/src/cloud/auth/provider-connect.ts index dbe6ce55..b872c04f 100644 --- a/src/cloud/auth/provider-connect.ts +++ b/src/cloud/auth/provider-connect.ts @@ -2,6 +2,15 @@ import type { ProviderConnectGuidance, ProviderType } from './types.js'; const GOOGLE_CONNECT_COMMAND = 'npx agent-relay cloud connect google'; +export const LINEAR_CONNECT_DASHBOARD_URL = '/dashboard/integrations/linear'; + +export const LINEAR_CONNECT_INSTRUCTIONS = [ + 'Open the Cloud dashboard Linear integration page.', + 'Click "Connect Linear" to install the Ricky OAuth Actor app.', + 'Choose the Linear workspace where Ricky should receive AgentSession events.', + 'Linear connection is managed through the Cloud dashboard, not the CLI.', +]; + export function getProviderConnectGuidance(provider: ProviderType): ProviderConnectGuidance { if (provider === 'google') { return { @@ -28,6 +37,14 @@ export function getProviderConnectGuidance(provider: ProviderType): ProviderConn }; } + if (provider === 'linear') { + return { + provider: 'linear', + dashboardUrl: LINEAR_CONNECT_DASHBOARD_URL, + instructions: LINEAR_CONNECT_INSTRUCTIONS, + }; + } + return { provider, dashboardUrl: '/dashboard/integrations', diff --git a/src/product/generation/pipeline.test.ts b/src/product/generation/pipeline.test.ts index 15c5f9f0..203c83a1 100644 --- a/src/product/generation/pipeline.test.ts +++ b/src/product/generation/pipeline.test.ts @@ -1070,7 +1070,7 @@ describe('workflow generation pipeline', () => { const postImplementationGate = artifact.gates.find((g) => g.name === 'post-implementation-file-gate')!; expect(leadPlanGate.command).toContain('GENERATION_LEAD_PLAN_READY'); - expect(leadPlanGate.command).toContain('/non-goals?/i'); + expect(leadPlanGate.command).toContain('out[- ]of[- ]scope'); expect(leadPlanGate.command).toContain('Routing contract'); expect(artifact.content).toContain('write .workflow-artifacts/generated/no-target-evidence-gates/fix-loop-report.md'); expect(fixLoopReportGate.command).toContain('FIX_LOOP_COMPLETE'); @@ -1081,6 +1081,22 @@ describe('workflow generation pipeline', () => { expect(postImplementationGate.command).toContain('validation-evidence.md'); }); + it('renders out-of-scope constraints as lead-plan non-goals', () => { + const result = generate({ + spec: spec({ + description: 'Implement Linear integration surface.', + targetFiles: ['src/surfaces/linear/index.ts'], + constraints: ['Non-goal: Passive Linear comment monitoring', 'Non-goal: Custom per-repo workflow templates'], + }), + artifactPath: 'workflows/generated/linear-scope.ts', + }); + + expect(result.success).toBe(true); + expect(result.artifact?.content).toContain('Non-goals:'); + expect(result.artifact?.content).toContain('- Non-goal: Passive Linear comment monitoring'); + expect(result.artifact?.content).toContain('Use this exact section heading in the lead plan.'); + }); + it('explicit target git diff gate includes untracked files for newly created outputs', () => { const result = generate({ spec: spec({ @@ -1384,7 +1400,7 @@ function spec(overrides: SpecFixtureOverrides = {}): NormalizedWorkflowSpec { }, constraints: constraints.map((constraint) => ({ constraint, - category: /\bonly\b|\bmust\b/i.test(constraint) ? 'scope' : 'quality', + category: /\b(only|must|non[- ]?goal|out[- ]of[- ]scope)\b/i.test(constraint) ? 'scope' : 'quality', })), evidenceRequirements: evidenceRequirements.map((requirement) => ({ requirement, diff --git a/src/product/generation/template-renderer.ts b/src/product/generation/template-renderer.ts index 05642c0f..99a75000 100644 --- a/src/product/generation/template-renderer.ts +++ b/src/product/generation/template-renderer.ts @@ -367,7 +367,7 @@ function buildLeadPlanGateCommand(leadPlanPath: string): string { `const leadPlanPath = ${literal(leadPlanPath)};`, "const body = fs.readFileSync(leadPlanPath, 'utf8');", "if (!body.includes('GENERATION_LEAD_PLAN_READY')) throw new Error('lead plan missing required marker: GENERATION_LEAD_PLAN_READY');", - "if (!/non-goals?/i.test(body)) throw new Error('lead plan missing required marker: Non-goals');", + "if (!/\\b(non-goals?|out[- ]of[- ]scope|not in scope)\\b/i.test(body)) throw new Error('lead plan missing required marker: Non-goals or Out of scope');", "const hasRoutingContract = /Routing contract/i.test(body) || /Local execution must run through Agent Relay/i.test(body) || /Run local execution through the generated Agent Relay workflow artifact/i.test(body) || /routes local execution through the generated Agent Relay artifact/i.test(body) || /Use the generated Agent Relay workflow artifact/i.test(body);", "if (!hasRoutingContract) throw new Error('lead plan missing required marker: Routing contract');", "const hasImplementationContract = /Implementation contract/i.test(body) || /This is an implementation spec/i.test(body);", @@ -612,6 +612,7 @@ ${formatList(spec.targetFiles.length > 0 ? spec.targetFiles : ['A generated work Non-goals: ${formatList(nonGoals)} +Use this exact section heading in the lead plan. Do not rename it to "Out of scope" or another synonym. Routing contract: - Local: run through Agent Relay using the generated workflow artifact and persist artifacts under ${artifactsDir}. diff --git a/src/product/spec-intake/normalizer.ts b/src/product/spec-intake/normalizer.ts index cccc5a57..affcb616 100644 --- a/src/product/spec-intake/normalizer.ts +++ b/src/product/spec-intake/normalizer.ts @@ -135,10 +135,12 @@ function validateNormalized(spec: NormalizedWorkflowSpec): ValidationIssue[] { } function categorizeConstraint(constraint: string): NormalizedConstraint['category'] { + if (/\b(non[- ]?goal|out[- ]of[- ]scope|not in scope|only|own|scope|do not modify|do not touch|exclude)\b/i.test(constraint)) { + return 'scope'; + } if (/\b(file|repo|typescript|node|api|mcp|slack|cli|network|llm|dependency|package)\b/i.test(constraint)) { return 'technical'; } - if (/\b(only|own|scope|do not modify|do not touch|non-goal|exclude)\b/i.test(constraint)) return 'scope'; if (/\b(timeout|deadline|minutes?|hours?|today|tomorrow|before|after)\b/i.test(constraint)) return 'timeline'; if (/\b(test|typecheck|review|evidence|acceptance|quality|deterministic)\b/i.test(constraint)) return 'quality'; return 'other'; diff --git a/src/product/spec-intake/parser.test.ts b/src/product/spec-intake/parser.test.ts index 4cd4e356..c7cb67d1 100644 --- a/src/product/spec-intake/parser.test.ts +++ b/src/product/spec-intake/parser.test.ts @@ -71,6 +71,35 @@ describe('spec intake parser, normalizer, and router', () => { ); }); + it('preserves out-of-scope sections as non-goal scope constraints', () => { + const result = intake( + natural( + [ + 'Generate a workflow for AgentWorkforce/ricky.', + '', + '## Out of scope', + '', + '- Passive Linear comment monitoring', + '- Custom per-repo workflow templates', + ].join('\n'), + ), + ); + + expect(result.success).toBe(true); + expect(result.routing?.normalizedSpec.constraints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + constraint: 'Non-goal: Passive Linear comment monitoring', + category: 'scope', + }), + expect.objectContaining({ + constraint: 'Non-goal: Custom per-repo workflow templates', + category: 'scope', + }), + ]), + ); + }); + it('preserves MCP-style structured payload source and provider context', () => { const payload: RawSpecPayload = { kind: 'mcp', diff --git a/src/product/spec-intake/parser.ts b/src/product/spec-intake/parser.ts index 32f62343..0cfaeffa 100644 --- a/src/product/spec-intake/parser.ts +++ b/src/product/spec-intake/parser.ts @@ -295,6 +295,7 @@ function extractFieldsFromRecord( export function extractConstraints(text: string): string[] { const constraints: string[] = []; const lines = text.split(/\r?\n/).map((line) => line.trim()); + constraints.push(...extractNonGoalSectionItems(text)); for (const line of lines) { if (/^(constraint|constraints|must|must not|do not|only|avoid|requirement|required)\b/i.test(stripBullet(line))) { constraints.push(stripBullet(line)); @@ -312,6 +313,27 @@ export function extractConstraints(text: string): string[] { return dedupe(constraints); } +function extractNonGoalSectionItems(text: string): string[] { + const constraints: string[] = []; + const lines = text.split(/\r?\n/); + let inNonGoalSection = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line)?.[1]?.replace(/#+$/, '').trim() ?? ''; + if (heading) { + inNonGoalSection = /\b(non[- ]?goals?|out[- ]of[- ]scope|not in scope|out of scope)\b/i.test(heading); + continue; + } + if (!inNonGoalSection) continue; + if (!line) continue; + const item = stripBullet(line); + if (item && item !== line) constraints.push(`Non-goal: ${item}`); + } + + return constraints; +} + export function extractEvidenceRequirements(text: string): string[] { const requirements = extractLabeledLines(text, [ 'evidence', diff --git a/src/surfaces/cli/commands/cli-main.test.ts b/src/surfaces/cli/commands/cli-main.test.ts index 3bf8de4d..8869848c 100644 --- a/src/surfaces/cli/commands/cli-main.test.ts +++ b/src/surfaces/cli/commands/cli-main.test.ts @@ -322,12 +322,23 @@ describe('parseArgs', () => { runId: 'ricky-local-123', json: true, }); + expect(parseArgs(['status', 'linear', '--json'])).toEqual({ + command: 'status', + surface: 'status', + statusTarget: 'linear', + json: true, + }); expect(parseArgs(['connect', 'agents', '--cloud', 'claude,codex'])).toMatchObject({ command: 'connect', surface: 'connect', connectTarget: 'agents', cloudTargets: ['claude', 'codex'], }); + expect(parseArgs(['connect', 'linear'])).toMatchObject({ + command: 'connect', + surface: 'connect', + connectTarget: 'linear', + }); }); }); @@ -2168,6 +2179,39 @@ describe('cliMain', () => { } }); + it('ricky status linear renders readiness in GitHub-first order', async () => { + const result = await cliMain({ + argv: ['status', 'linear', '--json'], + checkCloudReadiness: vi.fn().mockResolvedValue({ + account: { connected: true }, + credentials: { connected: true }, + workspace: { connected: true, label: 'workspace-1' }, + agents: { + claude: { connected: false, capable: false }, + codex: { connected: true, capable: true }, + opencode: { connected: false, capable: false }, + gemini: { connected: false, capable: false }, + }, + integrations: { + slack: { connected: false }, + github: { connected: false }, + notion: { connected: false }, + linear: { connected: true, label: 'linear-ricky' }, + }, + }), + }); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.output.join('\n')); + expect(parsed.target).toBe('linear'); + expect(parsed.rows.map((row: { label: string }) => row.label)).toEqual([ + 'GitHub App', + 'Connected agents', + 'Linear Actor app', + ]); + expect(parsed.rows[1]).toMatchObject({ status: 'blocked' }); + }); + it('ricky status treats rejected stored Cloud auth as not authenticated', async () => { const readCloudAuth = vi.fn().mockResolvedValue({ accessToken: 'expired-token', @@ -2468,6 +2512,29 @@ describe('cliMain', () => { }); }); + it('ricky connect linear returns Cloud dashboard guidance without invoking connector flows', async () => { + const connectProvider = vi.fn(); + const ensureCloudAuthenticated = vi.fn(); + const connectCloudIntegrations = vi.fn(); + + const result = await cliMain({ + argv: ['connect', 'linear', '--json'], + connectProvider, + ensureCloudAuthenticated, + connectCloudIntegrations, + }); + + expect(result.exitCode).toBe(0); + expect(connectProvider).not.toHaveBeenCalled(); + expect(ensureCloudAuthenticated).not.toHaveBeenCalled(); + expect(connectCloudIntegrations).not.toHaveBeenCalled(); + expect(JSON.parse(result.output.join('\n'))).toMatchObject({ + target: 'linear', + status: 'manual-dashboard', + nextActions: ['ricky status linear'], + }); + }); + it('fails ricky connect integrations --json without stored Cloud auth instead of opening login flow', async () => { const connectCloudIntegrations = vi.fn(); diff --git a/src/surfaces/cli/commands/cli-main.ts b/src/surfaces/cli/commands/cli-main.ts index 7dd37bed..fb768cd9 100644 --- a/src/surfaces/cli/commands/cli-main.ts +++ b/src/surfaces/cli/commands/cli-main.ts @@ -44,6 +44,8 @@ import { defaultArtifactPathForWorkflowName } from '../flows/spec-intake-flow.js import { CLOUD_IMPLEMENTATION_AGENTS, CLOUD_OPTIONAL_INTEGRATIONS } from '../flows/cloud-workflow-flow.js'; import { resolvePreferWorkforcePersonaWorkflowWriter } from '../flows/workforce-persona-cli-preference.js'; import { DEFAULT_AUTO_FIX_ATTEMPTS } from '../../../shared/constants.js'; +import { renderLinearConnectGuidance } from '../../linear/connect.js'; +import { linearStatusSummary, renderLinearStatus } from '../../linear/status.js'; // --------------------------------------------------------------------------- // Parsed CLI arguments @@ -54,6 +56,7 @@ export interface ParsedArgs { surface?: PowerUserSurface; mode?: RickyMode; connectTarget?: ConnectTarget; + statusTarget?: 'linear'; cloudTargets?: string[]; runId?: string; spec?: string; @@ -148,6 +151,7 @@ export function parseArgs(argv: string[]): ParsedArgs { if (parsed.surface && parsed.surface !== 'legacy') result.surface = parsed.surface; if (parsed.mode) result.mode = parsed.mode; if (parsed.connectTarget) result.connectTarget = parsed.connectTarget; + if (parsed.statusTarget) result.statusTarget = parsed.statusTarget; if (parsed.cloudTargets) result.cloudTargets = parsed.cloudTargets; if (parsed.runId) result.runId = parsed.runId; if (parsed.spec !== undefined) result.spec = parsed.spec; @@ -295,10 +299,12 @@ export function renderHelp(): string[] { ' ricky run Run attached in this terminal', ' ricky run --background Run it in the background', ' ricky status --run Check progress', + ' ricky status linear Check Linear readiness', '', 'Common commands:', ' ricky status Show local and Cloud readiness', ' ricky connect cloud Connect AgentWorkforce Cloud', + ' ricky connect linear Show Linear Actor app install guidance', ' ricky workflow --spec-file --run Generate, then run a workflow', ' ricky workflow --spec-file --mode cloud Generate with Cloud', ' ricky cloud --spec Generate with Cloud', @@ -706,6 +712,14 @@ async function renderStatus(parsed: ParsedArgs, cwd: string, deps: CliMainDeps = return renderRunMonitorStatus(parsed.runId, cwd, parsed); } + if (parsed.statusTarget === 'linear') { + const readiness = await readLinearStatusReadiness(deps); + if (parsed.json) { + return [JSON.stringify({ target: 'linear', ...linearStatusSummary(readiness) }, null, 2)]; + } + return renderLinearStatus(readiness); + } + const status = await statusPayload(cwd, deps); if (parsed.json) { return [JSON.stringify(status, null, 2)]; @@ -948,6 +962,17 @@ async function readStatusCloudReadiness(params: { } } +async function readLinearStatusReadiness( + deps: Pick = {}, +): Promise { + const warnings: string[] = []; + const auth = await readStatusCloudAuth(deps); + const workspaceId = await resolveStatusCloudWorkspaceId(deps, auth); + if (!deps.checkCloudReadiness && (!auth || !workspaceId)) return undefined; + return readStatusCloudReadiness({ deps, auth, workspaceId, warnings }); +} + + async function fetchStatusCloudReadiness( auth: StoredAuth, workspaceId: string, @@ -1218,6 +1243,17 @@ function connectExitCode(payload: ConnectPayload): number { } async function connectPayload(parsed: ParsedArgs, deps: CliMainDeps): Promise { + if (parsed.connectTarget === 'linear') { + const guidance = renderLinearConnectGuidance(); + return { + target: 'linear', + status: 'manual-dashboard', + message: guidance.join('\n'), + warnings: [], + nextActions: ['ricky status linear'], + }; + } + if (parsed.connectTarget === 'agents') { const providers = parsed.cloudTargets ?? []; if (providers.length === 0) { diff --git a/src/surfaces/cli/flows/power-user-parser.ts b/src/surfaces/cli/flows/power-user-parser.ts index 09e2c741..85188c60 100644 --- a/src/surfaces/cli/flows/power-user-parser.ts +++ b/src/surfaces/cli/flows/power-user-parser.ts @@ -4,7 +4,8 @@ import { DEFAULT_AUTO_FIX_ATTEMPTS } from '../../../shared/constants.js'; export type PowerUserCommand = 'run' | 'help' | 'version' | 'status' | 'connect'; export type PowerUserSurface = 'legacy' | 'local' | 'cloud' | 'workflow' | 'status' | 'connect'; -export type ConnectTarget = 'cloud' | 'agents' | 'integrations'; +export type ConnectTarget = 'cloud' | 'agents' | 'integrations' | 'linear'; +export type StatusTarget = 'linear'; const DEFAULT_CLOUD_AGENT_TARGETS = ['claude', 'codex', 'opencode', 'gemini']; const DEFAULT_CLOUD_INTEGRATION_TARGETS = ['slack', 'github', 'notion', 'linear']; @@ -14,6 +15,7 @@ export interface PowerUserParsedArgs { surface: PowerUserSurface; mode?: RickyMode; connectTarget?: ConnectTarget; + statusTarget?: StatusTarget; cloudTargets?: string[]; runId?: string; spec?: string; @@ -53,12 +55,15 @@ export function parsePowerUserArgs(argv: string[]): PowerUserParsedArgs { if (first === 'status') { const statusArgv = argv.slice(1); - const parsed = withCommonFlags({ command: 'status', surface: 'status' }, statusArgv); - const runId = readFlagValue(statusArgv, '--run'); + const statusTarget = readStatusTarget(statusArgv); + const parsed = withCommonFlags({ command: 'status', surface: 'status' }, statusTarget ? statusArgv.slice(1) : statusArgv); + const effectiveStatusArgv = statusTarget ? statusArgv.slice(1) : statusArgv; + const runId = readFlagValue(effectiveStatusArgv, '--run'); return { ...parsed, + ...(statusTarget ? { statusTarget } : {}), ...(runId ? { runId } : {}), - ...(statusArgv.includes('--run') && !runId ? { errors: [...(parsed.errors ?? []), '--run requires a value.'] } : {}), + ...(effectiveStatusArgv.includes('--run') && !runId ? { errors: [...(parsed.errors ?? []), '--run requires a value.'] } : {}), }; } @@ -153,8 +158,8 @@ function parseConnect(argv: string[]): PowerUserParsedArgs { const base = withCommonFlags({ command: 'connect', surface: 'connect' }, argv.slice(target ? 1 : 0)); const errors: string[] = [...(base.errors ?? [])]; - if (target !== 'cloud' && target !== 'agents' && target !== 'integrations') { - errors.push('connect requires one of: cloud, agents, integrations.'); + if (target !== 'cloud' && target !== 'agents' && target !== 'integrations' && target !== 'linear') { + errors.push('connect requires one of: cloud, agents, integrations, linear.'); return { ...base, ...(errors.length > 0 ? { errors } : {}) }; } @@ -182,6 +187,11 @@ function resolveCloudTargets( return undefined; } +function readStatusTarget(argv: string[]): StatusTarget | undefined { + const candidate = argv[0]?.trim().toLowerCase(); + return candidate === 'linear' ? 'linear' : undefined; +} + function withCommonFlags>( parsed: T, argv: string[], diff --git a/src/surfaces/linear/README.md b/src/surfaces/linear/README.md new file mode 100644 index 00000000..bdcdfa0c --- /dev/null +++ b/src/surfaces/linear/README.md @@ -0,0 +1,21 @@ +# Ricky Linear Surface + +This directory contains Ricky OSS artifacts for the Linear Actor integration. Cloud owns webhook ingress, OAuth installation, database-backed deduplication, workflow execution, and AgentActivity egress. Ricky owns the shared contracts and deterministic workflow construction logic Cloud imports. + +## Public Entry Points + +Cloud should import from `src/surfaces/linear/index.ts` for the orchestration helper, event contracts, workflow builder, status helper, and connect guidance. Cloud should import HTTP/session wire types from `src/cloud/api/linear-agent-types.ts`. It should not deep-import implementation files from this directory. + +## Contract + +`handleLinearMention(input, deps)` verifies signatures through an injected verifier, checks deduplication, classifies the Linear AgentSessionEvent, checks GitHub readiness before connected-agent readiness, builds a Ricky workflow, invokes an injected Cloud runner, mirrors lifecycle updates as `AgentActivity`, and ends the session with `completed`, `completed_no_changes`, or `failed`. + +`buildLinearWorkflow(input)` turns issue context, repo target, connected agents, and actor identity into a Cloud-executable Ricky workflow artifact. Pattern selection uses the existing product pattern selector, with the connected-agent count deciding whether the Linear run is `pipeline`, `supervisor`, or `dag`. + +## CLI Helpers + +`ricky status linear` renders Linear readiness in the required order: GitHub App, connected agents, then Linear Actor app. `ricky connect linear` prints Cloud dashboard guidance for installing the Linear OAuth Actor app. + +## Skill Boundary + +Skills apply while Ricky generates workflows. Runtime agents receive rendered workflow instructions and do not load or embody skill files. diff --git a/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json b/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json new file mode 100644 index 00000000..e256a1d2 --- /dev/null +++ b/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": [ + "id", + "webhookId", + "type", + "sessionId", + "organizationId", + "trigger", + "actor", + "issue", + "createdAt" + ], + "properties": { + "id": { "type": "string" }, + "webhookId": { "type": "string" }, + "type": { "enum": ["created", "prompted"] }, + "sessionId": { "type": "string" }, + "organizationId": { "type": "string" }, + "trigger": { "enum": ["issue_mention", "comment_mention", "assignment"] }, + "actor": { + "type": "object", + "required": ["linearUserId"] + }, + "issue": { + "type": "object", + "required": ["id", "title"] + }, + "createdAt": { "type": "string" } + } +} diff --git a/src/surfaces/linear/__tests__/event-types.test.ts b/src/surfaces/linear/__tests__/event-types.test.ts new file mode 100644 index 00000000..8235df36 --- /dev/null +++ b/src/surfaces/linear/__tests__/event-types.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { + classifyLinearMentionEvent, + isAgentSessionEvent, + type AgentActivity, + type AgentSessionEvent, +} from '../event-types.js'; + +const schema = JSON.parse(readFileSync( + fileURLToPath(new URL('./__fixtures__/linear-agent-session-event.schema.json', import.meta.url)), + 'utf8', +)) as { required: string[] }; + +const baseEvent: AgentSessionEvent = { + id: 'event-1', + webhookId: 'webhook-1', + type: 'created', + sessionId: 'session-1', + organizationId: 'org-1', + trigger: 'issue_mention', + actor: { linearUserId: 'linear-user-1', cloudUserId: 'cloud-user-1' }, + issue: { id: 'issue-1', title: 'Ship Linear integration', labels: ['linear'] }, + createdAt: '2026-05-06T00:00:00.000Z', +}; + +describe('Linear event types', () => { + it('matches the checked-in AgentSessionEvent schema fixture required fields', () => { + expect(schema.required).toEqual([ + 'id', + 'webhookId', + 'type', + 'sessionId', + 'organizationId', + 'trigger', + 'actor', + 'issue', + 'createdAt', + ]); + expect(isAgentSessionEvent(baseEvent)).toBe(true); + }); + + it('classifies issue mentions, comment mentions, and assignments', () => { + expect(classifyLinearMentionEvent({ ...baseEvent, trigger: 'issue_mention' }).classification).toBe('issue_mention'); + expect(classifyLinearMentionEvent({ ...baseEvent, trigger: 'comment_mention' }).classification).toBe('comment_mention'); + expect(classifyLinearMentionEvent({ ...baseEvent, trigger: 'assignment' }).classification).toBe('assignment'); + }); + + it('keeps AgentActivity kind constrained to Linear activity variants', () => { + const activity: AgentActivity = { + sessionId: 'session-1', + kind: 'thought', + body: 'Selected pipeline.', + createdAt: '2026-05-06T00:00:00.000Z', + }; + + expect(activity.kind).toBe('thought'); + }); +}); diff --git a/src/surfaces/linear/__tests__/index.test.ts b/src/surfaces/linear/__tests__/index.test.ts new file mode 100644 index 00000000..aff381c9 --- /dev/null +++ b/src/surfaces/linear/__tests__/index.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + handleLinearMention, + type LinearMentionDeps, +} from '../index.js'; +import type { AgentActivity, AgentSessionEvent } from '../event-types.js'; + +const event: AgentSessionEvent = { + id: 'event-1', + webhookId: 'webhook-1', + type: 'created', + sessionId: 'session-1', + organizationId: 'org-1', + workspaceId: 'workspace-1', + trigger: 'issue_mention', + actor: { linearUserId: 'linear-user-1', cloudUserId: 'cloud-user-1', name: 'A User' }, + issue: { + id: 'issue-1', + title: 'Fix generated workflow', + description: 'Run the repair path.', + labels: ['bug'], + }, + createdAt: '2026-05-06T00:00:00.000Z', +}; + +function deps(overrides: Partial = {}): LinearMentionDeps & { + activities: AgentActivity[]; + ended: Array<{ sessionId: string; reason: string }>; +} { + const activities: AgentActivity[] = []; + const ended: Array<{ sessionId: string; reason: string }> = []; + return { + activities, + ended, + signatureVerifier: vi.fn().mockResolvedValue(true), + dedupStore: { + has: vi.fn().mockResolvedValue(false), + mark: vi.fn().mockResolvedValue(undefined), + }, + githubInstallProbe: vi.fn().mockResolvedValue(true), + agentRegistry: { + list: vi.fn().mockResolvedValue([{ id: 'codex', name: 'Codex', capabilities: ['implementation'] }]), + }, + workflowRunner: vi.fn().mockResolvedValue({ + status: 'completed', + prUrl: 'https://github.example/pr/1', + summary: 'Opened PR.', + }), + activityWriter: { + write: vi.fn(async (activity: AgentActivity) => { + activities.push(activity); + }), + endSession: vi.fn(async (params) => { + ended.push(params); + }), + }, + clock: () => new Date('2026-05-06T00:00:00.000Z'), + ...overrides, + }; +} + +describe('handleLinearMention', () => { + it('fails before dedup when signature verification fails', async () => { + const d = deps({ signatureVerifier: vi.fn().mockResolvedValue(false) }); + + const result = await handleLinearMention({ payload: event }, d); + + expect(result).toEqual({ status: 'failed', reason: 'failed' }); + expect(d.dedupStore.has).not.toHaveBeenCalled(); + }); + + it('returns no-change ignored result on dedup hits', async () => { + const d = deps({ + dedupStore: { + has: vi.fn().mockResolvedValue(true), + mark: vi.fn(), + }, + }); + + const result = await handleLinearMention({ payload: event }, d); + + expect(result).toEqual({ status: 'ignored', sessionId: 'session-1', reason: 'completed_no_changes' }); + expect(d.dedupStore.mark).not.toHaveBeenCalled(); + }); + + it('checks GitHub readiness before connected agents', async () => { + const d = deps({ githubInstallProbe: vi.fn().mockResolvedValue(false) }); + + const result = await handleLinearMention({ payload: event }, d); + + expect(result.reason).toBe('failed'); + expect(d.githubInstallProbe).toHaveBeenCalledTimes(1); + expect(d.agentRegistry.list).not.toHaveBeenCalled(); + expect(d.activities[0]).toMatchObject({ kind: 'response' }); + }); + + it('fails readiness after GitHub when no capable Cloud agents are connected', async () => { + const d = deps({ + agentRegistry: { + list: vi.fn().mockResolvedValue([]), + }, + }); + + const result = await handleLinearMention({ payload: event }, d); + + expect(result).toEqual({ status: 'failed', sessionId: 'session-1', reason: 'failed' }); + expect(d.githubInstallProbe).toHaveBeenCalledTimes(1); + expect(d.agentRegistry.list).toHaveBeenCalledWith({ + scope: 'workspace-1', + actor: event.actor, + }); + expect(d.workflowRunner).not.toHaveBeenCalled(); + expect(d.activities).toEqual([ + expect.objectContaining({ + kind: 'response', + body: 'No connected Cloud implementation agents were found for A User.', + }), + ]); + expect(d.ended).toEqual([{ sessionId: 'session-1', reason: 'failed' }]); + }); + + it('mirrors workflow lifecycle to thought, action, response, and error activities', async () => { + const completed = deps(); + + await expect(handleLinearMention({ + payload: event, + repoTarget: { owner: 'AgentWorkforce', repo: 'ricky', defaultBranch: 'main' }, + }, completed)).resolves.toMatchObject({ reason: 'completed' }); + + expect(completed.activities.map((activity) => activity.kind)).toEqual(['thought', 'action', 'response']); + + const failed = deps({ + workflowRunner: vi.fn().mockResolvedValue({ status: 'failed', summary: 'Typecheck failed.' }), + }); + + await expect(handleLinearMention({ + payload: { ...event, webhookId: 'webhook-error' }, + repoTarget: { owner: 'AgentWorkforce', repo: 'ricky', defaultBranch: 'main' }, + }, failed)).resolves.toMatchObject({ reason: 'failed' }); + + expect(failed.activities.map((activity) => activity.kind)).toEqual(['thought', 'action', 'error']); + }); + + it('reaches completed, completed_no_changes, and failed terminal reasons', async () => { + const completed = deps(); + await expect(handleLinearMention({ + payload: event, + repoTarget: { owner: 'AgentWorkforce', repo: 'ricky', defaultBranch: 'main' }, + }, completed)).resolves.toMatchObject({ reason: 'completed' }); + + const noChanges = deps({ + workflowRunner: vi.fn().mockResolvedValue({ status: 'completed_no_changes', summary: 'No diff.' }), + }); + await expect(handleLinearMention({ + payload: { ...event, webhookId: 'webhook-2' }, + repoTarget: { owner: 'AgentWorkforce', repo: 'ricky', defaultBranch: 'main' }, + }, noChanges)).resolves.toMatchObject({ reason: 'completed_no_changes' }); + + const failed = deps({ + workflowRunner: vi.fn().mockResolvedValue({ status: 'failed', summary: 'Typecheck failed.' }), + }); + await expect(handleLinearMention({ + payload: { ...event, webhookId: 'webhook-3' }, + repoTarget: { owner: 'AgentWorkforce', repo: 'ricky', defaultBranch: 'main' }, + }, failed)).resolves.toMatchObject({ reason: 'failed' }); + }); +}); diff --git a/src/surfaces/linear/__tests__/status.test.ts b/src/surfaces/linear/__tests__/status.test.ts new file mode 100644 index 00000000..3a11ea2b --- /dev/null +++ b/src/surfaces/linear/__tests__/status.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { linearStatusSummary } from '../status.js'; + +describe('linearStatusSummary', () => { + it('checks GitHub before connected agents', () => { + const summary = linearStatusSummary({ + integrations: { + github: { connected: false }, + linear: { connected: true, label: 'linear-ricky' }, + }, + agents: { + codex: { connected: true, capable: true }, + }, + }); + + expect(summary.ready).toBe(false); + expect(summary.rows.map((row) => row.label)).toEqual(['GitHub App', 'Connected agents', 'Linear Actor app']); + expect(summary.rows[1]).toMatchObject({ + status: 'blocked', + message: 'not checked until GitHub App is installed', + }); + }); +}); diff --git a/src/surfaces/linear/__tests__/workflow-builder.test.ts b/src/surfaces/linear/__tests__/workflow-builder.test.ts new file mode 100644 index 00000000..5ba3156c --- /dev/null +++ b/src/surfaces/linear/__tests__/workflow-builder.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { buildLinearWorkflow } from '../workflow-builder.js'; + +function baseInput(agentCount: number) { + return { + issue: { + title: 'Fix flaky import path handling', + description: 'The generated workflow should normalize imports before running tests.', + labels: ['bug', 'backend'], + comments: ['Please keep the fix scoped.'], + }, + repoTarget: { owner: 'AgentWorkforce', repo: 'ricky', defaultBranch: 'main' }, + connectedAgents: Array.from({ length: agentCount }, (_, index) => ({ + id: `agent-${index + 1}`, + name: `Agent ${index + 1}`, + capabilities: ['implementation', 'tests'], + })), + actor: { linearUserId: 'linear-user-1', cloudUserId: 'cloud-user-1' }, + }; +} + +describe('buildLinearWorkflow', () => { + it('selects pipeline, supervisor, and dag from connected-agent count', () => { + expect(buildLinearWorkflow(baseInput(1)).pattern).toBe('pipeline'); + expect(buildLinearWorkflow(baseInput(2)).pattern).toBe('supervisor'); + expect(buildLinearWorkflow(baseInput(3)).pattern).toBe('dag'); + }); + + it('includes issue labels in the rationale and generated artifact', () => { + const result = buildLinearWorkflow(baseInput(2)); + + expect(result.rationale).toContain('bug, backend'); + expect(result.artifactContent).toContain('Respect issue labels: bug, backend'); + expect(result.selectedAgents).toEqual(['Agent 1', 'Agent 2']); + }); + + it('throws when repoTarget is missing', () => { + expect(() => buildLinearWorkflow({ ...baseInput(1), repoTarget: null })).toThrow(/repoTarget/); + }); +}); diff --git a/src/surfaces/linear/connect.ts b/src/surfaces/linear/connect.ts new file mode 100644 index 00000000..2ed66809 --- /dev/null +++ b/src/surfaces/linear/connect.ts @@ -0,0 +1,34 @@ +import { + LINEAR_CONNECT_DASHBOARD_URL, + LINEAR_CONNECT_INSTRUCTIONS, +} from '../../cloud/auth/provider-connect.js'; + +export interface LinearConnectGuidance { + provider: 'linear'; + dashboardUrl: string; + instructions: string[]; +} + +export { LINEAR_CONNECT_DASHBOARD_URL, LINEAR_CONNECT_INSTRUCTIONS }; + +export function getLinearConnectGuidance(): LinearConnectGuidance { + return { + provider: 'linear', + dashboardUrl: LINEAR_CONNECT_DASHBOARD_URL, + instructions: LINEAR_CONNECT_INSTRUCTIONS, + }; +} + +export function renderLinearConnectGuidance(): string[] { + const guidance = getLinearConnectGuidance(); + return [ + 'Ricky connect linear', + '', + 'Linear uses the Cloud dashboard to install the Ricky OAuth Actor app.', + `Dashboard: ${guidance.dashboardUrl}`, + '', + 'Next', + ...guidance.instructions.map((instruction) => ` ${instruction}`), + ' ricky status linear', + ]; +} diff --git a/src/surfaces/linear/event-types.ts b/src/surfaces/linear/event-types.ts new file mode 100644 index 00000000..20d49530 --- /dev/null +++ b/src/surfaces/linear/event-types.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; + +import type { + AgentActivity, + AgentActivityKind, + AgentSessionEvent, + AgentSessionEventType, + AgentSessionTrigger, + LinearActor, + LinearIssueContext, +} from '../../cloud/api/linear-agent-types.js'; + +export type { + AgentActivity, + AgentActivityKind, + AgentSessionEvent, + AgentSessionEventType, + AgentSessionTrigger, + LinearActor, + LinearIssueContext, +}; + +export const LinearActorSchema: z.ZodType = z.object({ + linearUserId: z.string().min(1), + cloudUserId: z.string().optional(), + name: z.string().optional(), +}); + +export const LinearIssueContextSchema: z.ZodType = z.object({ + id: z.string().min(1), + identifier: z.string().optional(), + title: z.string().min(1), + description: z.string().optional(), + labels: z.array(z.string()).optional(), + comments: z.array(z.string()).optional(), + project: z.string().optional(), + url: z.string().optional(), +}); + +export const AgentSessionEventSchema: z.ZodType = z.object({ + id: z.string().min(1), + webhookId: z.string().min(1), + type: z.enum(['created', 'prompted']), + sessionId: z.string().min(1), + organizationId: z.string().min(1), + workspaceId: z.string().optional(), + trigger: z.enum(['issue_mention', 'comment_mention', 'assignment']), + actor: LinearActorSchema, + issue: LinearIssueContextSchema, + prompt: z.string().optional(), + createdAt: z.string().min(1), +}); + +export const AgentActivitySchema: z.ZodType = z.object({ + id: z.string().optional(), + sessionId: z.string().min(1), + kind: z.enum(['thought', 'action', 'response', 'error']), + body: z.string(), + createdAt: z.string().min(1), + metadata: z.record(z.unknown()).optional(), +}); + +export interface IssueMentionEvent { + classification: 'issue_mention'; + event: AgentSessionEvent & { trigger: 'issue_mention' }; +} + +export interface CommentMentionEvent { + classification: 'comment_mention'; + event: AgentSessionEvent & { trigger: 'comment_mention' }; +} + +export interface AssignmentEvent { + classification: 'assignment'; + event: AgentSessionEvent & { trigger: 'assignment' }; +} + +export type LinearMentionEvent = IssueMentionEvent | CommentMentionEvent | AssignmentEvent; + +export function isAgentSessionEvent(value: unknown): value is AgentSessionEvent { + return AgentSessionEventSchema.safeParse(value).success; +} + +export function classifyLinearMentionEvent(event: AgentSessionEvent): LinearMentionEvent { + if (event.trigger === 'issue_mention') { + return { classification: 'issue_mention', event: event as AgentSessionEvent & { trigger: 'issue_mention' } }; + } + if (event.trigger === 'comment_mention') { + return { classification: 'comment_mention', event: event as AgentSessionEvent & { trigger: 'comment_mention' } }; + } + return { classification: 'assignment', event: event as AgentSessionEvent & { trigger: 'assignment' } }; +} diff --git a/src/surfaces/linear/index.ts b/src/surfaces/linear/index.ts new file mode 100644 index 00000000..9dde08b7 --- /dev/null +++ b/src/surfaces/linear/index.ts @@ -0,0 +1,179 @@ +export { + classifyLinearMentionEvent, + isAgentSessionEvent, +} from './event-types.js'; +export type { + AgentActivity, + AgentActivityKind, + AgentSessionEvent, + AgentSessionEventType, + AgentSessionTrigger, + AssignmentEvent, + CommentMentionEvent, + IssueMentionEvent, + LinearMentionEvent, +} from './event-types.js'; +export { + buildLinearWorkflow, +} from './workflow-builder.js'; +export type { + BuildLinearWorkflowInput, + BuildLinearWorkflowResult, +} from './workflow-builder.js'; +export { + linearStatusSummary, + renderLinearStatus, +} from './status.js'; +export type { + LinearReadinessSnapshot, + LinearStatusSummary, +} from './status.js'; +export { + getLinearConnectGuidance, + renderLinearConnectGuidance, +} from './connect.js'; + +import type { AgentActivity, AgentSessionEvent } from './event-types.js'; +import { classifyLinearMentionEvent, isAgentSessionEvent } from './event-types.js'; +import { buildLinearWorkflow, type BuildLinearWorkflowResult } from './workflow-builder.js'; +import type { SessionEndReason } from '../../cloud/api/linear-agent-types.js'; + +export type LinearMentionHandleStatus = 'completed' | 'completed_no_changes' | 'failed' | 'ignored'; + +export interface LinearMentionHandleInput { + payload: unknown; + rawBody?: string; + headers?: Record; + repoTarget?: { + owner: string; + repo: string; + defaultBranch: string; + }; +} + +export interface LinearMentionHandleResult { + status: LinearMentionHandleStatus; + sessionId?: string; + reason: SessionEndReason; +} + +export interface LinearMentionDeps { + signatureVerifier: (input: LinearMentionHandleInput) => boolean | Promise; + dedupStore: { + has: (webhookId: string) => boolean | Promise; + mark: (webhookId: string) => void | Promise; + }; + githubInstallProbe: (event: AgentSessionEvent) => boolean | Promise; + agentRegistry: { + list: (query: { + scope: string; + actor: AgentSessionEvent['actor']; + }) => Promise>; + }; + workflowRunner: (workflow: BuildLinearWorkflowResult, event: AgentSessionEvent) => Promise<{ + status: Exclude; + prUrl?: string; + summary?: string; + }>; + activityWriter: { + write: (activity: AgentActivity) => void | Promise; + endSession: (params: { sessionId: string; reason: SessionEndReason }) => void | Promise; + }; + clock?: () => Date; + logger?: { warn?: (message: string, metadata?: Record) => void }; +} + +export async function handleLinearMention( + input: LinearMentionHandleInput, + deps: LinearMentionDeps, +): Promise { + const verified = await deps.signatureVerifier(input); + if (!verified) { + return { status: 'failed', reason: 'failed' }; + } + + if (!isAgentSessionEvent(input.payload)) { + deps.logger?.warn?.('Ignoring non-AgentSessionEvent Linear payload.'); + return { status: 'ignored', reason: 'completed_no_changes' }; + } + + const event = input.payload; + const duplicate = await deps.dedupStore.has(event.webhookId); + if (duplicate) { + return { status: 'ignored', sessionId: event.sessionId, reason: 'completed_no_changes' }; + } + await deps.dedupStore.mark(event.webhookId); + + classifyLinearMentionEvent(event); + + const githubReady = await deps.githubInstallProbe(event); + if (!githubReady) { + await writeActivity(deps, event, 'response', 'Install the GitHub App before Ricky can open a pull request from Linear.'); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); + return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; + } + + const connectedAgents = await deps.agentRegistry.list({ + scope: event.workspaceId ?? event.organizationId, + actor: event.actor, + }); + if (connectedAgents.length === 0) { + await writeActivity(deps, event, 'response', `No connected Cloud implementation agents were found for ${event.actor.name ?? event.actor.linearUserId}.`); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); + return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; + } + + try { + const workflow = buildLinearWorkflow({ + issue: { + title: event.issue.title, + description: event.issue.description ?? '', + labels: event.issue.labels ?? [], + comments: event.issue.comments ?? [], + ...(event.issue.project ? { project: event.issue.project } : {}), + }, + repoTarget: input.repoTarget, + connectedAgents, + actor: { + linearUserId: event.actor.linearUserId, + cloudUserId: event.actor.cloudUserId ?? event.actor.linearUserId, + }, + }); + await writeActivity(deps, event, 'thought', workflow.rationale); + await writeActivity(deps, event, 'action', 'Generating workflow on AgentWorkforce Cloud.'); + const run = await deps.workflowRunner(workflow, event); + if (run.status === 'failed') { + await writeActivity(deps, event, 'error', run.summary ?? 'The Cloud workflow failed.'); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); + return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; + } + + const reason: SessionEndReason = run.status === 'completed_no_changes' ? 'completed_no_changes' : 'completed'; + const body = run.prUrl + ? `Workflow completed and opened a PR: ${run.prUrl}` + : run.summary ?? 'Workflow completed with no code changes needed.'; + await writeActivity(deps, event, 'response', body, run.prUrl ? { prUrl: run.prUrl } : undefined); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason }); + return { status: run.status, sessionId: event.sessionId, reason }; + } catch (error) { + await writeActivity(deps, event, 'error', error instanceof Error ? error.message : String(error)); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); + return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; + } +} + +async function writeActivity( + deps: LinearMentionDeps, + event: AgentSessionEvent, + kind: AgentActivity['kind'], + body: string, + metadata?: Record, +): Promise { + await deps.activityWriter.write({ + sessionId: event.sessionId, + kind, + body, + createdAt: (deps.clock?.() ?? new Date()).toISOString(), + ...(metadata ? { metadata } : {}), + }); +} diff --git a/src/surfaces/linear/status.ts b/src/surfaces/linear/status.ts new file mode 100644 index 00000000..733fdcdd --- /dev/null +++ b/src/surfaces/linear/status.ts @@ -0,0 +1,98 @@ +export interface LinearReadinessCheck { + connected: boolean; + label?: string; + recovery?: string; + capable?: boolean; +} + +export interface LinearReadinessSnapshot { + agents?: Record; + integrations?: { + github?: LinearReadinessCheck; + linear?: LinearReadinessCheck; + }; +} + +export interface LinearStatusRow { + label: string; + status: 'connected' | 'missing' | 'blocked' | 'unknown'; + message: string; +} + +export interface LinearStatusSummary { + ready: boolean; + rows: LinearStatusRow[]; + nextActions: string[]; +} + +export function linearStatusSummary(readiness: LinearReadinessSnapshot | undefined): LinearStatusSummary { + if (!readiness) { + return { + ready: false, + rows: [ + { label: 'GitHub App', status: 'unknown', message: 'Cloud readiness API unavailable' }, + { label: 'Connected agents', status: 'unknown', message: 'Cloud readiness API unavailable' }, + { label: 'Linear Actor app', status: 'unknown', message: 'Cloud readiness API unavailable' }, + ], + nextActions: ['ricky connect cloud', 'ricky status'], + }; + } + + const github = readiness.integrations?.github; + const linear = readiness.integrations?.linear; + const githubReady = github?.connected === true; + const capableAgents = Object.values(readiness.agents ?? {}).filter((agent) => agent.connected === true && agent.capable !== false); + const agentReady = capableAgents.length > 0; + const linearReady = linear?.connected === true; + const rows: LinearStatusRow[] = [ + statusFromCheck('GitHub App', github, 'required before Linear can open PRs'), + githubReady + ? { + label: 'Connected agents', + status: agentReady ? 'connected' : 'missing', + message: agentReady ? `${capableAgents.length} capable agent(s) connected` : 'no capable Cloud implementation agents connected', + } + : { + label: 'Connected agents', + status: 'blocked', + message: 'not checked until GitHub App is installed', + }, + statusFromCheck('Linear Actor app', linear, 'required before Linear can deliver agent sessions'), + ]; + + const nextActions = []; + if (!githubReady) nextActions.push('ricky connect integrations --cloud github'); + if (githubReady && !agentReady) nextActions.push('ricky connect agents --cloud'); + if (!linearReady) nextActions.push('ricky connect linear'); + if (githubReady && agentReady && linearReady) nextActions.push('Mention Ricky on a Linear issue to start a Cloud workflow.'); + + return { + ready: githubReady && agentReady && linearReady, + rows, + nextActions, + }; +} + +export function renderLinearStatus(readiness: LinearReadinessSnapshot | undefined): string[] { + const summary = linearStatusSummary(readiness); + return [ + 'Ricky status linear', + '', + ...summary.rows.map((row) => `${iconForStatus(row.status)} ${row.label}: ${row.message}`), + '', + summary.ready ? 'Ready' : 'Next', + ...summary.nextActions.map((action) => ` ${action}`), + ]; +} + +function statusFromCheck(label: string, check: LinearReadinessCheck | undefined, missingMessage: string): LinearStatusRow { + if (!check) return { label, status: 'unknown', message: 'not reported by Cloud readiness' }; + if (check.connected) return { label, status: 'connected', message: check.label ? `connected (${check.label})` : 'connected' }; + return { label, status: 'missing', message: check.recovery ?? missingMessage }; +} + +function iconForStatus(status: LinearStatusRow['status']): string { + if (status === 'connected') return '✓'; + if (status === 'blocked') return '!'; + return '○'; +} diff --git a/src/surfaces/linear/workflow-builder.ts b/src/surfaces/linear/workflow-builder.ts new file mode 100644 index 00000000..5e395c8f --- /dev/null +++ b/src/surfaces/linear/workflow-builder.ts @@ -0,0 +1,189 @@ +import { selectPattern } from '../../product/generation/pattern-selector.js'; +import type { NormalizedWorkflowSpec } from '../../product/spec-intake/types.js'; +import type { SwarmPattern } from '../../shared/models/workflow-config.js'; + +export interface BuildLinearWorkflowInput { + issue: { + title: string; + description: string; + labels: string[]; + comments: string[]; + project?: string; + }; + repoTarget: { owner: string; repo: string; defaultBranch: string } | null | undefined; + connectedAgents: ReadonlyArray<{ id: string; name: string; capabilities: string[] }>; + actor: { linearUserId: string; cloudUserId: string }; +} + +export interface BuildLinearWorkflowResult { + artifactPath: string; + artifactContent: string; + pattern: 'pipeline' | 'supervisor' | 'dag'; + selectedAgents: ReadonlyArray; + rationale: string; +} + +export function buildLinearWorkflow(input: BuildLinearWorkflowInput): BuildLinearWorkflowResult { + const repoTarget = normalizeRepoTarget(input.repoTarget); + const selectedAgents = input.connectedAgents.map((agent) => agent.name); + const patternOverride = patternForConnectedAgents(input.connectedAgents.length); + const normalizedSpec = toNormalizedWorkflowSpec(input, repoTarget); + const patternDecision = selectPattern(normalizedSpec, patternOverride); + const labelText = input.issue.labels.length > 0 ? input.issue.labels.join(', ') : 'none'; + const rationale = [ + `Selected ${patternDecision.pattern} for ${input.connectedAgents.length} connected agent(s).`, + `Issue labels: ${labelText}.`, + patternDecision.reason, + ].join(' '); + + return { + artifactPath: `workflows/generated/linear-${slugify(input.issue.title)}.ts`, + artifactContent: renderWorkflowArtifact({ + input, + repoTarget, + pattern: patternDecision.pattern, + selectedAgents, + rationale, + }), + pattern: patternDecision.pattern, + selectedAgents, + rationale, + }; +} + +function normalizeRepoTarget( + repoTarget: BuildLinearWorkflowInput['repoTarget'], +): { owner: string; repo: string; defaultBranch: string } { + if (!repoTarget?.owner?.trim() || !repoTarget.repo?.trim() || !repoTarget.defaultBranch?.trim()) { + throw new Error('Linear workflow generation requires repoTarget with owner, repo, and defaultBranch.'); + } + return { + owner: repoTarget.owner.trim(), + repo: repoTarget.repo.trim(), + defaultBranch: repoTarget.defaultBranch.trim(), + }; +} + +function patternForConnectedAgents(agentCount: number): SwarmPattern { + if (agentCount >= 3) return 'dag'; + if (agentCount === 2) return 'supervisor'; + return 'pipeline'; +} + +function toNormalizedWorkflowSpec( + input: BuildLinearWorkflowInput, + repoTarget: { owner: string; repo: string; defaultBranch: string }, +): NormalizedWorkflowSpec { + const description = [ + input.issue.title, + input.issue.description, + ...input.issue.comments.map((comment) => `Comment: ${comment}`), + ].filter(Boolean).join('\n\n'); + + return { + intent: 'generate', + description, + targetRepo: `${repoTarget.owner}/${repoTarget.repo}`, + targetContext: input.issue.project ?? null, + targetFiles: [], + desiredAction: { + kind: 'generate', + summary: `Resolve Linear issue: ${input.issue.title}`, + specText: description, + targetFiles: [], + }, + constraints: [ + { constraint: `Use connected Cloud agents: ${input.connectedAgents.map((agent) => agent.name).join(', ') || 'none'}.`, category: 'scope' }, + { constraint: `Open a GitHub PR against ${repoTarget.owner}/${repoTarget.repo}:${repoTarget.defaultBranch}.`, category: 'technical' }, + ], + evidenceRequirements: [ + { requirement: 'Capture PR URL or no-change conclusion in Linear AgentActivity.', verificationType: 'artifact_exists' }, + ], + requiredEvidence: [ + { requirement: 'Capture PR URL or no-change conclusion in Linear AgentActivity.', verificationType: 'artifact_exists' }, + ], + acceptanceGates: [ + { gate: 'Generated workflow must finish with completed, completed_no_changes, or failed.', kind: 'deterministic' }, + ], + acceptanceCriteria: [ + { gate: 'Generated workflow must finish with completed, completed_no_changes, or failed.', kind: 'deterministic' }, + ], + executionPreference: 'cloud', + providerContext: { + surface: 'api', + provider: 'linear', + userId: input.actor.cloudUserId, + metadata: { + linearUserId: input.actor.linearUserId, + issueLabels: input.issue.labels, + connectedAgentCount: input.connectedAgents.length, + }, + }, + sourceSpec: { + surface: 'api', + intent: { primary: 'generate', signals: ['linear-agent-session'] }, + description, + targetRepo: `${repoTarget.owner}/${repoTarget.repo}`, + targetFiles: [], + constraints: [], + evidenceRequirements: [], + acceptanceGates: [], + providerContext: { + surface: 'api', + provider: 'linear', + userId: input.actor.cloudUserId, + metadata: {}, + }, + rawPayload: { + kind: 'structured_json', + surface: 'api', + receivedAt: new Date(0).toISOString(), + data: { issue: input.issue }, + }, + parseConfidence: 'high', + parseWarnings: [], + }, + }; +} + +function renderWorkflowArtifact(params: { + input: BuildLinearWorkflowInput; + repoTarget: { owner: string; repo: string; defaultBranch: string }; + pattern: SwarmPattern; + selectedAgents: ReadonlyArray; + rationale: string; +}): string { + const agentLines = params.input.connectedAgents.map((agent) => ( + `- ${agent.name} (${agent.capabilities.join(', ') || 'general implementation'})` + )); + const comments = params.input.issue.comments.map((comment) => `- ${comment}`); + const labels = params.input.issue.labels.join(', ') || 'none'; + return `// Generated by Ricky Linear surface. Cloud executes this workflow artifact. +export default { + source: 'linear', + pattern: ${JSON.stringify(params.pattern)}, + repo: ${JSON.stringify(params.repoTarget)}, + actor: ${JSON.stringify(params.input.actor)}, + selectedAgents: ${JSON.stringify(params.selectedAgents)}, + rationale: ${JSON.stringify(params.rationale)}, + issue: { + title: ${JSON.stringify(params.input.issue.title)}, + description: ${JSON.stringify(params.input.issue.description)}, + labels: ${JSON.stringify(params.input.issue.labels)}, + project: ${JSON.stringify(params.input.issue.project ?? null)} + }, + instructions: ${JSON.stringify([ + `Resolve the Linear issue titled "${params.input.issue.title}".`, + `Use the connected agents: ${agentLines.join('; ') || 'Ricky default implementation agent'}.`, + `Respect issue labels: ${labels}.`, + `Review issue comments: ${comments.join('; ') || 'none'}.`, + `Open a pull request against ${params.repoTarget.owner}/${params.repoTarget.repo}:${params.repoTarget.defaultBranch}, or report completed_no_changes with evidence.`, + ].join('\n'))} +}; +`; +} + +function slugify(value: string): string { + const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return slug || 'issue'; +} From e34f96795bfe0cef76188b6a8f270e06b81d14a4 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 7 May 2026 12:05:57 +0200 Subject: [PATCH 2/3] fix(linear): address PR feedback --- specs/linear-integration.md | 8 +- src/cloud/api/linear-agent-types.ts | 7 +- src/product/spec-intake/parser.test.ts | 29 +++++++ src/product/spec-intake/parser.ts | 4 +- src/surfaces/cli/commands/cli-main.test.ts | 15 +++- src/surfaces/cli/commands/cli-main.ts | 11 ++- .../cli/flows/power-user-parser.test.ts | 9 +++ src/surfaces/cli/flows/power-user-parser.ts | 13 +++- .../linear-agent-session-event.schema.json | 11 ++- src/surfaces/linear/__tests__/index.test.ts | 63 +++++++++++---- .../linear/__tests__/workflow-builder.test.ts | 15 ++++ src/surfaces/linear/index.ts | 78 +++++++++++-------- src/surfaces/linear/workflow-builder.ts | 4 +- 13 files changed, 205 insertions(+), 62 deletions(-) diff --git a/specs/linear-integration.md b/specs/linear-integration.md index 711870bd..91cc3b3e 100644 --- a/specs/linear-integration.md +++ b/specs/linear-integration.md @@ -18,7 +18,7 @@ The Linear additions in Cloud land as a **follow-up PR after [PR #412](https://g ### Cloud file layout (mirrors PR #412 Slack) -``` +```text packages/web/app/api/v1/ricky/linear/ events/route.ts # Nango-forwarded Linear webhook receiver oauth/start/route.ts # Linear OAuth Actor app start @@ -38,7 +38,7 @@ packages/web/lib/ricky/linear/ packages/web/lib/ricky/linear-agent-v2.ts # main entry, parallel to slack-agent-v2.ts packages/web/drizzle/_ricky_linear_agent_v2.sql # whichever idx is next when this PR opens integrations/linear/ricky-manifest.json # OAuth Actor app manifest -``` +```text ### Ricky OSS additions @@ -141,7 +141,7 @@ Re-export Linear's `AgentSessionEvent` and `AgentActivity` shapes typed so Cloud ```ts export interface BuildLinearWorkflowInput { - issue: { title: string; description: string; labels: string[]; comments: string[]; project?: string }; + issue: { id: string; title: string; description: string; labels: string[]; comments: string[]; project?: string }; repoTarget: { owner: string; repo: string; defaultBranch: string }; connectedAgents: ReadonlyArray<{ id: string; name: string; capabilities: string[] }>; actor: { linearUserId: string; cloudUserId: string }; @@ -164,7 +164,7 @@ Implementation reuses `src/product/generation/pattern-selector.ts` and the exist The HTTP shapes Cloud uses for `/api/v1/ricky/linear/events` requests and the AgentActivity post payload, exported so Cloud can typecheck imports from this repo. -The exported wire types are `LinearMentionRequest`, `LinearMentionResponse`, `RickyLinearSession`, and `SessionEndReason`. The `SessionEndReason` values are exactly `completed`, `completed_no_changes`, and `failed`. +The exported wire types are `LinearMentionRequest`, `LinearMentionResponse`, `RickyLinearSession`, and `SessionEndReason`. The `SessionEndReason` values are exactly `completed`, `completed_no_changes`, `failed`, `awaiting_github_install`, and `awaiting_agent_connect`. ## CLI additions (Ricky OSS) diff --git a/src/cloud/api/linear-agent-types.ts b/src/cloud/api/linear-agent-types.ts index 954d8ec7..3fea4d8b 100644 --- a/src/cloud/api/linear-agent-types.ts +++ b/src/cloud/api/linear-agent-types.ts @@ -1,4 +1,9 @@ -export type SessionEndReason = 'completed' | 'completed_no_changes' | 'failed'; +export type SessionEndReason = + | 'completed' + | 'completed_no_changes' + | 'failed' + | 'awaiting_github_install' + | 'awaiting_agent_connect'; export type AgentSessionEventType = 'created' | 'prompted'; diff --git a/src/product/spec-intake/parser.test.ts b/src/product/spec-intake/parser.test.ts index c7cb67d1..aac06f37 100644 --- a/src/product/spec-intake/parser.test.ts +++ b/src/product/spec-intake/parser.test.ts @@ -100,6 +100,35 @@ describe('spec intake parser, normalizer, and router', () => { ); }); + it('preserves plain non-goal headings as scope constraints', () => { + const result = intake( + natural( + [ + 'Generate a workflow for AgentWorkforce/ricky.', + '', + 'Non-goals:', + '', + '- Broad dashboard changes', + '- Passive Linear monitoring', + ].join('\n'), + ), + ); + + expect(result.success).toBe(true); + expect(result.routing?.normalizedSpec.constraints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + constraint: 'Non-goal: Broad dashboard changes', + category: 'scope', + }), + expect.objectContaining({ + constraint: 'Non-goal: Passive Linear monitoring', + category: 'scope', + }), + ]), + ); + }); + it('preserves MCP-style structured payload source and provider context', () => { const payload: RawSpecPayload = { kind: 'mcp', diff --git a/src/product/spec-intake/parser.ts b/src/product/spec-intake/parser.ts index 0cfaeffa..6cba6fe4 100644 --- a/src/product/spec-intake/parser.ts +++ b/src/product/spec-intake/parser.ts @@ -320,7 +320,9 @@ function extractNonGoalSectionItems(text: string): string[] { for (const rawLine of lines) { const line = rawLine.trim(); - const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line)?.[1]?.replace(/#+$/, '').trim() ?? ''; + const markdownHeading = /^#{1,6}\s+(.+?)\s*$/.exec(line)?.[1]?.replace(/#+$/, '').trim(); + const plainHeading = /^[A-Za-z][A-Za-z0-9 /_-]*:\s*$/.exec(line)?.[0]?.replace(/:\s*$/, '').trim(); + const heading = markdownHeading ?? plainHeading ?? ''; if (heading) { inNonGoalSection = /\b(non[- ]?goals?|out[- ]of[- ]scope|not in scope|out of scope)\b/i.test(heading); continue; diff --git a/src/surfaces/cli/commands/cli-main.test.ts b/src/surfaces/cli/commands/cli-main.test.ts index 8869848c..dd0f830f 100644 --- a/src/surfaces/cli/commands/cli-main.test.ts +++ b/src/surfaces/cli/commands/cli-main.test.ts @@ -328,6 +328,12 @@ describe('parseArgs', () => { statusTarget: 'linear', json: true, }); + expect(parseArgs(['status', 'foo', '--json'])).toEqual({ + command: 'status', + surface: 'status', + json: true, + errors: ['unknown status target: foo'], + }); expect(parseArgs(['connect', 'agents', '--cloud', 'claude,codex'])).toMatchObject({ command: 'connect', surface: 'connect', @@ -2531,7 +2537,14 @@ describe('cliMain', () => { expect(JSON.parse(result.output.join('\n'))).toMatchObject({ target: 'linear', status: 'manual-dashboard', - nextActions: ['ricky status linear'], + message: expect.not.stringContaining('Ricky connect linear'), + nextActions: [ + 'Open the Cloud dashboard Linear integration page.', + 'Click "Connect Linear" to install the Ricky OAuth Actor app.', + 'Choose the Linear workspace where Ricky should receive AgentSession events.', + 'Linear connection is managed through the Cloud dashboard, not the CLI.', + 'ricky status linear', + ], }); }); diff --git a/src/surfaces/cli/commands/cli-main.ts b/src/surfaces/cli/commands/cli-main.ts index fb768cd9..edf72b84 100644 --- a/src/surfaces/cli/commands/cli-main.ts +++ b/src/surfaces/cli/commands/cli-main.ts @@ -44,7 +44,7 @@ import { defaultArtifactPathForWorkflowName } from '../flows/spec-intake-flow.js import { CLOUD_IMPLEMENTATION_AGENTS, CLOUD_OPTIONAL_INTEGRATIONS } from '../flows/cloud-workflow-flow.js'; import { resolvePreferWorkforcePersonaWorkflowWriter } from '../flows/workforce-persona-cli-preference.js'; import { DEFAULT_AUTO_FIX_ATTEMPTS } from '../../../shared/constants.js'; -import { renderLinearConnectGuidance } from '../../linear/connect.js'; +import { getLinearConnectGuidance } from '../../linear/connect.js'; import { linearStatusSummary, renderLinearStatus } from '../../linear/status.js'; // --------------------------------------------------------------------------- @@ -1244,13 +1244,16 @@ function connectExitCode(payload: ConnectPayload): number { async function connectPayload(parsed: ParsedArgs, deps: CliMainDeps): Promise { if (parsed.connectTarget === 'linear') { - const guidance = renderLinearConnectGuidance(); + const guidance = getLinearConnectGuidance(); return { target: 'linear', status: 'manual-dashboard', - message: guidance.join('\n'), + message: [ + 'Linear uses the Cloud dashboard to install the Ricky OAuth Actor app.', + `Dashboard: ${guidance.dashboardUrl}`, + ].join('\n'), warnings: [], - nextActions: ['ricky status linear'], + nextActions: [...guidance.instructions, 'ricky status linear'], }; } diff --git a/src/surfaces/cli/flows/power-user-parser.test.ts b/src/surfaces/cli/flows/power-user-parser.test.ts index c6f7ebeb..17a4e061 100644 --- a/src/surfaces/cli/flows/power-user-parser.test.ts +++ b/src/surfaces/cli/flows/power-user-parser.test.ts @@ -104,6 +104,15 @@ describe('power user parser defaults', () => { }); }); + it('rejects unknown status targets', () => { + expect(parsePowerUserArgs(['status', 'foo', '--json'])).toMatchObject({ + command: 'status', + surface: 'status', + json: true, + errors: ['unknown status target: foo'], + }); + }); + it('requires --run for power-user workflow artifact execution', () => { const preview = parsePowerUserArgs(['local', '--workflow', 'workflows/generated/review.ts']); expect(preview).toMatchObject({ diff --git a/src/surfaces/cli/flows/power-user-parser.ts b/src/surfaces/cli/flows/power-user-parser.ts index 85188c60..2f264b90 100644 --- a/src/surfaces/cli/flows/power-user-parser.ts +++ b/src/surfaces/cli/flows/power-user-parser.ts @@ -55,15 +55,24 @@ export function parsePowerUserArgs(argv: string[]): PowerUserParsedArgs { if (first === 'status') { const statusArgv = argv.slice(1); + const statusCandidate = statusArgv[0]?.trim().toLowerCase(); const statusTarget = readStatusTarget(statusArgv); - const parsed = withCommonFlags({ command: 'status', surface: 'status' }, statusTarget ? statusArgv.slice(1) : statusArgv); const effectiveStatusArgv = statusTarget ? statusArgv.slice(1) : statusArgv; + const parsed = withCommonFlags({ command: 'status', surface: 'status' }, effectiveStatusArgv); const runId = readFlagValue(effectiveStatusArgv, '--run'); + const errors: string[] = [...(parsed.errors ?? [])]; + if (statusCandidate && !statusCandidate.startsWith('-') && !statusTarget) { + errors.push(`unknown status target: ${statusCandidate}`); + } + if (effectiveStatusArgv.includes('--run') && !runId) { + errors.push('--run requires a value.'); + } + return { ...parsed, ...(statusTarget ? { statusTarget } : {}), ...(runId ? { runId } : {}), - ...(effectiveStatusArgv.includes('--run') && !runId ? { errors: [...(parsed.errors ?? []), '--run requires a value.'] } : {}), + ...(errors.length > 0 ? { errors } : {}), }; } diff --git a/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json b/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json index e256a1d2..f2fe2733 100644 --- a/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json +++ b/src/surfaces/linear/__tests__/__fixtures__/linear-agent-session-event.schema.json @@ -20,11 +20,18 @@ "trigger": { "enum": ["issue_mention", "comment_mention", "assignment"] }, "actor": { "type": "object", - "required": ["linearUserId"] + "required": ["linearUserId"], + "properties": { + "linearUserId": { "type": "string", "minLength": 1 } + } }, "issue": { "type": "object", - "required": ["id", "title"] + "required": ["id", "title"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 } + } }, "createdAt": { "type": "string" } } diff --git a/src/surfaces/linear/__tests__/index.test.ts b/src/surfaces/linear/__tests__/index.test.ts index aff381c9..71ce8eeb 100644 --- a/src/surfaces/linear/__tests__/index.test.ts +++ b/src/surfaces/linear/__tests__/index.test.ts @@ -30,14 +30,13 @@ function deps(overrides: Partial = {}): LinearMentionDeps & { } { const activities: AgentActivity[] = []; const ended: Array<{ sessionId: string; reason: string }> = []; - return { - activities, - ended, - signatureVerifier: vi.fn().mockResolvedValue(true), - dedupStore: { - has: vi.fn().mockResolvedValue(false), - mark: vi.fn().mockResolvedValue(undefined), - }, + return { + activities, + ended, + signatureVerifier: vi.fn().mockResolvedValue(true), + dedupStore: { + markIfAbsent: vi.fn().mockResolvedValue(true), + }, githubInstallProbe: vi.fn().mockResolvedValue(true), agentRegistry: { list: vi.fn().mockResolvedValue([{ id: 'codex', name: 'Codex', capabilities: ['implementation'] }]), @@ -67,21 +66,38 @@ describe('handleLinearMention', () => { const result = await handleLinearMention({ payload: event }, d); expect(result).toEqual({ status: 'failed', reason: 'failed' }); - expect(d.dedupStore.has).not.toHaveBeenCalled(); + expect(d.dedupStore.markIfAbsent).not.toHaveBeenCalled(); }); it('returns no-change ignored result on dedup hits', async () => { const d = deps({ dedupStore: { - has: vi.fn().mockResolvedValue(true), - mark: vi.fn(), + markIfAbsent: vi.fn().mockResolvedValue(false), }, }); const result = await handleLinearMention({ payload: event }, d); expect(result).toEqual({ status: 'ignored', sessionId: 'session-1', reason: 'completed_no_changes' }); - expect(d.dedupStore.mark).not.toHaveBeenCalled(); + expect(d.workflowRunner).not.toHaveBeenCalled(); + }); + + it('does not start a fresh workflow for prompted session follow-ups', async () => { + const d = deps(); + + const result = await handleLinearMention({ payload: { ...event, type: 'prompted', prompt: 'Can you also update tests?' } }, d); + + expect(result).toEqual({ status: 'ignored', sessionId: 'session-1', reason: 'completed_no_changes' }); + expect(d.githubInstallProbe).not.toHaveBeenCalled(); + expect(d.agentRegistry.list).not.toHaveBeenCalled(); + expect(d.workflowRunner).not.toHaveBeenCalled(); + expect(d.ended).toEqual([]); + expect(d.activities).toEqual([ + expect.objectContaining({ + kind: 'response', + body: expect.stringContaining('existing Linear Agent Session'), + }), + ]); }); it('checks GitHub readiness before connected agents', async () => { @@ -89,10 +105,11 @@ describe('handleLinearMention', () => { const result = await handleLinearMention({ payload: event }, d); - expect(result.reason).toBe('failed'); + expect(result.reason).toBe('awaiting_github_install'); expect(d.githubInstallProbe).toHaveBeenCalledTimes(1); expect(d.agentRegistry.list).not.toHaveBeenCalled(); expect(d.activities[0]).toMatchObject({ kind: 'response' }); + expect(d.ended).toEqual([{ sessionId: 'session-1', reason: 'awaiting_github_install' }]); }); it('fails readiness after GitHub when no capable Cloud agents are connected', async () => { @@ -104,7 +121,7 @@ describe('handleLinearMention', () => { const result = await handleLinearMention({ payload: event }, d); - expect(result).toEqual({ status: 'failed', sessionId: 'session-1', reason: 'failed' }); + expect(result).toEqual({ status: 'failed', sessionId: 'session-1', reason: 'awaiting_agent_connect' }); expect(d.githubInstallProbe).toHaveBeenCalledTimes(1); expect(d.agentRegistry.list).toHaveBeenCalledWith({ scope: 'workspace-1', @@ -117,6 +134,24 @@ describe('handleLinearMention', () => { body: 'No connected Cloud implementation agents were found for A User.', }), ]); + expect(d.ended).toEqual([{ sessionId: 'session-1', reason: 'awaiting_agent_connect' }]); + }); + + it('ends the session when pre-workflow readiness steps throw', async () => { + const d = deps({ + githubInstallProbe: vi.fn().mockRejectedValue(new Error('readiness exploded')), + }); + + const result = await handleLinearMention({ payload: event }, d); + + expect(result).toEqual({ status: 'failed', sessionId: 'session-1', reason: 'failed' }); + expect(d.workflowRunner).not.toHaveBeenCalled(); + expect(d.activities).toEqual([ + expect.objectContaining({ + kind: 'error', + body: 'readiness exploded', + }), + ]); expect(d.ended).toEqual([{ sessionId: 'session-1', reason: 'failed' }]); }); diff --git a/src/surfaces/linear/__tests__/workflow-builder.test.ts b/src/surfaces/linear/__tests__/workflow-builder.test.ts index 5ba3156c..02e2adec 100644 --- a/src/surfaces/linear/__tests__/workflow-builder.test.ts +++ b/src/surfaces/linear/__tests__/workflow-builder.test.ts @@ -5,6 +5,7 @@ import { buildLinearWorkflow } from '../workflow-builder.js'; function baseInput(agentCount: number) { return { issue: { + id: 'LIN-123', title: 'Fix flaky import path handling', description: 'The generated workflow should normalize imports before running tests.', labels: ['bug', 'backend'], @@ -38,4 +39,18 @@ describe('buildLinearWorkflow', () => { it('throws when repoTarget is missing', () => { expect(() => buildLinearWorkflow({ ...baseInput(1), repoTarget: null })).toThrow(/repoTarget/); }); + + it('includes a stable issue id in the generated artifact path', () => { + const first = buildLinearWorkflow(baseInput(1)); + const second = buildLinearWorkflow({ + ...baseInput(1), + issue: { + ...baseInput(1).issue, + id: 'LIN-456', + }, + }); + + expect(first.artifactPath).toBe('workflows/generated/linear-lin-123-fix-flaky-import-path-handling.ts'); + expect(second.artifactPath).toBe('workflows/generated/linear-lin-456-fix-flaky-import-path-handling.ts'); + }); }); diff --git a/src/surfaces/linear/index.ts b/src/surfaces/linear/index.ts index 9dde08b7..c7d05b2a 100644 --- a/src/surfaces/linear/index.ts +++ b/src/surfaces/linear/index.ts @@ -60,8 +60,7 @@ export interface LinearMentionHandleResult { export interface LinearMentionDeps { signatureVerifier: (input: LinearMentionHandleInput) => boolean | Promise; dedupStore: { - has: (webhookId: string) => boolean | Promise; - mark: (webhookId: string) => void | Promise; + markIfAbsent: (webhookId: string) => boolean | Promise; }; githubInstallProbe: (event: AgentSessionEvent) => boolean | Promise; agentRegistry: { @@ -92,40 +91,53 @@ export async function handleLinearMention( return { status: 'failed', reason: 'failed' }; } - if (!isAgentSessionEvent(input.payload)) { - deps.logger?.warn?.('Ignoring non-AgentSessionEvent Linear payload.'); - return { status: 'ignored', reason: 'completed_no_changes' }; - } + let event: AgentSessionEvent | undefined; + try { + if (!isAgentSessionEvent(input.payload)) { + deps.logger?.warn?.('Ignoring non-AgentSessionEvent Linear payload.'); + return { status: 'ignored', reason: 'completed_no_changes' }; + } - const event = input.payload; - const duplicate = await deps.dedupStore.has(event.webhookId); - if (duplicate) { - return { status: 'ignored', sessionId: event.sessionId, reason: 'completed_no_changes' }; - } - await deps.dedupStore.mark(event.webhookId); + event = input.payload; + const accepted = await deps.dedupStore.markIfAbsent(event.webhookId); + if (!accepted) { + return { status: 'ignored', sessionId: event.sessionId, reason: 'completed_no_changes' }; + } - classifyLinearMentionEvent(event); + if (event.type === 'prompted') { + await writeActivity( + deps, + event, + 'response', + 'Prompt received for an existing Linear Agent Session. Cloud should route it to the active session instead of starting a new workflow.', + ); + return { status: 'ignored', sessionId: event.sessionId, reason: 'completed_no_changes' }; + } - const githubReady = await deps.githubInstallProbe(event); - if (!githubReady) { - await writeActivity(deps, event, 'response', 'Install the GitHub App before Ricky can open a pull request from Linear.'); - await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); - return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; - } + classifyLinearMentionEvent(event); - const connectedAgents = await deps.agentRegistry.list({ - scope: event.workspaceId ?? event.organizationId, - actor: event.actor, - }); - if (connectedAgents.length === 0) { - await writeActivity(deps, event, 'response', `No connected Cloud implementation agents were found for ${event.actor.name ?? event.actor.linearUserId}.`); - await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); - return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; - } + const githubReady = await deps.githubInstallProbe(event); + if (!githubReady) { + const reason: SessionEndReason = 'awaiting_github_install'; + await writeActivity(deps, event, 'response', 'Install the GitHub App before Ricky can open a pull request from Linear.'); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason }); + return { status: 'failed', sessionId: event.sessionId, reason }; + } + + const connectedAgents = await deps.agentRegistry.list({ + scope: event.workspaceId ?? event.organizationId, + actor: event.actor, + }); + if (connectedAgents.length === 0) { + const reason: SessionEndReason = 'awaiting_agent_connect'; + await writeActivity(deps, event, 'response', `No connected Cloud implementation agents were found for ${event.actor.name ?? event.actor.linearUserId}.`); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason }); + return { status: 'failed', sessionId: event.sessionId, reason }; + } - try { const workflow = buildLinearWorkflow({ issue: { + id: event.issue.id, title: event.issue.title, description: event.issue.description ?? '', labels: event.issue.labels ?? [], @@ -156,9 +168,11 @@ export async function handleLinearMention( await deps.activityWriter.endSession({ sessionId: event.sessionId, reason }); return { status: run.status, sessionId: event.sessionId, reason }; } catch (error) { - await writeActivity(deps, event, 'error', error instanceof Error ? error.message : String(error)); - await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); - return { status: 'failed', sessionId: event.sessionId, reason: 'failed' }; + if (event) { + await writeActivity(deps, event, 'error', error instanceof Error ? error.message : String(error)); + await deps.activityWriter.endSession({ sessionId: event.sessionId, reason: 'failed' }); + } + return { status: 'failed', ...(event ? { sessionId: event.sessionId } : {}), reason: 'failed' }; } } diff --git a/src/surfaces/linear/workflow-builder.ts b/src/surfaces/linear/workflow-builder.ts index 5e395c8f..34d6ae94 100644 --- a/src/surfaces/linear/workflow-builder.ts +++ b/src/surfaces/linear/workflow-builder.ts @@ -4,6 +4,7 @@ import type { SwarmPattern } from '../../shared/models/workflow-config.js'; export interface BuildLinearWorkflowInput { issue: { + id: string; title: string; description: string; labels: string[]; @@ -37,7 +38,7 @@ export function buildLinearWorkflow(input: BuildLinearWorkflowInput): BuildLinea ].join(' '); return { - artifactPath: `workflows/generated/linear-${slugify(input.issue.title)}.ts`, + artifactPath: `workflows/generated/linear-${slugify(`${input.issue.id}-${input.issue.title}`)}.ts`, artifactContent: renderWorkflowArtifact({ input, repoTarget, @@ -167,6 +168,7 @@ export default { selectedAgents: ${JSON.stringify(params.selectedAgents)}, rationale: ${JSON.stringify(params.rationale)}, issue: { + id: ${JSON.stringify(params.input.issue.id)}, title: ${JSON.stringify(params.input.issue.title)}, description: ${JSON.stringify(params.input.issue.description)}, labels: ${JSON.stringify(params.input.issue.labels)}, From 843af6c597d52f7557ab6a72e5207df44daa8ebc Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 7 May 2026 12:09:21 +0200 Subject: [PATCH 3/3] docs(linear): clarify Cloud spec coverage --- specs/linear-integration.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specs/linear-integration.md b/specs/linear-integration.md index 91cc3b3e..b9e11ce7 100644 --- a/specs/linear-integration.md +++ b/specs/linear-integration.md @@ -18,6 +18,12 @@ The Linear additions in Cloud land as a **follow-up PR after [PR #412](https://g ### Cloud file layout (mirrors PR #412 Slack) +The Cloud-side implementation is covered in the sibling Cloud spec at +`../cloud/specs/ricky-linear-agent.md`, especially its architectural alignment, +schema, file layout, webhook flow, AgentActivity egress, readiness, and rollout +sections. The tree below is the Ricky OSS contract's expected Cloud shape; the +actual files land in `AgentWorkforce/cloud`, not this repository. + ```text packages/web/app/api/v1/ricky/linear/ events/route.ts # Nango-forwarded Linear webhook receiver @@ -38,7 +44,7 @@ packages/web/lib/ricky/linear/ packages/web/lib/ricky/linear-agent-v2.ts # main entry, parallel to slack-agent-v2.ts packages/web/drizzle/_ricky_linear_agent_v2.sql # whichever idx is next when this PR opens integrations/linear/ricky-manifest.json # OAuth Actor app manifest -```text +``` ### Ricky OSS additions