@calltelemetry/openclaw-linear 0.8.0 → 0.8.2

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.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * intent-classify.ts — LLM-based intent classification for Linear comments.
3
+ *
4
+ * Replaces static regex pattern matching with a lightweight LLM classifier.
5
+ * Follows the tier-assess.ts pattern: runAgent() subprocess call, JSON parsing,
6
+ * regex fallback on any failure.
7
+ *
8
+ * Cost: one short agent turn (~300 tokens). Latency: ~2-5s.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type Intent =
19
+ | "plan_start"
20
+ | "plan_finalize"
21
+ | "plan_abandon"
22
+ | "plan_continue"
23
+ | "ask_agent"
24
+ | "request_work"
25
+ | "question"
26
+ | "general";
27
+
28
+ export interface IntentResult {
29
+ intent: Intent;
30
+ agentId?: string;
31
+ reasoning: string;
32
+ fromFallback: boolean;
33
+ }
34
+
35
+ export interface IntentContext {
36
+ commentBody: string;
37
+ issueTitle: string;
38
+ issueStatus?: string;
39
+ isPlanning: boolean;
40
+ /** Names of available agents (e.g. ["mal", "kaylee", "inara"]) */
41
+ agentNames: string[];
42
+ /** Whether the issue belongs to a project */
43
+ hasProject: boolean;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Valid intents (for validation)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const VALID_INTENTS: Set<string> = new Set([
51
+ "plan_start",
52
+ "plan_finalize",
53
+ "plan_abandon",
54
+ "plan_continue",
55
+ "ask_agent",
56
+ "request_work",
57
+ "question",
58
+ "general",
59
+ ]);
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Classifier prompt
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const CLASSIFY_PROMPT = `You are an intent classifier for a developer tool. Respond ONLY with JSON.
66
+
67
+ Intents:
68
+ - plan_start: user wants to begin project planning
69
+ - plan_finalize: user wants to approve/finalize the plan (e.g. "looks good", "ship it", "approve plan")
70
+ - plan_abandon: user wants to cancel/stop planning (e.g. "nevermind", "cancel this", "stop planning")
71
+ - plan_continue: regular message during planning (default when planning is active)
72
+ - ask_agent: user is addressing a specific agent by name
73
+ - request_work: user wants something built, fixed, or implemented
74
+ - question: user asking for information or help
75
+ - general: none of the above, automated messages, or noise
76
+
77
+ Rules:
78
+ - plan_start ONLY if the issue belongs to a project (hasProject=true)
79
+ - If planning mode is active and no clear finalize/abandon intent, default to plan_continue
80
+ - For ask_agent, set agentId to the matching name from Available agents
81
+ - One sentence reasoning`;
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Classify
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Classify a comment's intent using a lightweight model.
89
+ *
90
+ * Uses `classifierAgentId` from plugin config (should point to a small/fast
91
+ * model like Haiku for low latency and cost). Falls back to the default
92
+ * agent if not configured.
93
+ *
94
+ * Falls back to regex patterns if the LLM call fails or returns invalid JSON.
95
+ */
96
+ export async function classifyIntent(
97
+ api: OpenClawPluginApi,
98
+ ctx: IntentContext,
99
+ pluginConfig?: Record<string, unknown>,
100
+ ): Promise<IntentResult> {
101
+ const contextBlock = [
102
+ `Issue: "${ctx.issueTitle}" (status: ${ctx.issueStatus ?? "unknown"})`,
103
+ `Planning mode: ${ctx.isPlanning}`,
104
+ `Has project: ${ctx.hasProject}`,
105
+ `Available agents: ${ctx.agentNames.join(", ") || "none"}`,
106
+ `Comment: "${ctx.commentBody.slice(0, 500)}"`,
107
+ ].join("\n");
108
+
109
+ const message = `${CLASSIFY_PROMPT}\n\nContext:\n${contextBlock}\n\nRespond ONLY with: {"intent":"<intent>","agentId":"<if ask_agent>","reasoning":"<one sentence>"}`;
110
+
111
+ try {
112
+ const { runAgent } = await import("../agent/agent.js");
113
+ const classifierAgent = resolveClassifierAgent(api, pluginConfig);
114
+ const result = await runAgent({
115
+ api,
116
+ agentId: classifierAgent,
117
+ sessionId: `intent-classify-${Date.now()}`,
118
+ message,
119
+ timeoutMs: 12_000, // 12s — fast classification
120
+ });
121
+
122
+ if (result.output) {
123
+ const parsed = parseIntentResponse(result.output, ctx);
124
+ if (parsed) {
125
+ api.logger.info(`Intent classified: ${parsed.intent}${parsed.agentId ? ` (agent: ${parsed.agentId})` : ""} — ${parsed.reasoning}`);
126
+ return parsed;
127
+ }
128
+ }
129
+
130
+ if (!result.success) {
131
+ api.logger.warn(`Intent classifier agent failed: ${result.output.slice(0, 200)}`);
132
+ } else {
133
+ api.logger.warn(`Intent classifier: could not parse response: ${result.output.slice(0, 200)}`);
134
+ }
135
+ } catch (err) {
136
+ api.logger.warn(`Intent classifier error: ${err}`);
137
+ }
138
+
139
+ // Fallback to regex
140
+ const fallback = regexFallback(ctx);
141
+ api.logger.info(`Intent classifier fallback: ${fallback.intent} — ${fallback.reasoning}`);
142
+ return fallback;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Response parsing
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function parseIntentResponse(raw: string, ctx: IntentContext): IntentResult | null {
150
+ // Extract JSON using indexOf/lastIndexOf (more robust than regex for nested JSON)
151
+ const start = raw.indexOf("{");
152
+ const end = raw.lastIndexOf("}");
153
+ if (start === -1 || end === -1 || end <= start) return null;
154
+
155
+ try {
156
+ const parsed = JSON.parse(raw.slice(start, end + 1));
157
+ const intent = parsed.intent as string;
158
+
159
+ if (!VALID_INTENTS.has(intent)) return null;
160
+
161
+ // Validate agentId for ask_agent
162
+ let agentId: string | undefined;
163
+ if (intent === "ask_agent" && parsed.agentId) {
164
+ const normalized = String(parsed.agentId).toLowerCase();
165
+ // Only accept agent names that actually exist
166
+ if (ctx.agentNames.some((n) => n.toLowerCase() === normalized)) {
167
+ agentId = normalized;
168
+ }
169
+ // If hallucinated name, clear agentId but keep the intent
170
+ }
171
+
172
+ return {
173
+ intent: intent as Intent,
174
+ agentId,
175
+ reasoning: parsed.reasoning ?? "no reasoning provided",
176
+ fromFallback: false,
177
+ };
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Regex fallback (moved from planner.ts + webhook.ts)
185
+ // ---------------------------------------------------------------------------
186
+
187
+ // Planning intent patterns
188
+ const PLAN_START_PATTERN = /\b(plan|planning)\s+(this\s+)(project|out)\b|\bplan\s+this\s+out\b/i;
189
+ const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
190
+ const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
191
+
192
+ export function regexFallback(ctx: IntentContext): IntentResult {
193
+ const text = ctx.commentBody;
194
+
195
+ // Planning-specific patterns (only when planning is active or issue has project)
196
+ if (ctx.isPlanning) {
197
+ if (FINALIZE_PATTERN.test(text)) {
198
+ return { intent: "plan_finalize", reasoning: "regex: finalize pattern matched", fromFallback: true };
199
+ }
200
+ if (ABANDON_PATTERN.test(text)) {
201
+ return { intent: "plan_abandon", reasoning: "regex: abandon pattern matched", fromFallback: true };
202
+ }
203
+ // Default to plan_continue during planning
204
+ return { intent: "plan_continue", reasoning: "regex: planning mode active, default continue", fromFallback: true };
205
+ }
206
+
207
+ // Plan start (only if issue has a project)
208
+ if (ctx.hasProject && PLAN_START_PATTERN.test(text)) {
209
+ return { intent: "plan_start", reasoning: "regex: plan start pattern matched", fromFallback: true };
210
+ }
211
+
212
+ // Agent name detection
213
+ if (ctx.agentNames.length > 0) {
214
+ const lower = text.toLowerCase();
215
+ for (const name of ctx.agentNames) {
216
+ if (lower.includes(name.toLowerCase())) {
217
+ return { intent: "ask_agent", agentId: name.toLowerCase(), reasoning: `regex: agent name "${name}" found in comment`, fromFallback: true };
218
+ }
219
+ }
220
+ }
221
+
222
+ // Default: general (no match)
223
+ return { intent: "general", reasoning: "regex: no pattern matched", fromFallback: true };
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Helpers
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Resolve the agent to use for intent classification.
232
+ *
233
+ * Priority: pluginConfig.classifierAgentId → defaultAgentId → profile default.
234
+ * Configure classifierAgentId to point to a small/fast model (e.g. Haiku)
235
+ * for low-latency, low-cost classification.
236
+ */
237
+ function resolveClassifierAgent(api: OpenClawPluginApi, pluginConfig?: Record<string, unknown>): string {
238
+ // 1. Explicit classifier agent
239
+ const classifierAgent = pluginConfig?.classifierAgentId ?? (api as any).pluginConfig?.classifierAgentId;
240
+ if (typeof classifierAgent === "string" && classifierAgent) return classifierAgent;
241
+
242
+ // 2. Fall back to default agent
243
+ return resolveDefaultAgent(api);
244
+ }
245
+
246
+ function resolveDefaultAgent(api: OpenClawPluginApi): string {
247
+ const fromConfig = (api as any).pluginConfig?.defaultAgentId;
248
+ if (typeof fromConfig === "string" && fromConfig) return fromConfig;
249
+
250
+ try {
251
+ const profilesPath = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
252
+ const raw = readFileSync(profilesPath, "utf8");
253
+ const profiles = JSON.parse(raw).agents ?? {};
254
+ const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
255
+ if (defaultAgent) return defaultAgent[0];
256
+ } catch { /* fall through */ }
257
+
258
+ return "default";
259
+ }
@@ -16,6 +16,17 @@ vi.mock("../api/linear-api.js", () => ({}));
16
16
 
17
17
  vi.mock("openclaw/plugin-sdk", () => ({}));
18
18
 
19
+ // Mock CLI tool runners for cross-model review
20
+ vi.mock("../tools/claude-tool.js", () => ({
21
+ runClaude: vi.fn().mockResolvedValue({ success: true, output: "Claude review feedback" }),
22
+ }));
23
+ vi.mock("../tools/codex-tool.js", () => ({
24
+ runCodex: vi.fn().mockResolvedValue({ success: true, output: "Codex review feedback" }),
25
+ }));
26
+ vi.mock("../tools/gemini-tool.js", () => ({
27
+ runGemini: vi.fn().mockResolvedValue({ success: true, output: "Gemini review feedback" }),
28
+ }));
29
+
19
30
  const mockLinearApi = {
20
31
  getProject: vi.fn().mockResolvedValue({
21
32
  id: "proj-1",
@@ -61,7 +72,13 @@ vi.mock("../tools/planner-tools.js", () => ({
61
72
  // Imports (AFTER mocks)
62
73
  // ---------------------------------------------------------------------------
63
74
 
64
- import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
75
+ import {
76
+ initiatePlanningSession,
77
+ handlePlannerTurn,
78
+ runPlanAudit,
79
+ runCrossModelReview,
80
+ resolveReviewModel,
81
+ } from "./planner.js";
65
82
  import {
66
83
  registerPlanningSession,
67
84
  updatePlanningSession,
@@ -73,6 +90,9 @@ import {
73
90
  clearActivePlannerContext,
74
91
  auditPlan,
75
92
  } from "../tools/planner-tools.js";
93
+ import { runClaude } from "../tools/claude-tool.js";
94
+ import { runCodex } from "../tools/codex-tool.js";
95
+ import { runGemini } from "../tools/gemini-tool.js";
76
96
 
77
97
  // ---------------------------------------------------------------------------
78
98
  // Helpers
@@ -230,47 +250,31 @@ describe("handlePlannerTurn", () => {
230
250
  );
231
251
  });
232
252
 
233
- it("detects finalize plan intent and triggers audit instead of regular turn", async () => {
234
- const ctx = createCtx();
235
- const session = createSession();
236
-
237
- await handlePlannerTurn(ctx, session, {
238
- issueId: "issue-1",
239
- commentBody: "finalize plan",
240
- commentorName: "Tester",
241
- });
253
+ // Note: finalize/abandon intent detection has moved to webhook.ts via
254
+ // intent-classify.ts. handlePlannerTurn is now a pure "continue planning"
255
+ // function that always runs the agent.
256
+ });
242
257
 
243
- // Audit path: auditPlan is called, runAgent is NOT called
244
- expect(auditPlan).toHaveBeenCalled();
245
- expect(runAgentMock).not.toHaveBeenCalled();
246
- });
258
+ // ---------------------------------------------------------------------------
259
+ // runPlanAudit
260
+ // ---------------------------------------------------------------------------
247
261
 
248
- it("detects abandon intent and ends session as abandoned", async () => {
262
+ describe("runPlanAudit", () => {
263
+ it("transitions to plan_review on passing audit", async () => {
264
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
249
265
  const ctx = createCtx();
250
266
  const session = createSession();
251
267
 
252
- await handlePlannerTurn(ctx, session, {
253
- issueId: "issue-1",
254
- commentBody: "abandon planning",
255
- commentorName: "Tester",
256
- });
268
+ await runPlanAudit(ctx, session);
257
269
 
258
- expect(endPlanningSession).toHaveBeenCalledWith(
270
+ expect(updatePlanningSession).toHaveBeenCalledWith(
259
271
  "proj-1",
260
- "abandoned",
272
+ { status: "plan_review" },
261
273
  undefined,
262
274
  );
263
- // Should NOT run the agent
264
- expect(runAgentMock).not.toHaveBeenCalled();
265
275
  });
266
- });
267
-
268
- // ---------------------------------------------------------------------------
269
- // runPlanAudit
270
- // ---------------------------------------------------------------------------
271
276
 
272
- describe("runPlanAudit", () => {
273
- it("posts success comment on passing audit", async () => {
277
+ it("posts 'Passed Checks' comment on passing audit", async () => {
274
278
  vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
275
279
  const ctx = createCtx();
276
280
  const session = createSession();
@@ -279,24 +283,46 @@ describe("runPlanAudit", () => {
279
283
 
280
284
  expect(mockLinearApi.createComment).toHaveBeenCalledWith(
281
285
  "issue-1",
282
- expect.stringContaining("Approved"),
286
+ expect.stringContaining("Plan Passed Checks"),
283
287
  );
284
288
  });
285
289
 
286
- it("ends session as approved on pass", async () => {
290
+ it("runs cross-model review automatically on passing audit", async () => {
287
291
  vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
288
292
  const ctx = createCtx();
289
293
  const session = createSession();
290
294
 
291
295
  await runPlanAudit(ctx, session);
292
296
 
293
- expect(endPlanningSession).toHaveBeenCalledWith(
294
- "proj-1",
295
- "approved",
296
- undefined,
297
+ // Default review model is "gemini" (since no primary model configured)
298
+ expect(runGemini).toHaveBeenCalled();
299
+ });
300
+
301
+ it("runs planner agent with review prompt including cross-model feedback", async () => {
302
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
303
+ const ctx = createCtx();
304
+ const session = createSession();
305
+
306
+ await runPlanAudit(ctx, session);
307
+
308
+ // Agent should run with a review prompt
309
+ expect(runAgentMock).toHaveBeenCalledWith(
310
+ expect.objectContaining({
311
+ message: expect.stringContaining("Plan Review"),
312
+ }),
297
313
  );
298
314
  });
299
315
 
316
+ it("does NOT end session as approved on passing audit (waits for user approval)", async () => {
317
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
318
+ const ctx = createCtx();
319
+ const session = createSession();
320
+
321
+ await runPlanAudit(ctx, session);
322
+
323
+ expect(endPlanningSession).not.toHaveBeenCalled();
324
+ });
325
+
300
326
  it("posts problems on failing audit", async () => {
301
327
  vi.mocked(auditPlan).mockReturnValue({
302
328
  pass: false,
@@ -314,7 +340,7 @@ describe("runPlanAudit", () => {
314
340
  );
315
341
  });
316
342
 
317
- it("does NOT end session as approved on fail", async () => {
343
+ it("does NOT transition to plan_review on failing audit", async () => {
318
344
  vi.mocked(auditPlan).mockReturnValue({
319
345
  pass: false,
320
346
  problems: ["No estimates"],
@@ -325,10 +351,103 @@ describe("runPlanAudit", () => {
325
351
 
326
352
  await runPlanAudit(ctx, session);
327
353
 
328
- expect(endPlanningSession).not.toHaveBeenCalledWith(
354
+ expect(updatePlanningSession).not.toHaveBeenCalledWith(
329
355
  "proj-1",
330
- "approved",
356
+ { status: "plan_review" },
331
357
  expect.anything(),
332
358
  );
333
359
  });
360
+
361
+ it("includes warnings in success comment when present", async () => {
362
+ vi.mocked(auditPlan).mockReturnValue({
363
+ pass: true,
364
+ problems: [],
365
+ warnings: ["PROJ-3 has no acceptance criteria"],
366
+ });
367
+ const ctx = createCtx();
368
+ const session = createSession();
369
+
370
+ await runPlanAudit(ctx, session);
371
+
372
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
373
+ "issue-1",
374
+ expect.stringContaining("PROJ-3 has no acceptance criteria"),
375
+ );
376
+ });
377
+ });
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // resolveReviewModel
381
+ // ---------------------------------------------------------------------------
382
+
383
+ describe("resolveReviewModel", () => {
384
+ it("returns 'codex' when primary model is claude-based", () => {
385
+ expect(resolveReviewModel({
386
+ agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4" } } },
387
+ } as any)).toBe("codex");
388
+ });
389
+
390
+ it("returns 'gemini' when primary model is codex-based", () => {
391
+ expect(resolveReviewModel({
392
+ agents: { defaults: { model: { primary: "openai/codex-3" } } },
393
+ } as any)).toBe("gemini");
394
+ });
395
+
396
+ it("returns 'codex' when primary model is gemini-based", () => {
397
+ expect(resolveReviewModel({
398
+ agents: { defaults: { model: { primary: "google/gemini-2" } } },
399
+ } as any)).toBe("codex");
400
+ });
401
+
402
+ it("returns 'gemini' when no primary model configured", () => {
403
+ expect(resolveReviewModel({})).toBe("gemini");
404
+ });
405
+
406
+ it("respects explicit plannerReviewModel config override", () => {
407
+ expect(resolveReviewModel({
408
+ plannerReviewModel: "gemini",
409
+ agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4" } } },
410
+ } as any)).toBe("gemini");
411
+ });
412
+ });
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // runCrossModelReview
416
+ // ---------------------------------------------------------------------------
417
+
418
+ describe("runCrossModelReview", () => {
419
+ it("calls the correct CLI runner for the specified model", async () => {
420
+ const api = createApi();
421
+
422
+ await runCrossModelReview(api, "claude", "test snapshot");
423
+ expect(runClaude).toHaveBeenCalled();
424
+
425
+ vi.clearAllMocks();
426
+ await runCrossModelReview(api, "codex", "test snapshot");
427
+ expect(runCodex).toHaveBeenCalled();
428
+
429
+ vi.clearAllMocks();
430
+ await runCrossModelReview(api, "gemini", "test snapshot");
431
+ expect(runGemini).toHaveBeenCalled();
432
+ });
433
+
434
+ it("returns review output on success", async () => {
435
+ const api = createApi();
436
+ const result = await runCrossModelReview(api, "claude", "test snapshot");
437
+ expect(result).toBe("Claude review feedback");
438
+ });
439
+
440
+ it("returns graceful fallback on failure", async () => {
441
+ vi.mocked(runClaude).mockResolvedValueOnce({ success: false, error: "timeout" } as any);
442
+ const api = createApi();
443
+ const result = await runCrossModelReview(api, "claude", "test snapshot");
444
+ expect(result).toContain("review failed");
445
+ });
446
+
447
+ it("returns graceful fallback on exception", async () => {
448
+ vi.mocked(runClaude).mockRejectedValueOnce(new Error("network error"));
449
+ const api = createApi();
450
+ const result = await runCrossModelReview(api, "claude", "test snapshot");
451
+ expect(result).toContain("review unavailable");
452
+ });
334
453
  });