@calltelemetry/openclaw-linear 0.7.0 → 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.
@@ -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, {
@@ -149,13 +149,14 @@ export async function handlePlannerTurn(
149
149
  ctx: PlannerContext,
150
150
  session: PlanningSession,
151
151
  input: { issueId: string; commentBody: string; commentorName: string },
152
+ opts?: { onApproved?: (projectId: string) => void },
152
153
  ): Promise<void> {
153
154
  const { api, linearApi, pluginConfig } = ctx;
154
155
  const configPath = pluginConfig?.planningStatePath as string | undefined;
155
156
 
156
157
  // Detect finalization intent
157
158
  if (FINALIZE_PATTERN.test(input.commentBody)) {
158
- await runPlanAudit(ctx, session);
159
+ await runPlanAudit(ctx, session, { onApproved: opts?.onApproved });
159
160
  return;
160
161
  }
161
162
 
@@ -234,6 +235,7 @@ export async function handlePlannerTurn(
234
235
  export async function runPlanAudit(
235
236
  ctx: PlannerContext,
236
237
  session: PlanningSession,
238
+ opts?: { onApproved?: (projectId: string) => void },
237
239
  ): Promise<void> {
238
240
  const { api, linearApi, pluginConfig } = ctx;
239
241
  const configPath = pluginConfig?.planningStatePath as string | undefined;
@@ -262,6 +264,9 @@ export async function runPlanAudit(
262
264
 
263
265
  await endPlanningSession(session.projectId, "approved", configPath);
264
266
  api.logger.info(`Planning: session approved for ${session.projectName}`);
267
+
268
+ // Trigger DAG-based dispatch if callback provided
269
+ opts?.onApproved?.(session.projectId);
265
270
  } else {
266
271
  // Post problems and keep planning
267
272
  const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
@@ -48,48 +48,10 @@ function resolveStatePath(configPath?: string): string {
48
48
  }
49
49
 
50
50
  // ---------------------------------------------------------------------------
51
- // File locking
51
+ // File locking (shared utility)
52
52
  // ---------------------------------------------------------------------------
53
53
 
54
- const LOCK_STALE_MS = 30_000;
55
- const LOCK_RETRY_MS = 50;
56
- const LOCK_TIMEOUT_MS = 10_000;
57
-
58
- function lockPath(statePath: string): string {
59
- return statePath + ".lock";
60
- }
61
-
62
- async function acquireLock(statePath: string): Promise<void> {
63
- const lock = lockPath(statePath);
64
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
65
-
66
- while (Date.now() < deadline) {
67
- try {
68
- await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
69
- return;
70
- } catch (err: any) {
71
- if (err.code !== "EEXIST") throw err;
72
-
73
- try {
74
- const content = await fs.readFile(lock, "utf-8");
75
- const lockTime = Number(content);
76
- if (Date.now() - lockTime > LOCK_STALE_MS) {
77
- try { await fs.unlink(lock); } catch { /* race */ }
78
- continue;
79
- }
80
- } catch { /* lock disappeared — retry */ }
81
-
82
- await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
83
- }
84
- }
85
-
86
- try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
87
- await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
88
- }
89
-
90
- async function releaseLock(statePath: string): Promise<void> {
91
- try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
92
- }
54
+ import { acquireLock, releaseLock } from "../infra/file-lock.js";
93
55
 
94
56
  // ---------------------------------------------------------------------------
95
57
  // Read / Write
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { TIER_MODELS, assessTier, type IssueContext } from "./tier-assess.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mock runAgent
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const mockRunAgent = vi.fn();
9
+
10
+ vi.mock("../agent/agent.js", () => ({
11
+ runAgent: (...args: unknown[]) => mockRunAgent(...args),
12
+ }));
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeApi(overrides?: { defaultAgentId?: string }) {
19
+ return {
20
+ logger: {
21
+ info: vi.fn(),
22
+ warn: vi.fn(),
23
+ error: vi.fn(),
24
+ },
25
+ pluginConfig: {
26
+ defaultAgentId: overrides?.defaultAgentId,
27
+ },
28
+ } as any;
29
+ }
30
+
31
+ function makeIssue(overrides?: Partial<IssueContext>): IssueContext {
32
+ return {
33
+ identifier: "CT-123",
34
+ title: "Fix login bug",
35
+ description: "Users cannot log in when using SSO",
36
+ labels: ["bug"],
37
+ commentCount: 2,
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Tests
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe("TIER_MODELS", () => {
47
+ it("maps junior to haiku, medior to sonnet, senior to opus", () => {
48
+ expect(TIER_MODELS.junior).toBe("anthropic/claude-haiku-4-5");
49
+ expect(TIER_MODELS.medior).toBe("anthropic/claude-sonnet-4-6");
50
+ expect(TIER_MODELS.senior).toBe("anthropic/claude-opus-4-6");
51
+ });
52
+ });
53
+
54
+ describe("assessTier", () => {
55
+ beforeEach(() => {
56
+ mockRunAgent.mockReset();
57
+ });
58
+
59
+ it("returns parsed tier from agent response", async () => {
60
+ mockRunAgent.mockResolvedValue({
61
+ success: true,
62
+ output: '{"tier":"senior","reasoning":"Multi-service architecture change"}',
63
+ });
64
+
65
+ const api = makeApi({ defaultAgentId: "mal" });
66
+ const result = await assessTier(api, makeIssue());
67
+
68
+ expect(result.tier).toBe("senior");
69
+ expect(result.model).toBe(TIER_MODELS.senior);
70
+ expect(result.reasoning).toBe("Multi-service architecture change");
71
+ expect(api.logger.info).toHaveBeenCalled();
72
+ });
73
+
74
+ it("falls back to medior when agent fails (success: false) with no parseable JSON", async () => {
75
+ mockRunAgent.mockResolvedValue({
76
+ success: false,
77
+ output: "Agent process exited with code 1",
78
+ });
79
+
80
+ const api = makeApi({ defaultAgentId: "mal" });
81
+ const result = await assessTier(api, makeIssue());
82
+
83
+ expect(result.tier).toBe("medior");
84
+ expect(result.model).toBe(TIER_MODELS.medior);
85
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
86
+ expect(api.logger.warn).toHaveBeenCalled();
87
+ });
88
+
89
+ it("falls back to medior when output has no JSON", async () => {
90
+ mockRunAgent.mockResolvedValue({
91
+ success: true,
92
+ output: "I think this is a medium complexity issue because it involves multiple files.",
93
+ });
94
+
95
+ const api = makeApi({ defaultAgentId: "mal" });
96
+ const result = await assessTier(api, makeIssue());
97
+
98
+ expect(result.tier).toBe("medior");
99
+ expect(result.model).toBe(TIER_MODELS.medior);
100
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
101
+ });
102
+
103
+ it("falls back to medior when JSON has invalid tier", async () => {
104
+ mockRunAgent.mockResolvedValue({
105
+ success: true,
106
+ output: '{"tier":"expert","reasoning":"Very hard problem"}',
107
+ });
108
+
109
+ const api = makeApi({ defaultAgentId: "mal" });
110
+ const result = await assessTier(api, makeIssue());
111
+
112
+ expect(result.tier).toBe("medior");
113
+ expect(result.model).toBe(TIER_MODELS.medior);
114
+ });
115
+
116
+ it("handles agent throwing an error", async () => {
117
+ mockRunAgent.mockRejectedValue(new Error("Connection refused"));
118
+
119
+ const api = makeApi({ defaultAgentId: "mal" });
120
+ const result = await assessTier(api, makeIssue());
121
+
122
+ expect(result.tier).toBe("medior");
123
+ expect(result.model).toBe(TIER_MODELS.medior);
124
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
125
+ expect(api.logger.warn).toHaveBeenCalledWith(
126
+ expect.stringContaining("Tier assessment error for CT-123"),
127
+ );
128
+ });
129
+
130
+ it("truncates long descriptions to 1500 chars", async () => {
131
+ const longDescription = "A".repeat(3000);
132
+ mockRunAgent.mockResolvedValue({
133
+ success: true,
134
+ output: '{"tier":"junior","reasoning":"Simple copy change"}',
135
+ });
136
+
137
+ const api = makeApi({ defaultAgentId: "mal" });
138
+ await assessTier(api, makeIssue({ description: longDescription }));
139
+
140
+ // Verify the message sent to runAgent has the description truncated
141
+ const callArgs = mockRunAgent.mock.calls[0][0];
142
+ const message: string = callArgs.message;
143
+ // The description in the prompt should be at most 1500 chars of the original
144
+ // "Description: " prefix + 1500 chars = the truncated form
145
+ expect(message).toContain("Description: " + "A".repeat(1500));
146
+ expect(message).not.toContain("A".repeat(1501));
147
+ });
148
+
149
+ it("uses configured agentId when provided", async () => {
150
+ mockRunAgent.mockResolvedValue({
151
+ success: true,
152
+ output: '{"tier":"junior","reasoning":"Typo fix"}',
153
+ });
154
+
155
+ const api = makeApi({ defaultAgentId: "mal" });
156
+ await assessTier(api, makeIssue(), "kaylee");
157
+
158
+ const callArgs = mockRunAgent.mock.calls[0][0];
159
+ expect(callArgs.agentId).toBe("kaylee");
160
+ });
161
+
162
+ it("parses JSON even when wrapped in markdown fences", async () => {
163
+ mockRunAgent.mockResolvedValue({
164
+ success: true,
165
+ output: '```json\n{"tier":"junior","reasoning":"Config tweak"}\n```',
166
+ });
167
+
168
+ const api = makeApi({ defaultAgentId: "mal" });
169
+ const result = await assessTier(api, makeIssue());
170
+
171
+ expect(result.tier).toBe("junior");
172
+ expect(result.model).toBe(TIER_MODELS.junior);
173
+ expect(result.reasoning).toBe("Config tweak");
174
+ });
175
+ });
@@ -12,6 +12,8 @@ import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
12
12
  import { ensureClawDir, writeManifest } from "./artifacts.js";
13
13
  import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
14
14
  import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
15
+ import { startProjectDispatch } from "./dag-dispatch.js";
16
+ import { emitDiagnostic } from "../infra/observability.js";
15
17
 
16
18
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
17
19
  interface AgentProfile {
@@ -148,6 +150,7 @@ export async function handleLinearWebhook(
148
150
  // Debug: log full payload structure for diagnosing webhook types
149
151
  const payloadKeys = Object.keys(payload).join(", ");
150
152
  api.logger.info(`Linear webhook received: type=${payload.type} action=${payload.action} keys=[${payloadKeys}]`);
153
+ emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
151
154
 
152
155
 
153
156
  // ── AppUserNotification — OAuth app webhook for agent mentions/assignments
@@ -666,6 +669,8 @@ export async function handleLinearWebhook(
666
669
  const issue = comment?.issue ?? payload.issue;
667
670
 
668
671
  // ── Planning mode intercept ──────────────────────────────────
672
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
673
+
669
674
  if (issue?.id) {
670
675
  const linearApiForPlanning = createLinearApi(api);
671
676
  if (linearApiForPlanning) {
@@ -698,6 +703,20 @@ export async function handleLinearWebhook(
698
703
  { api, linearApi: linearApiForPlanning, pluginConfig },
699
704
  session,
700
705
  { issueId: issue.id, commentBody, commentorName: commentor },
706
+ {
707
+ onApproved: (approvedProjectId) => {
708
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime);
709
+ const hookCtx: HookContext = {
710
+ api,
711
+ linearApi: linearApiForPlanning,
712
+ notify,
713
+ pluginConfig,
714
+ configPath: pluginConfig?.dispatchStatePath as string | undefined,
715
+ };
716
+ void startProjectDispatch(hookCtx, approvedProjectId)
717
+ .catch((err) => api.logger.error(`Project dispatch start error: ${err}`));
718
+ },
719
+ },
701
720
  ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
702
721
  }
703
722
  return true;
@@ -1283,6 +1302,7 @@ async function handleDispatch(
1283
1302
  });
1284
1303
 
1285
1304
  api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1305
+ emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
1286
1306
 
1287
1307
  // 5. Create persistent worktree
1288
1308
  let worktree;
@@ -1351,6 +1371,7 @@ async function handleDispatch(
1351
1371
  dispatchedAt: now,
1352
1372
  agentSessionId,
1353
1373
  attempt: 0,
1374
+ project: enrichedIssue?.project?.id,
1354
1375
  }, statePath);
1355
1376
 
1356
1377
  // 8. Register active session for tool resolution