@calltelemetry/openclaw-linear 0.6.1 → 0.7.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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -17
  3. package/index.ts +57 -22
  4. package/openclaw.plugin.json +37 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +47 -0
  7. package/src/api/linear-api.test.ts +494 -0
  8. package/src/api/linear-api.ts +193 -19
  9. package/src/gateway/dispatch-methods.ts +243 -0
  10. package/src/infra/cli.ts +284 -29
  11. package/src/infra/codex-worktree.ts +83 -0
  12. package/src/infra/commands.ts +156 -0
  13. package/src/infra/doctor.test.ts +4 -4
  14. package/src/infra/doctor.ts +7 -29
  15. package/src/infra/file-lock.test.ts +61 -0
  16. package/src/infra/file-lock.ts +49 -0
  17. package/src/infra/multi-repo.ts +85 -0
  18. package/src/infra/notify.test.ts +357 -108
  19. package/src/infra/notify.ts +222 -43
  20. package/src/infra/observability.ts +48 -0
  21. package/src/infra/resilience.test.ts +94 -0
  22. package/src/infra/resilience.ts +101 -0
  23. package/src/pipeline/artifacts.ts +38 -2
  24. package/src/pipeline/dag-dispatch.test.ts +553 -0
  25. package/src/pipeline/dag-dispatch.ts +390 -0
  26. package/src/pipeline/dispatch-service.ts +48 -1
  27. package/src/pipeline/dispatch-state.ts +2 -42
  28. package/src/pipeline/pipeline.ts +91 -17
  29. package/src/pipeline/planner.test.ts +334 -0
  30. package/src/pipeline/planner.ts +287 -0
  31. package/src/pipeline/planning-state.test.ts +236 -0
  32. package/src/pipeline/planning-state.ts +178 -0
  33. package/src/pipeline/tier-assess.test.ts +175 -0
  34. package/src/pipeline/webhook.ts +90 -17
  35. package/src/tools/dispatch-history-tool.ts +201 -0
  36. package/src/tools/orchestration-tools.test.ts +158 -0
  37. package/src/tools/planner-tools.test.ts +535 -0
  38. package/src/tools/planner-tools.ts +450 -0
@@ -34,6 +34,7 @@ import {
34
34
  getActiveDispatch,
35
35
  } from "./dispatch-state.js";
36
36
  import { type NotifyFn } from "../infra/notify.js";
37
+ import { onProjectIssueCompleted, onProjectIssueStuck } from "./dag-dispatch.js";
37
38
  import {
38
39
  saveWorkerOutput,
39
40
  saveAuditVerdict,
@@ -45,6 +46,7 @@ import {
45
46
  resolveOrchestratorWorkspace,
46
47
  } from "./artifacts.js";
47
48
  import { resolveWatchdogConfig } from "../agent/watchdog.js";
49
+ import { emitDiagnostic } from "../infra/observability.js";
48
50
 
49
51
  // ---------------------------------------------------------------------------
50
52
  // Prompt loading
@@ -70,13 +72,29 @@ const DEFAULT_PROMPTS: PromptTemplates = {
70
72
  },
71
73
  };
72
74
 
73
- let _cachedPrompts: PromptTemplates | null = null;
75
+ let _cachedGlobalPrompts: PromptTemplates | null = null;
76
+ const _projectPromptCache = new Map<string, PromptTemplates>();
74
77
 
75
- export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
76
- if (_cachedPrompts) return _cachedPrompts;
78
+ /**
79
+ * Merge two prompt layers. Overlay replaces individual fields per section
80
+ * (shallow section-level merge, not deep).
81
+ */
82
+ function mergePromptLayers(base: PromptTemplates, overlay: Partial<PromptTemplates>): PromptTemplates {
83
+ return {
84
+ worker: { ...base.worker, ...overlay.worker },
85
+ audit: { ...base.audit, ...overlay.audit },
86
+ rework: { ...base.rework, ...overlay.rework },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
92
+ * Cached after first load.
93
+ */
94
+ function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
95
+ if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
77
96
 
78
97
  try {
79
- // Try custom path first
80
98
  const customPath = pluginConfig?.promptsPath as string | undefined;
81
99
  let raw: string;
82
100
 
@@ -86,27 +104,52 @@ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTempl
86
104
  : customPath;
87
105
  raw = readFileSync(resolved, "utf-8");
88
106
  } else {
89
- // Load from plugin directory (sidecar file)
90
107
  const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
91
108
  raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
92
109
  }
93
110
 
94
111
  const parsed = parseYaml(raw) as Partial<PromptTemplates>;
95
- _cachedPrompts = {
96
- worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
97
- audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
98
- rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
99
- };
112
+ _cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed);
100
113
  } catch {
101
- _cachedPrompts = DEFAULT_PROMPTS;
114
+ _cachedGlobalPrompts = DEFAULT_PROMPTS;
102
115
  }
103
116
 
104
- return _cachedPrompts;
117
+ return _cachedGlobalPrompts;
118
+ }
119
+
120
+ /**
121
+ * Load prompts with three-layer merge:
122
+ * 1. Built-in defaults (hardcoded DEFAULT_PROMPTS)
123
+ * 2. Global override (promptsPath or sidecar prompts.yaml)
124
+ * 3. Per-project override ({worktreePath}/.claw/prompts.yaml) — optional
125
+ */
126
+ export function loadPrompts(pluginConfig?: Record<string, unknown>, worktreePath?: string): PromptTemplates {
127
+ const global = loadGlobalPrompts(pluginConfig);
128
+
129
+ if (!worktreePath) return global;
130
+
131
+ // Check per-project cache
132
+ const cached = _projectPromptCache.get(worktreePath);
133
+ if (cached) return cached;
134
+
135
+ // Try loading per-project prompts
136
+ try {
137
+ const projectPromptsPath = join(worktreePath, ".claw", "prompts.yaml");
138
+ const raw = readFileSync(projectPromptsPath, "utf-8");
139
+ const parsed = parseYaml(raw) as Partial<PromptTemplates>;
140
+ const merged = mergePromptLayers(global, parsed);
141
+ _projectPromptCache.set(worktreePath, merged);
142
+ return merged;
143
+ } catch {
144
+ // No per-project override — use global
145
+ return global;
146
+ }
105
147
  }
106
148
 
107
149
  /** Clear prompt cache (for testing or after config change) */
108
150
  export function clearPromptCache(): void {
109
- _cachedPrompts = null;
151
+ _cachedGlobalPrompts = null;
152
+ _projectPromptCache.clear();
110
153
  }
111
154
 
112
155
  function renderTemplate(template: string, vars: Record<string, string>): string {
@@ -137,7 +180,7 @@ export function buildWorkerTask(
137
180
  worktreePath: string,
138
181
  opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
139
182
  ): { system: string; task: string } {
140
- const prompts = loadPrompts(opts?.pluginConfig);
183
+ const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
141
184
  const vars: Record<string, string> = {
142
185
  identifier: issue.identifier,
143
186
  title: issue.title,
@@ -167,7 +210,7 @@ export function buildAuditTask(
167
210
  worktreePath: string,
168
211
  pluginConfig?: Record<string, unknown>,
169
212
  ): { system: string; task: string } {
170
- const prompts = loadPrompts(pluginConfig);
213
+ const prompts = loadPrompts(pluginConfig, worktreePath);
171
214
  const vars: Record<string, string> = {
172
215
  identifier: issue.identifier,
173
216
  title: issue.title,
@@ -274,6 +317,7 @@ export async function triggerAudit(
274
317
  }
275
318
 
276
319
  api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
320
+ emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "working", to: "auditing", attempt: dispatch.attempt });
277
321
 
278
322
  // Update .claw/ manifest
279
323
  try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
@@ -464,7 +508,14 @@ async function handleAuditPass(
464
508
  if (summary) {
465
509
  writeSummary(dispatch.worktreePath, summary);
466
510
  const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
467
- writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
511
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
512
+ title: dispatch.issueTitle ?? dispatch.issueIdentifier,
513
+ tier: dispatch.tier,
514
+ status: "done",
515
+ project: dispatch.project,
516
+ attempts: dispatch.attempt + 1,
517
+ model: dispatch.model,
518
+ });
468
519
  api.logger.info(`${TAG} .claw/ summary and memory written`);
469
520
  }
470
521
  } catch (err) {
@@ -480,6 +531,7 @@ async function handleAuditPass(
480
531
  ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
481
532
 
482
533
  api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
534
+ emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "done", attempt: dispatch.attempt });
483
535
 
484
536
  await notify("audit_pass", {
485
537
  identifier: dispatch.issueIdentifier,
@@ -489,6 +541,12 @@ async function handleAuditPass(
489
541
  verdict: { pass: true, gaps: [] },
490
542
  });
491
543
 
544
+ // DAG cascade: if this issue belongs to a project dispatch, check for newly unblocked issues
545
+ if (dispatch.project) {
546
+ void onProjectIssueCompleted(hookCtx, dispatch.project, dispatch.issueIdentifier)
547
+ .catch((err) => api.logger.error(`${TAG} DAG cascade error: ${err}`));
548
+ }
549
+
492
550
  clearActiveSession(dispatch.issueId);
493
551
  }
494
552
 
@@ -531,7 +589,14 @@ async function handleAuditFail(
531
589
  if (summary) {
532
590
  writeSummary(dispatch.worktreePath, summary);
533
591
  const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
534
- writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
592
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
593
+ title: dispatch.issueTitle ?? dispatch.issueIdentifier,
594
+ tier: dispatch.tier,
595
+ status: "stuck",
596
+ project: dispatch.project,
597
+ attempts: nextAttempt,
598
+ model: dispatch.model,
599
+ });
535
600
  }
536
601
  } catch {}
537
602
 
@@ -542,6 +607,7 @@ async function handleAuditFail(
542
607
  ).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
543
608
 
544
609
  api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
610
+ emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "stuck", attempt: nextAttempt });
545
611
 
546
612
  await notify("escalation", {
547
613
  identifier: dispatch.issueIdentifier,
@@ -552,6 +618,12 @@ async function handleAuditFail(
552
618
  verdict: { pass: false, gaps: verdict.gaps },
553
619
  });
554
620
 
621
+ // DAG cascade: mark this issue as stuck in the project dispatch
622
+ if (dispatch.project) {
623
+ void onProjectIssueStuck(hookCtx, dispatch.project, dispatch.issueIdentifier)
624
+ .catch((err) => api.logger.error(`${TAG} DAG stuck cascade error: ${err}`));
625
+ }
626
+
555
627
  return;
556
628
  }
557
629
 
@@ -579,6 +651,7 @@ async function handleAuditFail(
579
651
  ).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
580
652
 
581
653
  api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
654
+ emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "auditing", to: "working", attempt: nextAttempt });
582
655
 
583
656
  await notify("audit_fail", {
584
657
  identifier: dispatch.issueIdentifier,
@@ -698,6 +771,7 @@ export async function spawnWorker(
698
771
  const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
699
772
 
700
773
  api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
774
+ emitDiagnostic(api, { event: "watchdog_kill", identifier: dispatch.issueIdentifier, attempt: dispatch.attempt });
701
775
 
702
776
  try {
703
777
  appendLog(dispatch.worktreePath, {
@@ -0,0 +1,334 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks (vi.hoisted + vi.mock)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const { runAgentMock } = vi.hoisted(() => ({
8
+ runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
9
+ }));
10
+
11
+ vi.mock("../agent/agent.js", () => ({
12
+ runAgent: runAgentMock,
13
+ }));
14
+
15
+ vi.mock("../api/linear-api.js", () => ({}));
16
+
17
+ vi.mock("openclaw/plugin-sdk", () => ({}));
18
+
19
+ const mockLinearApi = {
20
+ getProject: vi.fn().mockResolvedValue({
21
+ id: "proj-1",
22
+ name: "Test Project",
23
+ teams: { nodes: [{ id: "team-1", name: "Team" }] },
24
+ }),
25
+ getProjectIssues: vi.fn().mockResolvedValue([]),
26
+ getTeamStates: vi.fn().mockResolvedValue([
27
+ { id: "st-1", name: "Backlog", type: "backlog" },
28
+ ]),
29
+ getTeamLabels: vi.fn().mockResolvedValue([]),
30
+ createComment: vi.fn().mockResolvedValue("comment-id"),
31
+ getIssueDetails: vi.fn().mockResolvedValue({
32
+ id: "issue-1",
33
+ identifier: "PROJ-1",
34
+ title: "Root",
35
+ comments: { nodes: [] },
36
+ project: { id: "proj-1" },
37
+ team: { id: "team-1" },
38
+ }),
39
+ };
40
+
41
+ vi.mock("./planning-state.js", () => ({
42
+ registerPlanningSession: vi.fn().mockResolvedValue(undefined),
43
+ updatePlanningSession: vi.fn().mockResolvedValue({
44
+ turnCount: 1,
45
+ projectId: "proj-1",
46
+ status: "interviewing",
47
+ }),
48
+ endPlanningSession: vi.fn().mockResolvedValue(undefined),
49
+ setPlanningCache: vi.fn(),
50
+ clearPlanningCache: vi.fn(),
51
+ }));
52
+
53
+ vi.mock("../tools/planner-tools.js", () => ({
54
+ setActivePlannerContext: vi.fn(),
55
+ clearActivePlannerContext: vi.fn(),
56
+ buildPlanSnapshot: vi.fn().mockReturnValue("_No issues created yet._"),
57
+ auditPlan: vi.fn().mockReturnValue({ pass: true, problems: [], warnings: [] }),
58
+ }));
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Imports (AFTER mocks)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
65
+ import {
66
+ registerPlanningSession,
67
+ updatePlanningSession,
68
+ endPlanningSession,
69
+ setPlanningCache,
70
+ } from "./planning-state.js";
71
+ import {
72
+ setActivePlannerContext,
73
+ clearActivePlannerContext,
74
+ auditPlan,
75
+ } from "../tools/planner-tools.js";
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function createApi() {
82
+ return {
83
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
84
+ pluginConfig: {},
85
+ } as any;
86
+ }
87
+
88
+ function createCtx(overrides?: Partial<{ api: any; linearApi: any; pluginConfig: any }>) {
89
+ return {
90
+ api: createApi(),
91
+ linearApi: mockLinearApi,
92
+ pluginConfig: {},
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ function createSession(overrides?: Record<string, unknown>) {
98
+ return {
99
+ projectId: "proj-1",
100
+ projectName: "Test Project",
101
+ rootIssueId: "issue-1",
102
+ rootIdentifier: "PROJ-1",
103
+ teamId: "team-1",
104
+ status: "interviewing" as const,
105
+ startedAt: new Date().toISOString(),
106
+ turnCount: 0,
107
+ ...overrides,
108
+ };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Reset
113
+ // ---------------------------------------------------------------------------
114
+
115
+ afterEach(() => {
116
+ vi.clearAllMocks();
117
+ runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
118
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // initiatePlanningSession
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("initiatePlanningSession", () => {
126
+ const rootIssue = {
127
+ id: "issue-1",
128
+ identifier: "PROJ-1",
129
+ title: "Root",
130
+ team: { id: "team-1" },
131
+ };
132
+
133
+ it("registers session in state with projectId and status interviewing", async () => {
134
+ const ctx = createCtx();
135
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
136
+
137
+ expect(registerPlanningSession).toHaveBeenCalledWith(
138
+ "proj-1",
139
+ expect.objectContaining({
140
+ projectId: "proj-1",
141
+ status: "interviewing",
142
+ }),
143
+ undefined,
144
+ );
145
+ });
146
+
147
+ it("sets planning cache with the session", async () => {
148
+ const ctx = createCtx();
149
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
150
+
151
+ expect(setPlanningCache).toHaveBeenCalledWith(
152
+ expect.objectContaining({
153
+ projectId: "proj-1",
154
+ projectName: "Test Project",
155
+ status: "interviewing",
156
+ }),
157
+ );
158
+ });
159
+
160
+ it("posts welcome comment containing the project name", async () => {
161
+ const ctx = createCtx();
162
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
163
+
164
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
165
+ "issue-1",
166
+ expect.stringContaining("Test Project"),
167
+ );
168
+ });
169
+
170
+ it("fetches project metadata and team states", async () => {
171
+ const ctx = createCtx();
172
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
173
+
174
+ expect(mockLinearApi.getProject).toHaveBeenCalledWith("proj-1");
175
+ expect(mockLinearApi.getTeamStates).toHaveBeenCalledWith("team-1");
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // handlePlannerTurn
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe("handlePlannerTurn", () => {
184
+ const input = {
185
+ issueId: "issue-1",
186
+ commentBody: "Let's add a search feature",
187
+ commentorName: "Tester",
188
+ };
189
+
190
+ it("increments turn count via updatePlanningSession", async () => {
191
+ const ctx = createCtx();
192
+ const session = createSession();
193
+ await handlePlannerTurn(ctx, session, input);
194
+
195
+ expect(updatePlanningSession).toHaveBeenCalledWith(
196
+ "proj-1",
197
+ { turnCount: 1 },
198
+ undefined,
199
+ );
200
+ });
201
+
202
+ it("builds plan snapshot from project issues", async () => {
203
+ const ctx = createCtx();
204
+ const session = createSession();
205
+ await handlePlannerTurn(ctx, session, input);
206
+
207
+ expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith("proj-1");
208
+ });
209
+
210
+ it("calls runAgent with system prompt", async () => {
211
+ const ctx = createCtx();
212
+ const session = createSession();
213
+ await handlePlannerTurn(ctx, session, input);
214
+
215
+ expect(runAgentMock).toHaveBeenCalledWith(
216
+ expect.objectContaining({
217
+ message: expect.stringContaining("planning"),
218
+ }),
219
+ );
220
+ });
221
+
222
+ it("posts agent response as comment", async () => {
223
+ const ctx = createCtx();
224
+ const session = createSession();
225
+ await handlePlannerTurn(ctx, session, input);
226
+
227
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
228
+ "issue-1",
229
+ "Mock planner response",
230
+ );
231
+ });
232
+
233
+ it("detects finalize plan intent and triggers audit instead of regular turn", async () => {
234
+ const ctx = createCtx();
235
+ const session = createSession();
236
+
237
+ await handlePlannerTurn(ctx, session, {
238
+ issueId: "issue-1",
239
+ commentBody: "finalize plan",
240
+ commentorName: "Tester",
241
+ });
242
+
243
+ // Audit path: auditPlan is called, runAgent is NOT called
244
+ expect(auditPlan).toHaveBeenCalled();
245
+ expect(runAgentMock).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("detects abandon intent and ends session as abandoned", async () => {
249
+ const ctx = createCtx();
250
+ const session = createSession();
251
+
252
+ await handlePlannerTurn(ctx, session, {
253
+ issueId: "issue-1",
254
+ commentBody: "abandon",
255
+ commentorName: "Tester",
256
+ });
257
+
258
+ expect(endPlanningSession).toHaveBeenCalledWith(
259
+ "proj-1",
260
+ "abandoned",
261
+ undefined,
262
+ );
263
+ // Should NOT run the agent
264
+ expect(runAgentMock).not.toHaveBeenCalled();
265
+ });
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // runPlanAudit
270
+ // ---------------------------------------------------------------------------
271
+
272
+ describe("runPlanAudit", () => {
273
+ it("posts success comment on passing audit", async () => {
274
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
275
+ const ctx = createCtx();
276
+ const session = createSession();
277
+
278
+ await runPlanAudit(ctx, session);
279
+
280
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
281
+ "issue-1",
282
+ expect.stringContaining("Approved"),
283
+ );
284
+ });
285
+
286
+ it("ends session as approved on pass", async () => {
287
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
288
+ const ctx = createCtx();
289
+ const session = createSession();
290
+
291
+ await runPlanAudit(ctx, session);
292
+
293
+ expect(endPlanningSession).toHaveBeenCalledWith(
294
+ "proj-1",
295
+ "approved",
296
+ undefined,
297
+ );
298
+ });
299
+
300
+ it("posts problems on failing audit", async () => {
301
+ vi.mocked(auditPlan).mockReturnValue({
302
+ pass: false,
303
+ problems: ["Missing description on PROJ-2"],
304
+ warnings: [],
305
+ });
306
+ const ctx = createCtx();
307
+ const session = createSession();
308
+
309
+ await runPlanAudit(ctx, session);
310
+
311
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
312
+ "issue-1",
313
+ expect.stringContaining("Missing description on PROJ-2"),
314
+ );
315
+ });
316
+
317
+ it("does NOT end session as approved on fail", async () => {
318
+ vi.mocked(auditPlan).mockReturnValue({
319
+ pass: false,
320
+ problems: ["No estimates"],
321
+ warnings: [],
322
+ });
323
+ const ctx = createCtx();
324
+ const session = createSession();
325
+
326
+ await runPlanAudit(ctx, session);
327
+
328
+ expect(endPlanningSession).not.toHaveBeenCalledWith(
329
+ "proj-1",
330
+ "approved",
331
+ expect.anything(),
332
+ );
333
+ });
334
+ });