@calltelemetry/openclaw-linear 0.9.8 → 0.9.10
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 +1 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +30 -30
- package/src/__test__/webhook-scenarios.test.ts +11 -10
- package/src/infra/cli.ts +260 -0
- package/src/infra/doctor.ts +40 -10
- package/src/infra/shared-profiles.test.ts +123 -1
- package/src/infra/shared-profiles.ts +37 -5
- package/src/pipeline/active-session.ts +7 -0
- package/src/pipeline/guidance.test.ts +1 -1
- package/src/pipeline/guidance.ts +3 -1
- package/src/pipeline/pipeline.test.ts +4 -4
- package/src/pipeline/pipeline.ts +2 -2
- package/src/pipeline/webhook.ts +17 -11
package/package.json
CHANGED
|
@@ -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:
|
|
5
|
+
* Last recorded: 2026-02-22T02:38:53.901Z
|
|
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": "
|
|
48
|
-
"identifier": "UAT-
|
|
47
|
+
"id": "953324f8-878f-40ba-b773-8d8815ee7987",
|
|
48
|
+
"identifier": "UAT-320"
|
|
49
49
|
},
|
|
50
50
|
"createSubIssue1": {
|
|
51
|
-
"id": "
|
|
52
|
-
"identifier": "UAT-
|
|
51
|
+
"id": "69ce9a01-3bdb-4f4a-a882-e28e23d1b628",
|
|
52
|
+
"identifier": "UAT-321"
|
|
53
53
|
},
|
|
54
54
|
"createSubIssue2": {
|
|
55
|
-
"id": "
|
|
56
|
-
"identifier": "UAT-
|
|
55
|
+
"id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
|
|
56
|
+
"identifier": "UAT-322"
|
|
57
57
|
},
|
|
58
58
|
"subIssue1Details": {
|
|
59
|
-
"id": "
|
|
60
|
-
"identifier": "UAT-
|
|
59
|
+
"id": "69ce9a01-3bdb-4f4a-a882-e28e23d1b628",
|
|
60
|
+
"identifier": "UAT-321",
|
|
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": "
|
|
87
|
-
"identifier": "UAT-
|
|
86
|
+
"id": "953324f8-878f-40ba-b773-8d8815ee7987",
|
|
87
|
+
"identifier": "UAT-320"
|
|
88
88
|
},
|
|
89
89
|
"relations": {
|
|
90
90
|
"nodes": []
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
"subIssue2Details": {
|
|
94
|
-
"id": "
|
|
95
|
-
"identifier": "UAT-
|
|
94
|
+
"id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
|
|
95
|
+
"identifier": "UAT-322",
|
|
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": "
|
|
122
|
-
"identifier": "UAT-
|
|
121
|
+
"id": "953324f8-878f-40ba-b773-8d8815ee7987",
|
|
122
|
+
"identifier": "UAT-320"
|
|
123
123
|
},
|
|
124
124
|
"relations": {
|
|
125
125
|
"nodes": []
|
|
126
126
|
}
|
|
127
127
|
},
|
|
128
128
|
"parentDetails": {
|
|
129
|
-
"id": "
|
|
130
|
-
"identifier": "UAT-
|
|
129
|
+
"id": "953324f8-878f-40ba-b773-8d8815ee7987",
|
|
130
|
+
"identifier": "UAT-320",
|
|
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-22T02:
|
|
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:38:52.132Z",
|
|
133
133
|
"estimate": null,
|
|
134
134
|
"state": {
|
|
135
135
|
"name": "Backlog",
|
|
@@ -158,11 +158,11 @@ export const RECORDED = {
|
|
|
158
158
|
}
|
|
159
159
|
},
|
|
160
160
|
"createRelation": {
|
|
161
|
-
"id": "
|
|
161
|
+
"id": "9207d26d-d65d-45a2-ac76-b9233acea181"
|
|
162
162
|
},
|
|
163
163
|
"subIssue1WithRelation": {
|
|
164
|
-
"id": "
|
|
165
|
-
"identifier": "UAT-
|
|
164
|
+
"id": "69ce9a01-3bdb-4f4a-a882-e28e23d1b628",
|
|
165
|
+
"identifier": "UAT-321",
|
|
166
166
|
"title": "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
167
167
|
"description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
|
|
168
168
|
"estimate": 2,
|
|
@@ -188,16 +188,16 @@ export const RECORDED = {
|
|
|
188
188
|
},
|
|
189
189
|
"project": null,
|
|
190
190
|
"parent": {
|
|
191
|
-
"id": "
|
|
192
|
-
"identifier": "UAT-
|
|
191
|
+
"id": "953324f8-878f-40ba-b773-8d8815ee7987",
|
|
192
|
+
"identifier": "UAT-320"
|
|
193
193
|
},
|
|
194
194
|
"relations": {
|
|
195
195
|
"nodes": [
|
|
196
196
|
{
|
|
197
197
|
"type": "blocks",
|
|
198
198
|
"relatedIssue": {
|
|
199
|
-
"id": "
|
|
200
|
-
"identifier": "UAT-
|
|
199
|
+
"id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
|
|
200
|
+
"identifier": "UAT-322",
|
|
201
201
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
|
|
202
202
|
}
|
|
203
203
|
}
|
|
@@ -205,8 +205,8 @@ export const RECORDED = {
|
|
|
205
205
|
}
|
|
206
206
|
},
|
|
207
207
|
"subIssue2WithRelation": {
|
|
208
|
-
"id": "
|
|
209
|
-
"identifier": "UAT-
|
|
208
|
+
"id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
|
|
209
|
+
"identifier": "UAT-322",
|
|
210
210
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
211
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.",
|
|
212
212
|
"estimate": 3,
|
|
@@ -232,14 +232,14 @@ export const RECORDED = {
|
|
|
232
232
|
{
|
|
233
233
|
"body": "This thread is for an agent session with ctclaw.",
|
|
234
234
|
"user": null,
|
|
235
|
-
"createdAt": "2026-02-22T02:
|
|
235
|
+
"createdAt": "2026-02-22T02:38:53.699Z"
|
|
236
236
|
}
|
|
237
237
|
]
|
|
238
238
|
},
|
|
239
239
|
"project": null,
|
|
240
240
|
"parent": {
|
|
241
|
-
"id": "
|
|
242
|
-
"identifier": "UAT-
|
|
241
|
+
"id": "953324f8-878f-40ba-b773-8d8815ee7987",
|
|
242
|
+
"identifier": "UAT-320"
|
|
243
243
|
},
|
|
244
244
|
"relations": {
|
|
245
245
|
"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("
|
|
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
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
expect(
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
786
|
+
expect(msg).toContain("Workspace Guidance");
|
|
786
787
|
expect(msg).toContain("Cached guidance from session event");
|
|
787
788
|
});
|
|
788
789
|
});
|
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")
|
package/src/infra/doctor.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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) });
|
|
@@ -4,12 +4,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
4
4
|
// Mocks
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
|
-
const { mockReadFileSync } = vi.hoisted(() => ({
|
|
7
|
+
const { mockReadFileSync, mockWriteFileSync, mockMkdirSync, mockExistsSync } = vi.hoisted(() => ({
|
|
8
8
|
mockReadFileSync: vi.fn(),
|
|
9
|
+
mockWriteFileSync: vi.fn(),
|
|
10
|
+
mockMkdirSync: vi.fn(),
|
|
11
|
+
mockExistsSync: vi.fn().mockReturnValue(true),
|
|
9
12
|
}));
|
|
10
13
|
|
|
11
14
|
vi.mock("node:fs", () => ({
|
|
12
15
|
readFileSync: mockReadFileSync,
|
|
16
|
+
writeFileSync: mockWriteFileSync,
|
|
17
|
+
mkdirSync: mockMkdirSync,
|
|
18
|
+
existsSync: mockExistsSync,
|
|
13
19
|
}));
|
|
14
20
|
|
|
15
21
|
// ---------------------------------------------------------------------------
|
|
@@ -21,6 +27,9 @@ import {
|
|
|
21
27
|
buildMentionPattern,
|
|
22
28
|
resolveAgentFromAlias,
|
|
23
29
|
resolveDefaultAgent,
|
|
30
|
+
createAgentProfilesFile,
|
|
31
|
+
validateProfiles,
|
|
32
|
+
PROFILES_PATH,
|
|
24
33
|
_resetProfilesCacheForTesting,
|
|
25
34
|
type AgentProfile,
|
|
26
35
|
} from "./shared-profiles.js";
|
|
@@ -61,6 +70,7 @@ beforeEach(() => {
|
|
|
61
70
|
vi.clearAllMocks();
|
|
62
71
|
_resetProfilesCacheForTesting();
|
|
63
72
|
mockReadFileSync.mockReturnValue(PROFILES_JSON);
|
|
73
|
+
mockExistsSync.mockReturnValue(true);
|
|
64
74
|
});
|
|
65
75
|
|
|
66
76
|
afterEach(() => {
|
|
@@ -260,3 +270,115 @@ describe("resolveDefaultAgent", () => {
|
|
|
260
270
|
expect(result).toBe("mal");
|
|
261
271
|
});
|
|
262
272
|
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// createAgentProfilesFile
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
describe("createAgentProfilesFile", () => {
|
|
279
|
+
it("writes correct JSON structure to PROFILES_PATH", () => {
|
|
280
|
+
createAgentProfilesFile({
|
|
281
|
+
agentId: "bobbin",
|
|
282
|
+
label: "Bobbin",
|
|
283
|
+
mentionAliases: ["bobbin", "bob"],
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(mockMkdirSync).toHaveBeenCalledTimes(1);
|
|
287
|
+
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
|
|
288
|
+
|
|
289
|
+
const [path, content] = mockWriteFileSync.mock.calls[0];
|
|
290
|
+
expect(path).toBe(PROFILES_PATH);
|
|
291
|
+
|
|
292
|
+
const parsed = JSON.parse(content);
|
|
293
|
+
expect(parsed.agents.bobbin).toEqual({
|
|
294
|
+
label: "Bobbin",
|
|
295
|
+
mission: "AI assistant for Linear issues",
|
|
296
|
+
isDefault: true,
|
|
297
|
+
mentionAliases: ["bobbin", "bob"],
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("uses custom mission when provided", () => {
|
|
302
|
+
createAgentProfilesFile({
|
|
303
|
+
agentId: "claw",
|
|
304
|
+
label: "The Claw",
|
|
305
|
+
mentionAliases: ["claw"],
|
|
306
|
+
mission: "Code review specialist",
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const [, content] = mockWriteFileSync.mock.calls[0];
|
|
310
|
+
const parsed = JSON.parse(content);
|
|
311
|
+
expect(parsed.agents.claw.mission).toBe("Code review specialist");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("creates parent directory recursively", () => {
|
|
315
|
+
createAgentProfilesFile({
|
|
316
|
+
agentId: "test",
|
|
317
|
+
label: "Test",
|
|
318
|
+
mentionAliases: ["test"],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
322
|
+
expect.any(String),
|
|
323
|
+
{ recursive: true },
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("busts the profile cache", () => {
|
|
328
|
+
// Load to populate cache
|
|
329
|
+
loadAgentProfiles();
|
|
330
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
|
|
331
|
+
|
|
332
|
+
// Create a new profile — should bust cache
|
|
333
|
+
createAgentProfilesFile({
|
|
334
|
+
agentId: "fresh",
|
|
335
|
+
label: "Fresh",
|
|
336
|
+
mentionAliases: ["fresh"],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Next load should re-read from disk (cache was busted)
|
|
340
|
+
loadAgentProfiles();
|
|
341
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// validateProfiles
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe("validateProfiles", () => {
|
|
350
|
+
it("returns error when file is missing", () => {
|
|
351
|
+
mockExistsSync.mockReturnValue(false);
|
|
352
|
+
|
|
353
|
+
const result = validateProfiles();
|
|
354
|
+
expect(result).not.toBeNull();
|
|
355
|
+
expect(result).toContain("not found");
|
|
356
|
+
expect(result).toContain("openclaw openclaw-linear setup");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("returns null when file is valid with agents", () => {
|
|
360
|
+
mockExistsSync.mockReturnValue(true);
|
|
361
|
+
mockReadFileSync.mockReturnValue(PROFILES_JSON);
|
|
362
|
+
|
|
363
|
+
const result = validateProfiles();
|
|
364
|
+
expect(result).toBeNull();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("returns error when JSON is invalid", () => {
|
|
368
|
+
mockExistsSync.mockReturnValue(true);
|
|
369
|
+
mockReadFileSync.mockImplementation(() => { throw new SyntaxError("Unexpected token"); });
|
|
370
|
+
|
|
371
|
+
const result = validateProfiles();
|
|
372
|
+
expect(result).not.toBeNull();
|
|
373
|
+
expect(result).toContain("could not be parsed");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("returns error when agents object is empty", () => {
|
|
377
|
+
mockExistsSync.mockReturnValue(true);
|
|
378
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({ agents: {} }));
|
|
379
|
+
|
|
380
|
+
const result = validateProfiles();
|
|
381
|
+
expect(result).not.toBeNull();
|
|
382
|
+
expect(result).toContain("no agents configured");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -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
|
-
`
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -42,9 +42,16 @@ let _affinityTtlMs = 30 * 60_000; // 30 minutes default
|
|
|
42
42
|
/**
|
|
43
43
|
* Register the active session for an issue. Idempotent — calling again
|
|
44
44
|
* for the same issue just updates the session.
|
|
45
|
+
*
|
|
46
|
+
* Also eagerly records agent affinity so that follow-up webhooks arriving
|
|
47
|
+
* during or after the run resolve to the correct agent — even if the
|
|
48
|
+
* gateway restarts before clearActiveSession is called.
|
|
45
49
|
*/
|
|
46
50
|
export function setActiveSession(session: ActiveSession): void {
|
|
47
51
|
sessions.set(session.issueId, session);
|
|
52
|
+
if (session.agentId) {
|
|
53
|
+
recordIssueAffinity(session.issueId, session.agentId);
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
/**
|
|
@@ -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("##
|
|
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---$/);
|
package/src/pipeline/guidance.ts
CHANGED
|
@@ -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
|
-
`##
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
454
|
+
expect(task).not.toContain("Workspace Guidance");
|
|
455
455
|
});
|
|
456
456
|
|
|
457
457
|
it("handles undefined description", () => {
|
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -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##
|
|
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##
|
|
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
|
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -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,17 +478,16 @@ 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)
|
|
480
484
|
activeRuns.add(issue.id);
|
|
481
485
|
void (async () => {
|
|
482
486
|
const profiles = loadAgentProfiles();
|
|
483
|
-
const
|
|
484
|
-
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
487
|
+
const label = profiles[agentId]?.label ?? agentId;
|
|
485
488
|
|
|
486
489
|
// Register active session for tool resolution (code_run, etc.)
|
|
490
|
+
// Also eagerly records affinity so follow-ups route to the same agent.
|
|
487
491
|
setActiveSession({
|
|
488
492
|
agentSessionId: session.id,
|
|
489
493
|
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
@@ -527,7 +531,7 @@ export async function handleLinearWebhook(
|
|
|
527
531
|
}).then(() => true).catch(() => false);
|
|
528
532
|
|
|
529
533
|
if (!emitted) {
|
|
530
|
-
const avatarUrl =
|
|
534
|
+
const avatarUrl = profiles[agentId]?.avatarUrl;
|
|
531
535
|
const agentOpts = avatarUrl
|
|
532
536
|
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
533
537
|
: undefined;
|
|
@@ -644,8 +648,7 @@ export async function handleLinearWebhook(
|
|
|
644
648
|
activeRuns.add(issue.id);
|
|
645
649
|
void (async () => {
|
|
646
650
|
const profiles = loadAgentProfiles();
|
|
647
|
-
const
|
|
648
|
-
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
651
|
+
const label = profiles[agentId]?.label ?? agentId;
|
|
649
652
|
|
|
650
653
|
// Fetch full issue details for context
|
|
651
654
|
let enrichedIssue: any = issue;
|
|
@@ -697,12 +700,17 @@ export async function handleLinearWebhook(
|
|
|
697
700
|
? [`**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
701
|
: [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
|
|
699
702
|
|
|
703
|
+
if (followUpGuidanceAppendix) {
|
|
704
|
+
api.logger.info(`Follow-up guidance injected: ${(guidanceCtxPrompted.guidance ?? "cached").slice(0, 120)}...`);
|
|
705
|
+
}
|
|
706
|
+
|
|
700
707
|
const message = [
|
|
701
708
|
`You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
|
|
702
709
|
``,
|
|
703
710
|
...followUpToolAccessLines,
|
|
704
711
|
``,
|
|
705
712
|
...followUpRoleLines,
|
|
713
|
+
followUpGuidanceAppendix ? `\n${followUpGuidanceAppendix}` : "",
|
|
706
714
|
``,
|
|
707
715
|
`## Issue: ${followUpIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
708
716
|
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
@@ -713,7 +721,6 @@ export async function handleLinearWebhook(
|
|
|
713
721
|
`\n**User's follow-up message:**\n> ${userMessage}`,
|
|
714
722
|
``,
|
|
715
723
|
`Respond to the user's follow-up. For work requests, dispatch via \`code_run\`. Be concise and action-oriented.`,
|
|
716
|
-
followUpGuidanceAppendix,
|
|
717
724
|
].filter(Boolean).join("\n");
|
|
718
725
|
|
|
719
726
|
setActiveSession({
|
|
@@ -756,7 +763,7 @@ export async function handleLinearWebhook(
|
|
|
756
763
|
}).then(() => true).catch(() => false);
|
|
757
764
|
|
|
758
765
|
if (!emitted) {
|
|
759
|
-
const avatarUrl =
|
|
766
|
+
const avatarUrl = profiles[agentId]?.avatarUrl;
|
|
760
767
|
const agentOpts = avatarUrl
|
|
761
768
|
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
762
769
|
: undefined;
|
|
@@ -1142,9 +1149,8 @@ export async function handleLinearWebhook(
|
|
|
1142
1149
|
// Dispatch triage (non-blocking)
|
|
1143
1150
|
void (async () => {
|
|
1144
1151
|
const profiles = loadAgentProfiles();
|
|
1145
|
-
const
|
|
1146
|
-
const
|
|
1147
|
-
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
1152
|
+
const label = profiles[agentId]?.label ?? agentId;
|
|
1153
|
+
const avatarUrl = profiles[agentId]?.avatarUrl;
|
|
1148
1154
|
let agentSessionId: string | null = null;
|
|
1149
1155
|
|
|
1150
1156
|
try {
|