@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.
@@ -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
  });
@@ -60,15 +60,15 @@ interface PromptTemplates {
60
60
 
61
61
  const DEFAULT_PROMPTS: PromptTemplates = {
62
62
  worker: {
63
- system: "You are implementing a Linear issue. Post an implementation summary as a Linear comment when done. DO NOT mark the issue as Done.",
64
- task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}",
63
+ system: "You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary. Do NOT attempt to update, close, comment on, or modify the Linear issue. Do NOT mark the issue as Done.",
64
+ task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nImplement the solution, run tests, commit your work, and return a text summary.",
65
65
  },
66
66
  audit: {
67
67
  system: "You are an independent auditor. The Linear issue body is the SOURCE OF TRUTH. Worker comments are secondary evidence.",
68
68
  task: 'Audit issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nReturn JSON verdict: {"pass": true/false, "criteria": [...], "gaps": [...], "testResults": "..."}',
69
69
  },
70
70
  rework: {
71
- addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues.",
71
+ addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues. Preserve correct code from prior attempts.",
72
72
  },
73
73
  };
74
74
 
@@ -88,12 +88,11 @@ function mergePromptLayers(base: PromptTemplates, overlay: Partial<PromptTemplat
88
88
  }
89
89
 
90
90
  /**
91
- * Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
92
- * Cached after first load.
91
+ * Load and parse the raw prompts YAML file (global promptsPath or sidecar).
92
+ * Returns the parsed object, or null if no file found.
93
+ * Shared by both pipeline and planner prompt loaders.
93
94
  */
94
- function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
95
- if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
96
-
95
+ export function loadRawPromptYaml(pluginConfig?: Record<string, unknown>): Record<string, any> | null {
97
96
  try {
98
97
  const customPath = pluginConfig?.promptsPath as string | undefined;
99
98
  let raw: string;
@@ -108,9 +107,23 @@ function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTempla
108
107
  raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
109
108
  }
110
109
 
111
- const parsed = parseYaml(raw) as Partial<PromptTemplates>;
112
- _cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed);
110
+ return parseYaml(raw) as Record<string, any>;
113
111
  } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
118
+ * Cached after first load.
119
+ */
120
+ function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
121
+ if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
122
+
123
+ const parsed = loadRawPromptYaml(pluginConfig);
124
+ if (parsed) {
125
+ _cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed as Partial<PromptTemplates>);
126
+ } else {
114
127
  _cachedGlobalPrompts = DEFAULT_PROMPTS;
115
128
  }
116
129
 
@@ -188,7 +201,7 @@ export function buildWorkerTask(
188
201
  worktreePath,
189
202
  tier: "",
190
203
  attempt: String(opts?.attempt ?? 0),
191
- gaps: opts?.gaps?.join("\n- ") ?? "",
204
+ gaps: opts?.gaps?.length ? "- " + opts.gaps.join("\n- ") : "",
192
205
  };
193
206
 
194
207
  let task = renderTemplate(prompts.worker.task, vars);
@@ -332,7 +345,12 @@ export async function triggerAudit(
332
345
  };
333
346
 
334
347
  // Build audit prompt from YAML templates
335
- const auditPrompt = buildAuditTask(issue, dispatch.worktreePath, pluginConfig);
348
+ // For multi-repo dispatches, render worktreePath as a list of repo→path mappings
349
+ const effectiveAuditPath = dispatch.worktrees
350
+ ? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
351
+ : dispatch.worktreePath;
352
+
353
+ const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig);
336
354
 
337
355
  // Set Linear label
338
356
  await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
@@ -444,6 +462,11 @@ export async function processVerdict(
444
462
  const verdict = parseVerdict(auditOutput);
445
463
  if (!verdict) {
446
464
  api.logger.warn(`${TAG} could not parse audit verdict from output (${auditOutput.length} chars)`);
465
+ // Post comment so user knows what happened
466
+ await linearApi.createComment(
467
+ dispatch.issueId,
468
+ `## Audit Inconclusive\n\nThe auditor's response couldn't be parsed as a verdict. **Retrying automatically** — this usually resolves itself.\n\n**If it keeps happening:** \`openclaw openclaw-linear prompts validate\`\n\n**Status:** Retrying audit now. No action needed.`,
469
+ ).catch((err) => api.logger.error(`${TAG} failed to post inconclusive comment: ${err}`));
447
470
  // Treat unparseable verdict as failure
448
471
  await handleAuditFail(hookCtx, dispatch, {
449
472
  pass: false,
@@ -527,7 +550,7 @@ async function handleAuditPass(
527
550
  const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
528
551
  await linearApi.createComment(
529
552
  dispatch.issueId,
530
- `## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Attempt ${dispatch.attempt + 1} audit passed. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
553
+ `## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`\n- Create a PR from the worktree branch if one wasn't opened automatically`,
531
554
  ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
532
555
 
533
556
  api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
@@ -603,7 +626,7 @@ async function handleAuditFail(
603
626
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
604
627
  await linearApi.createComment(
605
628
  dispatch.issueId,
606
- `## Audit Failed — Escalating\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Max rework attempts reached. Needs human review. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
629
+ `## Needs Your Help\n\nAll ${nextAttempt} attempts failed. The agent couldn't resolve these issues on its own.\n\n**What went wrong:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n---\n\n**What you can do:**\n1. **Clarify requirements** — update the issue body with more detail, then re-assign to try again\n2. **Fix it manually** — the agent's work is in the worktree: \`cd ${dispatch.worktreePath}\`\n3. **Force retry** — \`/dispatch retry ${dispatch.issueIdentifier}\`\n4. **View logs** — worker output: \`.claw/worker-*.md\`, audit verdicts: \`.claw/audit-*.json\``,
607
630
  ).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
608
631
 
609
632
  api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
@@ -647,7 +670,7 @@ async function handleAuditFail(
647
670
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
648
671
  await linearApi.createComment(
649
672
  dispatch.issueId,
650
- `## Audit FailedRework\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Reworking: addressing gaps above.*`,
673
+ `## Needs More Work\n\nThe audit found gaps. **Retrying now** the worker gets the feedback below as context.\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}** — ${maxAttempts + 1 - nextAttempt > 0 ? `${maxAttempts + 1 - nextAttempt} more ${maxAttempts + 1 - nextAttempt === 1 ? "retry" : "retries"} if this fails too` : "this is the last attempt"}.\n\n**What needs fixing:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n**Status:** Worker is restarting with the gaps above as context. No action needed unless all retries fail.`,
651
674
  ).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
652
675
 
653
676
  api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
@@ -716,7 +739,12 @@ export async function spawnWorker(
716
739
  };
717
740
 
718
741
  // Build worker prompt from YAML templates
719
- const workerPrompt = buildWorkerTask(issue, dispatch.worktreePath, {
742
+ // For multi-repo dispatches, render worktreePath as a list of repo→path mappings
743
+ const effectiveWorkerPath = dispatch.worktrees
744
+ ? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
745
+ : dispatch.worktreePath;
746
+
747
+ const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
720
748
  attempt: dispatch.attempt,
721
749
  gaps: opts?.gaps,
722
750
  pluginConfig,
@@ -798,8 +826,9 @@ export async function spawnWorker(
798
826
 
799
827
  await linearApi.createComment(
800
828
  dispatch.issueId,
801
- `## Watchdog Kill\n\nAgent killed by inactivity watchdog (no I/O for ${thresholdSec}s). ` +
802
- `Automatic retry also failed.\n\n---\n*Needs human review. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
829
+ `## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
830
+ `**What you can do:**\n1. **Try again** re-assign this issue or \`/dispatch retry ${dispatch.issueIdentifier}\`\n2. **Break it down** — if it keeps timing out, split into smaller issues\n3. **Increase timeout** — set \`inactivitySec\` higher in your agent profile\n\n` +
831
+ `**Logs:** \`${dispatch.worktreePath}/.claw/log.jsonl\` (look for \`"phase": "watchdog"\`)\n\n**Current status:** Stuck — waiting for you.`,
803
832
  ).catch(() => {});
804
833
 
805
834
  await hookCtx.notify("watchdog_kill", {