@calltelemetry/openclaw-linear 0.5.2 → 0.6.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 (37) hide show
  1. package/README.md +359 -195
  2. package/index.ts +10 -10
  3. package/openclaw.plugin.json +4 -1
  4. package/package.json +9 -2
  5. package/src/agent/agent.test.ts +127 -0
  6. package/src/{agent.ts → agent/agent.ts} +84 -7
  7. package/src/agent/watchdog.test.ts +266 -0
  8. package/src/agent/watchdog.ts +176 -0
  9. package/src/{cli.ts → infra/cli.ts} +32 -5
  10. package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
  11. package/src/infra/doctor.test.ts +399 -0
  12. package/src/infra/doctor.ts +781 -0
  13. package/src/infra/notify.test.ts +169 -0
  14. package/src/{notify.ts → infra/notify.ts} +6 -1
  15. package/src/pipeline/active-session.test.ts +154 -0
  16. package/src/pipeline/artifacts.test.ts +383 -0
  17. package/src/{artifacts.ts → pipeline/artifacts.ts} +9 -1
  18. package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
  19. package/src/pipeline/dispatch-state.test.ts +382 -0
  20. package/src/pipeline/pipeline.test.ts +226 -0
  21. package/src/{pipeline.ts → pipeline/pipeline.ts} +61 -7
  22. package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
  23. package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
  24. package/src/{webhook.ts → pipeline/webhook.ts} +8 -8
  25. package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
  26. package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
  27. package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
  28. package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
  29. package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
  30. package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
  31. package/src/client.ts +0 -94
  32. /package/src/{auth.ts → api/auth.ts} +0 -0
  33. /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
  34. /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
  35. /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
  36. /package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +0 -0
  37. /package/src/{tools.ts → tools/tools.ts} +0 -0
@@ -0,0 +1,382 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ readDispatchState,
7
+ registerDispatch,
8
+ transitionDispatch,
9
+ completeDispatch,
10
+ updateDispatchStatus,
11
+ getActiveDispatch,
12
+ listActiveDispatches,
13
+ listStaleDispatches,
14
+ listRecoverableDispatches,
15
+ registerSessionMapping,
16
+ lookupSessionMapping,
17
+ removeSessionMapping,
18
+ markEventProcessed,
19
+ pruneCompleted,
20
+ removeActiveDispatch,
21
+ TransitionError,
22
+ type ActiveDispatch,
23
+ } from "./dispatch-state.js";
24
+
25
+ function tmpStatePath(): string {
26
+ const dir = mkdtempSync(join(tmpdir(), "claw-ds-"));
27
+ return join(dir, "state.json");
28
+ }
29
+
30
+ function makeDispatch(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
31
+ return {
32
+ issueId: "uuid-1",
33
+ issueIdentifier: "API-100",
34
+ worktreePath: "/tmp/wt/API-100",
35
+ branch: "codex/API-100",
36
+ tier: "junior",
37
+ model: "test-model",
38
+ status: "dispatched",
39
+ dispatchedAt: new Date().toISOString(),
40
+ attempt: 0,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Read / Register
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("readDispatchState", () => {
50
+ it("returns empty state when file missing", async () => {
51
+ const p = tmpStatePath();
52
+ const state = await readDispatchState(p);
53
+ expect(state.dispatches.active).toEqual({});
54
+ expect(state.dispatches.completed).toEqual({});
55
+ expect(state.sessionMap).toEqual({});
56
+ expect(state.processedEvents).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe("registerDispatch", () => {
61
+ it("registers and reads back", async () => {
62
+ const p = tmpStatePath();
63
+ const d = makeDispatch();
64
+ await registerDispatch("API-100", d, p);
65
+ const state = await readDispatchState(p);
66
+ const active = getActiveDispatch(state, "API-100");
67
+ expect(active).not.toBeNull();
68
+ expect(active!.issueIdentifier).toBe("API-100");
69
+ expect(active!.attempt).toBe(0);
70
+ });
71
+
72
+ it("sets attempt=0 default", async () => {
73
+ const p = tmpStatePath();
74
+ const d = makeDispatch();
75
+ (d as any).attempt = undefined;
76
+ await registerDispatch("API-100", d, p);
77
+ const state = await readDispatchState(p);
78
+ expect(getActiveDispatch(state, "API-100")!.attempt).toBe(0);
79
+ });
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // CAS transitions
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe("transitionDispatch", () => {
87
+ it("dispatched → working", async () => {
88
+ const p = tmpStatePath();
89
+ await registerDispatch("API-100", makeDispatch(), p);
90
+ const updated = await transitionDispatch("API-100", "dispatched", "working", undefined, p);
91
+ expect(updated.status).toBe("working");
92
+ });
93
+
94
+ it("working → auditing", async () => {
95
+ const p = tmpStatePath();
96
+ await registerDispatch("API-100", makeDispatch({ status: "working" }), p);
97
+ const updated = await transitionDispatch("API-100", "working", "auditing", undefined, p);
98
+ expect(updated.status).toBe("auditing");
99
+ });
100
+
101
+ it("auditing → done", async () => {
102
+ const p = tmpStatePath();
103
+ await registerDispatch("API-100", makeDispatch({ status: "auditing" }), p);
104
+ const updated = await transitionDispatch("API-100", "auditing", "done", undefined, p);
105
+ expect(updated.status).toBe("done");
106
+ });
107
+
108
+ it("auditing → working (rework) with attempt update", async () => {
109
+ const p = tmpStatePath();
110
+ await registerDispatch("API-100", makeDispatch({ status: "auditing", attempt: 0 }), p);
111
+ const updated = await transitionDispatch("API-100", "auditing", "working", { attempt: 1 }, p);
112
+ expect(updated.status).toBe("working");
113
+ expect(updated.attempt).toBe(1);
114
+ });
115
+
116
+ it("throws TransitionError when fromStatus mismatch", async () => {
117
+ const p = tmpStatePath();
118
+ await registerDispatch("API-100", makeDispatch({ status: "working" }), p);
119
+ await expect(
120
+ transitionDispatch("API-100", "dispatched", "working", undefined, p),
121
+ ).rejects.toThrow(TransitionError);
122
+ });
123
+
124
+ it("throws on invalid transition", async () => {
125
+ const p = tmpStatePath();
126
+ await registerDispatch("API-100", makeDispatch({ status: "done" }), p);
127
+ await expect(
128
+ transitionDispatch("API-100", "done", "working", undefined, p),
129
+ ).rejects.toThrow();
130
+ });
131
+
132
+ it("throws when dispatch missing", async () => {
133
+ const p = tmpStatePath();
134
+ await expect(
135
+ transitionDispatch("MISSING-1", "dispatched", "working", undefined, p),
136
+ ).rejects.toThrow("No active dispatch");
137
+ });
138
+
139
+ it("applies stuckReason", async () => {
140
+ const p = tmpStatePath();
141
+ await registerDispatch("API-100", makeDispatch({ status: "working" }), p);
142
+ const updated = await transitionDispatch(
143
+ "API-100", "working", "stuck", { stuckReason: "watchdog_kill_2x" }, p,
144
+ );
145
+ expect(updated.status).toBe("stuck");
146
+ expect(updated.stuckReason).toBe("watchdog_kill_2x");
147
+ });
148
+
149
+ it("applies session key updates", async () => {
150
+ const p = tmpStatePath();
151
+ await registerDispatch("API-100", makeDispatch({ status: "dispatched" }), p);
152
+ const updated = await transitionDispatch(
153
+ "API-100", "dispatched", "working",
154
+ { workerSessionKey: "sess-w-1" }, p,
155
+ );
156
+ expect(updated.workerSessionKey).toBe("sess-w-1");
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Session mapping
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe("session mapping", () => {
165
+ it("register + lookup round-trip", async () => {
166
+ const p = tmpStatePath();
167
+ await registerSessionMapping("sess-1", {
168
+ dispatchId: "API-100",
169
+ phase: "worker",
170
+ attempt: 0,
171
+ }, p);
172
+ const state = await readDispatchState(p);
173
+ const mapping = lookupSessionMapping(state, "sess-1");
174
+ expect(mapping).not.toBeNull();
175
+ expect(mapping!.dispatchId).toBe("API-100");
176
+ expect(mapping!.phase).toBe("worker");
177
+ });
178
+
179
+ it("lookup returns null for unknown key", async () => {
180
+ const p = tmpStatePath();
181
+ const state = await readDispatchState(p);
182
+ expect(lookupSessionMapping(state, "no-such")).toBeNull();
183
+ });
184
+
185
+ it("removeSessionMapping deletes", async () => {
186
+ const p = tmpStatePath();
187
+ await registerSessionMapping("sess-1", {
188
+ dispatchId: "API-100",
189
+ phase: "worker",
190
+ attempt: 0,
191
+ }, p);
192
+ await removeSessionMapping("sess-1", p);
193
+ const state = await readDispatchState(p);
194
+ expect(lookupSessionMapping(state, "sess-1")).toBeNull();
195
+ });
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Idempotency
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe("markEventProcessed", () => {
203
+ it("returns true (new) first call", async () => {
204
+ const p = tmpStatePath();
205
+ const isNew = await markEventProcessed("evt-1", p);
206
+ expect(isNew).toBe(true);
207
+ });
208
+
209
+ it("returns false (dupe) second call", async () => {
210
+ const p = tmpStatePath();
211
+ await markEventProcessed("evt-1", p);
212
+ const isDupe = await markEventProcessed("evt-1", p);
213
+ expect(isDupe).toBe(false);
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Complete dispatch
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("completeDispatch", () => {
222
+ it("moves active → completed", async () => {
223
+ const p = tmpStatePath();
224
+ await registerDispatch("API-100", makeDispatch(), p);
225
+ await completeDispatch("API-100", {
226
+ tier: "junior",
227
+ status: "done",
228
+ completedAt: new Date().toISOString(),
229
+ }, p);
230
+ const state = await readDispatchState(p);
231
+ expect(getActiveDispatch(state, "API-100")).toBeNull();
232
+ expect(state.dispatches.completed["API-100"]).toBeDefined();
233
+ expect(state.dispatches.completed["API-100"].status).toBe("done");
234
+ });
235
+
236
+ it("cleans up session mappings for the dispatch", async () => {
237
+ const p = tmpStatePath();
238
+ await registerDispatch("API-100", makeDispatch(), p);
239
+ await registerSessionMapping("sess-w", { dispatchId: "API-100", phase: "worker", attempt: 0 }, p);
240
+ await registerSessionMapping("sess-a", { dispatchId: "API-100", phase: "audit", attempt: 0 }, p);
241
+ await completeDispatch("API-100", {
242
+ tier: "junior",
243
+ status: "done",
244
+ completedAt: new Date().toISOString(),
245
+ }, p);
246
+ const state = await readDispatchState(p);
247
+ expect(lookupSessionMapping(state, "sess-w")).toBeNull();
248
+ expect(lookupSessionMapping(state, "sess-a")).toBeNull();
249
+ });
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // List helpers
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe("listStaleDispatches", () => {
257
+ it("returns old dispatches", async () => {
258
+ const p = tmpStatePath();
259
+ const oldDate = new Date(Date.now() - 3 * 60 * 60_000).toISOString(); // 3 hours ago
260
+ await registerDispatch("OLD-1", makeDispatch({
261
+ issueIdentifier: "OLD-1",
262
+ dispatchedAt: oldDate,
263
+ }), p);
264
+ const state = await readDispatchState(p);
265
+ const stale = listStaleDispatches(state, 2 * 60 * 60_000); // 2h threshold
266
+ expect(stale).toHaveLength(1);
267
+ expect(stale[0].issueIdentifier).toBe("OLD-1");
268
+ });
269
+
270
+ it("excludes recent dispatches", async () => {
271
+ const p = tmpStatePath();
272
+ await registerDispatch("NEW-1", makeDispatch({ issueIdentifier: "NEW-1" }), p);
273
+ const state = await readDispatchState(p);
274
+ const stale = listStaleDispatches(state, 2 * 60 * 60_000);
275
+ expect(stale).toHaveLength(0);
276
+ });
277
+ });
278
+
279
+ describe("listRecoverableDispatches", () => {
280
+ it("returns working + workerKey - no auditKey", async () => {
281
+ const p = tmpStatePath();
282
+ await registerDispatch("REC-1", makeDispatch({
283
+ issueIdentifier: "REC-1",
284
+ status: "working",
285
+ workerSessionKey: "sess-w",
286
+ }), p);
287
+ const state = await readDispatchState(p);
288
+ const rec = listRecoverableDispatches(state);
289
+ expect(rec).toHaveLength(1);
290
+ });
291
+
292
+ it("excludes dispatches with auditKey", async () => {
293
+ const p = tmpStatePath();
294
+ await registerDispatch("REC-2", makeDispatch({
295
+ issueIdentifier: "REC-2",
296
+ status: "working",
297
+ workerSessionKey: "sess-w",
298
+ auditSessionKey: "sess-a",
299
+ }), p);
300
+ const state = await readDispatchState(p);
301
+ expect(listRecoverableDispatches(state)).toHaveLength(0);
302
+ });
303
+ });
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Prune completed
307
+ // ---------------------------------------------------------------------------
308
+
309
+ describe("pruneCompleted", () => {
310
+ it("removes old entries", async () => {
311
+ const p = tmpStatePath();
312
+ await registerDispatch("DONE-1", makeDispatch({ issueIdentifier: "DONE-1" }), p);
313
+ await completeDispatch("DONE-1", {
314
+ tier: "junior",
315
+ status: "done",
316
+ completedAt: new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString(), // 2 days ago
317
+ }, p);
318
+ const pruned = await pruneCompleted(24 * 60 * 60_000, p); // 1 day threshold
319
+ expect(pruned).toBe(1);
320
+ });
321
+
322
+ it("preserves recent entries", async () => {
323
+ const p = tmpStatePath();
324
+ await registerDispatch("DONE-2", makeDispatch({ issueIdentifier: "DONE-2" }), p);
325
+ await completeDispatch("DONE-2", {
326
+ tier: "junior",
327
+ status: "done",
328
+ completedAt: new Date().toISOString(),
329
+ }, p);
330
+ const pruned = await pruneCompleted(24 * 60 * 60_000, p);
331
+ expect(pruned).toBe(0);
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Migration
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe("migration", () => {
340
+ it("adds missing sessionMap and processedEvents", async () => {
341
+ const p = tmpStatePath();
342
+ // Write v1 state with no v2 fields
343
+ writeFileSync(p, JSON.stringify({
344
+ dispatches: {
345
+ active: { "X-1": makeDispatch({ issueIdentifier: "X-1" }) },
346
+ completed: {},
347
+ },
348
+ }), "utf-8");
349
+ const state = await readDispatchState(p);
350
+ expect(state.sessionMap).toEqual({});
351
+ expect(state.processedEvents).toEqual([]);
352
+ });
353
+
354
+ it('migrates "running" → "working"', async () => {
355
+ const p = tmpStatePath();
356
+ const d = makeDispatch({ issueIdentifier: "X-2" });
357
+ (d as any).status = "running"; // old v1 status
358
+ writeFileSync(p, JSON.stringify({
359
+ dispatches: { active: { "X-2": d }, completed: {} },
360
+ sessionMap: {},
361
+ processedEvents: [],
362
+ }), "utf-8");
363
+ const state = await readDispatchState(p);
364
+ expect(getActiveDispatch(state, "X-2")!.status).toBe("working");
365
+ });
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Remove active dispatch
370
+ // ---------------------------------------------------------------------------
371
+
372
+ describe("removeActiveDispatch", () => {
373
+ it("removes dispatch and cleans session mappings", async () => {
374
+ const p = tmpStatePath();
375
+ await registerDispatch("RM-1", makeDispatch({ issueIdentifier: "RM-1" }), p);
376
+ await registerSessionMapping("sess-rm", { dispatchId: "RM-1", phase: "worker", attempt: 0 }, p);
377
+ await removeActiveDispatch("RM-1", p);
378
+ const state = await readDispatchState(p);
379
+ expect(getActiveDispatch(state, "RM-1")).toBeNull();
380
+ expect(lookupSessionMapping(state, "sess-rm")).toBeNull();
381
+ });
382
+ });
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+
3
+ // Mock all heavy dependencies to isolate pure functions
4
+ vi.mock("../agent/agent.js", () => ({ runAgent: vi.fn() }));
5
+ vi.mock("./dispatch-state.js", () => ({
6
+ transitionDispatch: vi.fn(),
7
+ registerSessionMapping: vi.fn(),
8
+ markEventProcessed: vi.fn(),
9
+ completeDispatch: vi.fn(),
10
+ readDispatchState: vi.fn(),
11
+ getActiveDispatch: vi.fn(),
12
+ TransitionError: class TransitionError extends Error {},
13
+ }));
14
+ vi.mock("./active-session.js", () => ({
15
+ setActiveSession: vi.fn(),
16
+ clearActiveSession: vi.fn(),
17
+ }));
18
+ vi.mock("../infra/notify.js", () => ({}));
19
+ vi.mock("./artifacts.js", () => ({
20
+ saveWorkerOutput: vi.fn(),
21
+ saveAuditVerdict: vi.fn(),
22
+ appendLog: vi.fn(),
23
+ updateManifest: vi.fn(),
24
+ writeSummary: vi.fn(),
25
+ buildSummaryFromArtifacts: vi.fn(),
26
+ writeDispatchMemory: vi.fn(),
27
+ resolveOrchestratorWorkspace: vi.fn(),
28
+ }));
29
+ vi.mock("../agent/watchdog.js", () => ({
30
+ resolveWatchdogConfig: vi.fn(() => ({
31
+ inactivityMs: 120000,
32
+ maxTotalMs: 7200000,
33
+ toolTimeoutMs: 600000,
34
+ })),
35
+ }));
36
+
37
+ import {
38
+ parseVerdict,
39
+ buildWorkerTask,
40
+ buildAuditTask,
41
+ loadPrompts,
42
+ clearPromptCache,
43
+ type IssueContext,
44
+ } from "./pipeline.js";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // parseVerdict
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("parseVerdict", () => {
51
+ it("parses clean JSON pass=true", () => {
52
+ const output = '{"pass": true, "criteria": ["tests"], "gaps": [], "testResults": "ok"}';
53
+ const v = parseVerdict(output)!;
54
+ expect(v.pass).toBe(true);
55
+ expect(v.criteria).toEqual(["tests"]);
56
+ expect(v.gaps).toEqual([]);
57
+ expect(v.testResults).toBe("ok");
58
+ });
59
+
60
+ it("parses clean JSON pass=false", () => {
61
+ const output = '{"pass": false, "criteria": [], "gaps": ["missing tests"], "testResults": "none"}';
62
+ const v = parseVerdict(output)!;
63
+ expect(v.pass).toBe(false);
64
+ expect(v.gaps).toEqual(["missing tests"]);
65
+ });
66
+
67
+ it("extracts JSON embedded in prose", () => {
68
+ const output = `Here is my verdict:
69
+
70
+ \`\`\`json
71
+ {"pass": true, "criteria": ["implemented"], "gaps": [], "testResults": "pass"}
72
+ \`\`\`
73
+
74
+ That's my assessment.`;
75
+ const v = parseVerdict(output)!;
76
+ expect(v.pass).toBe(true);
77
+ });
78
+
79
+ it("takes last JSON when multiple present", () => {
80
+ const output = `
81
+ {"pass": true, "criteria": [], "gaps": [], "testResults": ""}
82
+ After review:
83
+ {"pass": false, "criteria": ["a"], "gaps": ["b"], "testResults": "fail"}
84
+ `;
85
+ const v = parseVerdict(output)!;
86
+ expect(v.pass).toBe(false);
87
+ expect(v.gaps).toEqual(["b"]);
88
+ });
89
+
90
+ it("returns null for no JSON", () => {
91
+ expect(parseVerdict("no json here")).toBeNull();
92
+ });
93
+
94
+ it("returns null for malformed JSON", () => {
95
+ expect(parseVerdict('{"pass": tru')).toBeNull();
96
+ });
97
+
98
+ it("returns null for empty string", () => {
99
+ expect(parseVerdict("")).toBeNull();
100
+ });
101
+
102
+ it("coerces missing fields to defaults", () => {
103
+ const output = '{"pass": true}';
104
+ const v = parseVerdict(output)!;
105
+ expect(v.pass).toBe(true);
106
+ expect(v.criteria).toEqual([]);
107
+ expect(v.gaps).toEqual([]);
108
+ expect(v.testResults).toBe("");
109
+ });
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // buildWorkerTask
114
+ // ---------------------------------------------------------------------------
115
+
116
+ describe("buildWorkerTask", () => {
117
+ const issue: IssueContext = {
118
+ id: "id-1",
119
+ identifier: "API-42",
120
+ title: "Fix auth",
121
+ description: "The login endpoint fails.",
122
+ };
123
+
124
+ beforeEach(() => {
125
+ clearPromptCache();
126
+ });
127
+
128
+ it("substitutes template variables", () => {
129
+ const { system, task } = buildWorkerTask(issue, "/wt/API-42");
130
+ expect(task).toContain("API-42");
131
+ expect(task).toContain("Fix auth");
132
+ expect(task).toContain("The login endpoint fails.");
133
+ expect(task).toContain("/wt/API-42");
134
+ expect(system.length).toBeGreaterThan(0);
135
+ });
136
+
137
+ it('uses "(no description)" for null description', () => {
138
+ const noDesc = { ...issue, description: null };
139
+ const { task } = buildWorkerTask(noDesc, "/wt/API-42");
140
+ expect(task).toContain("(no description)");
141
+ });
142
+
143
+ it("appends rework addendum when attempt>0 and gaps present", () => {
144
+ const { task } = buildWorkerTask(issue, "/wt/API-42", {
145
+ attempt: 1,
146
+ gaps: ["missing validation", "no error handling"],
147
+ });
148
+ expect(task).toContain("PREVIOUS AUDIT FAILED");
149
+ expect(task).toContain("missing validation");
150
+ expect(task).toContain("no error handling");
151
+ });
152
+
153
+ it("no addendum when attempt=0", () => {
154
+ const { task } = buildWorkerTask(issue, "/wt/API-42", { attempt: 0, gaps: ["gap"] });
155
+ expect(task).not.toContain("PREVIOUS AUDIT FAILED");
156
+ });
157
+
158
+ it("no addendum when gaps empty", () => {
159
+ const { task } = buildWorkerTask(issue, "/wt/API-42", { attempt: 1, gaps: [] });
160
+ expect(task).not.toContain("PREVIOUS AUDIT FAILED");
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // buildAuditTask
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe("buildAuditTask", () => {
169
+ const issue: IssueContext = {
170
+ id: "id-2",
171
+ identifier: "API-99",
172
+ title: "Add caching",
173
+ description: "Cache API responses.",
174
+ };
175
+
176
+ beforeEach(() => {
177
+ clearPromptCache();
178
+ });
179
+
180
+ it("substitutes template variables", () => {
181
+ const { system, task } = buildAuditTask(issue, "/wt/API-99");
182
+ expect(task).toContain("API-99");
183
+ expect(task).toContain("Add caching");
184
+ expect(task).toContain("Cache API responses.");
185
+ expect(task).toContain("/wt/API-99");
186
+ expect(system).toContain("auditor");
187
+ });
188
+
189
+ it('uses "(no description)" for null description', () => {
190
+ const noDesc = { ...issue, description: null };
191
+ const { task } = buildAuditTask(noDesc, "/wt/API-99");
192
+ expect(task).toContain("(no description)");
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // loadPrompts / clearPromptCache
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe("loadPrompts", () => {
201
+ beforeEach(() => {
202
+ clearPromptCache();
203
+ });
204
+
205
+ it("returns defaults when no YAML available", () => {
206
+ const prompts = loadPrompts();
207
+ expect(prompts.worker.task).toContain("{{identifier}}");
208
+ expect(prompts.audit.task).toContain("{{identifier}}");
209
+ expect(prompts.rework.addendum).toContain("PREVIOUS AUDIT FAILED");
210
+ });
211
+
212
+ it("caches (same reference on 2nd call)", () => {
213
+ const first = loadPrompts();
214
+ const second = loadPrompts();
215
+ expect(first).toBe(second);
216
+ });
217
+
218
+ it("clearPromptCache forces re-read", () => {
219
+ const first = loadPrompts();
220
+ clearPromptCache();
221
+ const second = loadPrompts();
222
+ // Same content but different object ref after cache clear
223
+ expect(first).not.toBe(second);
224
+ expect(first).toEqual(second);
225
+ });
226
+ });