@calltelemetry/openclaw-linear 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -251,7 +251,7 @@ describe("handlePlannerTurn", () => {
251
251
 
252
252
  await handlePlannerTurn(ctx, session, {
253
253
  issueId: "issue-1",
254
- commentBody: "abandon",
254
+ commentBody: "abandon planning",
255
255
  commentorName: "Tester",
256
256
  });
257
257
 
@@ -6,11 +6,8 @@
6
6
  * - handlePlannerTurn: processes each user comment during planning
7
7
  * - runPlanAudit: validates the plan before finalizing
8
8
  */
9
- import { readFileSync } from "node:fs";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- import { parse as parseYaml } from "yaml";
13
9
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
+ import { loadRawPromptYaml } from "./pipeline.js";
14
11
  import type { LinearAgentApi } from "../api/linear-api.js";
15
12
  import { runAgent } from "../agent/agent.js";
16
13
  import {
@@ -56,30 +53,15 @@ function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerProm
56
53
  welcome: "Entering planning mode for **{{projectName}}**. What are the main feature areas?",
57
54
  };
58
55
 
59
- try {
60
- const customPath = pluginConfig?.promptsPath as string | undefined;
61
- let raw: string;
62
-
63
- if (customPath) {
64
- const resolved = customPath.startsWith("~")
65
- ? customPath.replace("~", process.env.HOME ?? "")
66
- : customPath;
67
- raw = readFileSync(resolved, "utf-8");
68
- } else {
69
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
70
- raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
71
- }
72
-
73
- const parsed = parseYaml(raw) as any;
74
- if (parsed?.planner) {
75
- return {
76
- system: parsed.planner.system ?? defaults.system,
77
- interview: parsed.planner.interview ?? defaults.interview,
78
- audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
79
- welcome: parsed.planner.welcome ?? defaults.welcome,
80
- };
81
- }
82
- } catch { /* use defaults */ }
56
+ const parsed = loadRawPromptYaml(pluginConfig);
57
+ if (parsed?.planner) {
58
+ return {
59
+ system: parsed.planner.system ?? defaults.system,
60
+ interview: parsed.planner.interview ?? defaults.interview,
61
+ audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
62
+ welcome: parsed.planner.welcome ?? defaults.welcome,
63
+ };
64
+ }
83
65
 
84
66
  return defaults;
85
67
  }
@@ -142,8 +124,8 @@ export async function initiatePlanningSession(
142
124
  // Interview turn
143
125
  // ---------------------------------------------------------------------------
144
126
 
145
- const FINALIZE_PATTERN = /\b(finalize\s+plan|finalize|done\s+planning|approve\s+plan|plan\s+looks\s+good)\b/i;
146
- const ABANDON_PATTERN = /\b(abandon|cancel\s+planning|stop\s+planning|exit\s+planning)\b/i;
127
+ const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
128
+ const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
147
129
 
148
130
  export async function handlePlannerTurn(
149
131
  ctx: PlannerContext,
@@ -172,4 +172,93 @@ describe("assessTier", () => {
172
172
  expect(result.model).toBe(TIER_MODELS.junior);
173
173
  expect(result.reasoning).toBe("Config tweak");
174
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
+ });
175
264
  });
@@ -8,8 +8,9 @@ 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";
15
16
  import { startProjectDispatch } from "./dag-dispatch.js";
@@ -262,7 +263,7 @@ export async function handleLinearWebhook(
262
263
 
263
264
  const responseBody = result.success
264
265
  ? result.output
265
- : `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.`;
266
267
 
267
268
  // 5. Post branded comment (fallback to prefix)
268
269
  const brandingOpts = avatarUrl
@@ -438,7 +439,7 @@ export async function handleLinearWebhook(
438
439
 
439
440
  const responseBody = result.success
440
441
  ? result.output
441
- : `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.`;
442
443
 
443
444
  // Post as comment
444
445
  const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
@@ -616,7 +617,7 @@ export async function handleLinearWebhook(
616
617
 
617
618
  const responseBody = result.success
618
619
  ? result.output
619
- : `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.`;
620
621
 
621
622
  const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
622
623
  const brandingOpts = avatarUrl
@@ -683,7 +684,7 @@ export async function handleLinearWebhook(
683
684
  const planState = await readPlanningState(planStatePath);
684
685
 
685
686
  // Check if this is a plan initiation request
686
- 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);
687
688
  if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
688
689
  api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
689
690
  void initiatePlanningSession(
@@ -697,7 +698,15 @@ export async function handleLinearWebhook(
697
698
  // Route to planner if project is in planning mode
698
699
  if (isInPlanningMode(planState, projectId)) {
699
700
  const session = getPlanningSession(planState, projectId);
700
- 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}`)) {
701
710
  api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
702
711
  void handlePlannerTurn(
703
712
  { api, linearApi: linearApiForPlanning, pluginConfig },
@@ -705,7 +714,7 @@ export async function handleLinearWebhook(
705
714
  { issueId: issue.id, commentBody, commentorName: commentor },
706
715
  {
707
716
  onApproved: (approvedProjectId) => {
708
- const notify = createNotifierFromConfig(pluginConfig, api.runtime);
717
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
709
718
  const hookCtx: HookContext = {
710
719
  api,
711
720
  linearApi: linearApiForPlanning,
@@ -875,7 +884,7 @@ export async function handleLinearWebhook(
875
884
 
876
885
  const responseBody = result.success
877
886
  ? result.output
878
- : `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.`;
879
888
 
880
889
  // 5. Post branded comment (fall back to [Label] prefix if branding fails)
881
890
  const brandingOpts = profile?.avatarUrl
@@ -1029,6 +1038,27 @@ export async function handleLinearWebhook(
1029
1038
  api.logger.warn(`Could not fetch issue details for triage: ${err}`);
1030
1039
  }
1031
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
+
1032
1062
  const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1033
1063
  const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
1034
1064
  const currentLabels = enrichedIssue?.labels?.nodes ?? [];
@@ -1113,7 +1143,7 @@ export async function handleLinearWebhook(
1113
1143
 
1114
1144
  const responseBody = result.success
1115
1145
  ? result.output
1116
- : `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.`;
1117
1147
 
1118
1148
  // Parse triage JSON and apply to issue
1119
1149
  let commentBody = responseBody;
@@ -1237,7 +1267,7 @@ async function handleDispatch(
1237
1267
  api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1238
1268
  await linearApi.createComment(
1239
1269
  issue.id,
1240
- "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.`,
1241
1271
  );
1242
1272
  return;
1243
1273
  }
@@ -1260,7 +1290,7 @@ async function handleDispatch(
1260
1290
  api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1261
1291
  await linearApi.createComment(
1262
1292
  issue.id,
1263
- `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"\``,
1264
1294
  );
1265
1295
  return;
1266
1296
  }
@@ -1292,6 +1322,9 @@ async function handleDispatch(
1292
1322
  const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1293
1323
  const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
1294
1324
 
1325
+ // Resolve repos for this dispatch (issue body markers, labels, or config default)
1326
+ const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
1327
+
1295
1328
  // 4. Assess complexity tier
1296
1329
  const assessment = await assessTier(api, {
1297
1330
  identifier,
@@ -1304,28 +1337,51 @@ async function handleDispatch(
1304
1337
  api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1305
1338
  emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
1306
1339
 
1307
- // 5. Create persistent worktree
1308
- let worktree;
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;
1345
+
1309
1346
  try {
1310
- worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1311
- 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
+ }
1312
1361
  } catch (err) {
1313
1362
  api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1314
1363
  await linearApi.createComment(
1315
1364
  issue.id,
1316
- `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"\``,
1317
1366
  );
1318
1367
  return;
1319
1368
  }
1320
1369
 
1321
- // 5b. Prepare workspace: pull latest from origin + init submodules
1322
- const prep = prepareWorkspace(worktree.path, worktree.branch);
1323
- if (prep.errors.length > 0) {
1324
- 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
+ }
1325
1378
  } else {
1326
- api.logger.info(
1327
- `@dispatch: workspace prepared pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
1328
- );
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
+ }
1329
1385
  }
1330
1386
 
1331
1387
  // 6. Create agent session on Linear
@@ -1339,16 +1395,16 @@ async function handleDispatch(
1339
1395
 
1340
1396
  // 6b. Initialize .claw/ artifact directory
1341
1397
  try {
1342
- ensureClawDir(worktree.path);
1343
- writeManifest(worktree.path, {
1398
+ ensureClawDir(worktreePath);
1399
+ writeManifest(worktreePath, {
1344
1400
  issueIdentifier: identifier,
1345
1401
  issueTitle: enrichedIssue.title ?? "(untitled)",
1346
1402
  issueId: issue.id,
1347
1403
  tier: assessment.tier,
1348
1404
  model: assessment.model,
1349
1405
  dispatchedAt: new Date().toISOString(),
1350
- worktreePath: worktree.path,
1351
- branch: worktree.branch,
1406
+ worktreePath,
1407
+ branch: worktreeBranch,
1352
1408
  attempts: 0,
1353
1409
  status: "dispatched",
1354
1410
  plugin: "openclaw-linear",
@@ -1363,8 +1419,8 @@ async function handleDispatch(
1363
1419
  issueId: issue.id,
1364
1420
  issueIdentifier: identifier,
1365
1421
  issueTitle: enrichedIssue.title ?? "(untitled)",
1366
- worktreePath: worktree.path,
1367
- branch: worktree.branch,
1422
+ worktreePath,
1423
+ branch: worktreeBranch,
1368
1424
  tier: assessment.tier,
1369
1425
  model: assessment.model,
1370
1426
  status: "dispatched",
@@ -1372,6 +1428,7 @@ async function handleDispatch(
1372
1428
  agentSessionId,
1373
1429
  attempt: 0,
1374
1430
  project: enrichedIssue?.project?.id,
1431
+ worktrees,
1375
1432
  }, statePath);
1376
1433
 
1377
1434
  // 8. Register active session for tool resolution
@@ -1384,16 +1441,24 @@ async function handleDispatch(
1384
1441
  });
1385
1442
 
1386
1443
  // 9. Post dispatch confirmation comment
1387
- const prepStatus = prep.errors.length > 0
1388
- ? `Workspace prep: partial (${prep.errors.join("; ")})`
1389
- : `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}\``;
1390
1447
  const statusComment = [
1391
1448
  `**Dispatched** as **${assessment.tier}** (${assessment.model})`,
1392
1449
  `> ${assessment.reasoning}`,
1393
1450
  ``,
1394
- `Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
1395
- `Branch: \`${worktree.branch}\``,
1396
- 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\``,
1397
1462
  ].join("\n");
1398
1463
 
1399
1464
  await linearApi.createComment(issue.id, statusComment);
@@ -1425,7 +1490,7 @@ async function handleDispatch(
1425
1490
  activeRuns.add(issue.id);
1426
1491
 
1427
1492
  // Instantiate notifier (Discord, Slack, or both — config-driven)
1428
- const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
1493
+ const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
1429
1494
 
1430
1495
  const hookCtx: HookContext = {
1431
1496
  api,
@@ -1450,6 +1515,18 @@ async function handleDispatch(
1450
1515
  .catch(async (err) => {
1451
1516
  api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
1452
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 */ }
1453
1530
  })
1454
1531
  .finally(() => {
1455
1532
  activeRuns.delete(issue.id);
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock the active-session module
4
+ vi.mock("../pipeline/active-session.js", () => ({
5
+ getCurrentSession: vi.fn(() => null),
6
+ getActiveSessionByIdentifier: vi.fn(() => null),
7
+ }));
8
+
9
+ // Mock the linear-api module — LinearAgentApi must be a class (used with `new`)
10
+ vi.mock("../api/linear-api.js", () => {
11
+ const MockClass = vi.fn(function (this: any) {
12
+ return this;
13
+ });
14
+ return {
15
+ resolveLinearToken: vi.fn(() => ({ accessToken: null, source: "none" })),
16
+ LinearAgentApi: MockClass,
17
+ };
18
+ });
19
+
20
+ // Mock the watchdog module (re-exported constants)
21
+ vi.mock("../agent/watchdog.js", () => ({
22
+ DEFAULT_INACTIVITY_SEC: 120,
23
+ DEFAULT_MAX_TOTAL_SEC: 7200,
24
+ DEFAULT_TOOL_TIMEOUT_SEC: 600,
25
+ }));
26
+
27
+ import { getCurrentSession, getActiveSessionByIdentifier } from "../pipeline/active-session.js";
28
+ import { resolveLinearToken, LinearAgentApi } from "../api/linear-api.js";
29
+ import { extractPrompt, resolveSession, buildLinearApi } from "./cli-shared.js";
30
+ import type { CliToolParams } from "./cli-shared.js";
31
+
32
+ const mockedGetCurrentSession = vi.mocked(getCurrentSession);
33
+ const mockedGetActiveByIdentifier = vi.mocked(getActiveSessionByIdentifier);
34
+ const mockedResolveLinearToken = vi.mocked(resolveLinearToken);
35
+ const MockedLinearAgentApi = vi.mocked(LinearAgentApi);
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // extractPrompt
43
+ // ---------------------------------------------------------------------------
44
+ describe("extractPrompt", () => {
45
+ it("returns prompt field when present", () => {
46
+ const params: CliToolParams = { prompt: "do the thing" };
47
+ expect(extractPrompt(params)).toBe("do the thing");
48
+ });
49
+
50
+ it("falls back to text field", () => {
51
+ const params = { text: "text fallback" } as unknown as CliToolParams;
52
+ expect(extractPrompt(params)).toBe("text fallback");
53
+ });
54
+
55
+ it("falls back to message field", () => {
56
+ const params = { message: "message fallback" } as unknown as CliToolParams;
57
+ expect(extractPrompt(params)).toBe("message fallback");
58
+ });
59
+
60
+ it("falls back to task field", () => {
61
+ const params = { task: "task fallback" } as unknown as CliToolParams;
62
+ expect(extractPrompt(params)).toBe("task fallback");
63
+ });
64
+
65
+ it("returns undefined when no fields present", () => {
66
+ const params = {} as unknown as CliToolParams;
67
+ expect(extractPrompt(params)).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // resolveSession
73
+ // ---------------------------------------------------------------------------
74
+ describe("resolveSession", () => {
75
+ it("uses explicit session params when provided", () => {
76
+ const params: CliToolParams = {
77
+ prompt: "test",
78
+ agentSessionId: "sess-123",
79
+ issueIdentifier: "API-42",
80
+ };
81
+
82
+ const result = resolveSession(params);
83
+ expect(result.agentSessionId).toBe("sess-123");
84
+ expect(result.issueIdentifier).toBe("API-42");
85
+ // Should not consult the registry at all
86
+ expect(mockedGetCurrentSession).not.toHaveBeenCalled();
87
+ expect(mockedGetActiveByIdentifier).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("falls back to active session registry", () => {
91
+ mockedGetCurrentSession.mockReturnValue({
92
+ agentSessionId: "active-sess-456",
93
+ issueIdentifier: "API-99",
94
+ issueId: "issue-id-99",
95
+ startedAt: Date.now(),
96
+ });
97
+
98
+ const params: CliToolParams = { prompt: "test" };
99
+ const result = resolveSession(params);
100
+
101
+ expect(result.agentSessionId).toBe("active-sess-456");
102
+ expect(result.issueIdentifier).toBe("API-99");
103
+ expect(mockedGetCurrentSession).toHaveBeenCalled();
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // buildLinearApi
109
+ // ---------------------------------------------------------------------------
110
+ describe("buildLinearApi", () => {
111
+ it("returns null when no agentSessionId provided", () => {
112
+ const api = { pluginConfig: {} } as any;
113
+ expect(buildLinearApi(api)).toBeNull();
114
+ expect(buildLinearApi(api, undefined)).toBeNull();
115
+ });
116
+
117
+ it("returns null when no token available", () => {
118
+ mockedResolveLinearToken.mockReturnValue({
119
+ accessToken: null,
120
+ source: "none",
121
+ });
122
+
123
+ const api = { pluginConfig: {} } as any;
124
+ expect(buildLinearApi(api, "sess-123")).toBeNull();
125
+ });
126
+
127
+ it("creates a LinearAgentApi when token is available", () => {
128
+ mockedResolveLinearToken.mockReturnValue({
129
+ accessToken: "lin_tok_abc",
130
+ refreshToken: "refresh_xyz",
131
+ expiresAt: 9999999999,
132
+ source: "profile",
133
+ });
134
+ MockedLinearAgentApi.mockImplementation(function (this: any) {
135
+ this.fake = true;
136
+ return this;
137
+ } as any);
138
+
139
+ const api = {
140
+ pluginConfig: {
141
+ clientId: "cid",
142
+ clientSecret: "csecret",
143
+ },
144
+ } as any;
145
+
146
+ const result = buildLinearApi(api, "sess-123");
147
+ expect(result).not.toBeNull();
148
+ expect(MockedLinearAgentApi).toHaveBeenCalledWith("lin_tok_abc", {
149
+ refreshToken: "refresh_xyz",
150
+ expiresAt: 9999999999,
151
+ clientId: "cid",
152
+ clientSecret: "csecret",
153
+ });
154
+ });
155
+ });