@calltelemetry/openclaw-linear 0.9.7 → 0.9.9

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.7",
3
+ "version": "0.9.9",
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-22T02:01:43.760Z
5
+ * Last recorded: 2026-02-22T02:27:42.743Z
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": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
48
- "identifier": "UAT-265"
47
+ "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
48
+ "identifier": "UAT-305"
49
49
  },
50
50
  "createSubIssue1": {
51
- "id": "de6d1ea0-3358-406a-bcfd-1d19b1516442",
52
- "identifier": "UAT-266"
51
+ "id": "f342ec90-c5f1-483d-987b-9de406d65fac",
52
+ "identifier": "UAT-306"
53
53
  },
54
54
  "createSubIssue2": {
55
- "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
56
- "identifier": "UAT-267"
55
+ "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
56
+ "identifier": "UAT-307"
57
57
  },
58
58
  "subIssue1Details": {
59
- "id": "de6d1ea0-3358-406a-bcfd-1d19b1516442",
60
- "identifier": "UAT-266",
59
+ "id": "f342ec90-c5f1-483d-987b-9de406d65fac",
60
+ "identifier": "UAT-306",
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": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
87
- "identifier": "UAT-265"
86
+ "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
87
+ "identifier": "UAT-305"
88
88
  },
89
89
  "relations": {
90
90
  "nodes": []
91
91
  }
92
92
  },
93
93
  "subIssue2Details": {
94
- "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
95
- "identifier": "UAT-267",
94
+ "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
95
+ "identifier": "UAT-307",
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,
@@ -114,22 +114,28 @@ export const RECORDED = {
114
114
  "issueEstimationType": "tShirt"
115
115
  },
116
116
  "comments": {
117
- "nodes": []
117
+ "nodes": [
118
+ {
119
+ "body": "This thread is for an agent session with ctclaw.",
120
+ "user": null,
121
+ "createdAt": "2026-02-22T02:27:41.198Z"
122
+ }
123
+ ]
118
124
  },
119
125
  "project": null,
120
126
  "parent": {
121
- "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
122
- "identifier": "UAT-265"
127
+ "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
128
+ "identifier": "UAT-305"
123
129
  },
124
130
  "relations": {
125
131
  "nodes": []
126
132
  }
127
133
  },
128
134
  "parentDetails": {
129
- "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
130
- "identifier": "UAT-265",
135
+ "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
136
+ "identifier": "UAT-305",
131
137
  "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-22T02:01:42.469Z",
138
+ "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:27:39.194Z",
133
139
  "estimate": null,
134
140
  "state": {
135
141
  "name": "Backlog",
@@ -149,7 +155,13 @@ export const RECORDED = {
149
155
  "issueEstimationType": "tShirt"
150
156
  },
151
157
  "comments": {
152
- "nodes": []
158
+ "nodes": [
159
+ {
160
+ "body": "This thread is for an agent session with ctclaw.",
161
+ "user": null,
162
+ "createdAt": "2026-02-22T02:27:39.821Z"
163
+ }
164
+ ]
153
165
  },
154
166
  "project": null,
155
167
  "parent": null,
@@ -158,11 +170,11 @@ export const RECORDED = {
158
170
  }
159
171
  },
160
172
  "createRelation": {
161
- "id": "4c97b0aa-563e-4aad-84aa-826078f012fd"
173
+ "id": "b7040537-aa53-4e7d-a76d-fd220df3e527"
162
174
  },
163
175
  "subIssue1WithRelation": {
164
- "id": "de6d1ea0-3358-406a-bcfd-1d19b1516442",
165
- "identifier": "UAT-266",
176
+ "id": "f342ec90-c5f1-483d-987b-9de406d65fac",
177
+ "identifier": "UAT-306",
166
178
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
167
179
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
168
180
  "estimate": 2,
@@ -184,20 +196,26 @@ export const RECORDED = {
184
196
  "issueEstimationType": "tShirt"
185
197
  },
186
198
  "comments": {
187
- "nodes": []
199
+ "nodes": [
200
+ {
201
+ "body": "This thread is for an agent session with ctclaw.",
202
+ "user": null,
203
+ "createdAt": "2026-02-22T02:27:40.662Z"
204
+ }
205
+ ]
188
206
  },
189
207
  "project": null,
190
208
  "parent": {
191
- "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
192
- "identifier": "UAT-265"
209
+ "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
210
+ "identifier": "UAT-305"
193
211
  },
194
212
  "relations": {
195
213
  "nodes": [
196
214
  {
197
215
  "type": "blocks",
198
216
  "relatedIssue": {
199
- "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
200
- "identifier": "UAT-267",
217
+ "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
218
+ "identifier": "UAT-307",
201
219
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
202
220
  }
203
221
  }
@@ -205,8 +223,8 @@ export const RECORDED = {
205
223
  }
206
224
  },
207
225
  "subIssue2WithRelation": {
208
- "id": "7ca89fc7-b246-44b6-8400-0e2a4d169f47",
209
- "identifier": "UAT-267",
226
+ "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
227
+ "identifier": "UAT-307",
210
228
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
211
229
  "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.",
212
230
  "estimate": 3,
@@ -232,14 +250,14 @@ export const RECORDED = {
232
250
  {
233
251
  "body": "This thread is for an agent session with ctclaw.",
234
252
  "user": null,
235
- "createdAt": "2026-02-22T02:01:43.418Z"
253
+ "createdAt": "2026-02-22T02:27:41.198Z"
236
254
  }
237
255
  ]
238
256
  },
239
257
  "project": null,
240
258
  "parent": {
241
- "id": "7b5cc5d3-ec16-47e0-b0c4-caf8eea47609",
242
- "identifier": "UAT-265"
259
+ "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
260
+ "identifier": "UAT-305"
243
261
  },
244
262
  "relations": {
245
263
  "nodes": []
@@ -649,7 +649,7 @@ describe("webhook scenario tests — full handler flows", () => {
649
649
 
650
650
  expect(mockRunAgent).toHaveBeenCalledOnce();
651
651
  const runArgs = mockRunAgent.mock.calls[0][0];
652
- expect(runArgs.message).toContain("Additional Guidance");
652
+ expect(runArgs.message).toContain("Workspace Guidance");
653
653
  expect(runArgs.message).toContain("Always use the main branch");
654
654
  });
655
655
 
@@ -668,11 +668,12 @@ describe("webhook scenario tests — full handler flows", () => {
668
668
  expect(mockRunAgent).toHaveBeenCalledOnce();
669
669
  const msg = mockRunAgent.mock.calls[0][0].message;
670
670
 
671
- // Guidance text should appear in the appendix section, not as the user's comment
672
- const userMsgSection = msg.split("Additional Guidance")[0];
673
- expect(userMsgSection).toContain("Please fix the routing bug");
674
- // The guidance string itself should not appear before the appendix
675
- expect(userMsgSection).not.toContain("Always use the main branch");
671
+ // Guidance text should appear in the guidance section, not as the user's latest message
672
+ expect(msg).toContain("Please fix the routing bug");
673
+ // The guidance text should be within the guidance section, not in the "Latest message" block
674
+ const latestMsgSection = msg.split("**Latest message:**")[1] ?? "";
675
+ expect(latestMsgSection).toContain("Please fix the routing bug");
676
+ expect(latestMsgSection).not.toContain("Always use the main branch");
676
677
  });
677
678
 
678
679
  it("prompted: includes guidance from promptContext", async () => {
@@ -688,7 +689,7 @@ describe("webhook scenario tests — full handler flows", () => {
688
689
  expect(mockRunAgent).toHaveBeenCalledOnce();
689
690
  const msg = mockRunAgent.mock.calls[0][0].message;
690
691
  expect(msg).toContain("Can you also fix the tests?");
691
- expect(msg).toContain("Additional Guidance");
692
+ expect(msg).toContain("Workspace Guidance");
692
693
  expect(msg).toContain("Use TypeScript strict mode");
693
694
  });
694
695
 
@@ -704,7 +705,7 @@ describe("webhook scenario tests — full handler flows", () => {
704
705
 
705
706
  expect(mockRunAgent).toHaveBeenCalledOnce();
706
707
  const msg = mockRunAgent.mock.calls[0][0].message;
707
- expect(msg).not.toContain("Additional Guidance");
708
+ expect(msg).not.toContain("Workspace Guidance");
708
709
  expect(msg).not.toContain("Should not appear in prompt");
709
710
  });
710
711
 
@@ -724,7 +725,7 @@ describe("webhook scenario tests — full handler flows", () => {
724
725
 
725
726
  expect(mockRunAgent).toHaveBeenCalledOnce();
726
727
  const msg = mockRunAgent.mock.calls[0][0].message;
727
- expect(msg).not.toContain("Additional Guidance");
728
+ expect(msg).not.toContain("Workspace Guidance");
728
729
  expect(msg).not.toContain("Should be suppressed");
729
730
  });
730
731
 
@@ -782,7 +783,7 @@ describe("webhook scenario tests — full handler flows", () => {
782
783
 
783
784
  expect(mockRunAgent).toHaveBeenCalledOnce();
784
785
  const msg = mockRunAgent.mock.calls[0][0].message;
785
- expect(msg).toContain("Additional Guidance");
786
+ expect(msg).toContain("Workspace Guidance");
786
787
  expect(msg).toContain("Cached guidance from session event");
787
788
  });
788
789
  });
@@ -204,6 +204,52 @@ describe("runAgent subprocess", () => {
204
204
  });
205
205
  });
206
206
 
207
+ it("extracts text from flat envelope (payloads at top level)", async () => {
208
+ const api = createApi();
209
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
210
+ code: 0,
211
+ stdout: JSON.stringify({ payloads: [{ text: "flat response" }], meta: {} }),
212
+ stderr: "",
213
+ });
214
+
215
+ const result = await runAgent({
216
+ api,
217
+ agentId: "test",
218
+ sessionId: "s1",
219
+ message: "test",
220
+ });
221
+
222
+ expect(result.success).toBe(true);
223
+ expect(result.output).toBe("flat response");
224
+ });
225
+
226
+ it("strips plugin init log noise before JSON blob", async () => {
227
+ const api = createApi();
228
+ const noisyOutput = [
229
+ "[plugins] Dispatch gateway methods registered",
230
+ "[plugins] Linear agent extension registered (agent: zoe)",
231
+ '[plugins] code_run: default backend=codex, aliases={"claude":"claude"}',
232
+ JSON.stringify({ payloads: [{ text: "clean response" }], meta: {} }),
233
+ ].join("\n");
234
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
235
+ code: 0,
236
+ stdout: noisyOutput,
237
+ stderr: "",
238
+ });
239
+
240
+ const result = await runAgent({
241
+ api,
242
+ agentId: "test",
243
+ sessionId: "s1",
244
+ message: "test",
245
+ });
246
+
247
+ expect(result.success).toBe(true);
248
+ expect(result.output).toBe("clean response");
249
+ expect(result.output).not.toContain("[plugins]");
250
+ expect(result.output).not.toContain("payloads");
251
+ });
252
+
207
253
  describe("runAgent date/time injection", () => {
208
254
  it("injects current date/time into the message sent to subprocess", async () => {
209
255
  const api = createApi();
@@ -38,9 +38,12 @@ function resolveAgentDirs(agentId: string, config: Record<string, any>): AgentDi
38
38
  let _extensionAPI: any | null = null;
39
39
  async function getExtensionAPI() {
40
40
  if (!_extensionAPI) {
41
- // Resolve the openclaw package location dynamically, then import extensionAPI
41
+ // Resolve the openclaw package location dynamically, then import extensionAPI.
42
+ // openclaw's package.json exports don't expose ./package.json, so resolve
43
+ // via the main entry point and walk up to the package root.
42
44
  const _require = createRequire(import.meta.url);
43
- const openclawDir = dirname(_require.resolve("openclaw/package.json"));
45
+ const mainEntry = _require.resolve("openclaw");
46
+ const openclawDir = dirname(dirname(mainEntry));
44
47
  _extensionAPI = await import(join(openclawDir, "dist", "extensionAPI.js"));
45
48
  }
46
49
  return _extensionAPI;
@@ -415,17 +418,47 @@ async function runSubprocess(
415
418
  const raw = result.stdout || "";
416
419
  api.logger.info(`Agent ${agentId} completed for session ${sessionId}`);
417
420
 
418
- // Extract clean text from --json output
419
- try {
420
- const parsed = JSON.parse(raw);
421
- const payloads = parsed?.result?.payloads;
422
- if (Array.isArray(payloads) && payloads.length > 0) {
423
- const text = payloads.map((p: any) => p.text).filter(Boolean).join("\n\n");
424
- if (text) return { success: true, output: text };
421
+ // Extract clean text from --json output.
422
+ // The subprocess stdout may contain plugin init log lines before the JSON blob.
423
+ // Strip everything before the first `{` to isolate the JSON envelope.
424
+ const extracted = extractJsonFromOutput(raw);
425
+ if (extracted) return { success: true, output: extracted };
426
+
427
+ return { success: true, output: raw };
428
+ }
429
+
430
+ /**
431
+ * Extract text from subprocess --json output. Handles:
432
+ * - Log noise before the JSON blob (plugin init lines)
433
+ * - Both envelope shapes: `{ payloads }` (flat) and `{ result: { payloads } }` (nested)
434
+ */
435
+ function extractJsonFromOutput(raw: string): string | null {
436
+ // The subprocess stdout may contain plugin init log lines before the JSON
437
+ // result blob. Try parsing the whole thing first; if that fails, scan lines
438
+ // backwards for a `{` that starts a valid JSON envelope with payloads.
439
+ const candidates: string[] = [raw];
440
+
441
+ // Also try from each line that starts with `{` (the JSON blob typically
442
+ // starts on its own line after log noise).
443
+ const lines = raw.split("\n");
444
+ for (let i = 0; i < lines.length; i++) {
445
+ if (lines[i].trimStart().startsWith("{")) {
446
+ candidates.push(lines.slice(i).join("\n"));
425
447
  }
426
- } catch {
427
- // Not JSON — use raw output as-is
428
448
  }
429
449
 
430
- return { success: true, output: raw };
450
+ for (const candidate of candidates) {
451
+ try {
452
+ const parsed = JSON.parse(candidate);
453
+ // Try both envelope shapes: flat `{ payloads }` and nested `{ result: { payloads } }`
454
+ const payloads = parsed?.payloads ?? parsed?.result?.payloads;
455
+ if (Array.isArray(payloads) && payloads.length > 0) {
456
+ const text = payloads.map((p: any) => p.text).filter(Boolean).join("\n\n");
457
+ if (text) return text;
458
+ }
459
+ } catch {
460
+ // Not valid JSON at this position — try next candidate
461
+ }
462
+ }
463
+ return null;
431
464
  }
package/src/infra/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { validateRepoPath } from "./multi-repo.js";
13
13
  import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
14
14
  import { listWorktrees } from "./codex-worktree.js";
15
15
  import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
16
+ import { PROFILES_PATH, createAgentProfilesFile, loadAgentProfiles } from "./shared-profiles.js";
16
17
  import {
17
18
  formatMessage,
18
19
  parseNotificationsConfig,
@@ -213,6 +214,265 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
213
214
  console.log();
214
215
  });
215
216
 
217
+ // --- interactive profile creation helper ---
218
+ async function createProfileInteractive(): Promise<void> {
219
+ const name = await prompt(" Agent name (lowercase, no spaces — e.g. bobbin): ");
220
+ if (!name) {
221
+ console.log(" Skipped.\n");
222
+ return;
223
+ }
224
+
225
+ const agentId = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
226
+ if (!agentId) {
227
+ console.log(" Invalid name. Skipped.\n");
228
+ return;
229
+ }
230
+
231
+ const labelDefault = agentId.charAt(0).toUpperCase() + agentId.slice(1);
232
+ const labelInput = await prompt(` Display label [${labelDefault}]: `);
233
+ const label = labelInput || labelDefault;
234
+
235
+ const aliasInput = await prompt(` Additional @mention aliases (comma-separated, or blank): `);
236
+ const extraAliases = aliasInput
237
+ ? aliasInput.split(",").map(a => a.trim().toLowerCase()).filter(Boolean)
238
+ : [];
239
+ const mentionAliases = [agentId, ...extraAliases.filter(a => a !== agentId)];
240
+
241
+ try {
242
+ createAgentProfilesFile({ agentId, label, mentionAliases });
243
+ console.log(`\n ✓ Created ${PROFILES_PATH}`);
244
+ console.log(` Agent: ${agentId} (${label})`);
245
+ console.log(` Aliases: ${mentionAliases.map(a => "@" + a).join(", ")}`);
246
+ console.log(` Default: yes`);
247
+ } catch (err) {
248
+ console.log(`\n ✗ Failed to create agent-profiles.json: ${err instanceof Error ? err.message : String(err)}`);
249
+ }
250
+ }
251
+
252
+ // --- openclaw openclaw-linear setup ---
253
+ linear
254
+ .command("setup")
255
+ .description("Guided first-time setup — creates agent profile, checks auth, provisions webhook")
256
+ .action(async () => {
257
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
258
+
259
+ console.log("\nLinear Plugin Setup");
260
+ console.log("═".repeat(50));
261
+
262
+ // ---------------------------------------------------------------
263
+ // Step 1: Agent Profile
264
+ // ---------------------------------------------------------------
265
+ console.log("\nStep 1: Agent Profile");
266
+ console.log("─".repeat(50));
267
+
268
+ if (existsSync(PROFILES_PATH)) {
269
+ const profiles = loadAgentProfiles();
270
+ const count = Object.keys(profiles).length;
271
+ if (count > 0) {
272
+ const names = Object.entries(profiles)
273
+ .map(([id, p]) => `${id}${p.isDefault ? " (default)" : ""}`)
274
+ .join(", ");
275
+ console.log(` ✓ agent-profiles.json found (${count} agent${count > 1 ? "s" : ""}: ${names})`);
276
+ } else {
277
+ console.log(" ⚠ agent-profiles.json exists but has no agents.");
278
+ const fix = await prompt(" Overwrite with a new profile? [Y/n]: ");
279
+ if (fix.toLowerCase() === "n") {
280
+ console.log(" Skipped. Fix the file manually and re-run setup.\n");
281
+ } else {
282
+ await createProfileInteractive();
283
+ }
284
+ }
285
+ } else {
286
+ console.log(" No agent-profiles.json found — let's create one!\n");
287
+ console.log(" Your agent needs a name so Linear users can @mention it.");
288
+ console.log(" Example: if your agent is called \"bobbin\", users type @bobbin in comments.\n");
289
+ await createProfileInteractive();
290
+ }
291
+
292
+ // ---------------------------------------------------------------
293
+ // Step 2: Authentication
294
+ // ---------------------------------------------------------------
295
+ console.log("\nStep 2: Authentication");
296
+ console.log("─".repeat(50));
297
+
298
+ const tokenInfo = resolveLinearToken(pluginConfig);
299
+ if (tokenInfo.accessToken) {
300
+ // Verify the token works
301
+ try {
302
+ const authHeader = tokenInfo.refreshToken
303
+ ? `Bearer ${tokenInfo.accessToken}`
304
+ : tokenInfo.accessToken;
305
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
308
+ body: JSON.stringify({ query: `{ viewer { name } organization { name } }` }),
309
+ });
310
+ if (res.ok) {
311
+ const payload = await res.json() as any;
312
+ if (payload.data?.viewer) {
313
+ console.log(` ✓ Authenticated as ${payload.data.viewer.name} (${payload.data.organization.name})`);
314
+ } else {
315
+ console.log(` ⚠ Token found but API returned errors. Run: openclaw openclaw-linear auth`);
316
+ }
317
+ } else {
318
+ console.log(` ⚠ Token found but API returned ${res.status}. Run: openclaw openclaw-linear auth`);
319
+ }
320
+ } catch {
321
+ console.log(` ⚠ Token found but API unreachable. Check network and retry.`);
322
+ }
323
+ } else {
324
+ console.log(" No Linear token found.\n");
325
+ const doAuth = await prompt(" Run OAuth authorization now? [Y/n]: ");
326
+ if (doAuth.toLowerCase() !== "n") {
327
+ const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
328
+ const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
329
+
330
+ if (!clientId || !clientSecret) {
331
+ console.log("\n ✗ OAuth client ID and secret not configured.");
332
+ console.log(" Set LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET env vars,");
333
+ console.log(" or add clientId/clientSecret to the plugin config in openclaw.json.");
334
+ console.log(" Then re-run: openclaw openclaw-linear setup\n");
335
+ } else {
336
+ const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
337
+ const redirectUri = (pluginConfig?.redirectUri as string)
338
+ ?? process.env.LINEAR_REDIRECT_URI
339
+ ?? `http://localhost:${gatewayPort}/linear/oauth/callback`;
340
+
341
+ const state = Math.random().toString(36).substring(7);
342
+ 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`;
343
+
344
+ console.log("\n Opening Linear OAuth page...\n");
345
+ console.log(` ${authUrl}\n`);
346
+ openBrowser(authUrl);
347
+
348
+ const code = await prompt(" Paste the authorization code from Linear: ");
349
+ if (code) {
350
+ try {
351
+ const response = await fetch(LINEAR_OAUTH_TOKEN_URL, {
352
+ method: "POST",
353
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
354
+ body: new URLSearchParams({
355
+ grant_type: "authorization_code",
356
+ code,
357
+ client_id: clientId,
358
+ client_secret: clientSecret,
359
+ redirect_uri: redirectUri,
360
+ }),
361
+ });
362
+
363
+ if (response.ok) {
364
+ const tokens = await response.json() as any;
365
+ let store: any = { version: 1, profiles: {} };
366
+ try {
367
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
368
+ store = JSON.parse(raw);
369
+ } catch { /* fresh store */ }
370
+
371
+ store.profiles = store.profiles ?? {};
372
+ store.profiles["linear:default"] = {
373
+ type: "oauth",
374
+ provider: "linear",
375
+ accessToken: tokens.access_token,
376
+ access: tokens.access_token,
377
+ refreshToken: tokens.refresh_token ?? null,
378
+ refresh: tokens.refresh_token ?? null,
379
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
380
+ expires: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
381
+ scope: tokens.scope,
382
+ };
383
+
384
+ writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(store, null, 2), "utf8");
385
+ console.log(" ✓ Token saved to auth-profiles.json");
386
+ } else {
387
+ const error = await response.text();
388
+ console.log(` ✗ Token exchange failed (${response.status}): ${error}`);
389
+ }
390
+ } catch (err) {
391
+ console.log(` ✗ Token exchange error: ${err instanceof Error ? err.message : String(err)}`);
392
+ }
393
+ } else {
394
+ console.log(" Skipped. Run: openclaw openclaw-linear auth later.");
395
+ }
396
+ }
397
+ } else {
398
+ console.log(" Skipped. Run: openclaw openclaw-linear auth when ready.");
399
+ }
400
+ }
401
+
402
+ // ---------------------------------------------------------------
403
+ // Step 3: Webhook
404
+ // ---------------------------------------------------------------
405
+ console.log("\nStep 3: Webhook");
406
+ console.log("─".repeat(50));
407
+
408
+ const freshToken = resolveLinearToken(pluginConfig);
409
+ if (!freshToken.accessToken) {
410
+ console.log(" ⚠ Skipped — no auth token available. Complete Step 2 first.");
411
+ } else {
412
+ const { provisionWebhook, getWebhookStatus } = await import("./webhook-provision.js");
413
+ const linearApi = new LinearAgentApi(freshToken.accessToken, {
414
+ refreshToken: freshToken.refreshToken,
415
+ expiresAt: freshToken.expiresAt,
416
+ });
417
+
418
+ const webhookUrl = (pluginConfig?.webhookUrl as string)
419
+ ?? "https://linear.calltelemetry.com/linear/webhook";
420
+
421
+ try {
422
+ const status = await getWebhookStatus(linearApi, webhookUrl);
423
+ if (status && status.issues.length === 0) {
424
+ console.log(` ✓ Webhook already configured (${webhookUrl})`);
425
+ } else {
426
+ const result = await provisionWebhook(linearApi, webhookUrl, { allPublicTeams: true });
427
+ if (result.action === "created") {
428
+ console.log(` ✓ Webhook created (${result.webhookId})`);
429
+ } else if (result.action === "updated") {
430
+ console.log(` ✓ Webhook updated (${result.webhookId})`);
431
+ } else {
432
+ console.log(` ✓ Webhook OK (${result.webhookId})`);
433
+ }
434
+ }
435
+ } catch (err) {
436
+ console.log(` ⚠ Webhook check failed: ${err instanceof Error ? err.message : String(err)}`);
437
+ console.log(" Run: openclaw openclaw-linear webhooks setup");
438
+ }
439
+ }
440
+
441
+ // ---------------------------------------------------------------
442
+ // Step 4: Verify
443
+ // ---------------------------------------------------------------
444
+ console.log("\nStep 4: Verification");
445
+ console.log("─".repeat(50));
446
+
447
+ try {
448
+ const { runDoctor, formatReport } = await import("./doctor.js");
449
+ const report = await runDoctor({
450
+ fix: false,
451
+ json: false,
452
+ pluginConfig,
453
+ });
454
+
455
+ console.log(formatReport(report));
456
+
457
+ if (report.summary.errors === 0) {
458
+ const profiles = loadAgentProfiles();
459
+ const defaultAgent = Object.entries(profiles).find(([, p]) => p.isDefault);
460
+ const agentName = defaultAgent?.[1]?.label ?? defaultAgent?.[0] ?? "your agent";
461
+ console.log("─".repeat(50));
462
+ console.log(`Setup complete! ${agentName} is ready to receive Linear issues.`);
463
+ console.log(`\nRestart the gateway to apply changes:`);
464
+ console.log(` systemctl --user restart openclaw-gateway\n`);
465
+ } else {
466
+ console.log("─".repeat(50));
467
+ console.log("Some issues remain. Fix them and run: openclaw openclaw-linear doctor --fix\n");
468
+ process.exitCode = 1;
469
+ }
470
+ } catch (err) {
471
+ console.log(` ⚠ Doctor failed: ${err instanceof Error ? err.message : String(err)}`);
472
+ console.log(" Run: openclaw openclaw-linear doctor for details.\n");
473
+ }
474
+ });
475
+
216
476
  // --- openclaw openclaw-linear worktrees ---
217
477
  linear
218
478
  .command("worktrees")
@@ -14,6 +14,7 @@ import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
14
14
  import { listWorktrees } from "./codex-worktree.js";
15
15
  import { loadCodingConfig, resolveCodingBackend, type CodingBackend } from "../tools/code-tool.js";
16
16
  import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
17
+ import { createAgentProfilesFile } from "./shared-profiles.js";
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Types
@@ -227,22 +228,51 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
227
228
  // Section 2: Agent Configuration
228
229
  // ---------------------------------------------------------------------------
229
230
 
230
- export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckResult[] {
231
+ export function checkAgentConfig(pluginConfig?: Record<string, unknown>, fix = false): CheckResult[] {
231
232
  const checks: CheckResult[] = [];
232
233
 
233
234
  // Load profiles
234
235
  let profiles: Record<string, AgentProfile>;
235
236
  try {
236
237
  if (!existsSync(AGENT_PROFILES_PATH)) {
237
- checks.push(fail(
238
- "agent-profiles.json not found",
239
- `Expected at: ${AGENT_PROFILES_PATH}`,
240
- ));
241
- return checks;
238
+ if (fix) {
239
+ try {
240
+ createAgentProfilesFile({
241
+ agentId: "my-agent",
242
+ label: "My Agent",
243
+ mentionAliases: ["my-agent"],
244
+ });
245
+ checks.push(pass('agent-profiles.json created with default "my-agent" profile (--fix)'));
246
+ checks.push(warn(
247
+ 'Customize your agent profile',
248
+ undefined,
249
+ { fix: `Edit ${AGENT_PROFILES_PATH} to set your agent's name and aliases, or run: openclaw openclaw-linear setup` },
250
+ ));
251
+ // Reload after creation
252
+ const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
253
+ profiles = JSON.parse(raw).agents ?? {};
254
+ // Continue to remaining checks below
255
+ } catch (err) {
256
+ checks.push(fail(
257
+ "Failed to create agent-profiles.json",
258
+ err instanceof Error ? err.message : String(err),
259
+ "Run: openclaw openclaw-linear setup",
260
+ ));
261
+ return checks;
262
+ }
263
+ } else {
264
+ checks.push(fail(
265
+ "agent-profiles.json not found",
266
+ `Expected at: ${AGENT_PROFILES_PATH}`,
267
+ "Run: openclaw openclaw-linear setup",
268
+ ));
269
+ return checks;
270
+ }
271
+ } else {
272
+ const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
273
+ const parsed = JSON.parse(raw);
274
+ profiles = parsed.agents ?? {};
242
275
  }
243
- const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
244
- const parsed = JSON.parse(raw);
245
- profiles = parsed.agents ?? {};
246
276
  } catch (err) {
247
277
  checks.push(fail(
248
278
  "agent-profiles.json invalid JSON",
@@ -730,7 +760,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
730
760
  sections.push({ name: "Authentication & Tokens", checks: auth.checks });
731
761
 
732
762
  // 2. Agent config
733
- sections.push({ name: "Agent Configuration", checks: checkAgentConfig(opts.pluginConfig) });
763
+ sections.push({ name: "Agent Configuration", checks: checkAgentConfig(opts.pluginConfig, opts.fix) });
734
764
 
735
765
  // 3. Coding tools
736
766
  sections.push({ name: "Coding Tools", checks: checkCodingTools(opts.pluginConfig) });
@@ -5,8 +5,8 @@
5
5
  * resolveAgentFromAlias() implementations that were previously in
6
6
  * webhook.ts, intent-classify.ts, and tier-assess.ts.
7
7
  */
8
- import { readFileSync, existsSync } from "node:fs";
9
- import { join } from "node:path";
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
 
12
12
  // ---------------------------------------------------------------------------
@@ -26,7 +26,7 @@ export interface AgentProfile {
26
26
  // Cached profile loader (5s TTL)
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
- const PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
29
+ export const PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
30
30
 
31
31
  let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
32
32
  const PROFILES_CACHE_TTL_MS = 5_000;
@@ -137,7 +137,7 @@ export function validateProfiles(): string | null {
137
137
  `EOF\n` +
138
138
  "```\n\n" +
139
139
  `Then restart the gateway: \`systemctl --user restart openclaw-gateway\`\n\n` +
140
- `Run \`openclaw openclaw-linear doctor\` to verify your setup.`
140
+ `Or run the guided setup: \`openclaw openclaw-linear setup\``
141
141
  );
142
142
  }
143
143
 
@@ -158,13 +158,45 @@ export function validateProfiles(): string | null {
158
158
  return (
159
159
  `**Critical setup error:** \`agent-profiles.json\` has no agents configured.\n\n` +
160
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.`
161
+ `Run \`openclaw openclaw-linear setup\` for guided setup.`
162
162
  );
163
163
  }
164
164
 
165
165
  return null;
166
166
  }
167
167
 
168
+ // ---------------------------------------------------------------------------
169
+ // Profile creation (setup wizard / doctor --fix)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface CreateProfileOpts {
173
+ agentId: string;
174
+ label: string;
175
+ mentionAliases: string[];
176
+ mission?: string;
177
+ }
178
+
179
+ /**
180
+ * Create `agent-profiles.json` with a single default agent.
181
+ * Creates the parent directory if needed. Throws on write failure.
182
+ */
183
+ export function createAgentProfilesFile(opts: CreateProfileOpts): void {
184
+ const data = {
185
+ agents: {
186
+ [opts.agentId]: {
187
+ label: opts.label,
188
+ mission: opts.mission ?? "AI assistant for Linear issues",
189
+ isDefault: true,
190
+ mentionAliases: opts.mentionAliases,
191
+ },
192
+ },
193
+ };
194
+ mkdirSync(dirname(PROFILES_PATH), { recursive: true });
195
+ writeFileSync(PROFILES_PATH, JSON.stringify(data, null, 2) + "\n", "utf8");
196
+ // Bust the cache so the next load picks up the new file
197
+ profilesCache = null;
198
+ }
199
+
168
200
  // ---------------------------------------------------------------------------
169
201
  // Test-only: reset cache
170
202
  // ---------------------------------------------------------------------------
@@ -93,7 +93,7 @@ describe("extractGuidanceFromPromptContext", () => {
93
93
  describe("formatGuidanceAppendix", () => {
94
94
  it("formats guidance as appendix block", () => {
95
95
  const result = formatGuidanceAppendix("Use main branch.");
96
- expect(result).toContain("## Additional Guidance");
96
+ expect(result).toContain("## IMPORTANT — Workspace Guidance");
97
97
  expect(result).toContain("Use main branch.");
98
98
  expect(result).toMatch(/^---\n/);
99
99
  expect(result).toMatch(/\n---$/);
@@ -119,7 +119,9 @@ export function formatGuidanceAppendix(guidance: string | null): string {
119
119
  const trimmed = guidance.trim().slice(0, MAX_GUIDANCE_CHARS);
120
120
  return [
121
121
  `---`,
122
- `## Additional Guidance (from Linear workspace/team settings)`,
122
+ `## IMPORTANT — Workspace Guidance (MUST follow)`,
123
+ `The workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:`,
124
+ ``,
123
125
  trimmed,
124
126
  `---`,
125
127
  ].join("\n");
@@ -405,7 +405,7 @@ describe("buildWorkerTask (additional branches)", () => {
405
405
  const { task } = buildWorkerTask(issue, "/wt/API-42", {
406
406
  guidance: "Always use TypeScript strict mode",
407
407
  });
408
- expect(task).toContain("Additional Guidance");
408
+ expect(task).toContain("Workspace Guidance");
409
409
  expect(task).toContain("Always use TypeScript strict mode");
410
410
  });
411
411
 
@@ -413,7 +413,7 @@ describe("buildWorkerTask (additional branches)", () => {
413
413
  const { task } = buildWorkerTask(issue, "/wt/API-42", {
414
414
  guidance: undefined,
415
415
  });
416
- expect(task).not.toContain("Additional Guidance");
416
+ expect(task).not.toContain("Workspace Guidance");
417
417
  });
418
418
 
419
419
  it("uses undefined description as (no description)", () => {
@@ -443,7 +443,7 @@ describe("buildAuditTask (additional branches)", () => {
443
443
  const { task } = buildAuditTask(issue, "/wt/API-99", undefined, {
444
444
  guidance: "Focus on security",
445
445
  });
446
- expect(task).toContain("Additional Guidance");
446
+ expect(task).toContain("Workspace Guidance");
447
447
  expect(task).toContain("Focus on security");
448
448
  });
449
449
 
@@ -451,7 +451,7 @@ describe("buildAuditTask (additional branches)", () => {
451
451
  const { task } = buildAuditTask(issue, "/wt/API-99", undefined, {
452
452
  guidance: undefined,
453
453
  });
454
- expect(task).not.toContain("Additional Guidance");
454
+ expect(task).not.toContain("Workspace Guidance");
455
455
  });
456
456
 
457
457
  it("handles undefined description", () => {
@@ -197,7 +197,7 @@ export function buildWorkerTask(
197
197
  attempt: String(opts?.attempt ?? 0),
198
198
  gaps: opts?.gaps?.length ? "- " + opts.gaps.join("\n- ") : "",
199
199
  guidance: opts?.guidance
200
- ? `\n---\n## Additional Guidance (from Linear workspace/team settings)\n${opts.guidance.slice(0, 2000)}\n---`
200
+ ? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
201
201
  : "",
202
202
  };
203
203
 
@@ -231,7 +231,7 @@ export function buildAuditTask(
231
231
  attempt: "0",
232
232
  gaps: "",
233
233
  guidance: opts?.guidance
234
- ? `\n---\n## Additional Guidance (from Linear workspace/team settings)\n${opts.guidance.slice(0, 2000)}\n---`
234
+ ? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
235
235
  : "",
236
236
  };
237
237
 
@@ -457,12 +457,17 @@ export async function handleLinearWebhook(
457
457
  ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
458
458
  : [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post comments via linear_issues — the audit system handles lifecycle transitions.`];
459
459
 
460
+ if (guidanceAppendix) {
461
+ api.logger.info(`Guidance injected (${guidanceCtx.source}): ${guidanceCtx.guidance?.slice(0, 120)}...`);
462
+ }
463
+
460
464
  const message = [
461
465
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
462
466
  ``,
463
467
  ...toolAccessLines,
464
468
  ``,
465
469
  ...roleLines,
470
+ guidanceAppendix ? `\n${guidanceAppendix}` : "",
466
471
  ``,
467
472
  `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
468
473
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
@@ -473,7 +478,6 @@ export async function handleLinearWebhook(
473
478
  userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
474
479
  ``,
475
480
  `Respond to the user's request. For work requests, dispatch via \`code_run\` and summarize the result. Be concise and action-oriented.`,
476
- guidanceAppendix,
477
481
  ].filter(Boolean).join("\n");
478
482
 
479
483
  // Run agent directly (non-blocking)
@@ -697,12 +701,17 @@ export async function handleLinearWebhook(
697
701
  ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
698
702
  : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
699
703
 
704
+ if (followUpGuidanceAppendix) {
705
+ api.logger.info(`Follow-up guidance injected: ${(guidanceCtxPrompted.guidance ?? "cached").slice(0, 120)}...`);
706
+ }
707
+
700
708
  const message = [
701
709
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
702
710
  ``,
703
711
  ...followUpToolAccessLines,
704
712
  ``,
705
713
  ...followUpRoleLines,
714
+ followUpGuidanceAppendix ? `\n${followUpGuidanceAppendix}` : "",
706
715
  ``,
707
716
  `## Issue: ${followUpIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
708
717
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
@@ -713,7 +722,6 @@ export async function handleLinearWebhook(
713
722
  `\n**User's follow-up message:**\n> ${userMessage}`,
714
723
  ``,
715
724
  `Respond to the user's follow-up. For work requests, dispatch via \`code_run\`. Be concise and action-oriented.`,
716
- followUpGuidanceAppendix,
717
725
  ].filter(Boolean).join("\n");
718
726
 
719
727
  setActiveSession({