@calltelemetry/openclaw-linear 0.7.1 → 0.8.1

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.
Files changed (41) hide show
  1. package/README.md +834 -536
  2. package/index.ts +1 -1
  3. package/openclaw.plugin.json +3 -2
  4. package/package.json +1 -1
  5. package/prompts.yaml +46 -6
  6. package/src/__test__/fixtures/linear-responses.ts +75 -0
  7. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  8. package/src/__test__/helpers.ts +133 -0
  9. package/src/agent/agent.test.ts +192 -0
  10. package/src/agent/agent.ts +26 -1
  11. package/src/api/linear-api.test.ts +93 -1
  12. package/src/api/linear-api.ts +37 -1
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/infra/cli.ts +176 -1
  15. package/src/infra/commands.test.ts +276 -0
  16. package/src/infra/doctor.test.ts +19 -0
  17. package/src/infra/doctor.ts +30 -25
  18. package/src/infra/multi-repo.test.ts +163 -0
  19. package/src/infra/multi-repo.ts +29 -0
  20. package/src/infra/notify.test.ts +155 -16
  21. package/src/infra/notify.ts +26 -15
  22. package/src/infra/observability.test.ts +85 -0
  23. package/src/pipeline/artifacts.test.ts +26 -3
  24. package/src/pipeline/dispatch-state.ts +1 -0
  25. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  26. package/src/pipeline/e2e-planning.test.ts +478 -0
  27. package/src/pipeline/intent-classify.test.ts +285 -0
  28. package/src/pipeline/intent-classify.ts +259 -0
  29. package/src/pipeline/pipeline.test.ts +69 -0
  30. package/src/pipeline/pipeline.ts +47 -18
  31. package/src/pipeline/planner.test.ts +159 -40
  32. package/src/pipeline/planner.ts +108 -60
  33. package/src/pipeline/tier-assess.test.ts +89 -0
  34. package/src/pipeline/webhook.ts +424 -251
  35. package/src/tools/claude-tool.ts +6 -0
  36. package/src/tools/cli-shared.test.ts +155 -0
  37. package/src/tools/code-tool.test.ts +210 -0
  38. package/src/tools/code-tool.ts +2 -2
  39. package/src/tools/dispatch-history-tool.test.ts +315 -0
  40. package/src/tools/planner-tools.test.ts +1 -1
  41. package/src/tools/planner-tools.ts +10 -2
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const { runAgentMock } = vi.hoisted(() => ({
8
+ runAgentMock: vi.fn().mockResolvedValue({
9
+ success: true,
10
+ output: '{"intent":"general","reasoning":"test"}',
11
+ }),
12
+ }));
13
+
14
+ vi.mock("../agent/agent.js", () => ({
15
+ runAgent: runAgentMock,
16
+ }));
17
+
18
+ vi.mock("../api/linear-api.js", () => ({}));
19
+ vi.mock("openclaw/plugin-sdk", () => ({}));
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Imports (AFTER mocks)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ import { classifyIntent, regexFallback, type IntentContext } from "./intent-classify.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function createApi(overrides?: Record<string, unknown>) {
32
+ return {
33
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
34
+ pluginConfig: overrides ?? {},
35
+ } as any;
36
+ }
37
+
38
+ function createCtx(overrides?: Partial<IntentContext>): IntentContext {
39
+ return {
40
+ commentBody: "hello world",
41
+ issueTitle: "Test Issue",
42
+ issueStatus: "In Progress",
43
+ isPlanning: false,
44
+ agentNames: ["mal", "kaylee", "inara"],
45
+ hasProject: false,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Reset
52
+ // ---------------------------------------------------------------------------
53
+
54
+ afterEach(() => {
55
+ vi.clearAllMocks();
56
+ runAgentMock.mockResolvedValue({
57
+ success: true,
58
+ output: '{"intent":"general","reasoning":"test"}',
59
+ });
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // classifyIntent — LLM path
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe("classifyIntent", () => {
67
+ it("parses valid intent from LLM response", async () => {
68
+ runAgentMock.mockResolvedValueOnce({
69
+ success: true,
70
+ output: '{"intent":"request_work","reasoning":"user wants something built"}',
71
+ });
72
+
73
+ const result = await classifyIntent(createApi(), createCtx({ commentBody: "fix the bug" }));
74
+
75
+ expect(result.intent).toBe("request_work");
76
+ expect(result.reasoning).toBe("user wants something built");
77
+ expect(result.fromFallback).toBe(false);
78
+ });
79
+
80
+ it("parses intent with extra text around JSON", async () => {
81
+ runAgentMock.mockResolvedValueOnce({
82
+ success: true,
83
+ output: 'Here is my analysis:\n{"intent":"question","reasoning":"user asking for help"}\nDone.',
84
+ });
85
+
86
+ const result = await classifyIntent(createApi(), createCtx());
87
+ expect(result.intent).toBe("question");
88
+ expect(result.fromFallback).toBe(false);
89
+ });
90
+
91
+ it("populates agentId for ask_agent with valid name", async () => {
92
+ runAgentMock.mockResolvedValueOnce({
93
+ success: true,
94
+ output: '{"intent":"ask_agent","agentId":"Kaylee","reasoning":"user addressing kaylee"}',
95
+ });
96
+
97
+ const result = await classifyIntent(createApi(), createCtx({
98
+ commentBody: "hey kaylee look at this",
99
+ }));
100
+
101
+ expect(result.intent).toBe("ask_agent");
102
+ expect(result.agentId).toBe("kaylee");
103
+ });
104
+
105
+ it("clears agentId for ask_agent with hallucinated name", async () => {
106
+ runAgentMock.mockResolvedValueOnce({
107
+ success: true,
108
+ output: '{"intent":"ask_agent","agentId":"wash","reasoning":"user wants wash"}',
109
+ });
110
+
111
+ const result = await classifyIntent(createApi(), createCtx());
112
+
113
+ expect(result.intent).toBe("ask_agent");
114
+ expect(result.agentId).toBeUndefined();
115
+ });
116
+
117
+ it("falls back to regex when LLM returns invalid JSON", async () => {
118
+ runAgentMock.mockResolvedValueOnce({
119
+ success: true,
120
+ output: "I cannot determine the intent",
121
+ });
122
+
123
+ const result = await classifyIntent(createApi(), createCtx());
124
+ expect(result.fromFallback).toBe(true);
125
+ });
126
+
127
+ it("falls back to regex when LLM returns invalid intent enum", async () => {
128
+ runAgentMock.mockResolvedValueOnce({
129
+ success: true,
130
+ output: '{"intent":"destroy_everything","reasoning":"chaos"}',
131
+ });
132
+
133
+ const result = await classifyIntent(createApi(), createCtx());
134
+ expect(result.fromFallback).toBe(true);
135
+ });
136
+
137
+ it("falls back to regex when LLM call fails", async () => {
138
+ runAgentMock.mockResolvedValueOnce({
139
+ success: false,
140
+ output: "Agent error",
141
+ });
142
+
143
+ const result = await classifyIntent(createApi(), createCtx());
144
+ expect(result.fromFallback).toBe(true);
145
+ });
146
+
147
+ it("falls back to regex when LLM call throws", async () => {
148
+ runAgentMock.mockRejectedValueOnce(new Error("timeout"));
149
+
150
+ const result = await classifyIntent(createApi(), createCtx());
151
+ expect(result.fromFallback).toBe(true);
152
+ });
153
+
154
+ it("uses 12s timeout for classification", async () => {
155
+ await classifyIntent(createApi(), createCtx());
156
+
157
+ expect(runAgentMock).toHaveBeenCalledWith(
158
+ expect.objectContaining({
159
+ timeoutMs: 12_000,
160
+ }),
161
+ );
162
+ });
163
+
164
+ it("includes context in the prompt", async () => {
165
+ await classifyIntent(createApi(), createCtx({
166
+ commentBody: "what can I do?",
167
+ issueTitle: "Auth Feature",
168
+ isPlanning: true,
169
+ }));
170
+
171
+ const call = runAgentMock.mock.calls[0][0];
172
+ expect(call.message).toContain("Auth Feature");
173
+ expect(call.message).toContain("Planning mode: true");
174
+ expect(call.message).toContain("what can I do?");
175
+ });
176
+
177
+ it("uses classifierAgentId from pluginConfig when configured", async () => {
178
+ await classifyIntent(
179
+ createApi({ classifierAgentId: "haiku-classifier" }),
180
+ createCtx(),
181
+ { classifierAgentId: "haiku-classifier" },
182
+ );
183
+
184
+ expect(runAgentMock).toHaveBeenCalledWith(
185
+ expect.objectContaining({
186
+ agentId: "haiku-classifier",
187
+ }),
188
+ );
189
+ });
190
+
191
+ it("truncates long comments to 500 chars", async () => {
192
+ const longComment = "x".repeat(1000);
193
+ await classifyIntent(createApi(), createCtx({ commentBody: longComment }));
194
+
195
+ const call = runAgentMock.mock.calls[0][0];
196
+ expect(call.message).not.toContain("x".repeat(501));
197
+ });
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // regexFallback
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe("regexFallback", () => {
205
+ describe("planning mode active", () => {
206
+ it("detects finalize intent", () => {
207
+ const result = regexFallback(createCtx({
208
+ isPlanning: true,
209
+ commentBody: "finalize the plan",
210
+ }));
211
+ expect(result.intent).toBe("plan_finalize");
212
+ expect(result.fromFallback).toBe(true);
213
+ });
214
+
215
+ it("detects approve plan intent", () => {
216
+ const result = regexFallback(createCtx({
217
+ isPlanning: true,
218
+ commentBody: "approve plan",
219
+ }));
220
+ expect(result.intent).toBe("plan_finalize");
221
+ });
222
+
223
+ it("detects abandon intent", () => {
224
+ const result = regexFallback(createCtx({
225
+ isPlanning: true,
226
+ commentBody: "abandon planning",
227
+ }));
228
+ expect(result.intent).toBe("plan_abandon");
229
+ expect(result.fromFallback).toBe(true);
230
+ });
231
+
232
+ it("detects cancel planning", () => {
233
+ const result = regexFallback(createCtx({
234
+ isPlanning: true,
235
+ commentBody: "cancel planning",
236
+ }));
237
+ expect(result.intent).toBe("plan_abandon");
238
+ });
239
+
240
+ it("defaults to plan_continue for unmatched text", () => {
241
+ const result = regexFallback(createCtx({
242
+ isPlanning: true,
243
+ commentBody: "add a search feature please",
244
+ }));
245
+ expect(result.intent).toBe("plan_continue");
246
+ expect(result.fromFallback).toBe(true);
247
+ });
248
+ });
249
+
250
+ describe("not planning", () => {
251
+ it("detects plan_start when issue has project", () => {
252
+ const result = regexFallback(createCtx({
253
+ hasProject: true,
254
+ commentBody: "plan this project",
255
+ }));
256
+ expect(result.intent).toBe("plan_start");
257
+ expect(result.fromFallback).toBe(true);
258
+ });
259
+
260
+ it("does NOT detect plan_start without project", () => {
261
+ const result = regexFallback(createCtx({
262
+ hasProject: false,
263
+ commentBody: "plan this project",
264
+ }));
265
+ expect(result.intent).toBe("general");
266
+ });
267
+
268
+ it("detects agent name in comment", () => {
269
+ const result = regexFallback(createCtx({
270
+ commentBody: "hey kaylee check this out",
271
+ }));
272
+ expect(result.intent).toBe("ask_agent");
273
+ expect(result.agentId).toBe("kaylee");
274
+ });
275
+
276
+ it("returns general for no pattern match", () => {
277
+ const result = regexFallback(createCtx({
278
+ commentBody: "thanks for the update",
279
+ agentNames: [],
280
+ }));
281
+ expect(result.intent).toBe("general");
282
+ expect(result.fromFallback).toBe(true);
283
+ });
284
+ });
285
+ });
@@ -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
+ }
@@ -223,4 +223,73 @@ describe("loadPrompts", () => {
223
223
  expect(first).not.toBe(second);
224
224
  expect(first).toEqual(second);
225
225
  });
226
+
227
+ it("merges global overlay with defaults (section-level shallow merge)", () => {
228
+ // Use promptsPath in pluginConfig to load custom YAML from a temp file
229
+ const { writeFileSync, mkdtempSync } = require("node:fs");
230
+ const { join } = require("node:path");
231
+ const { tmpdir } = require("node:os");
232
+ const dir = mkdtempSync(join(tmpdir(), "claw-prompt-"));
233
+ const yamlPath = join(dir, "prompts.yaml");
234
+ writeFileSync(yamlPath, "worker:\n system: custom worker system\n");
235
+
236
+ clearPromptCache();
237
+ const prompts = loadPrompts({ promptsPath: yamlPath });
238
+ // Worker system should be overridden
239
+ expect(prompts.worker.system).toBe("custom worker system");
240
+ // Worker task should still be default
241
+ expect(prompts.worker.task).toContain("{{identifier}}");
242
+ // Audit should be completely default
243
+ expect(prompts.audit.system).toContain("auditor");
244
+ clearPromptCache();
245
+ });
246
+
247
+ it("merges per-project overlay on top of global (three layers)", () => {
248
+ const { writeFileSync, mkdtempSync, mkdirSync } = require("node:fs");
249
+ const { join } = require("node:path");
250
+ const { tmpdir } = require("node:os");
251
+
252
+ // Layer 2: global override via promptsPath
253
+ const globalDir = mkdtempSync(join(tmpdir(), "claw-prompt-global-"));
254
+ const globalYaml = join(globalDir, "prompts.yaml");
255
+ writeFileSync(globalYaml, "worker:\n system: global system\n");
256
+
257
+ // Layer 3: per-project override in worktree/.claw/prompts.yaml
258
+ const worktreeDir = mkdtempSync(join(tmpdir(), "claw-prompt-wt-"));
259
+ mkdirSync(join(worktreeDir, ".claw"), { recursive: true });
260
+ writeFileSync(join(worktreeDir, ".claw", "prompts.yaml"), "audit:\n system: project auditor\n");
261
+
262
+ clearPromptCache();
263
+ const prompts = loadPrompts({ promptsPath: globalYaml }, worktreeDir);
264
+ // Layer 2: global override
265
+ expect(prompts.worker.system).toBe("global system");
266
+ // Layer 3: per-project override
267
+ expect(prompts.audit.system).toBe("project auditor");
268
+ // Layer 1: defaults retained where not overridden
269
+ expect(prompts.rework.addendum).toContain("PREVIOUS AUDIT FAILED");
270
+ clearPromptCache();
271
+ });
272
+
273
+ it("clearPromptCache clears both global and project caches", () => {
274
+ const { writeFileSync, mkdtempSync, mkdirSync } = require("node:fs");
275
+ const { join } = require("node:path");
276
+ const { tmpdir } = require("node:os");
277
+
278
+ // Per-project YAML only (no global — uses defaults for global)
279
+ const worktreeDir = mkdtempSync(join(tmpdir(), "claw-prompt-cache-"));
280
+ mkdirSync(join(worktreeDir, ".claw"), { recursive: true });
281
+ writeFileSync(join(worktreeDir, ".claw", "prompts.yaml"), "worker:\n system: cached project\n");
282
+
283
+ clearPromptCache();
284
+ const first = loadPrompts(undefined, worktreeDir);
285
+ expect(first.worker.system).toBe("cached project");
286
+ // Same ref from cache
287
+ expect(loadPrompts(undefined, worktreeDir)).toBe(first);
288
+ // Clear both caches
289
+ clearPromptCache();
290
+ const second = loadPrompts(undefined, worktreeDir);
291
+ expect(second).not.toBe(first);
292
+ expect(second.worker.system).toBe("cached project");
293
+ clearPromptCache();
294
+ });
226
295
  });