@calltelemetry/openclaw-linear 0.7.0 → 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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * E2E planning pipeline tests.
3
+ *
4
+ * Exercises the real planning lifecycle: initiatePlanningSession → handlePlannerTurn
5
+ * → runPlanAudit → onApproved → DAG dispatch cascade.
6
+ *
7
+ * Mocked: runAgent, LinearAgentApi. Real: planning-state.ts, planner.ts,
8
+ * planner-tools.ts (auditPlan, buildPlanSnapshot), dag-dispatch.ts.
9
+ */
10
+ import { describe, it, expect, beforeEach, vi } from "vitest";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mocks — external boundaries only
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const { runAgentMock } = vi.hoisted(() => ({
17
+ runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
18
+ }));
19
+
20
+ vi.mock("../agent/agent.js", () => ({
21
+ runAgent: runAgentMock,
22
+ }));
23
+
24
+ vi.mock("../api/linear-api.js", () => ({}));
25
+ vi.mock("openclaw/plugin-sdk", () => ({}));
26
+ vi.mock("../infra/observability.js", () => ({
27
+ emitDiagnostic: vi.fn(),
28
+ }));
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Imports (AFTER mocks)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
35
+ import { readPlanningState, type PlanningSession } from "./planning-state.js";
36
+ import { writeProjectDispatch, readProjectDispatch, onProjectIssueCompleted, onProjectIssueStuck, type ProjectDispatchState } from "./dag-dispatch.js";
37
+ import { createMockLinearApi, tmpStatePath } from "../__test__/helpers.js";
38
+ import { makeProjectIssue } from "../__test__/fixtures/linear-responses.js";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function createCtx(configPath: string, overrides?: Record<string, unknown>) {
45
+ const linearApi = createMockLinearApi();
46
+ return {
47
+ ctx: {
48
+ api: {
49
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
50
+ pluginConfig: { planningStatePath: configPath, ...overrides },
51
+ } as any,
52
+ linearApi: linearApi as any,
53
+ pluginConfig: { planningStatePath: configPath, ...overrides },
54
+ },
55
+ linearApi,
56
+ };
57
+ }
58
+
59
+ function createSession(configPath: string, overrides?: Partial<PlanningSession>): PlanningSession {
60
+ return {
61
+ projectId: "proj-1",
62
+ projectName: "Test Project",
63
+ rootIssueId: "issue-1",
64
+ rootIdentifier: "PROJ-1",
65
+ teamId: "team-1",
66
+ status: "interviewing",
67
+ startedAt: new Date().toISOString(),
68
+ turnCount: 0,
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ /** Build a set of project issues that will pass auditPlan (description ≥ 50 chars, estimate, priority). */
74
+ function makePassingIssues() {
75
+ return [
76
+ makeProjectIssue("PROJ-2", {
77
+ title: "Implement search API",
78
+ description: "Build the search API endpoint with filtering and pagination support for the frontend.",
79
+ estimate: 3,
80
+ priority: 2,
81
+ labels: ["Epic"],
82
+ }),
83
+ makeProjectIssue("PROJ-3", {
84
+ title: "Build search results page",
85
+ description: "Create a search results page component that displays results from the search API endpoint.",
86
+ estimate: 2,
87
+ priority: 2,
88
+ parentIdentifier: "PROJ-2",
89
+ }),
90
+ makeProjectIssue("PROJ-4", {
91
+ title: "Add search autocomplete",
92
+ description: "Implement autocomplete suggestions in the search input using the search API typeahead endpoint.",
93
+ estimate: 1,
94
+ priority: 3,
95
+ parentIdentifier: "PROJ-2",
96
+ }),
97
+ ];
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Tests
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("E2E planning pipeline", () => {
105
+ let configPath: string;
106
+
107
+ beforeEach(() => {
108
+ vi.clearAllMocks();
109
+ configPath = tmpStatePath("claw-e2e-plan-");
110
+ runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
111
+ });
112
+
113
+ // =========================================================================
114
+ // Test 1: Full lifecycle — initiate → interview → approve
115
+ // =========================================================================
116
+ it("full lifecycle: initiate → interview turns → finalize → approved", async () => {
117
+ const { ctx, linearApi } = createCtx(configPath);
118
+
119
+ const rootIssue = { id: "issue-1", identifier: "PROJ-1", title: "Root Issue", team: { id: "team-1" } };
120
+
121
+ // Mock getProject and getTeamStates for initiation
122
+ linearApi.getProject.mockResolvedValue({
123
+ id: "proj-1",
124
+ name: "Test Project",
125
+ teams: { nodes: [{ id: "team-1", name: "Team" }] },
126
+ });
127
+ linearApi.getTeamStates.mockResolvedValue([
128
+ { id: "st-1", name: "Backlog", type: "backlog" },
129
+ ]);
130
+
131
+ // Step 1: Initiate planning session
132
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
133
+
134
+ // Verify welcome comment posted
135
+ expect(linearApi.createComment).toHaveBeenCalledWith(
136
+ "issue-1",
137
+ expect.stringContaining("planning mode"),
138
+ );
139
+
140
+ // Verify session registered
141
+ let state = await readPlanningState(configPath);
142
+ expect(state.sessions["proj-1"]).toBeDefined();
143
+ expect(state.sessions["proj-1"].status).toBe("interviewing");
144
+ expect(state.sessions["proj-1"].turnCount).toBe(0);
145
+
146
+ // Step 2: Interview turn 1
147
+ const session = createSession(configPath);
148
+
149
+ // Mock getProjectIssues and getIssueDetails for interview
150
+ linearApi.getProjectIssues.mockResolvedValue([]);
151
+ linearApi.getIssueDetails.mockResolvedValue({
152
+ id: "issue-1",
153
+ identifier: "PROJ-1",
154
+ title: "Root Issue",
155
+ comments: { nodes: [] },
156
+ project: { id: "proj-1" },
157
+ team: { id: "team-1" },
158
+ });
159
+
160
+ await handlePlannerTurn(ctx, session, {
161
+ issueId: "issue-1",
162
+ commentBody: "Build a search API and results page",
163
+ commentorName: "User",
164
+ });
165
+
166
+ // Verify runAgent called for interview
167
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
168
+
169
+ // Verify agent response posted as comment
170
+ expect(linearApi.createComment).toHaveBeenCalledWith("issue-1", "Mock planner response");
171
+
172
+ // Verify turnCount incremented
173
+ state = await readPlanningState(configPath);
174
+ expect(state.sessions["proj-1"].turnCount).toBe(1);
175
+
176
+ // Step 3: Interview turn 2
177
+ vi.clearAllMocks();
178
+ runAgentMock.mockResolvedValue({ success: true, output: "Great, plan updated." });
179
+
180
+ const session2 = { ...session, turnCount: 1 };
181
+ await handlePlannerTurn(ctx, session2, {
182
+ issueId: "issue-1",
183
+ commentBody: "Add autocomplete too",
184
+ commentorName: "User",
185
+ });
186
+
187
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
188
+ state = await readPlanningState(configPath);
189
+ expect(state.sessions["proj-1"].turnCount).toBe(2);
190
+
191
+ // Step 4: Finalize — with passing issues in the project
192
+ vi.clearAllMocks();
193
+ linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
194
+
195
+ const session3 = { ...session, turnCount: 2 };
196
+ const onApproved = vi.fn();
197
+
198
+ await handlePlannerTurn(ctx, session3, {
199
+ issueId: "issue-1",
200
+ commentBody: "finalize plan",
201
+ commentorName: "User",
202
+ }, { onApproved });
203
+
204
+ // Verify "Plan Approved" comment
205
+ expect(linearApi.createComment).toHaveBeenCalledWith(
206
+ "issue-1",
207
+ expect.stringContaining("Plan Approved"),
208
+ );
209
+
210
+ // Verify session ended as approved
211
+ state = await readPlanningState(configPath);
212
+ expect(state.sessions["proj-1"].status).toBe("approved");
213
+
214
+ // Verify onApproved callback fired
215
+ expect(onApproved).toHaveBeenCalledWith("proj-1");
216
+ });
217
+
218
+ // =========================================================================
219
+ // Test 2: Audit fail → re-plan → pass
220
+ // =========================================================================
221
+ it("audit fail → fix issues → re-finalize → approved", async () => {
222
+ const { ctx, linearApi } = createCtx(configPath);
223
+ const session = createSession(configPath);
224
+
225
+ // Register session so updatePlanningSession/endPlanningSession can find it
226
+ const { registerPlanningSession } = await import("./planning-state.js");
227
+ await registerPlanningSession("proj-1", session, configPath);
228
+
229
+ linearApi.getIssueDetails.mockResolvedValue({
230
+ id: "issue-1",
231
+ identifier: "PROJ-1",
232
+ title: "Root Issue",
233
+ comments: { nodes: [] },
234
+ });
235
+
236
+ // First finalize — with issues that fail audit (missing descriptions/estimates)
237
+ linearApi.getProjectIssues.mockResolvedValue([
238
+ makeProjectIssue("PROJ-2", {
239
+ title: "Bad issue",
240
+ description: "short", // <50 chars → audit fails
241
+ }),
242
+ ]);
243
+
244
+ await runPlanAudit(ctx, session);
245
+
246
+ // Verify "Plan Audit Failed" comment
247
+ expect(linearApi.createComment).toHaveBeenCalledWith(
248
+ "issue-1",
249
+ expect.stringContaining("Plan Audit Failed"),
250
+ );
251
+
252
+ // Session should still be interviewing (NOT approved)
253
+ let state = await readPlanningState(configPath);
254
+ expect(state.sessions["proj-1"].status).toBe("interviewing");
255
+
256
+ // Second finalize — with proper issues
257
+ vi.clearAllMocks();
258
+ linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
259
+
260
+ const onApproved = vi.fn();
261
+ await runPlanAudit(ctx, session, { onApproved });
262
+
263
+ // Now should be approved
264
+ expect(linearApi.createComment).toHaveBeenCalledWith(
265
+ "issue-1",
266
+ expect.stringContaining("Plan Approved"),
267
+ );
268
+
269
+ state = await readPlanningState(configPath);
270
+ expect(state.sessions["proj-1"].status).toBe("approved");
271
+ expect(onApproved).toHaveBeenCalledWith("proj-1");
272
+ });
273
+
274
+ // =========================================================================
275
+ // Test 3: Abandon
276
+ // =========================================================================
277
+ it("abandon: cancel planning ends session", async () => {
278
+ const { ctx, linearApi } = createCtx(configPath);
279
+ const session = createSession(configPath);
280
+
281
+ // Register session so endPlanningSession can find it
282
+ const { registerPlanningSession } = await import("./planning-state.js");
283
+ await registerPlanningSession("proj-1", session, configPath);
284
+
285
+ await handlePlannerTurn(ctx, session, {
286
+ issueId: "issue-1",
287
+ commentBody: "cancel planning",
288
+ commentorName: "User",
289
+ });
290
+
291
+ // Verify abandonment comment
292
+ expect(linearApi.createComment).toHaveBeenCalledWith(
293
+ "issue-1",
294
+ expect.stringContaining("Planning mode ended"),
295
+ );
296
+
297
+ // Session ended as abandoned
298
+ const state = await readPlanningState(configPath);
299
+ expect(state.sessions["proj-1"].status).toBe("abandoned");
300
+ });
301
+
302
+ // =========================================================================
303
+ // Test 4: onApproved fires
304
+ // =========================================================================
305
+ it("onApproved callback fires with projectId on approval", async () => {
306
+ const { ctx, linearApi } = createCtx(configPath);
307
+ const session = createSession(configPath);
308
+
309
+ // Register session so endPlanningSession can update it
310
+ const { registerPlanningSession } = await import("./planning-state.js");
311
+ await registerPlanningSession("proj-1", session, configPath);
312
+
313
+ linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
314
+
315
+ const onApproved = vi.fn();
316
+ await runPlanAudit(ctx, session, { onApproved });
317
+
318
+ expect(onApproved).toHaveBeenCalledTimes(1);
319
+ expect(onApproved).toHaveBeenCalledWith("proj-1");
320
+ });
321
+
322
+ // =========================================================================
323
+ // Test 5: DAG cascade — full chain
324
+ // =========================================================================
325
+ it("DAG cascade: complete issues in sequence, project completes", async () => {
326
+ const dagConfigPath = tmpStatePath("claw-e2e-dag-");
327
+ const notifyCalls: Array<[string, unknown]> = [];
328
+ const hookCtx = {
329
+ api: {
330
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
331
+ } as any,
332
+ linearApi: {} as any,
333
+ notify: vi.fn(async (kind: string, payload: unknown) => {
334
+ notifyCalls.push([kind, payload]);
335
+ }),
336
+ pluginConfig: {},
337
+ configPath: dagConfigPath,
338
+ };
339
+
340
+ // Set up 3-issue project: A → B → C
341
+ const projectDispatch: ProjectDispatchState = {
342
+ projectId: "proj-1",
343
+ projectName: "Test Project",
344
+ rootIdentifier: "PROJ-1",
345
+ status: "dispatching",
346
+ startedAt: new Date().toISOString(),
347
+ maxConcurrent: 3,
348
+ issues: {
349
+ "PROJ-A": {
350
+ identifier: "PROJ-A",
351
+ issueId: "id-a",
352
+ dependsOn: [],
353
+ unblocks: ["PROJ-B"],
354
+ dispatchStatus: "dispatched",
355
+ },
356
+ "PROJ-B": {
357
+ identifier: "PROJ-B",
358
+ issueId: "id-b",
359
+ dependsOn: ["PROJ-A"],
360
+ unblocks: ["PROJ-C"],
361
+ dispatchStatus: "pending",
362
+ },
363
+ "PROJ-C": {
364
+ identifier: "PROJ-C",
365
+ issueId: "id-c",
366
+ dependsOn: ["PROJ-B"],
367
+ unblocks: [],
368
+ dispatchStatus: "pending",
369
+ },
370
+ },
371
+ };
372
+ await writeProjectDispatch(projectDispatch, dagConfigPath);
373
+
374
+ // Complete A → B becomes ready
375
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-A");
376
+
377
+ let state = await readProjectDispatch("proj-1", dagConfigPath);
378
+ expect(state!.issues["PROJ-A"].dispatchStatus).toBe("done");
379
+ expect(state!.issues["PROJ-B"].dispatchStatus).toBe("dispatched");
380
+ expect(state!.issues["PROJ-C"].dispatchStatus).toBe("pending");
381
+ expect(state!.status).toBe("dispatching");
382
+
383
+ // Complete B → C becomes ready
384
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-B");
385
+
386
+ state = await readProjectDispatch("proj-1", dagConfigPath);
387
+ expect(state!.issues["PROJ-B"].dispatchStatus).toBe("done");
388
+ expect(state!.issues["PROJ-C"].dispatchStatus).toBe("dispatched");
389
+ expect(state!.status).toBe("dispatching");
390
+
391
+ // Complete C → project complete
392
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-C");
393
+
394
+ state = await readProjectDispatch("proj-1", dagConfigPath);
395
+ expect(state!.issues["PROJ-C"].dispatchStatus).toBe("done");
396
+ expect(state!.status).toBe("completed");
397
+
398
+ // Verify notifications
399
+ const progressNotifications = notifyCalls.filter(([k]) => k === "project_progress");
400
+ const completeNotifications = notifyCalls.filter(([k]) => k === "project_complete");
401
+ expect(progressNotifications.length).toBe(2); // after A, after B
402
+ expect(completeNotifications.length).toBe(1); // after C
403
+ });
404
+
405
+ // =========================================================================
406
+ // Test 6: DAG stuck
407
+ // =========================================================================
408
+ it("DAG stuck: stuck issue blocks dependent, project stuck", async () => {
409
+ const dagConfigPath = tmpStatePath("claw-e2e-dag-stuck-");
410
+ const hookCtx = {
411
+ api: {
412
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
413
+ } as any,
414
+ linearApi: {} as any,
415
+ notify: vi.fn().mockResolvedValue(undefined),
416
+ pluginConfig: {},
417
+ configPath: dagConfigPath,
418
+ };
419
+
420
+ // 2-issue project: A → B
421
+ const projectDispatch: ProjectDispatchState = {
422
+ projectId: "proj-1",
423
+ projectName: "Test Project",
424
+ rootIdentifier: "PROJ-1",
425
+ status: "dispatching",
426
+ startedAt: new Date().toISOString(),
427
+ maxConcurrent: 3,
428
+ issues: {
429
+ "PROJ-A": {
430
+ identifier: "PROJ-A",
431
+ issueId: "id-a",
432
+ dependsOn: [],
433
+ unblocks: ["PROJ-B"],
434
+ dispatchStatus: "dispatched",
435
+ },
436
+ "PROJ-B": {
437
+ identifier: "PROJ-B",
438
+ issueId: "id-b",
439
+ dependsOn: ["PROJ-A"],
440
+ unblocks: [],
441
+ dispatchStatus: "pending",
442
+ },
443
+ },
444
+ };
445
+ await writeProjectDispatch(projectDispatch, dagConfigPath);
446
+
447
+ // A gets stuck
448
+ await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-A");
449
+
450
+ const state = await readProjectDispatch("proj-1", dagConfigPath);
451
+ expect(state!.issues["PROJ-A"].dispatchStatus).toBe("stuck");
452
+ expect(state!.issues["PROJ-B"].dispatchStatus).toBe("pending"); // still blocked
453
+ expect(state!.status).toBe("stuck"); // project stuck — no progress possible
454
+ });
455
+ });
@@ -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
  });