@calltelemetry/openclaw-linear 0.7.1 → 0.8.0

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.
@@ -3,6 +3,7 @@ import {
3
3
  createNoopNotifier,
4
4
  createNotifierFromConfig,
5
5
  formatMessage,
6
+ formatRichMessage,
6
7
  sendToTarget,
7
8
  parseNotificationsConfig,
8
9
  type NotifyKind,
@@ -23,24 +24,24 @@ describe("formatMessage", () => {
23
24
 
24
25
  it("formats dispatch message", () => {
25
26
  const msg = formatMessage("dispatch", basePayload);
26
- expect(msg).toBe("API-42 dispatched — Fix auth");
27
+ expect(msg).toBe("API-42 started — Fix auth");
27
28
  });
28
29
 
29
30
  it("formats working message with attempt", () => {
30
31
  const msg = formatMessage("working", { ...basePayload, attempt: 1 });
31
- expect(msg).toContain("worker started");
32
- expect(msg).toContain("attempt 1");
32
+ expect(msg).toContain("working on it");
33
+ expect(msg).toContain("attempt 2"); // 1-based for humans
33
34
  });
34
35
 
35
36
  it("formats auditing message", () => {
36
37
  const msg = formatMessage("auditing", basePayload);
37
- expect(msg).toContain("audit in progress");
38
+ expect(msg).toContain("checking the work");
38
39
  });
39
40
 
40
41
  it("formats audit_pass message", () => {
41
42
  const msg = formatMessage("audit_pass", basePayload);
42
- expect(msg).toContain("passed audit");
43
- expect(msg).toContain("PR ready");
43
+ expect(msg).toContain("done!");
44
+ expect(msg).toContain("Ready for review");
44
45
  });
45
46
 
46
47
  it("formats audit_fail message with gaps", () => {
@@ -49,8 +50,8 @@ describe("formatMessage", () => {
49
50
  attempt: 1,
50
51
  verdict: { pass: false, gaps: ["no tests", "missing validation"] },
51
52
  });
52
- expect(msg).toContain("failed audit");
53
- expect(msg).toContain("attempt 1");
53
+ expect(msg).toContain("needs more work");
54
+ expect(msg).toContain("attempt 2"); // 1-based for humans
54
55
  expect(msg).toContain("no tests");
55
56
  expect(msg).toContain("missing validation");
56
57
  });
@@ -67,10 +68,10 @@ describe("formatMessage", () => {
67
68
  it("formats escalation message with reason", () => {
68
69
  const msg = formatMessage("escalation", {
69
70
  ...basePayload,
70
- reason: "audit failed 3x",
71
+ attempt: 2,
71
72
  });
72
- expect(msg).toContain("needs human review");
73
- expect(msg).toContain("audit failed 3x");
73
+ expect(msg).toContain("needs your help");
74
+ expect(msg).toContain("3 tries"); // 1-based
74
75
  });
75
76
 
76
77
  it("formats stuck message", () => {
@@ -86,11 +87,11 @@ describe("formatMessage", () => {
86
87
  const msg = formatMessage("watchdog_kill", {
87
88
  ...basePayload,
88
89
  attempt: 0,
89
- reason: "no I/O for 120s",
90
+ reason: "no activity for 120s",
90
91
  });
91
- expect(msg).toContain("killed by watchdog");
92
- expect(msg).toContain("no I/O for 120s");
93
- expect(msg).toContain("Retrying (attempt 0)");
92
+ expect(msg).toContain("timed out");
93
+ expect(msg).toContain("no activity for 120s");
94
+ expect(msg).toContain("Retrying (attempt 1)"); // 1-based
94
95
  });
95
96
 
96
97
  it("formats watchdog_kill without attempt", () => {
@@ -325,7 +326,7 @@ describe("createNotifierFromConfig", () => {
325
326
  expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledOnce();
326
327
  expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
327
328
  "-100388",
328
- expect.stringContaining("worker started"),
329
+ expect.stringContaining("working on it"),
329
330
  { silent: true },
330
331
  );
331
332
  });
@@ -402,6 +403,144 @@ describe("createNotifierFromConfig", () => {
402
403
  });
403
404
  });
404
405
 
406
+ // ---------------------------------------------------------------------------
407
+ // formatRichMessage
408
+ // ---------------------------------------------------------------------------
409
+
410
+ describe("formatRichMessage", () => {
411
+ const basePayload: NotifyPayload = {
412
+ identifier: "CT-10",
413
+ title: "Add caching",
414
+ status: "dispatched",
415
+ };
416
+
417
+ it("returns Discord embed with correct color for dispatch (blue)", () => {
418
+ const msg = formatRichMessage("dispatch", basePayload);
419
+ expect(msg.discord?.embeds).toHaveLength(1);
420
+ expect(msg.discord!.embeds[0].color).toBe(0x3498db);
421
+ });
422
+
423
+ it("returns Discord embed with green for audit_pass", () => {
424
+ const msg = formatRichMessage("audit_pass", basePayload);
425
+ expect(msg.discord!.embeds[0].color).toBe(0x2ecc71);
426
+ });
427
+
428
+ it("returns Discord embed with red for audit_fail", () => {
429
+ const msg = formatRichMessage("audit_fail", { ...basePayload, attempt: 1, verdict: { pass: false, gaps: ["no tests"] } });
430
+ expect(msg.discord!.embeds[0].color).toBe(0xe74c3c);
431
+ expect(msg.discord!.embeds[0].fields).toEqual(
432
+ expect.arrayContaining([expect.objectContaining({ name: "Issues to fix", value: "no tests" })]),
433
+ );
434
+ });
435
+
436
+ it("returns Discord embed with orange for stuck", () => {
437
+ const msg = formatRichMessage("stuck", { ...basePayload, reason: "stale 2h" });
438
+ expect(msg.discord!.embeds[0].color).toBe(0xe67e22);
439
+ });
440
+
441
+ it("returns Telegram HTML with bold identifier", () => {
442
+ const msg = formatRichMessage("dispatch", basePayload);
443
+ expect(msg.telegram?.html).toContain("<b>CT-10</b>");
444
+ expect(msg.telegram?.html).toContain("<i>Add caching</i>");
445
+ });
446
+
447
+ it("includes plain text fallback", () => {
448
+ const msg = formatRichMessage("dispatch", basePayload);
449
+ expect(msg.text).toBe("CT-10 started — Add caching");
450
+ });
451
+ });
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // sendToTarget with RichMessage
455
+ // ---------------------------------------------------------------------------
456
+
457
+ describe("sendToTarget (RichMessage)", () => {
458
+ function mockRuntime(): any {
459
+ return {
460
+ channel: {
461
+ discord: { sendMessageDiscord: vi.fn(async () => {}) },
462
+ slack: { sendMessageSlack: vi.fn(async () => ({})) },
463
+ telegram: { sendMessageTelegram: vi.fn(async () => {}) },
464
+ signal: { sendMessageSignal: vi.fn(async () => {}) },
465
+ },
466
+ };
467
+ }
468
+
469
+ afterEach(() => { vi.restoreAllMocks(); });
470
+
471
+ it("passes embeds to Discord when RichMessage provided", async () => {
472
+ const runtime = mockRuntime();
473
+ const target: NotifyTarget = { channel: "discord", target: "D-1" };
474
+ const rich = {
475
+ text: "plain",
476
+ discord: { embeds: [{ title: "test", color: 0x3498db }] },
477
+ };
478
+ await sendToTarget(target, rich, runtime);
479
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith(
480
+ "D-1", "plain", { embeds: [{ title: "test", color: 0x3498db }] },
481
+ );
482
+ });
483
+
484
+ it("passes textMode html to Telegram when RichMessage provided", async () => {
485
+ const runtime = mockRuntime();
486
+ const target: NotifyTarget = { channel: "telegram", target: "-999" };
487
+ const rich = {
488
+ text: "plain",
489
+ telegram: { html: "<b>CT-10</b> dispatched" },
490
+ };
491
+ await sendToTarget(target, rich, runtime);
492
+ expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
493
+ "-999", "<b>CT-10</b> dispatched", { silent: true, textMode: "html" },
494
+ );
495
+ });
496
+ });
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // createNotifierFromConfig (richFormat)
500
+ // ---------------------------------------------------------------------------
501
+
502
+ describe("createNotifierFromConfig (richFormat)", () => {
503
+ function mockRuntime(): any {
504
+ return {
505
+ channel: {
506
+ discord: { sendMessageDiscord: vi.fn(async () => {}) },
507
+ slack: { sendMessageSlack: vi.fn(async () => ({})) },
508
+ telegram: { sendMessageTelegram: vi.fn(async () => {}) },
509
+ signal: { sendMessageSignal: vi.fn(async () => {}) },
510
+ },
511
+ };
512
+ }
513
+
514
+ afterEach(() => { vi.restoreAllMocks(); });
515
+
516
+ it("sends Discord embeds when richFormat is true", async () => {
517
+ const runtime = mockRuntime();
518
+ const notify = createNotifierFromConfig({
519
+ notifications: {
520
+ richFormat: true,
521
+ targets: [{ channel: "discord", target: "D-1" }],
522
+ },
523
+ }, runtime);
524
+ await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
525
+ const [, , opts] = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
526
+ expect(opts?.embeds).toBeDefined();
527
+ expect(opts.embeds).toHaveLength(1);
528
+ });
529
+
530
+ it("sends plain text when richFormat is false", async () => {
531
+ const runtime = mockRuntime();
532
+ const notify = createNotifierFromConfig({
533
+ notifications: {
534
+ richFormat: false,
535
+ targets: [{ channel: "discord", target: "D-1" }],
536
+ },
537
+ }, runtime);
538
+ await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
539
+ const call = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
540
+ expect(call).toHaveLength(2); // no third arg with embeds
541
+ });
542
+ });
543
+
405
544
  // ---------------------------------------------------------------------------
406
545
  // createNoopNotifier
407
546
  // ---------------------------------------------------------------------------
@@ -8,7 +8,8 @@
8
8
  * Modeled on DevClaw's notify.ts pattern — the runtime handles token resolution,
9
9
  * formatting differences (markdown vs mrkdwn), and delivery per channel.
10
10
  */
11
- import type { PluginRuntime } from "openclaw/plugin-sdk";
11
+ import type { PluginRuntime, OpenClawPluginApi } from "openclaw/plugin-sdk";
12
+ import { emitDiagnostic } from "./observability.js";
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Types
@@ -81,26 +82,27 @@ export interface RichMessage {
81
82
 
82
83
  export function formatMessage(kind: NotifyKind, payload: NotifyPayload): string {
83
84
  const id = payload.identifier;
85
+ const attempt = (payload.attempt ?? 0) + 1; // 1-based for humans
84
86
  switch (kind) {
85
87
  case "dispatch":
86
- return `${id} dispatched — ${payload.title}`;
88
+ return `${id} started — ${payload.title}`;
87
89
  case "working":
88
- return `${id} worker started (attempt ${payload.attempt ?? 0})`;
90
+ return `${id} working on it (attempt ${attempt})`;
89
91
  case "auditing":
90
- return `${id} audit in progress`;
92
+ return `${id} checking the work...`;
91
93
  case "audit_pass":
92
- return `${id} passed audit. PR ready.`;
94
+ return `✅ ${id} done! Ready for review.`;
93
95
  case "audit_fail": {
94
- const gaps = payload.verdict?.gaps?.join(", ") ?? "unspecified";
95
- return `${id} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
96
+ const issues = payload.verdict?.gaps?.join(", ") ?? "unspecified";
97
+ return `${id} needs more work (attempt ${attempt}). Issues: ${issues}`;
96
98
  }
97
99
  case "escalation":
98
- return `🚨 ${id} needs human review — ${payload.reason ?? "audit failed 2x"}`;
100
+ return `🚨 ${id} needs your helpcouldn't fix it after ${attempt} ${attempt === 1 ? "try" : "tries"}`;
99
101
  case "stuck":
100
- return `⏰ ${id} stuck — ${payload.reason ?? "stale 2h"}`;
102
+ return `⏰ ${id} stuck — ${payload.reason ?? "inactive for 2h"}`;
101
103
  case "watchdog_kill":
102
- return `⚡ ${id} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
103
- payload.attempt != null ? `Retrying (attempt ${payload.attempt}).` : "Will retry."
104
+ return `⚡ ${id} timed out (${payload.reason ?? "no activity for 120s"}). ${
105
+ payload.attempt != null ? `Retrying (attempt ${attempt}).` : "Will retry."
104
106
  }`;
105
107
  case "project_progress":
106
108
  return `📊 ${payload.title} (${id}): ${payload.status}`;
@@ -134,10 +136,10 @@ export function formatRichMessage(kind: NotifyKind, payload: NotifyPayload): Ric
134
136
 
135
137
  // Discord embed
136
138
  const fields: DiscordEmbed["fields"] = [];
137
- if (payload.attempt != null) fields.push({ name: "Attempt", value: String(payload.attempt), inline: true });
139
+ if (payload.attempt != null) fields.push({ name: "Attempt", value: String((payload.attempt ?? 0) + 1), inline: true });
138
140
  if (payload.status) fields.push({ name: "Status", value: payload.status, inline: true });
139
141
  if (payload.verdict?.gaps?.length) {
140
- fields.push({ name: "Gaps", value: payload.verdict.gaps.join("\n").slice(0, 1024) });
142
+ fields.push({ name: "Issues to fix", value: payload.verdict.gaps.join("\n").slice(0, 1024) });
141
143
  }
142
144
  if (payload.reason) fields.push({ name: "Reason", value: payload.reason });
143
145
 
@@ -154,10 +156,10 @@ export function formatRichMessage(kind: NotifyKind, payload: NotifyPayload): Ric
154
156
  `<b>${escapeHtml(payload.identifier)}</b> — ${escapeHtml(kind.replace(/_/g, " "))}`,
155
157
  `<i>${escapeHtml(payload.title)}</i>`,
156
158
  ];
157
- if (payload.attempt != null) htmlParts.push(`Attempt: <code>${payload.attempt}</code>`);
159
+ if (payload.attempt != null) htmlParts.push(`Attempt: <code>${(payload.attempt ?? 0) + 1}</code>`);
158
160
  if (payload.status) htmlParts.push(`Status: <code>${escapeHtml(payload.status)}</code>`);
159
161
  if (payload.verdict?.gaps?.length) {
160
- htmlParts.push(`Gaps:\n${payload.verdict.gaps.map(g => `• ${escapeHtml(g)}`).join("\n")}`);
162
+ htmlParts.push(`Issues to fix:\n${payload.verdict.gaps.map(g => `• ${escapeHtml(g)}`).join("\n")}`);
161
163
  }
162
164
  if (payload.reason) htmlParts.push(`Reason: ${escapeHtml(payload.reason)}`);
163
165
 
@@ -241,6 +243,7 @@ export function parseNotificationsConfig(
241
243
  export function createNotifierFromConfig(
242
244
  pluginConfig: Record<string, unknown> | undefined,
243
245
  runtime: PluginRuntime,
246
+ api?: OpenClawPluginApi,
244
247
  ): NotifyFn {
245
248
  const config = parseNotificationsConfig(pluginConfig);
246
249
 
@@ -260,6 +263,14 @@ export function createNotifierFromConfig(
260
263
  await sendToTarget(target, message, runtime);
261
264
  } catch (err) {
262
265
  console.error(`Notify error (${target.channel}:${target.target}):`, err);
266
+ if (api) {
267
+ emitDiagnostic(api, {
268
+ event: "notify_failed",
269
+ identifier: payload.identifier,
270
+ phase: kind,
271
+ error: String(err),
272
+ });
273
+ }
263
274
  }
264
275
  }),
265
276
  );
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { emitDiagnostic, type DiagnosticPayload } from "./observability.ts";
3
+
4
+ function makeApi(infoFn = vi.fn()) {
5
+ return { logger: { info: infoFn } } as any;
6
+ }
7
+
8
+ describe("emitDiagnostic", () => {
9
+ it("emits JSON with [linear:diagnostic] prefix via api.logger.info", () => {
10
+ const info = vi.fn();
11
+ const api = makeApi(info);
12
+ emitDiagnostic(api, { event: "webhook_received", identifier: "ISS-42" });
13
+ expect(info).toHaveBeenCalledOnce();
14
+ const line = info.mock.calls[0][0] as string;
15
+ expect(line).toMatch(/^\[linear:diagnostic\] \{/);
16
+ const json = JSON.parse(line.replace("[linear:diagnostic] ", ""));
17
+ expect(json.event).toBe("webhook_received");
18
+ expect(json.identifier).toBe("ISS-42");
19
+ });
20
+
21
+ it("includes all payload fields in JSON output", () => {
22
+ const info = vi.fn();
23
+ const api = makeApi(info);
24
+ const payload: DiagnosticPayload = {
25
+ event: "dispatch_started",
26
+ identifier: "ISS-99",
27
+ issueId: "abc-123",
28
+ phase: "planning",
29
+ from: "triage",
30
+ to: "execution",
31
+ attempt: 2,
32
+ tier: "gold",
33
+ webhookType: "Comment",
34
+ webhookAction: "create",
35
+ channel: "discord",
36
+ target: "kaylee",
37
+ error: "none",
38
+ durationMs: 1234,
39
+ };
40
+ emitDiagnostic(api, payload);
41
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
42
+ expect(json).toMatchObject(payload);
43
+ });
44
+
45
+ it("works with partial payload (only event + identifier)", () => {
46
+ const info = vi.fn();
47
+ const api = makeApi(info);
48
+ emitDiagnostic(api, { event: "health_check" });
49
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
50
+ expect(json.event).toBe("health_check");
51
+ expect(json.identifier).toBeUndefined();
52
+ });
53
+
54
+ it("never throws even if logger throws", () => {
55
+ const api = makeApi(() => { throw new Error("logger exploded"); });
56
+ expect(() => {
57
+ emitDiagnostic(api, { event: "notify_failed", identifier: "ISS-1" });
58
+ }).not.toThrow();
59
+ });
60
+
61
+ it("includes timestamp-relevant fields — payload is faithfully serialized", () => {
62
+ const info = vi.fn();
63
+ const api = makeApi(info);
64
+ const now = Date.now();
65
+ emitDiagnostic(api, { event: "phase_transition", identifier: "ISS-7", timestamp: now } as any);
66
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
67
+ expect(json.timestamp).toBe(now);
68
+ });
69
+
70
+ it("handles payload with special characters", () => {
71
+ const info = vi.fn();
72
+ const api = makeApi(info);
73
+ emitDiagnostic(api, {
74
+ event: "notify_sent",
75
+ identifier: 'ISS-"special"',
76
+ error: "line1\nline2\ttab",
77
+ channel: "<script>alert('xss')</script>",
78
+ });
79
+ expect(info).toHaveBeenCalledOnce();
80
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
81
+ expect(json.identifier).toBe('ISS-"special"');
82
+ expect(json.error).toBe("line1\nline2\ttab");
83
+ expect(json.channel).toBe("<script>alert('xss')</script>");
84
+ });
85
+ });
@@ -337,11 +337,13 @@ describe("buildSummaryFromArtifacts", () => {
337
337
  // ---------------------------------------------------------------------------
338
338
 
339
339
  describe("writeDispatchMemory", () => {
340
- it("creates memory/ dir and writes file", () => {
340
+ it("creates memory/ dir and writes file with frontmatter", () => {
341
341
  const tmp = makeTmpDir();
342
342
  writeDispatchMemory("API-100", "summary content", tmp);
343
343
  const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
344
- expect(content).toBe("summary content");
344
+ expect(content).toContain("---\n");
345
+ expect(content).toContain('issue: "API-100"');
346
+ expect(content).toContain("summary content");
345
347
  });
346
348
 
347
349
  it("overwrites on second call", () => {
@@ -349,7 +351,28 @@ describe("writeDispatchMemory", () => {
349
351
  writeDispatchMemory("API-100", "first", tmp);
350
352
  writeDispatchMemory("API-100", "second", tmp);
351
353
  const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
352
- expect(content).toBe("second");
354
+ expect(content).toContain("second");
355
+ expect(content).not.toContain("first");
356
+ });
357
+
358
+ it("includes custom metadata in frontmatter", () => {
359
+ const tmp = makeTmpDir();
360
+ writeDispatchMemory("CT-50", "done summary", tmp, {
361
+ title: "Fix login bug",
362
+ tier: "senior",
363
+ status: "done",
364
+ project: "Auth",
365
+ attempts: 2,
366
+ model: "kimi-k2.5",
367
+ });
368
+ const content = readFileSync(join(tmp, "memory", "dispatch-CT-50.md"), "utf-8");
369
+ expect(content).toContain('title: "Fix login bug"');
370
+ expect(content).toContain('tier: "senior"');
371
+ expect(content).toContain('status: "done"');
372
+ expect(content).toContain('project: "Auth"');
373
+ expect(content).toContain("attempts: 2");
374
+ expect(content).toContain('model: "kimi-k2.5"');
375
+ expect(content).toContain("done summary");
353
376
  });
354
377
  });
355
378
 
@@ -58,6 +58,7 @@ export interface ActiveDispatch {
58
58
  auditSessionKey?: string; // session key for current audit sub-agent
59
59
  stuckReason?: string; // only set when status === "stuck"
60
60
  issueTitle?: string; // for artifact summaries and memory headings
61
+ worktrees?: Array<{ repoName: string; path: string; branch: string }>;
61
62
  }
62
63
 
63
64
  export interface CompletedDispatch {