@calltelemetry/openclaw-linear 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import {
3
+ extractGuidance,
4
+ extractGuidanceFromPromptContext,
5
+ cacheGuidanceForTeam,
6
+ getCachedGuidanceForTeam,
7
+ formatGuidanceAppendix,
8
+ isGuidanceEnabled,
9
+ _resetGuidanceCacheForTesting,
10
+ } from "./guidance.js";
11
+
12
+ beforeEach(() => {
13
+ _resetGuidanceCacheForTesting();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // extractGuidance
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe("extractGuidance", () => {
22
+ it("extracts from top-level guidance field", () => {
23
+ const result = extractGuidance({ guidance: "Always use main branch." });
24
+ expect(result.guidance).toBe("Always use main branch.");
25
+ expect(result.source).toBe("webhook");
26
+ });
27
+
28
+ it("falls back to promptContext when guidance is missing", () => {
29
+ const result = extractGuidance({
30
+ promptContext: "## Guidance\nUse TypeScript.\n\n## Issue\nENG-123",
31
+ });
32
+ expect(result.guidance).toBe("Use TypeScript.");
33
+ expect(result.source).toBe("promptContext");
34
+ });
35
+
36
+ it("prefers top-level guidance over promptContext", () => {
37
+ const result = extractGuidance({
38
+ guidance: "From top-level",
39
+ promptContext: "## Guidance\nFrom promptContext",
40
+ });
41
+ expect(result.guidance).toBe("From top-level");
42
+ expect(result.source).toBe("webhook");
43
+ });
44
+
45
+ it("returns null when neither field exists", () => {
46
+ const result = extractGuidance({});
47
+ expect(result.guidance).toBeNull();
48
+ expect(result.source).toBeNull();
49
+ });
50
+
51
+ it("ignores empty/whitespace guidance", () => {
52
+ const result = extractGuidance({ guidance: " " });
53
+ expect(result.guidance).toBeNull();
54
+ expect(result.source).toBeNull();
55
+ });
56
+ });
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // extractGuidanceFromPromptContext
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe("extractGuidanceFromPromptContext", () => {
63
+ it("extracts from XML-style <guidance> tags", () => {
64
+ const pc = "Some preamble.\n<guidance>Use Go for API work.\nAlways add tests.</guidance>\nMore stuff.";
65
+ expect(extractGuidanceFromPromptContext(pc)).toBe("Use Go for API work.\nAlways add tests.");
66
+ });
67
+
68
+ it("extracts from markdown ## Guidance heading", () => {
69
+ const pc = "## Issue\nENG-123\n\n## Guidance\nCommit messages must reference JIRA.\n\n## Comments\nSome comments.";
70
+ expect(extractGuidanceFromPromptContext(pc)).toBe("Commit messages must reference JIRA.");
71
+ });
72
+
73
+ it("extracts from markdown # Guidance heading", () => {
74
+ const pc = "# Guidance\nRun make test.\n\n# Issue\nENG-456";
75
+ expect(extractGuidanceFromPromptContext(pc)).toBe("Run make test.");
76
+ });
77
+
78
+ it("returns null when no guidance section found", () => {
79
+ const pc = "## Issue\nENG-123\n\n## Description\nSome text.";
80
+ expect(extractGuidanceFromPromptContext(pc)).toBeNull();
81
+ });
82
+
83
+ it("handles guidance at end of string (no trailing heading)", () => {
84
+ const pc = "## Guidance\nAlways use TypeScript.";
85
+ expect(extractGuidanceFromPromptContext(pc)).toBe("Always use TypeScript.");
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // formatGuidanceAppendix
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe("formatGuidanceAppendix", () => {
94
+ it("formats guidance as appendix block", () => {
95
+ const result = formatGuidanceAppendix("Use main branch.");
96
+ expect(result).toContain("## Additional Guidance");
97
+ expect(result).toContain("Use main branch.");
98
+ expect(result).toMatch(/^---\n/);
99
+ expect(result).toMatch(/\n---$/);
100
+ });
101
+
102
+ it("returns empty string for null", () => {
103
+ expect(formatGuidanceAppendix(null)).toBe("");
104
+ });
105
+
106
+ it("returns empty string for empty string", () => {
107
+ expect(formatGuidanceAppendix("")).toBe("");
108
+ });
109
+
110
+ it("returns empty string for whitespace-only", () => {
111
+ expect(formatGuidanceAppendix(" \n ")).toBe("");
112
+ });
113
+
114
+ it("truncates at 2000 chars", () => {
115
+ const long = "x".repeat(3000);
116
+ const result = formatGuidanceAppendix(long);
117
+ // 2000 x's + the framing text
118
+ expect(result).toContain("x".repeat(2000));
119
+ expect(result).not.toContain("x".repeat(2001));
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Cache: cacheGuidanceForTeam / getCachedGuidanceForTeam
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe("guidance cache", () => {
128
+ it("stores and retrieves guidance by team ID", () => {
129
+ cacheGuidanceForTeam("team-1", "Use TypeScript.");
130
+ expect(getCachedGuidanceForTeam("team-1")).toBe("Use TypeScript.");
131
+ });
132
+
133
+ it("returns null for unknown team", () => {
134
+ expect(getCachedGuidanceForTeam("team-unknown")).toBeNull();
135
+ });
136
+
137
+ it("returns null after TTL expiry", () => {
138
+ const now = Date.now();
139
+ vi.spyOn(Date, "now").mockReturnValue(now);
140
+ cacheGuidanceForTeam("team-1", "guidance");
141
+
142
+ // Advance 25 hours
143
+ vi.spyOn(Date, "now").mockReturnValue(now + 25 * 60 * 60 * 1000);
144
+ expect(getCachedGuidanceForTeam("team-1")).toBeNull();
145
+ });
146
+
147
+ it("evicts oldest entry when at capacity", () => {
148
+ // Fill cache to 50 entries
149
+ for (let i = 0; i < 50; i++) {
150
+ cacheGuidanceForTeam(`team-${i}`, `guidance-${i}`);
151
+ }
152
+ expect(getCachedGuidanceForTeam("team-0")).toBe("guidance-0");
153
+
154
+ // Adding one more should evict team-0 (oldest)
155
+ cacheGuidanceForTeam("team-new", "new guidance");
156
+ expect(getCachedGuidanceForTeam("team-0")).toBeNull();
157
+ expect(getCachedGuidanceForTeam("team-new")).toBe("new guidance");
158
+ });
159
+
160
+ it("updates existing entry without eviction", () => {
161
+ cacheGuidanceForTeam("team-1", "old");
162
+ cacheGuidanceForTeam("team-1", "new");
163
+ expect(getCachedGuidanceForTeam("team-1")).toBe("new");
164
+ });
165
+
166
+ it("clears on _resetGuidanceCacheForTesting", () => {
167
+ cacheGuidanceForTeam("team-1", "guidance");
168
+ _resetGuidanceCacheForTesting();
169
+ expect(getCachedGuidanceForTeam("team-1")).toBeNull();
170
+ });
171
+ });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // isGuidanceEnabled
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe("isGuidanceEnabled", () => {
178
+ it("returns true by default (no config)", () => {
179
+ expect(isGuidanceEnabled(undefined, "team-1")).toBe(true);
180
+ });
181
+
182
+ it("returns true when enableGuidance not set", () => {
183
+ expect(isGuidanceEnabled({}, "team-1")).toBe(true);
184
+ });
185
+
186
+ it("returns false when enableGuidance is false", () => {
187
+ expect(isGuidanceEnabled({ enableGuidance: false }, "team-1")).toBe(false);
188
+ });
189
+
190
+ it("returns true when enableGuidance is true", () => {
191
+ expect(isGuidanceEnabled({ enableGuidance: true }, "team-1")).toBe(true);
192
+ });
193
+
194
+ it("team override true overrides workspace false", () => {
195
+ const config = {
196
+ enableGuidance: false,
197
+ teamGuidanceOverrides: { "team-1": true },
198
+ };
199
+ expect(isGuidanceEnabled(config, "team-1")).toBe(true);
200
+ });
201
+
202
+ it("team override false overrides workspace true", () => {
203
+ const config = {
204
+ enableGuidance: true,
205
+ teamGuidanceOverrides: { "team-1": false },
206
+ };
207
+ expect(isGuidanceEnabled(config, "team-1")).toBe(false);
208
+ });
209
+
210
+ it("unset team inherits workspace setting", () => {
211
+ const config = {
212
+ enableGuidance: false,
213
+ teamGuidanceOverrides: { "team-other": true },
214
+ };
215
+ expect(isGuidanceEnabled(config, "team-1")).toBe(false);
216
+ });
217
+
218
+ it("works with undefined teamId", () => {
219
+ expect(isGuidanceEnabled({ enableGuidance: false }, undefined)).toBe(false);
220
+ expect(isGuidanceEnabled({ enableGuidance: true }, undefined)).toBe(true);
221
+ });
222
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Linear workspace & team guidance extraction, caching, and formatting.
3
+ *
4
+ * Guidance is delivered via AgentSessionEvent webhook payloads (both the
5
+ * top-level `guidance` field and embedded within `promptContext`). This
6
+ * module extracts it, caches it per-team (so Comment webhook paths can
7
+ * benefit), and formats it as a prompt appendix.
8
+ *
9
+ * @see https://linear.app/docs/agents-in-linear
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface GuidanceContext {
17
+ /** The raw guidance string (workspace + team merged by Linear) */
18
+ guidance: string | null;
19
+ /** Where the guidance came from */
20
+ source: "webhook" | "promptContext" | null;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Extraction
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Extract guidance from an AgentSessionEvent webhook payload.
29
+ * Priority: payload.guidance (string) > parsed from payload.promptContext > null.
30
+ */
31
+ export function extractGuidance(payload: Record<string, unknown>): GuidanceContext {
32
+ // 1. Top-level guidance field (clean string from Linear)
33
+ const guidance = payload.guidance;
34
+ if (typeof guidance === "string" && guidance.trim()) {
35
+ return { guidance: guidance.trim(), source: "webhook" };
36
+ }
37
+
38
+ // 2. Try parsing from promptContext
39
+ const pc = payload.promptContext;
40
+ if (typeof pc === "string" && pc.trim()) {
41
+ const extracted = extractGuidanceFromPromptContext(pc);
42
+ if (extracted) {
43
+ return { guidance: extracted, source: "promptContext" };
44
+ }
45
+ }
46
+
47
+ return { guidance: null, source: null };
48
+ }
49
+
50
+ /**
51
+ * Best-effort parse guidance from a promptContext string.
52
+ * Linear's promptContext format may include guidance as XML-style tags
53
+ * or markdown-headed sections.
54
+ */
55
+ export function extractGuidanceFromPromptContext(promptContext: string): string | null {
56
+ // Pattern 1: XML-style tags (e.g. <guidance>...</guidance>)
57
+ const xmlMatch = promptContext.match(/<guidance>([\s\S]*?)<\/guidance>/i);
58
+ if (xmlMatch?.[1]?.trim()) return xmlMatch[1].trim();
59
+
60
+ // Pattern 2: Markdown heading "## Guidance" or "# Guidance"
61
+ const mdMatch = promptContext.match(/#{1,3}\s*[Gg]uidance\s*\n([\s\S]*?)(?=\n#{1,3}\s|$)/);
62
+ if (mdMatch?.[1]?.trim()) return mdMatch[1].trim();
63
+
64
+ return null;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Cache (per-team, 24h TTL, 50-entry cap)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ interface CacheEntry {
72
+ guidance: string;
73
+ cachedAt: number;
74
+ }
75
+
76
+ const guidanceCache = new Map<string, CacheEntry>();
77
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
78
+ const CACHE_MAX_ENTRIES = 50;
79
+
80
+ /** Cache guidance by team ID. */
81
+ export function cacheGuidanceForTeam(teamId: string, guidance: string): void {
82
+ // Evict oldest if at capacity
83
+ if (guidanceCache.size >= CACHE_MAX_ENTRIES && !guidanceCache.has(teamId)) {
84
+ const oldest = guidanceCache.keys().next().value;
85
+ if (oldest !== undefined) guidanceCache.delete(oldest);
86
+ }
87
+ guidanceCache.set(teamId, { guidance, cachedAt: Date.now() });
88
+ }
89
+
90
+ /** Look up cached guidance for a team. Returns null on miss or expiry. */
91
+ export function getCachedGuidanceForTeam(teamId: string): string | null {
92
+ const entry = guidanceCache.get(teamId);
93
+ if (!entry) return null;
94
+ if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
95
+ guidanceCache.delete(teamId);
96
+ return null;
97
+ }
98
+ return entry.guidance;
99
+ }
100
+
101
+ /** Test-only: reset the guidance cache. */
102
+ export function _resetGuidanceCacheForTesting(): void {
103
+ guidanceCache.clear();
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Formatting
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const MAX_GUIDANCE_CHARS = 2000;
111
+
112
+ /**
113
+ * Format guidance as a prompt appendix.
114
+ * Returns empty string if guidance is null/empty.
115
+ * Truncates at 2000 chars to protect token budget.
116
+ */
117
+ export function formatGuidanceAppendix(guidance: string | null): string {
118
+ if (!guidance?.trim()) return "";
119
+ const trimmed = guidance.trim().slice(0, MAX_GUIDANCE_CHARS);
120
+ return [
121
+ `---`,
122
+ `## Additional Guidance (from Linear workspace/team settings)`,
123
+ trimmed,
124
+ `---`,
125
+ ].join("\n");
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Config toggle
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Resolve whether guidance is enabled for a given team.
134
+ * Resolution: teamGuidanceOverrides[teamId] ?? enableGuidance ?? true
135
+ */
136
+ export function isGuidanceEnabled(
137
+ pluginConfig: Record<string, unknown> | undefined,
138
+ teamId: string | undefined,
139
+ ): boolean {
140
+ if (!pluginConfig) return true;
141
+
142
+ // Check per-team override first
143
+ if (teamId) {
144
+ const overrides = pluginConfig.teamGuidanceOverrides;
145
+ if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) {
146
+ const teamOverride = (overrides as Record<string, unknown>)[teamId];
147
+ if (typeof teamOverride === "boolean") return teamOverride;
148
+ }
149
+ }
150
+
151
+ // Fall back to workspace-level toggle
152
+ const enabled = pluginConfig.enableGuidance;
153
+ if (typeof enabled === "boolean") return enabled;
154
+
155
+ return true; // default on
156
+ }
@@ -19,6 +19,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
19
19
  import type { LinearAgentApi, ActivityContent } from "../api/linear-api.js";
20
20
  import { runAgent } from "../agent/agent.js";
21
21
  import { setActiveSession, clearActiveSession } from "./active-session.js";
22
+ import { getCachedGuidanceForTeam, isGuidanceEnabled } from "./guidance.js";
22
23
  import {
23
24
  type Tier,
24
25
  type DispatchStatus,
@@ -191,7 +192,7 @@ export interface IssueContext {
191
192
  export function buildWorkerTask(
192
193
  issue: IssueContext,
193
194
  worktreePath: string,
194
- opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
195
+ opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string },
195
196
  ): { system: string; task: string } {
196
197
  const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
197
198
  const vars: Record<string, string> = {
@@ -202,6 +203,9 @@ export function buildWorkerTask(
202
203
  tier: "",
203
204
  attempt: String(opts?.attempt ?? 0),
204
205
  gaps: opts?.gaps?.length ? "- " + opts.gaps.join("\n- ") : "",
206
+ guidance: opts?.guidance
207
+ ? `\n---\n## Additional Guidance (from Linear workspace/team settings)\n${opts.guidance.slice(0, 2000)}\n---`
208
+ : "",
205
209
  };
206
210
 
207
211
  let task = renderTemplate(prompts.worker.task, vars);
@@ -222,6 +226,7 @@ export function buildAuditTask(
222
226
  issue: IssueContext,
223
227
  worktreePath: string,
224
228
  pluginConfig?: Record<string, unknown>,
229
+ opts?: { guidance?: string },
225
230
  ): { system: string; task: string } {
226
231
  const prompts = loadPrompts(pluginConfig, worktreePath);
227
232
  const vars: Record<string, string> = {
@@ -232,6 +237,9 @@ export function buildAuditTask(
232
237
  tier: "",
233
238
  attempt: "0",
234
239
  gaps: "",
240
+ guidance: opts?.guidance
241
+ ? `\n---\n## Additional Guidance (from Linear workspace/team settings)\n${opts.guidance.slice(0, 2000)}\n---`
242
+ : "",
235
243
  };
236
244
 
237
245
  return {
@@ -350,7 +358,13 @@ export async function triggerAudit(
350
358
  ? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
351
359
  : dispatch.worktreePath;
352
360
 
353
- const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig);
361
+ // Look up cached guidance for audit
362
+ const auditTeamId = issueDetails?.team?.id;
363
+ const auditGuidance = (auditTeamId && isGuidanceEnabled(pluginConfig, auditTeamId))
364
+ ? getCachedGuidanceForTeam(auditTeamId) ?? undefined
365
+ : undefined;
366
+
367
+ const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig, { guidance: auditGuidance });
354
368
 
355
369
  // Set Linear label
356
370
  await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
@@ -744,10 +758,17 @@ export async function spawnWorker(
744
758
  ? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
745
759
  : dispatch.worktreePath;
746
760
 
761
+ // Look up cached guidance for the issue's team
762
+ const workerTeamId = issueDetails?.team?.id;
763
+ const workerGuidance = (workerTeamId && isGuidanceEnabled(pluginConfig, workerTeamId))
764
+ ? getCachedGuidanceForTeam(workerTeamId) ?? undefined
765
+ : undefined;
766
+
747
767
  const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
748
768
  attempt: dispatch.attempt,
749
769
  gaps: opts?.gaps,
750
770
  pluginConfig,
771
+ guidance: workerGuidance,
751
772
  });
752
773
 
753
774
  const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;