@calltelemetry/openclaw-linear 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,12 +4,60 @@ Webhook-driven Linear integration with OAuth support, multi-agent routing, and a
4
4
 
5
5
  ## What It Does
6
6
 
7
+ - **Auto-triage** — New issues are automatically estimated (story points), labeled, and prioritized with a posted assessment
7
8
  - **Issue triage** — When an issue is assigned/delegated to the app user, an agent estimates story points, applies labels, and posts an assessment
8
9
  - **Agent sessions** — Full plan-approve-implement-audit pipeline triggered from Linear's agent UI
9
10
  - **@mention routing** — Comment mentions like `@qa` or `@infra` route to specific role-based agents with different expertise
10
11
  - **App notifications** — Responds to Linear app mentions and assignments via branded comments
11
12
  - **Activity tracking** — Emits thought/action/response events visible in Linear's agent session UI
12
13
 
14
+ ## Quick Install
15
+
16
+ ```bash
17
+ openclaw plugins install @calltelemetry/openclaw-linear
18
+ openclaw gateway restart
19
+ ```
20
+
21
+ That's it — the plugin is installed and enabled. Continue with the [setup steps](#setup) below to configure Linear OAuth and webhooks.
22
+
23
+ > To install from a local checkout instead: `openclaw plugins install --link /path/to/linear`
24
+
25
+ ## First Run
26
+
27
+ After installing, the plugin loads but **will not process any webhooks or agent tools until you authenticate**. You'll see this in the logs:
28
+
29
+ ```
30
+ Linear agent extension registered (agent: default, token: missing)
31
+ ```
32
+
33
+ This is normal — the plugin does not crash without auth. It registers all routes and CLI commands, but webhook handlers and tools will return errors until a token is available.
34
+
35
+ To authenticate:
36
+
37
+ ```bash
38
+ # Set your OAuth app credentials
39
+ export LINEAR_CLIENT_ID="your_client_id"
40
+ export LINEAR_CLIENT_SECRET="your_client_secret"
41
+
42
+ # Run the OAuth flow
43
+ openclaw openclaw-linear auth
44
+
45
+ # Verify
46
+ openclaw openclaw-linear status
47
+ ```
48
+
49
+ The auth flow stores tokens in `~/.openclaw/auth-profiles.json`. This file is created automatically — you do not need to create it manually. After auth, restart the gateway:
50
+
51
+ ```bash
52
+ openclaw gateway restart
53
+ ```
54
+
55
+ You should now see `token: profile` in the logs:
56
+
57
+ ```
58
+ Linear agent extension registered (agent: default, token: profile)
59
+ ```
60
+
13
61
  ## Prerequisites
14
62
 
15
63
  - OpenClaw gateway running (systemd service)
@@ -153,16 +201,28 @@ export OPENCLAW_GATEWAY_PORT="18789" # if non-default
153
201
 
154
202
  ### 5. Install the Plugin
155
203
 
156
- Add the plugin path to your OpenClaw config (`~/.openclaw/openclaw.json`):
204
+ If you haven't already installed via [Quick Install](#quick-install):
205
+
206
+ ```bash
207
+ openclaw plugins install @calltelemetry/openclaw-linear
208
+ openclaw gateway restart
209
+ ```
210
+
211
+ This registers the plugin in your OpenClaw config and restarts the gateway to load it.
212
+
213
+ <details>
214
+ <summary>Manual config (advanced)</summary>
215
+
216
+ If you prefer to manage config by hand, add the plugin path to `~/.openclaw/openclaw.json`:
157
217
 
158
218
  ```json
159
219
  {
160
220
  "plugins": {
161
221
  "load": {
162
- "paths": ["/path/to/claw-extensions/linear"]
222
+ "paths": ["/path/to/linear"]
163
223
  },
164
224
  "entries": {
165
- "linear": {
225
+ "openclaw-linear": {
166
226
  "enabled": true
167
227
  }
168
228
  }
@@ -170,11 +230,8 @@ Add the plugin path to your OpenClaw config (`~/.openclaw/openclaw.json`):
170
230
  }
171
231
  ```
172
232
 
173
- Restart the gateway to load the plugin:
174
-
175
- ```bash
176
- openclaw gateway restart
177
- ```
233
+ Then restart: `openclaw gateway restart`
234
+ </details>
178
235
 
179
236
  ### 6. Run the OAuth Flow
180
237
 
@@ -183,7 +240,7 @@ There are two ways to authorize the plugin with Linear.
183
240
  #### Option A: CLI Flow (Recommended)
184
241
 
185
242
  ```bash
186
- openclaw auth linear oauth
243
+ openclaw openclaw-linear auth
187
244
  ```
188
245
 
189
246
  This launches the OAuth flow interactively:
@@ -283,13 +340,61 @@ Create `~/.openclaw/agent-profiles.json` to define role-based agents:
283
340
  | Field | Required | Description |
284
341
  |---|---|---|
285
342
  | `label` | Yes | Display name in Linear comments |
286
- | `mission` | Yes | Agent's role description (provided as context when dispatched) |
287
- | `isDefault` | One agent | The default agent handles OAuth app events and assignment triage |
343
+ | `mission` | Yes | Agent's role description (injected as system context when the agent is dispatched) |
344
+ | `isDefault` | One agent | The default agent handles OAuth app events, agent sessions, and assignment triage |
288
345
  | `mentionAliases` | Yes | @mention triggers in comments (e.g., `@qa` in a comment routes to the QA agent) |
289
346
  | `appAliases` | No | Triggers via OAuth app webhook (default agent only, for app-level @mentions) |
290
347
  | `avatarUrl` | No | Avatar displayed on branded comments. Falls back to `[Label]` prefix if not set. |
291
348
 
292
- Each agent ID (the JSON key) must match a configured OpenClaw agent in `openclaw.json`. The plugin dispatches to agents via `openclaw agent --agent <id>`.
349
+ #### How agent-profiles.json connects to openclaw.json
350
+
351
+ Each key in `agent-profiles.json` (e.g., `"lead"`, `"qa"`) must have a matching agent definition in your OpenClaw config (`~/.openclaw/openclaw.json`). The Linear plugin dispatches work via `openclaw agent --agent <id>`, so the agent must actually exist.
352
+
353
+ Example — if `agent-profiles.json` defines `"lead"` and `"qa"`, your `openclaw.json` needs:
354
+
355
+ ```json
356
+ {
357
+ "agents": {
358
+ "lead": {
359
+ "model": "claude-sonnet-4-5-20250929",
360
+ "systemPrompt": "You are a product lead agent...",
361
+ "tools": ["linear_list_issues", "linear_create_issue", "linear_add_comment"]
362
+ },
363
+ "qa": {
364
+ "model": "claude-sonnet-4-5-20250929",
365
+ "systemPrompt": "You are a QA engineer agent...",
366
+ "tools": ["linear_list_issues", "linear_add_comment"]
367
+ }
368
+ }
369
+ }
370
+ ```
371
+
372
+ #### Routing flow
373
+
374
+ ```
375
+ Linear comment "@qa review this test plan"
376
+ → Plugin matches "qa" in mentionAliases
377
+ → Looks up agent-profiles.json → finds "qa" profile
378
+ → Dispatches: openclaw agent --agent qa --message "<issue context + comment>"
379
+ → OpenClaw loads "qa" agent config from openclaw.json
380
+ → Agent runs with the qa profile's mission as context
381
+ → Response posted back to Linear as a branded comment with qa's label/avatar
382
+ ```
383
+
384
+ For agent sessions (triggered by the Linear agent UI or app @mentions):
385
+
386
+ ```
387
+ Linear AgentSessionEvent.created
388
+ → Plugin resolves the default agent (isDefault: true)
389
+ → Runs the 3-stage pipeline (plan → implement → audit)
390
+ → Each stage dispatches via the default agent's openclaw.json config
391
+ ```
392
+
393
+ #### What happens if they don't match
394
+
395
+ - **Agent profile exists but no matching openclaw.json agent:** The dispatch fails and an error is logged. The webhook returns 200 (Linear requirement) but no comment is posted.
396
+ - **openclaw.json agent exists but no profile:** The agent works for direct CLI use but won't be reachable from Linear. No @mention alias maps to it.
397
+ - **No agent marked `isDefault`:** Agent sessions and assignment triage will fail with `"No defaultAgentId"` error.
293
398
 
294
399
  ### 8. Verify
295
400
 
@@ -327,6 +432,7 @@ POST /linear/webhook
327
432
  +-- AgentSessionEvent.prompted --> Resume pipeline (user approved plan)
328
433
  +-- AppUserNotification --> Direct agent response to mention/assignment
329
434
  +-- Comment.create --> Route @mention to role-based agent
435
+ +-- Issue.create --> Auto-triage new issues (estimate, labels, priority)
330
436
  +-- Issue.update --> Triage if assigned/delegated to app user
331
437
  ```
332
438
 
@@ -381,7 +487,7 @@ Optional settings in `openclaw.json` under the plugin entry:
381
487
  {
382
488
  "plugins": {
383
489
  "entries": {
384
- "linear": {
490
+ "openclaw-linear": {
385
491
  "enabled": true,
386
492
  "clientId": "...",
387
493
  "clientSecret": "...",
@@ -451,7 +557,7 @@ openclaw logs | grep -i "linear\|plugin\|error"
451
557
  **Agent sessions not working:**
452
558
  - OAuth tokens require `app:assignable` and `app:mentionable` scopes
453
559
  - Personal API keys cannot create agent sessions — use OAuth
454
- - Re-run `openclaw auth linear oauth` to get fresh tokens
560
+ - Re-run `openclaw openclaw-linear auth` to get fresh tokens
455
561
 
456
562
  **"No defaultAgentId" error:**
457
563
  - Set `defaultAgentId` in plugin config, OR
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { registerLinearProvider } from "./src/auth.js";
3
+ import { registerCli } from "./src/cli.js";
3
4
  import { createLinearTools } from "./src/tools.js";
4
5
  import { handleLinearWebhook } from "./src/webhook.js";
5
6
  import { handleOAuthCallback } from "./src/oauth-callback.js";
@@ -20,6 +21,11 @@ export default function register(api: OpenClawPluginApi) {
20
21
  // Register Linear as an auth provider (OAuth flow with agent scopes)
21
22
  registerLinearProvider(api);
22
23
 
24
+ // Register CLI commands: openclaw openclaw-linear auth|status
25
+ api.registerCli(({ program }) => registerCli(program, api), {
26
+ commands: ["openclaw-linear"],
27
+ });
28
+
23
29
  // Register Linear tools for the agent
24
30
  api.registerTool((ctx) => {
25
31
  return createLinearTools(api, ctx);
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "linear",
2
+ "id": "openclaw-linear",
3
3
  "name": "Linear Agent",
4
4
  "description": "Linear integration with OAuth support, agent pipeline, and webhook-driven AI agent lifecycle",
5
5
  "version": "0.2.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/auth.ts CHANGED
@@ -4,11 +4,11 @@ import type {
4
4
  ProviderAuthResult
5
5
  } from "openclaw/plugin-sdk";
6
6
 
7
- const LINEAR_OAUTH_AUTH_URL = "https://linear.app/oauth/authorize";
8
- const LINEAR_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token";
7
+ export const LINEAR_OAUTH_AUTH_URL = "https://linear.app/oauth/authorize";
8
+ export const LINEAR_OAUTH_TOKEN_URL = "https://api.linear.app/oauth/token";
9
9
 
10
10
  // Agent scopes: read/write + assignable (appear in assignment menus) + mentionable (respond to @mentions)
11
- const LINEAR_AGENT_SCOPES = "read,write,app:assignable,app:mentionable";
11
+ export const LINEAR_AGENT_SCOPES = "read,write,app:assignable,app:mentionable";
12
12
 
13
13
  // Token refresh helper — Linear tokens expire; refresh before they do
14
14
  export async function refreshLinearToken(
package/src/cli.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * cli.ts — CLI registration for `openclaw openclaw-linear auth` and `openclaw openclaw-linear status`.
3
+ */
4
+ import type { Command } from "commander";
5
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
+ import { createInterface } from "node:readline";
7
+ import { exec } from "node:child_process";
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
10
+ import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
11
+
12
+ function prompt(question: string): Promise<string> {
13
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14
+ return new Promise((resolve) => {
15
+ rl.question(question, (answer) => {
16
+ rl.close();
17
+ resolve(answer.trim());
18
+ });
19
+ });
20
+ }
21
+
22
+ function openBrowser(url: string): void {
23
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
24
+ exec(`${cmd} ${JSON.stringify(url)}`, () => {});
25
+ }
26
+
27
+ export function registerCli(program: Command, api: OpenClawPluginApi): void {
28
+ const linear = program
29
+ .command("openclaw-linear")
30
+ .description("Linear plugin — auth and status");
31
+
32
+ // --- openclaw openclaw-linear auth ---
33
+ linear
34
+ .command("auth")
35
+ .description("Run Linear OAuth flow to authorize the agent")
36
+ .action(async () => {
37
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
38
+ const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
39
+ const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
40
+
41
+ if (!clientId || !clientSecret) {
42
+ console.error("Error: Linear client ID and secret must be configured.");
43
+ console.error("Set LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET env vars, or add clientId/clientSecret to plugin config.");
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+
48
+ const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
49
+ const redirectUri = (pluginConfig?.redirectUri as string)
50
+ ?? process.env.LINEAR_REDIRECT_URI
51
+ ?? `http://localhost:${gatewayPort}/linear/oauth/callback`;
52
+
53
+ const state = Math.random().toString(36).substring(7);
54
+ const authUrl = `${LINEAR_OAUTH_AUTH_URL}?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(LINEAR_AGENT_SCOPES)}&state=${state}&actor=app`;
55
+
56
+ console.log("\nOpening Linear OAuth authorization page...\n");
57
+ console.log(` ${authUrl}\n`);
58
+ openBrowser(authUrl);
59
+
60
+ const code = await prompt("Paste the authorization code from Linear: ");
61
+ if (!code) {
62
+ console.error("No code provided. Aborting.");
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+
67
+ console.log("Exchanging code for token...");
68
+
69
+ const response = await fetch(LINEAR_OAUTH_TOKEN_URL, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
72
+ body: new URLSearchParams({
73
+ grant_type: "authorization_code",
74
+ code,
75
+ client_id: clientId,
76
+ client_secret: clientSecret,
77
+ redirect_uri: redirectUri,
78
+ }),
79
+ });
80
+
81
+ if (!response.ok) {
82
+ const error = await response.text();
83
+ console.error(`OAuth token exchange failed (${response.status}): ${error}`);
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+
88
+ const tokens = await response.json();
89
+
90
+ // Store in auth profile store
91
+ let store: any = { version: 1, profiles: {} };
92
+ try {
93
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
94
+ store = JSON.parse(raw);
95
+ } catch {
96
+ // Fresh store
97
+ }
98
+
99
+ store.profiles = store.profiles ?? {};
100
+ store.profiles["linear:default"] = {
101
+ type: "oauth",
102
+ provider: "linear",
103
+ accessToken: tokens.access_token,
104
+ access: tokens.access_token,
105
+ refreshToken: tokens.refresh_token ?? null,
106
+ refresh: tokens.refresh_token ?? null,
107
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
108
+ expires: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
109
+ scope: tokens.scope,
110
+ };
111
+
112
+ writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(store, null, 2), "utf8");
113
+
114
+ const expiresIn = tokens.expires_in ? `${Math.round(tokens.expires_in / 3600)}h` : "unknown";
115
+ console.log(`\nAuthorized! Token stored in auth profile store.`);
116
+ console.log(` Scopes: ${tokens.scope ?? "unknown"}`);
117
+ console.log(` Expires: ${expiresIn}`);
118
+ console.log(`\nRestart the gateway to pick up the new token.`);
119
+ });
120
+
121
+ // --- openclaw openclaw-linear status ---
122
+ linear
123
+ .command("status")
124
+ .description("Show current Linear auth and connection status")
125
+ .action(async () => {
126
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
127
+ const tokenInfo = resolveLinearToken(pluginConfig);
128
+
129
+ console.log("\nLinear Auth Status");
130
+ console.log("─".repeat(40));
131
+ console.log(` Source: ${tokenInfo.source}`);
132
+
133
+ if (!tokenInfo.accessToken) {
134
+ console.log(` Token: not found`);
135
+ console.log(`\nRun "openclaw openclaw-linear auth" to authorize.`);
136
+ return;
137
+ }
138
+
139
+ console.log(` Token: ${tokenInfo.accessToken.slice(0, 12)}...`);
140
+ console.log(` Refresh token: ${tokenInfo.refreshToken ? "present" : "none"}`);
141
+
142
+ if (tokenInfo.expiresAt) {
143
+ const remaining = tokenInfo.expiresAt - Date.now();
144
+ if (remaining <= 0) {
145
+ console.log(` Expires: EXPIRED`);
146
+ } else {
147
+ const hours = Math.floor(remaining / 3_600_000);
148
+ const mins = Math.floor((remaining % 3_600_000) / 60_000);
149
+ console.log(` Expires: ${hours}h ${mins}m`);
150
+ }
151
+ } else {
152
+ console.log(` Expires: unknown (no expiry set)`);
153
+ }
154
+
155
+ // Try reading scope from profile
156
+ try {
157
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
158
+ const store = JSON.parse(raw);
159
+ const scope = store?.profiles?.["linear:default"]?.scope;
160
+ if (scope) console.log(` Scopes: ${scope}`);
161
+ } catch {}
162
+
163
+ // Verify token with API call
164
+ console.log("\nConnection Test");
165
+ console.log("─".repeat(40));
166
+ try {
167
+ const authHeader = tokenInfo.refreshToken
168
+ ? `Bearer ${tokenInfo.accessToken}`
169
+ : tokenInfo.accessToken;
170
+
171
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/json",
175
+ Authorization: authHeader,
176
+ },
177
+ body: JSON.stringify({
178
+ query: `{ viewer { id name email } organization { name urlKey } }`,
179
+ }),
180
+ });
181
+
182
+ if (!res.ok) {
183
+ console.log(` API response: ${res.status} ${res.statusText}`);
184
+ return;
185
+ }
186
+
187
+ const payload = await res.json();
188
+ if (payload.errors?.length) {
189
+ console.log(` API error: ${payload.errors[0].message}`);
190
+ return;
191
+ }
192
+
193
+ const { viewer, organization } = payload.data;
194
+ console.log(` API: connected`);
195
+ console.log(` User: ${viewer.name} (${viewer.email})`);
196
+ console.log(` Workspace: ${organization.name} (${organization.urlKey})`);
197
+ } catch (err) {
198
+ console.log(` API: failed — ${err instanceof Error ? err.message : String(err)}`);
199
+ }
200
+
201
+ console.log();
202
+ });
203
+ }
package/src/linear-api.ts CHANGED
@@ -2,8 +2,8 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { refreshLinearToken } from "./auth.js";
4
4
 
5
- const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
6
- const AUTH_PROFILES_PATH = join(
5
+ export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
6
+ export const AUTH_PROFILES_PATH = join(
7
7
  process.env.HOME ?? "/home/claw",
8
8
  ".openclaw",
9
9
  "auth-profiles.json",
package/src/tools.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk";
2
2
  import { jsonResult } from "openclaw/plugin-sdk";
3
3
  import { LinearClient } from "./client.js";
4
+ import { resolveLinearToken } from "./linear-api.js";
4
5
 
5
6
  export function createLinearTools(api: OpenClawPluginApi, ctx: OpenClawPluginToolContext): AnyAgentTool[] {
6
7
  const getClient = () => {
7
- // In a real implementation, we would resolve the token from auth profiles
8
- // For now, we'll try to get it from environment or a known profile
9
- const token = process.env.LINEAR_ACCESS_TOKEN;
10
- if (!token) {
11
- throw new Error("Linear access token not found. Please authenticate first.");
8
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
9
+ const resolved = resolveLinearToken(pluginConfig);
10
+ if (!resolved.accessToken) {
11
+ throw new Error("Linear access token not found. Run 'openclaw openclaw-linear auth' to authenticate.");
12
12
  }
13
- return new LinearClient(token);
13
+ return new LinearClient(resolved.accessToken);
14
14
  };
15
15
 
16
16
  return [
package/src/webhook.ts CHANGED
@@ -844,6 +844,215 @@ export async function handleLinearWebhook(
844
844
  return true;
845
845
  }
846
846
 
847
+ // ── Issue.create — auto-triage new issues ───────────────────────
848
+ if (payload.type === "Issue" && payload.action === "create") {
849
+ res.statusCode = 200;
850
+ res.end("ok");
851
+
852
+ const issue = payload.data;
853
+ if (!issue?.id) {
854
+ api.logger.error("Issue.create missing issue data");
855
+ return true;
856
+ }
857
+
858
+ // Dedup
859
+ if (wasRecentlyProcessed(`issue-create:${issue.id}`)) {
860
+ api.logger.info(`Issue.create ${issue.id} already processed — skipping`);
861
+ return true;
862
+ }
863
+
864
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
865
+
866
+ const linearApi = createLinearApi(api);
867
+ if (!linearApi) {
868
+ api.logger.error("No Linear access token — cannot triage new issue");
869
+ return true;
870
+ }
871
+
872
+ const agentId = resolveAgentId(api);
873
+
874
+ // Dispatch triage (non-blocking)
875
+ void (async () => {
876
+ const profiles = loadAgentProfiles();
877
+ const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
878
+ const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
879
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
880
+ let agentSessionId: string | null = null;
881
+
882
+ try {
883
+ // Fetch enriched issue + team labels
884
+ let enrichedIssue: any = issue;
885
+ let teamLabels: Array<{ id: string; name: string }> = [];
886
+ try {
887
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
888
+ if (enrichedIssue?.team?.id) {
889
+ teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
890
+ }
891
+ } catch (err) {
892
+ api.logger.warn(`Could not fetch issue details for triage: ${err}`);
893
+ }
894
+
895
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
896
+ const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
897
+ const currentLabels = enrichedIssue?.labels?.nodes ?? [];
898
+ const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
899
+ const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
900
+
901
+ // Create agent session
902
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
903
+ agentSessionId = sessionResult.sessionId;
904
+ if (agentSessionId) {
905
+ wasRecentlyProcessed(`session:${agentSessionId}`);
906
+ api.logger.info(`Created agent session ${agentSessionId} for Issue.create triage`);
907
+ }
908
+
909
+ if (agentSessionId) {
910
+ await linearApi.emitActivity(agentSessionId, {
911
+ type: "thought",
912
+ body: `Triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
913
+ }).catch(() => {});
914
+ }
915
+
916
+ if (agentSessionId) {
917
+ await linearApi.emitActivity(agentSessionId, {
918
+ type: "action",
919
+ action: "Triaging",
920
+ parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling`,
921
+ }).catch(() => {});
922
+ }
923
+
924
+ const message = [
925
+ `IMPORTANT: You are triaging a new Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
926
+ ``,
927
+ `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
928
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
929
+ ``,
930
+ `**Description:**`,
931
+ description,
932
+ ``,
933
+ `## Your Triage Tasks`,
934
+ ``,
935
+ `1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
936
+ `2. **Labels** — Select appropriate labels from the team's available labels`,
937
+ `3. **Priority** — Set priority (1=Urgent, 2=High, 3=Medium, 4=Low) if not already set`,
938
+ `4. **Assessment** — Brief analysis of what this issue needs`,
939
+ ``,
940
+ `## Available Labels`,
941
+ availableLabelList || " (no labels configured)",
942
+ ``,
943
+ `## Response Format`,
944
+ ``,
945
+ `You MUST start your response with a JSON block, then follow with your assessment:`,
946
+ ``,
947
+ '```json',
948
+ `{`,
949
+ ` "estimate": <number>,`,
950
+ ` "labelIds": ["<id1>", "<id2>"],`,
951
+ ` "priority": <number or null>,`,
952
+ ` "assessment": "<one-line summary of your sizing rationale>"`,
953
+ `}`,
954
+ '```',
955
+ ``,
956
+ `Then write your full assessment as markdown below the JSON block.`,
957
+ ].filter(Boolean).join("\n");
958
+
959
+ const sessionId = `linear-triage-${issue.id}-${Date.now()}`;
960
+ const { runAgent } = await import("./agent.js");
961
+ const result = await runAgent({
962
+ api,
963
+ agentId,
964
+ sessionId,
965
+ message,
966
+ timeoutMs: 3 * 60_000,
967
+ });
968
+
969
+ const responseBody = result.success
970
+ ? result.output
971
+ : `I encountered an error triaging this issue. Please triage manually.`;
972
+
973
+ // Parse triage JSON and apply to issue
974
+ let commentBody = responseBody;
975
+ if (result.success) {
976
+ const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
977
+ if (jsonMatch) {
978
+ try {
979
+ const triage = JSON.parse(jsonMatch[1]);
980
+ const updateInput: Record<string, unknown> = {};
981
+
982
+ if (typeof triage.estimate === "number") {
983
+ updateInput.estimate = triage.estimate;
984
+ }
985
+ if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
986
+ const existingIds = currentLabels.map((l: any) => l.id);
987
+ const allIds = [...new Set([...existingIds, ...triage.labelIds])];
988
+ updateInput.labelIds = allIds;
989
+ }
990
+ if (typeof triage.priority === "number" && triage.priority >= 1 && triage.priority <= 4) {
991
+ updateInput.priority = triage.priority;
992
+ }
993
+
994
+ if (Object.keys(updateInput).length > 0) {
995
+ await linearApi.updateIssue(issue.id, updateInput);
996
+ api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
997
+
998
+ if (agentSessionId) {
999
+ await linearApi.emitActivity(agentSessionId, {
1000
+ type: "action",
1001
+ action: "Applied triage",
1002
+ result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0}, priority=${triage.priority ?? "unchanged"}`,
1003
+ }).catch(() => {});
1004
+ }
1005
+ }
1006
+
1007
+ // Strip JSON block from comment
1008
+ commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
1009
+ } catch (parseErr) {
1010
+ api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ // Post branded triage comment
1016
+ const brandingOpts = avatarUrl
1017
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1018
+ : undefined;
1019
+
1020
+ try {
1021
+ if (brandingOpts) {
1022
+ await linearApi.createComment(issue.id, commentBody, brandingOpts);
1023
+ } else {
1024
+ await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1025
+ }
1026
+ } catch (brandErr) {
1027
+ api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1028
+ await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1029
+ }
1030
+
1031
+ if (agentSessionId) {
1032
+ const truncated = commentBody.length > 2000
1033
+ ? commentBody.slice(0, 2000) + "…"
1034
+ : commentBody;
1035
+ await linearApi.emitActivity(agentSessionId, {
1036
+ type: "response",
1037
+ body: truncated,
1038
+ }).catch(() => {});
1039
+ }
1040
+
1041
+ api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
1042
+ } catch (err) {
1043
+ api.logger.error(`Issue.create triage error: ${err}`);
1044
+ if (agentSessionId) {
1045
+ await linearApi.emitActivity(agentSessionId, {
1046
+ type: "error",
1047
+ body: `Failed to triage: ${String(err).slice(0, 500)}`,
1048
+ }).catch(() => {});
1049
+ }
1050
+ }
1051
+ })();
1052
+
1053
+ return true;
1054
+ }
1055
+
847
1056
  // ── Default: log unhandled webhook types for debugging ──────────
848
1057
  api.logger.warn(`Unhandled webhook type=${payload.type} action=${payload.action} — payload: ${JSON.stringify(payload).slice(0, 500)}`);
849
1058
  res.statusCode = 200;