@calltelemetry/openclaw-linear 0.9.6 → 0.9.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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",
@@ -2,7 +2,7 @@
2
2
  * Recorded API responses from sub-issue decomposition smoke test.
3
3
  * Auto-generated — do not edit manually.
4
4
  * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
5
- * Last recorded: 2026-02-22T01:35:48.289Z
5
+ * Last recorded: 2026-02-22T02:01:43.760Z
6
6
  */
7
7
 
8
8
  export const RECORDED = {
@@ -44,20 +44,20 @@ export const RECORDED = {
44
44
  }
45
45
  ],
46
46
  "createParent": {
47
- "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
48
- "identifier": "UAT-235"
47
+ "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
48
+ "identifier": "UAT-265"
49
49
  },
50
50
  "createSubIssue1": {
51
- "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
52
- "identifier": "UAT-236"
51
+ "id": "de6d1ea0-3358-406a-bcfd-1d19b1516442",
52
+ "identifier": "UAT-266"
53
53
  },
54
54
  "createSubIssue2": {
55
- "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
56
- "identifier": "UAT-237"
55
+ "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
56
+ "identifier": "UAT-267"
57
57
  },
58
58
  "subIssue1Details": {
59
- "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
60
- "identifier": "UAT-236",
59
+ "id": "de6d1ea0-3358-406a-bcfd-1d19b1516442",
60
+ "identifier": "UAT-266",
61
61
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
62
62
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
63
63
  "estimate": 2,
@@ -83,16 +83,16 @@ export const RECORDED = {
83
83
  },
84
84
  "project": null,
85
85
  "parent": {
86
- "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
87
- "identifier": "UAT-235"
86
+ "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
87
+ "identifier": "UAT-265"
88
88
  },
89
89
  "relations": {
90
90
  "nodes": []
91
91
  }
92
92
  },
93
93
  "subIssue2Details": {
94
- "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
95
- "identifier": "UAT-237",
94
+ "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
95
+ "identifier": "UAT-267",
96
96
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
97
97
  "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
98
98
  "estimate": 3,
@@ -118,18 +118,18 @@ export const RECORDED = {
118
118
  },
119
119
  "project": null,
120
120
  "parent": {
121
- "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
122
- "identifier": "UAT-235"
121
+ "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
122
+ "identifier": "UAT-265"
123
123
  },
124
124
  "relations": {
125
125
  "nodes": []
126
126
  }
127
127
  },
128
128
  "parentDetails": {
129
- "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
130
- "identifier": "UAT-235",
129
+ "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
130
+ "identifier": "UAT-265",
131
131
  "title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
132
- "description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T01:35:46.183Z",
132
+ "description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T02:01:42.469Z",
133
133
  "estimate": null,
134
134
  "state": {
135
135
  "name": "Backlog",
@@ -149,13 +149,7 @@ export const RECORDED = {
149
149
  "issueEstimationType": "tShirt"
150
150
  },
151
151
  "comments": {
152
- "nodes": [
153
- {
154
- "body": "This thread is for an agent session with ctclaw.",
155
- "user": null,
156
- "createdAt": "2026-02-22T01:35:47.210Z"
157
- }
158
- ]
152
+ "nodes": []
159
153
  },
160
154
  "project": null,
161
155
  "parent": null,
@@ -164,11 +158,11 @@ export const RECORDED = {
164
158
  }
165
159
  },
166
160
  "createRelation": {
167
- "id": "f2b33a57-b79a-4dcc-b973-a5453676a78f"
161
+ "id": "4c97b0aa-563e-4aad-84aa-826078f012fd"
168
162
  },
169
163
  "subIssue1WithRelation": {
170
- "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
171
- "identifier": "UAT-236",
164
+ "id": "de6d1ea0-3358-406a-bcfd-1d19b1516442",
165
+ "identifier": "UAT-266",
172
166
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
173
167
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
174
168
  "estimate": 2,
@@ -190,26 +184,20 @@ export const RECORDED = {
190
184
  "issueEstimationType": "tShirt"
191
185
  },
192
186
  "comments": {
193
- "nodes": [
194
- {
195
- "body": "This thread is for an agent session with ctclaw.",
196
- "user": null,
197
- "createdAt": "2026-02-22T01:35:47.302Z"
198
- }
199
- ]
187
+ "nodes": []
200
188
  },
201
189
  "project": null,
202
190
  "parent": {
203
- "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
204
- "identifier": "UAT-235"
191
+ "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
192
+ "identifier": "UAT-265"
205
193
  },
206
194
  "relations": {
207
195
  "nodes": [
208
196
  {
209
197
  "type": "blocks",
210
198
  "relatedIssue": {
211
- "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
212
- "identifier": "UAT-237",
199
+ "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
200
+ "identifier": "UAT-267",
213
201
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
214
202
  }
215
203
  }
@@ -217,8 +205,8 @@ export const RECORDED = {
217
205
  }
218
206
  },
219
207
  "subIssue2WithRelation": {
220
- "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
221
- "identifier": "UAT-237",
208
+ "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
209
+ "identifier": "UAT-267",
222
210
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
223
211
  "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
224
212
  "estimate": 3,
@@ -240,12 +228,18 @@ export const RECORDED = {
240
228
  "issueEstimationType": "tShirt"
241
229
  },
242
230
  "comments": {
243
- "nodes": []
231
+ "nodes": [
232
+ {
233
+ "body": "This thread is for an agent session with ctclaw.",
234
+ "user": null,
235
+ "createdAt": "2026-02-22T02:01:43.418Z"
236
+ }
237
+ ]
244
238
  },
245
239
  "project": null,
246
240
  "parent": {
247
- "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
248
- "identifier": "UAT-235"
241
+ "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
242
+ "identifier": "UAT-265"
249
243
  },
250
244
  "relations": {
251
245
  "nodes": []
@@ -441,7 +441,7 @@ describe("Linear API smoke tests", () => {
441
441
  }
442
442
 
443
443
  expect(issue.id).toBe(sessionIssueId);
444
- });
444
+ }, 20_000);
445
445
 
446
446
  it("Path B: createSessionOnIssue via OAuth (programmatic)", async () => {
447
447
  expect(sessionIssueId).toBeTruthy();
@@ -152,6 +152,11 @@ vi.mock("../pipeline/dag-dispatch.js", () => ({
152
152
  startProjectDispatch: vi.fn().mockResolvedValue(undefined),
153
153
  }));
154
154
 
155
+ vi.mock("../infra/shared-profiles.js", async (importOriginal) => {
156
+ const actual = await importOriginal() as Record<string, unknown>;
157
+ return { ...actual, validateProfiles: vi.fn().mockReturnValue(null) };
158
+ });
159
+
155
160
  // ── Imports (after mocks) ────────────────────────────────────────
156
161
 
157
162
  import { handleLinearWebhook, _resetForTesting } from "../pipeline/webhook.js";
@@ -5,7 +5,7 @@
5
5
  * resolveAgentFromAlias() implementations that were previously in
6
6
  * webhook.ts, intent-classify.ts, and tier-assess.ts.
7
7
  */
8
- import { readFileSync } from "node:fs";
8
+ import { readFileSync, existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
 
@@ -107,6 +107,64 @@ export function resolveDefaultAgent(api: { pluginConfig?: Record<string, unknown
107
107
  return "default";
108
108
  }
109
109
 
110
+ // ---------------------------------------------------------------------------
111
+ // Profile validation — returns a user-facing error string or null if OK.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Validate that agent-profiles.json exists, is parseable, and has at least
116
+ * one agent. Returns a human-readable error string suitable for posting
117
+ * back to Linear, or null when everything looks good.
118
+ */
119
+ export function validateProfiles(): string | null {
120
+ if (!existsSync(PROFILES_PATH)) {
121
+ return (
122
+ `**Critical setup error:** \`agent-profiles.json\` not found.\n\n` +
123
+ `The Linear plugin requires this file to route messages to your agent.\n\n` +
124
+ `**Create it now:**\n` +
125
+ "```\n" +
126
+ `cat > ${PROFILES_PATH} << 'EOF'\n` +
127
+ `{\n` +
128
+ ` "agents": {\n` +
129
+ ` "my-agent": {\n` +
130
+ ` "label": "My Agent",\n` +
131
+ ` "mission": "AI assistant",\n` +
132
+ ` "isDefault": true,\n` +
133
+ ` "mentionAliases": ["my-agent"]\n` +
134
+ ` }\n` +
135
+ ` }\n` +
136
+ `}\n` +
137
+ `EOF\n` +
138
+ "```\n\n" +
139
+ `Then restart the gateway: \`systemctl --user restart openclaw-gateway\`\n\n` +
140
+ `Run \`openclaw openclaw-linear doctor\` to verify your setup.`
141
+ );
142
+ }
143
+
144
+ let profiles: Record<string, unknown>;
145
+ try {
146
+ const raw = readFileSync(PROFILES_PATH, "utf8");
147
+ profiles = JSON.parse(raw).agents ?? {};
148
+ } catch (err) {
149
+ return (
150
+ `**Critical setup error:** \`agent-profiles.json\` exists but could not be parsed.\n\n` +
151
+ `Error: ${err instanceof Error ? err.message : String(err)}\n\n` +
152
+ `Fix the JSON syntax in \`${PROFILES_PATH}\` and restart the gateway.\n` +
153
+ `Run \`openclaw openclaw-linear doctor\` to verify.`
154
+ );
155
+ }
156
+
157
+ if (Object.keys(profiles).length === 0) {
158
+ return (
159
+ `**Critical setup error:** \`agent-profiles.json\` has no agents configured.\n\n` +
160
+ `Add at least one agent entry to the \`"agents"\` object in \`${PROFILES_PATH}\`.\n` +
161
+ `Run \`openclaw openclaw-linear doctor\` for a guided setup check.`
162
+ );
163
+ }
164
+
165
+ return null;
166
+ }
167
+
110
168
  // ---------------------------------------------------------------------------
111
169
  // Test-only: reset cache
112
170
  // ---------------------------------------------------------------------------
@@ -14,6 +14,7 @@ const {
14
14
  loadAgentProfilesMock,
15
15
  buildMentionPatternMock,
16
16
  resolveAgentFromAliasMock,
17
+ validateProfilesMock,
17
18
  resetProfilesCacheMock,
18
19
  classifyIntentMock,
19
20
  extractGuidanceMock,
@@ -86,6 +87,7 @@ const {
86
87
  }),
87
88
  buildMentionPatternMock: vi.fn().mockReturnValue(/@(mal|mason|kaylee|eureka)/i),
88
89
  resolveAgentFromAliasMock: vi.fn().mockReturnValue(null),
90
+ validateProfilesMock: vi.fn().mockReturnValue(null),
89
91
  resetProfilesCacheMock: vi.fn(),
90
92
  classifyIntentMock: vi.fn().mockResolvedValue({
91
93
  intent: "general",
@@ -154,6 +156,7 @@ vi.mock("../infra/shared-profiles.js", () => ({
154
156
  loadAgentProfiles: loadAgentProfilesMock,
155
157
  buildMentionPattern: buildMentionPatternMock,
156
158
  resolveAgentFromAlias: resolveAgentFromAliasMock,
159
+ validateProfiles: validateProfilesMock,
157
160
  _resetProfilesCacheForTesting: resetProfilesCacheMock,
158
161
  }));
159
162
 
@@ -352,6 +355,7 @@ afterEach(() => {
352
355
  });
353
356
  buildMentionPatternMock.mockReset().mockReturnValue(/@(mal|mason|kaylee|eureka)/i);
354
357
  resolveAgentFromAliasMock.mockReset().mockReturnValue(null);
358
+ validateProfilesMock.mockReset().mockReturnValue(null);
355
359
  classifyIntentMock.mockReset().mockResolvedValue({
356
360
  intent: "general",
357
361
  reasoning: "Not actionable",
@@ -17,7 +17,7 @@ import { startProjectDispatch } from "./dag-dispatch.js";
17
17
  import { emitDiagnostic } from "../infra/observability.js";
18
18
  import { classifyIntent } from "./intent-classify.js";
19
19
  import { extractGuidance, formatGuidanceAppendix, cacheGuidanceForTeam, getCachedGuidanceForTeam, isGuidanceEnabled, _resetGuidanceCacheForTesting } from "./guidance.js";
20
- import { loadAgentProfiles, buildMentionPattern, resolveAgentFromAlias, _resetProfilesCacheForTesting, type AgentProfile } from "../infra/shared-profiles.js";
20
+ import { loadAgentProfiles, buildMentionPattern, resolveAgentFromAlias, validateProfiles, _resetProfilesCacheForTesting, type AgentProfile } from "../infra/shared-profiles.js";
21
21
 
22
22
  // ── Prompt input sanitization ─────────────────────────────────────
23
23
 
@@ -322,6 +322,21 @@ export async function handleLinearWebhook(
322
322
  return true;
323
323
  }
324
324
 
325
+ // Validate agent profiles before doing any work
326
+ const profilesError = validateProfiles();
327
+ if (profilesError) {
328
+ api.logger.error("Agent profiles validation failed — posting setup error to Linear");
329
+ await linearApi.emitActivity(session.id, {
330
+ type: "error",
331
+ body: profilesError,
332
+ }).catch(() => {});
333
+ // Also try posting as a comment in case emitActivity doesn't render markdown
334
+ try {
335
+ await createCommentWithDedup(linearApi, issue.id, profilesError);
336
+ } catch {}
337
+ return true;
338
+ }
339
+
325
340
  const previousComments = payload.previousComments ?? [];
326
341
  const guidanceCtx = extractGuidance(payload);
327
342
 
@@ -331,21 +346,54 @@ export async function handleLinearWebhook(
331
346
  : null;
332
347
  const userMessage = lastComment?.body ?? "";
333
348
 
349
+ // Also extract the session prompt from promptContext — on initial session
350
+ // creation, previousComments is empty but the user's message lives here.
351
+ const promptContext = typeof payload.promptContext === "string" ? payload.promptContext : "";
352
+
334
353
  // Route to the mentioned agent if the user's message contains an @mention.
335
354
  // AgentSessionEvent doesn't carry mention routing — we must check manually.
355
+ // Check the last comment body, promptContext, AND agentSession prompt for @mentions.
336
356
  const profiles = loadAgentProfiles();
337
357
  const mentionPattern = buildMentionPattern(profiles);
338
358
  let agentId = resolveAgentId(api);
339
359
  let mentionOverride = false;
340
- if (mentionPattern && userMessage) {
341
- const mentionMatch = userMessage.match(mentionPattern);
342
- if (mentionMatch) {
343
- const alias = mentionMatch[1];
344
- const resolved = resolveAgentFromAlias(alias, profiles);
345
- if (resolved) {
346
- api.logger.info(`AgentSession routed to ${resolved.agentId} via @${alias} mention`);
347
- agentId = resolved.agentId;
348
- mentionOverride = true;
360
+ const sessionPrompt = typeof (payload.agentSession as any)?.prompt === "string"
361
+ ? (payload.agentSession as any).prompt : "";
362
+ const textsToScan = [userMessage, sessionPrompt, promptContext].filter(Boolean);
363
+ for (const text of textsToScan) {
364
+ if (mentionOverride) break;
365
+ if (mentionPattern) {
366
+ mentionPattern.lastIndex = 0;
367
+ const mentionMatch = text.match(mentionPattern);
368
+ if (mentionMatch) {
369
+ const alias = mentionMatch[1];
370
+ const resolved = resolveAgentFromAlias(alias, profiles);
371
+ if (resolved) {
372
+ api.logger.info(`AgentSession routed to ${resolved.agentId} via @${alias} mention in ${text === userMessage ? "comment" : text === sessionPrompt ? "session prompt" : "promptContext"}`);
373
+ agentId = resolved.agentId;
374
+ mentionOverride = true;
375
+ }
376
+ }
377
+ }
378
+ }
379
+ // Also try bare-name matching (e.g. "hey mal" without @) in the user's text
380
+ if (!mentionOverride) {
381
+ for (const text of textsToScan) {
382
+ if (mentionOverride) break;
383
+ const lowerText = text.toLowerCase();
384
+ for (const [id, profile] of Object.entries(profiles)) {
385
+ const allNames = [...profile.mentionAliases, ...(profile.appAliases ?? [])];
386
+ for (const name of allNames) {
387
+ // Match bare name at word boundary (e.g. "hey mal" but not "malware")
388
+ const nameRe = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
389
+ if (nameRe.test(lowerText)) {
390
+ api.logger.info(`AgentSession routed to ${id} via bare name "${name}" in ${text === userMessage ? "comment" : text === sessionPrompt ? "session prompt" : "promptContext"}`);
391
+ agentId = id;
392
+ mentionOverride = true;
393
+ break;
394
+ }
395
+ }
396
+ if (mentionOverride) break;
349
397
  }
350
398
  }
351
399
  }
@@ -553,6 +601,17 @@ export async function handleLinearWebhook(
553
601
  return true;
554
602
  }
555
603
 
604
+ // Validate agent profiles before doing any work
605
+ const profilesError = validateProfiles();
606
+ if (profilesError) {
607
+ api.logger.error("Agent profiles validation failed — posting setup error to Linear");
608
+ await linearApi.emitActivity(session.id, {
609
+ type: "error",
610
+ body: profilesError,
611
+ }).catch(() => {});
612
+ return true;
613
+ }
614
+
556
615
  // Route to mentioned agent if user's message contains an @mention (one-time detour)
557
616
  const promptedProfiles = loadAgentProfiles();
558
617
  const promptedMentionPattern = buildMentionPattern(promptedProfiles);
@@ -764,6 +823,16 @@ export async function handleLinearWebhook(
764
823
  return true;
765
824
  }
766
825
 
826
+ // Validate agent profiles before doing any work
827
+ const profilesError = validateProfiles();
828
+ if (profilesError) {
829
+ api.logger.error("Agent profiles validation failed — posting setup error to Linear");
830
+ try {
831
+ await createCommentWithDedup(linearApi, issue.id, profilesError);
832
+ } catch {}
833
+ return true;
834
+ }
835
+
767
836
  // Load agent profiles
768
837
  const profiles = loadAgentProfiles();
769
838
  const agentNames = Object.keys(profiles);
@@ -1050,6 +1119,16 @@ export async function handleLinearWebhook(
1050
1119
  return true;
1051
1120
  }
1052
1121
 
1122
+ // Validate agent profiles
1123
+ const profilesError = validateProfiles();
1124
+ if (profilesError) {
1125
+ api.logger.error("Agent profiles validation failed — cannot triage new issue");
1126
+ try {
1127
+ await createCommentWithDedup(linearApi, issue.id, profilesError);
1128
+ } catch {}
1129
+ return true;
1130
+ }
1131
+
1053
1132
  const agentId = resolveAgentId(api);
1054
1133
 
1055
1134
  // Guard: prevent duplicate runs on same issue (also blocks AgentSessionEvent