@calltelemetry/openclaw-linear 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -0,0 +1,264 @@
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
+
176
+ it("handles null description gracefully", async () => {
177
+ mockRunAgent.mockResolvedValue({
178
+ success: true,
179
+ output: '{"tier":"junior","reasoning":"Trivial"}',
180
+ });
181
+
182
+ const api = makeApi({ defaultAgentId: "mal" });
183
+ const result = await assessTier(api, makeIssue({ description: null }));
184
+
185
+ expect(result.tier).toBe("junior");
186
+ });
187
+
188
+ it("handles empty labels and no comments", async () => {
189
+ mockRunAgent.mockResolvedValue({
190
+ success: true,
191
+ output: '{"tier":"medior","reasoning":"Standard feature"}',
192
+ });
193
+
194
+ const api = makeApi({ defaultAgentId: "mal" });
195
+ const result = await assessTier(api, makeIssue({ labels: [], commentCount: undefined }));
196
+
197
+ expect(result.tier).toBe("medior");
198
+ });
199
+
200
+ it("falls back to medior on malformed JSON (half JSON)", async () => {
201
+ mockRunAgent.mockResolvedValue({
202
+ success: true,
203
+ output: '{"tier":"seni',
204
+ });
205
+
206
+ const api = makeApi({ defaultAgentId: "mal" });
207
+ const result = await assessTier(api, makeIssue());
208
+
209
+ expect(result.tier).toBe("medior");
210
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
211
+ });
212
+
213
+ it("provides default reasoning when missing from response", async () => {
214
+ mockRunAgent.mockResolvedValue({
215
+ success: true,
216
+ output: '{"tier":"senior"}',
217
+ });
218
+
219
+ const api = makeApi({ defaultAgentId: "mal" });
220
+ const result = await assessTier(api, makeIssue());
221
+
222
+ expect(result.tier).toBe("senior");
223
+ expect(result.reasoning).toBe("no reasoning provided");
224
+ });
225
+
226
+ it("extracts JSON from output with success=false but valid JSON", async () => {
227
+ mockRunAgent.mockResolvedValue({
228
+ success: false,
229
+ output: 'Agent exited early but: {"tier":"junior","reasoning":"Simple fix"}',
230
+ });
231
+
232
+ const api = makeApi({ defaultAgentId: "mal" });
233
+ const result = await assessTier(api, makeIssue());
234
+
235
+ expect(result.tier).toBe("junior");
236
+ expect(result.reasoning).toBe("Simple fix");
237
+ });
238
+
239
+ it("defaults agentId from pluginConfig when not passed", async () => {
240
+ mockRunAgent.mockResolvedValue({
241
+ success: true,
242
+ output: '{"tier":"medior","reasoning":"Normal"}',
243
+ });
244
+
245
+ const api = makeApi({ defaultAgentId: "zoe" });
246
+ await assessTier(api, makeIssue());
247
+
248
+ const callArgs = mockRunAgent.mock.calls[0][0];
249
+ expect(callArgs.agentId).toBe("zoe");
250
+ });
251
+
252
+ it("uses 30s timeout for assessment", async () => {
253
+ mockRunAgent.mockResolvedValue({
254
+ success: true,
255
+ output: '{"tier":"medior","reasoning":"Normal"}',
256
+ });
257
+
258
+ const api = makeApi({ defaultAgentId: "mal" });
259
+ await assessTier(api, makeIssue());
260
+
261
+ const callArgs = mockRunAgent.mock.calls[0][0];
262
+ expect(callArgs.timeoutMs).toBe(30_000);
263
+ });
264
+ });
@@ -8,10 +8,13 @@ import { setActiveSession, clearActiveSession } from "./active-session.js";
8
8
  import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
9
9
  import { createNotifierFromConfig, type NotifyFn } from "../infra/notify.js";
10
10
  import { assessTier } from "./tier-assess.js";
11
- import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
12
- import { ensureClawDir, writeManifest } from "./artifacts.js";
11
+ import { createWorktree, createMultiWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
12
+ import { resolveRepos, isMultiRepo } from "../infra/multi-repo.js";
13
+ import { ensureClawDir, writeManifest, writeDispatchMemory, resolveOrchestratorWorkspace } from "./artifacts.js";
13
14
  import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
14
15
  import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
16
+ import { startProjectDispatch } from "./dag-dispatch.js";
17
+ import { emitDiagnostic } from "../infra/observability.js";
15
18
 
16
19
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
17
20
  interface AgentProfile {
@@ -148,6 +151,7 @@ export async function handleLinearWebhook(
148
151
  // Debug: log full payload structure for diagnosing webhook types
149
152
  const payloadKeys = Object.keys(payload).join(", ");
150
153
  api.logger.info(`Linear webhook received: type=${payload.type} action=${payload.action} keys=[${payloadKeys}]`);
154
+ emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
151
155
 
152
156
 
153
157
  // ── AppUserNotification — OAuth app webhook for agent mentions/assignments
@@ -259,7 +263,7 @@ export async function handleLinearWebhook(
259
263
 
260
264
  const responseBody = result.success
261
265
  ? result.output
262
- : `I encountered an error processing this request. Please try again.`;
266
+ : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
263
267
 
264
268
  // 5. Post branded comment (fallback to prefix)
265
269
  const brandingOpts = avatarUrl
@@ -435,7 +439,7 @@ export async function handleLinearWebhook(
435
439
 
436
440
  const responseBody = result.success
437
441
  ? result.output
438
- : `I encountered an error processing this request. Please try again.`;
442
+ : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
439
443
 
440
444
  // Post as comment
441
445
  const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
@@ -613,7 +617,7 @@ export async function handleLinearWebhook(
613
617
 
614
618
  const responseBody = result.success
615
619
  ? result.output
616
- : `I encountered an error processing this request. Please try again.`;
620
+ : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
617
621
 
618
622
  const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
619
623
  const brandingOpts = avatarUrl
@@ -666,6 +670,8 @@ export async function handleLinearWebhook(
666
670
  const issue = comment?.issue ?? payload.issue;
667
671
 
668
672
  // ── Planning mode intercept ──────────────────────────────────
673
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
674
+
669
675
  if (issue?.id) {
670
676
  const linearApiForPlanning = createLinearApi(api);
671
677
  if (linearApiForPlanning) {
@@ -678,7 +684,7 @@ export async function handleLinearWebhook(
678
684
  const planState = await readPlanningState(planStatePath);
679
685
 
680
686
  // Check if this is a plan initiation request
681
- const isPlanRequest = /\b(plan|planning)\s+(this\s+)?(project|out)\b/i.test(commentBody);
687
+ const isPlanRequest = /\b(plan|planning)\s+(this\s+)(project|out)\b/i.test(commentBody) || /\bplan\s+this\s+out\b/i.test(commentBody);
682
688
  if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
683
689
  api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
684
690
  void initiatePlanningSession(
@@ -692,12 +698,34 @@ export async function handleLinearWebhook(
692
698
  // Route to planner if project is in planning mode
693
699
  if (isInPlanningMode(planState, projectId)) {
694
700
  const session = getPlanningSession(planState, projectId);
695
- if (session && comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
701
+ if (!session) {
702
+ api.logger.error(`Planning: project ${projectId} in planning mode but no session found — state may be corrupted`);
703
+ await linearApiForPlanning.createComment(
704
+ issue.id,
705
+ `**Planning mode is active** for this project, but the session state appears corrupted.\n\n**To fix:** Say **"abandon planning"** to exit planning mode, then start fresh with **"plan this project"**.`,
706
+ ).catch(() => {});
707
+ return true;
708
+ }
709
+ if (comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
696
710
  api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
697
711
  void handlePlannerTurn(
698
712
  { api, linearApi: linearApiForPlanning, pluginConfig },
699
713
  session,
700
714
  { issueId: issue.id, commentBody, commentorName: commentor },
715
+ {
716
+ onApproved: (approvedProjectId) => {
717
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
718
+ const hookCtx: HookContext = {
719
+ api,
720
+ linearApi: linearApiForPlanning,
721
+ notify,
722
+ pluginConfig,
723
+ configPath: pluginConfig?.dispatchStatePath as string | undefined,
724
+ };
725
+ void startProjectDispatch(hookCtx, approvedProjectId)
726
+ .catch((err) => api.logger.error(`Project dispatch start error: ${err}`));
727
+ },
728
+ },
701
729
  ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
702
730
  }
703
731
  return true;
@@ -856,7 +884,7 @@ export async function handleLinearWebhook(
856
884
 
857
885
  const responseBody = result.success
858
886
  ? result.output
859
- : `I encountered an error processing this request. Please try again or check the logs.`;
887
+ : `Something went wrong while processing this. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
860
888
 
861
889
  // 5. Post branded comment (fall back to [Label] prefix if branding fails)
862
890
  const brandingOpts = profile?.avatarUrl
@@ -1010,6 +1038,27 @@ export async function handleLinearWebhook(
1010
1038
  api.logger.warn(`Could not fetch issue details for triage: ${err}`);
1011
1039
  }
1012
1040
 
1041
+ // Skip triage for issues in projects that are actively being planned —
1042
+ // the planner creates issues and triage would overwrite its estimates/labels.
1043
+ const triageProjectId = enrichedIssue?.project?.id;
1044
+ if (triageProjectId) {
1045
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
1046
+ try {
1047
+ const planState = await readPlanningState(planStatePath);
1048
+ if (isInPlanningMode(planState, triageProjectId)) {
1049
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} belongs to project in planning mode — skipping triage`);
1050
+ return;
1051
+ }
1052
+ } catch { /* proceed with triage if planning state check fails */ }
1053
+ }
1054
+
1055
+ // Skip triage for issues created by our own bot user
1056
+ const viewerId = await linearApi.getViewerId();
1057
+ if (viewerId && issue.creatorId === viewerId) {
1058
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} created by our bot — skipping triage`);
1059
+ return;
1060
+ }
1061
+
1013
1062
  const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1014
1063
  const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
1015
1064
  const currentLabels = enrichedIssue?.labels?.nodes ?? [];
@@ -1094,7 +1143,7 @@ export async function handleLinearWebhook(
1094
1143
 
1095
1144
  const responseBody = result.success
1096
1145
  ? result.output
1097
- : `I encountered an error triaging this issue. Please triage manually.`;
1146
+ : `Something went wrong while triaging this issue. You may need to set the estimate and labels manually.`;
1098
1147
 
1099
1148
  // Parse triage JSON and apply to issue
1100
1149
  let commentBody = responseBody;
@@ -1218,7 +1267,7 @@ async function handleDispatch(
1218
1267
  api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1219
1268
  await linearApi.createComment(
1220
1269
  issue.id,
1221
- "This project is in planning mode. Finalize the plan before dispatching implementation.",
1270
+ `**Can't dispatch yet** — this project is in planning mode.\n\n**To continue:** Comment on the planning issue with your requirements, then say **"finalize plan"** when ready.\n\n**To cancel planning:** Comment **"abandon"** on the planning issue.`,
1222
1271
  );
1223
1272
  return;
1224
1273
  }
@@ -1241,7 +1290,7 @@ async function handleDispatch(
1241
1290
  api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1242
1291
  await linearApi.createComment(
1243
1292
  issue.id,
1244
- `Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
1293
+ `**Already running** as **${existing.tier}** status: **${existing.status}**, started ${Math.round(ageMs / 60_000)}m ago.\n\nWorktree: \`${existing.worktreePath}\`\n\n**Options:**\n- Check progress: \`/dispatch status ${identifier}\`\n- Force restart: \`/dispatch retry ${identifier}\` (only works when stuck)\n- Escalate: \`/dispatch escalate ${identifier} "reason"\``,
1245
1294
  );
1246
1295
  return;
1247
1296
  }
@@ -1273,6 +1322,9 @@ async function handleDispatch(
1273
1322
  const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1274
1323
  const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
1275
1324
 
1325
+ // Resolve repos for this dispatch (issue body markers, labels, or config default)
1326
+ const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
1327
+
1276
1328
  // 4. Assess complexity tier
1277
1329
  const assessment = await assessTier(api, {
1278
1330
  identifier,
@@ -1283,29 +1335,53 @@ async function handleDispatch(
1283
1335
  });
1284
1336
 
1285
1337
  api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1338
+ emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
1339
+
1340
+ // 5. Create persistent worktree(s)
1341
+ let worktreePath: string;
1342
+ let worktreeBranch: string;
1343
+ let worktreeResumed: boolean;
1344
+ let worktrees: Array<{ repoName: string; path: string; branch: string }> | undefined;
1286
1345
 
1287
- // 5. Create persistent worktree
1288
- let worktree;
1289
1346
  try {
1290
- worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1291
- api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
1347
+ if (isMultiRepo(repoResolution)) {
1348
+ const multi = createMultiWorktree(identifier, repoResolution.repos, { baseDir: worktreeBaseDir });
1349
+ worktreePath = multi.parentPath;
1350
+ worktreeBranch = `codex/${identifier}`;
1351
+ worktreeResumed = multi.worktrees.some(w => w.resumed);
1352
+ worktrees = multi.worktrees.map(w => ({ repoName: w.repoName, path: w.path, branch: w.branch }));
1353
+ api.logger.info(`@dispatch: multi-repo worktrees ${worktreeResumed ? "resumed" : "created"} at ${worktreePath} (${repoResolution.repos.map(r => r.name).join(", ")})`);
1354
+ } else {
1355
+ const single = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1356
+ worktreePath = single.path;
1357
+ worktreeBranch = single.branch;
1358
+ worktreeResumed = single.resumed;
1359
+ api.logger.info(`@dispatch: worktree ${worktreeResumed ? "resumed" : "created"} at ${worktreePath}`);
1360
+ }
1292
1361
  } catch (err) {
1293
1362
  api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1294
1363
  await linearApi.createComment(
1295
1364
  issue.id,
1296
- `Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
1365
+ `**Dispatch failed**couldn't create the worktree.\n\n> ${String(err).slice(0, 200)}\n\n**What to try:**\n- Check that the base repo exists\n- Re-assign this issue to try again\n- Check logs: \`journalctl --user -u openclaw-gateway --since "5 min ago"\``,
1297
1366
  );
1298
1367
  return;
1299
1368
  }
1300
1369
 
1301
- // 5b. Prepare workspace: pull latest from origin + init submodules
1302
- const prep = prepareWorkspace(worktree.path, worktree.branch);
1303
- if (prep.errors.length > 0) {
1304
- api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
1370
+ // 5b. Prepare workspace(s)
1371
+ if (worktrees) {
1372
+ for (const wt of worktrees) {
1373
+ const prep = prepareWorkspace(wt.path, wt.branch);
1374
+ if (prep.errors.length > 0) {
1375
+ api.logger.warn(`@dispatch: workspace prep for ${wt.repoName} had errors: ${prep.errors.join("; ")}`);
1376
+ }
1377
+ }
1305
1378
  } else {
1306
- api.logger.info(
1307
- `@dispatch: workspace prepared pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
1308
- );
1379
+ const prep = prepareWorkspace(worktreePath, worktreeBranch);
1380
+ if (prep.errors.length > 0) {
1381
+ api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
1382
+ } else {
1383
+ api.logger.info(`@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`);
1384
+ }
1309
1385
  }
1310
1386
 
1311
1387
  // 6. Create agent session on Linear
@@ -1319,16 +1395,16 @@ async function handleDispatch(
1319
1395
 
1320
1396
  // 6b. Initialize .claw/ artifact directory
1321
1397
  try {
1322
- ensureClawDir(worktree.path);
1323
- writeManifest(worktree.path, {
1398
+ ensureClawDir(worktreePath);
1399
+ writeManifest(worktreePath, {
1324
1400
  issueIdentifier: identifier,
1325
1401
  issueTitle: enrichedIssue.title ?? "(untitled)",
1326
1402
  issueId: issue.id,
1327
1403
  tier: assessment.tier,
1328
1404
  model: assessment.model,
1329
1405
  dispatchedAt: new Date().toISOString(),
1330
- worktreePath: worktree.path,
1331
- branch: worktree.branch,
1406
+ worktreePath,
1407
+ branch: worktreeBranch,
1332
1408
  attempts: 0,
1333
1409
  status: "dispatched",
1334
1410
  plugin: "openclaw-linear",
@@ -1343,14 +1419,16 @@ async function handleDispatch(
1343
1419
  issueId: issue.id,
1344
1420
  issueIdentifier: identifier,
1345
1421
  issueTitle: enrichedIssue.title ?? "(untitled)",
1346
- worktreePath: worktree.path,
1347
- branch: worktree.branch,
1422
+ worktreePath,
1423
+ branch: worktreeBranch,
1348
1424
  tier: assessment.tier,
1349
1425
  model: assessment.model,
1350
1426
  status: "dispatched",
1351
1427
  dispatchedAt: now,
1352
1428
  agentSessionId,
1353
1429
  attempt: 0,
1430
+ project: enrichedIssue?.project?.id,
1431
+ worktrees,
1354
1432
  }, statePath);
1355
1433
 
1356
1434
  // 8. Register active session for tool resolution
@@ -1363,16 +1441,24 @@ async function handleDispatch(
1363
1441
  });
1364
1442
 
1365
1443
  // 9. Post dispatch confirmation comment
1366
- const prepStatus = prep.errors.length > 0
1367
- ? `Workspace prep: partial (${prep.errors.join("; ")})`
1368
- : `Workspace prep: OK (pulled=${prep.pulled}, submodules=${prep.submodulesInitialized})`;
1444
+ const worktreeDesc = worktrees
1445
+ ? worktrees.map(wt => `\`${wt.repoName}\`: \`${wt.path}\``).join("\n")
1446
+ : `\`${worktreePath}\``;
1369
1447
  const statusComment = [
1370
1448
  `**Dispatched** as **${assessment.tier}** (${assessment.model})`,
1371
1449
  `> ${assessment.reasoning}`,
1372
1450
  ``,
1373
- `Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
1374
- `Branch: \`${worktree.branch}\``,
1375
- prepStatus,
1451
+ worktrees
1452
+ ? `Worktrees ${worktreeResumed ? "(resumed)" : "(fresh)"}:\n${worktreeDesc}`
1453
+ : `Worktree: ${worktreeDesc} ${worktreeResumed ? "(resumed)" : "(fresh)"}`,
1454
+ `Branch: \`${worktreeBranch}\``,
1455
+ ``,
1456
+ `**Status:** Worker is starting now. An independent audit runs automatically after implementation.`,
1457
+ ``,
1458
+ `**While you wait:**`,
1459
+ `- Check progress: \`/dispatch status ${identifier}\``,
1460
+ `- Cancel: \`/dispatch escalate ${identifier} "reason"\``,
1461
+ `- All dispatches: \`/dispatch list\``,
1376
1462
  ].join("\n");
1377
1463
 
1378
1464
  await linearApi.createComment(issue.id, statusComment);
@@ -1404,7 +1490,7 @@ async function handleDispatch(
1404
1490
  activeRuns.add(issue.id);
1405
1491
 
1406
1492
  // Instantiate notifier (Discord, Slack, or both — config-driven)
1407
- const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
1493
+ const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
1408
1494
 
1409
1495
  const hookCtx: HookContext = {
1410
1496
  api,
@@ -1429,6 +1515,18 @@ async function handleDispatch(
1429
1515
  .catch(async (err) => {
1430
1516
  api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
1431
1517
  await updateDispatchStatus(identifier, "failed", statePath);
1518
+ // Write memory for failed dispatches so they're searchable in dispatch history
1519
+ try {
1520
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
1521
+ writeDispatchMemory(identifier, `Pipeline failed: ${String(err).slice(0, 500)}`, wsDir, {
1522
+ title: enrichedIssue.title ?? identifier,
1523
+ tier: assessment.tier,
1524
+ status: "failed",
1525
+ project: enrichedIssue?.project?.id,
1526
+ attempts: 1,
1527
+ model: assessment.model,
1528
+ });
1529
+ } catch { /* best effort */ }
1432
1530
  })
1433
1531
  .finally(() => {
1434
1532
  activeRuns.delete(issue.id);