@calltelemetry/openclaw-linear 0.9.15 → 0.9.17

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.
@@ -49,6 +49,80 @@ import {
49
49
  import { resolveWatchdogConfig } from "../agent/watchdog.js";
50
50
  import { emitDiagnostic } from "../infra/observability.js";
51
51
  import { renderTemplate } from "../infra/template.js";
52
+ import { getWorktreeStatus, createPullRequest } from "../infra/codex-worktree.js";
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Linear issue state transitions (best-effort)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Transition a Linear issue to a target state by type (e.g., "started", "completed", "triage").
60
+ * Resolves the team's workflow states and finds a matching state.
61
+ * All transitions are best-effort — failures are logged as warnings, never thrown.
62
+ */
63
+ async function transitionLinearIssueState(
64
+ linearApi: LinearAgentApi,
65
+ issueId: string,
66
+ teamId: string | undefined,
67
+ targetStateType: string,
68
+ opts?: { preferredStateName?: string },
69
+ logger?: { warn: (msg: string) => void; info: (msg: string) => void },
70
+ ): Promise<void> {
71
+ if (!teamId) {
72
+ logger?.warn(`[linear-state] no teamId — skipping state transition to ${targetStateType}`);
73
+ return;
74
+ }
75
+ try {
76
+ const states = await linearApi.getTeamStates(teamId);
77
+ // Prefer a specific state name if provided, otherwise match by type
78
+ let target = opts?.preferredStateName
79
+ ? states.find(s => s.name === opts.preferredStateName) ?? states.find(s => s.type === targetStateType)
80
+ : states.find(s => s.type === targetStateType);
81
+ if (target) {
82
+ await linearApi.updateIssue(issueId, { stateId: target.id });
83
+ logger?.info(`[linear-state] ${issueId} → ${target.name} (type=${target.type})`);
84
+ } else {
85
+ logger?.warn(`[linear-state] no state with type="${targetStateType}" found for team ${teamId}`);
86
+ }
87
+ } catch (err) {
88
+ logger?.warn(`[linear-state] failed to transition ${issueId} to ${targetStateType}: ${err}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Transition a Linear issue to "In Review" if it exists, otherwise "Done".
94
+ * Used after audit pass when a PR was created.
95
+ */
96
+ async function transitionToReviewOrDone(
97
+ linearApi: LinearAgentApi,
98
+ issueId: string,
99
+ teamId: string | undefined,
100
+ hasPr: boolean,
101
+ logger?: { warn: (msg: string) => void; info: (msg: string) => void },
102
+ ): Promise<void> {
103
+ if (!teamId) return;
104
+ try {
105
+ const states = await linearApi.getTeamStates(teamId);
106
+ if (hasPr) {
107
+ // Prefer "In Review" state; fall back to any "started" state named "In Review", then "completed"
108
+ const inReview = states.find(s => s.name === "In Review")
109
+ ?? states.find(s => s.type === "started" && s.name.toLowerCase().includes("review"));
110
+ if (inReview) {
111
+ await linearApi.updateIssue(issueId, { stateId: inReview.id });
112
+ logger?.info(`[linear-state] ${issueId} → ${inReview.name} (PR created)`);
113
+ return;
114
+ }
115
+ }
116
+ // No PR or no "In Review" state — mark as Done
117
+ const done = states.find(s => s.type === "completed");
118
+ if (done) {
119
+ await linearApi.updateIssue(issueId, { stateId: done.id });
120
+ logger?.info(`[linear-state] ${issueId} → ${done.name} (completed)`);
121
+ }
122
+ } catch (err) {
123
+ logger?.warn(`[linear-state] failed to transition ${issueId} to review/done: ${err}`);
124
+ }
125
+ }
52
126
 
53
127
  // ---------------------------------------------------------------------------
54
128
  // Prompt loading
@@ -218,7 +292,7 @@ export interface IssueContext {
218
292
  export function buildWorkerTask(
219
293
  issue: IssueContext,
220
294
  worktreePath: string,
221
- opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string },
295
+ opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string; teamContext?: string },
222
296
  ): { system: string; task: string } {
223
297
  const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
224
298
  const vars: Record<string, string> = {
@@ -233,6 +307,7 @@ export function buildWorkerTask(
233
307
  ? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
234
308
  : "",
235
309
  projectContext: buildProjectContext(opts?.pluginConfig),
310
+ teamContext: opts?.teamContext ?? "",
236
311
  };
237
312
 
238
313
  let task = renderTemplate(prompts.worker.task, vars);
@@ -253,7 +328,7 @@ export function buildAuditTask(
253
328
  issue: IssueContext,
254
329
  worktreePath: string,
255
330
  pluginConfig?: Record<string, unknown>,
256
- opts?: { guidance?: string },
331
+ opts?: { guidance?: string; teamContext?: string },
257
332
  ): { system: string; task: string } {
258
333
  const prompts = loadPrompts(pluginConfig, worktreePath);
259
334
  const vars: Record<string, string> = {
@@ -268,6 +343,7 @@ export function buildAuditTask(
268
343
  ? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
269
344
  : "",
270
345
  projectContext: buildProjectContext(pluginConfig),
346
+ teamContext: opts?.teamContext ?? "",
271
347
  };
272
348
 
273
349
  return {
@@ -388,11 +464,19 @@ export async function triggerAudit(
388
464
 
389
465
  // Look up cached guidance for audit
390
466
  const auditTeamId = issueDetails?.team?.id;
467
+ const auditTeamKey = issueDetails?.team?.key as string | undefined;
391
468
  const auditGuidance = (auditTeamId && isGuidanceEnabled(pluginConfig, auditTeamId))
392
469
  ? getCachedGuidanceForTeam(auditTeamId) ?? undefined
393
470
  : undefined;
394
471
 
395
- const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig, { guidance: auditGuidance });
472
+ // Resolve team context from teamMappings config
473
+ const auditTeamMappings = pluginConfig?.teamMappings as Record<string, any> | undefined;
474
+ const auditTeamMapping = auditTeamKey ? auditTeamMappings?.[auditTeamKey] : undefined;
475
+ const auditTeamContext = auditTeamMapping?.context
476
+ ? `\n## Team Context (${auditTeamKey})\n${auditTeamMapping.context}\n`
477
+ : "";
478
+
479
+ const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig, { guidance: auditGuidance, teamContext: auditTeamContext });
396
480
 
397
481
  // Set Linear label
398
482
  await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
@@ -424,11 +508,22 @@ export async function triggerAudit(
424
508
  activeDispatch.auditSessionKey = auditSessionId;
425
509
  }
426
510
 
511
+ // Resolve audit agent: team mapping → plugin default
512
+ const auditDefaultAgentId = (pluginConfig?.defaultAgentId as string) ?? "default";
513
+ let auditAgentId = auditDefaultAgentId;
514
+ if (auditTeamKey) {
515
+ const auditTeamDefault = auditTeamMappings?.[auditTeamKey]?.defaultAgent;
516
+ if (typeof auditTeamDefault === "string") {
517
+ auditAgentId = auditTeamDefault;
518
+ api.logger.info(`${TAG} audit agent routed to ${auditAgentId} via team mapping (${auditTeamKey})`);
519
+ }
520
+ }
521
+
427
522
  api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
428
523
 
429
524
  const result = await runAgent({
430
525
  api,
431
- agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
526
+ agentId: auditAgentId,
432
527
  sessionId: auditSessionId,
433
528
  message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
434
529
  streaming: dispatch.agentSessionId
@@ -558,15 +653,7 @@ async function handleAuditPass(
558
653
  throw err;
559
654
  }
560
655
 
561
- // Move to completed
562
- await completeDispatch(dispatch.issueIdentifier, {
563
- tier: dispatch.tier,
564
- status: "done",
565
- completedAt: new Date().toISOString(),
566
- project: dispatch.project,
567
- }, configPath);
568
-
569
- // Build summary from .claw/ artifacts and write to memory
656
+ // Build summary from .claw/ artifacts and write to memory (before PR so body can include it)
570
657
  let summary: string | null = null;
571
658
  try {
572
659
  summary = buildSummaryFromArtifacts(dispatch.worktreePath);
@@ -587,12 +674,58 @@ async function handleAuditPass(
587
674
  api.logger.warn(`${TAG} failed to write summary/memory: ${err}`);
588
675
  }
589
676
 
590
- // Post approval comment (with summary excerpt if available)
677
+ // Auto-PR creation (best-effort, non-fatal)
678
+ let prUrl: string | null = null;
679
+ try {
680
+ const wtStatus = getWorktreeStatus(dispatch.worktreePath);
681
+ if (wtStatus.lastCommit) {
682
+ const prBody = [
683
+ `Fixes ${dispatch.issueIdentifier}`,
684
+ "",
685
+ "## What changed",
686
+ summary?.slice(0, 2000) ?? "(see audit verdict)",
687
+ "",
688
+ "## Audit",
689
+ `- **Criteria:** ${verdict.criteria.join(", ") || "N/A"}`,
690
+ `- **Tests:** ${verdict.testResults || "N/A"}`,
691
+ "",
692
+ `*Auto-generated by OpenClaw dispatch pipeline*`,
693
+ ].join("\n");
694
+ const prResult = createPullRequest(
695
+ dispatch.worktreePath,
696
+ `${dispatch.issueIdentifier}: ${dispatch.issueTitle ?? "implementation"}`,
697
+ prBody,
698
+ );
699
+ prUrl = prResult.prUrl;
700
+ api.logger.info(`${TAG} PR created: ${prUrl}`);
701
+ } else {
702
+ api.logger.info(`${TAG} no commits in worktree — skipping PR creation`);
703
+ }
704
+ } catch (err) {
705
+ api.logger.warn(`${TAG} PR creation failed (non-fatal): ${err}`);
706
+ }
707
+
708
+ // Move to completed (include prUrl if PR was created)
709
+ await completeDispatch(dispatch.issueIdentifier, {
710
+ tier: dispatch.tier,
711
+ status: "done",
712
+ completedAt: new Date().toISOString(),
713
+ project: dispatch.project,
714
+ prUrl: prUrl ?? undefined,
715
+ }, configPath);
716
+
717
+ // Linear state transition: "In Review" if PR was created, otherwise "Done"
718
+ const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
719
+ const teamId = issueDetails?.team?.id;
720
+ await transitionToReviewOrDone(linearApi, dispatch.issueId, teamId, !!prUrl, api.logger);
721
+
722
+ // Post approval comment (with summary excerpt and PR link if available)
591
723
  const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
592
724
  const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
725
+ const prLine = prUrl ? `\n- **Pull request:** ${prUrl}` : "";
593
726
  await linearApi.createComment(
594
727
  dispatch.issueId,
595
- `## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`\n- Create a PR from the worktree branch if one wasn't opened automatically`,
728
+ `## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}${prLine}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`${prUrl ? "" : "\n- Create a PR from the worktree branch if one wasn't opened automatically"}`,
596
729
  ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
597
730
 
598
731
  api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
@@ -673,6 +806,13 @@ async function handleAuditFail(
673
806
  }
674
807
  } catch {}
675
808
 
809
+ // Linear state transition: move to "Triage" so it shows up for human review
810
+ const stuckIssueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
811
+ await transitionLinearIssueState(
812
+ linearApi, dispatch.issueId, stuckIssueDetails?.team?.id,
813
+ "triage", undefined, api.logger,
814
+ );
815
+
676
816
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
677
817
  await linearApi.createComment(
678
818
  dispatch.issueId,
@@ -804,15 +944,24 @@ export async function spawnWorker(
804
944
 
805
945
  // Look up cached guidance for the issue's team
806
946
  const workerTeamId = issueDetails?.team?.id;
947
+ const workerTeamKey = issueDetails?.team?.key as string | undefined;
807
948
  const workerGuidance = (workerTeamId && isGuidanceEnabled(pluginConfig, workerTeamId))
808
949
  ? getCachedGuidanceForTeam(workerTeamId) ?? undefined
809
950
  : undefined;
810
951
 
952
+ // Resolve team context from teamMappings config
953
+ const workerTeamMappings = pluginConfig?.teamMappings as Record<string, any> | undefined;
954
+ const workerTeamMapping = workerTeamKey ? workerTeamMappings?.[workerTeamKey] : undefined;
955
+ const workerTeamContext = workerTeamMapping?.context
956
+ ? `\n## Team Context (${workerTeamKey})\n${workerTeamMapping.context}\n`
957
+ : "";
958
+
811
959
  const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
812
960
  attempt: dispatch.attempt,
813
961
  gaps: opts?.gaps,
814
962
  pluginConfig,
815
963
  guidance: workerGuidance,
964
+ teamContext: workerTeamContext,
816
965
  });
817
966
 
818
967
  const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
@@ -833,10 +982,21 @@ export async function spawnWorker(
833
982
 
834
983
  api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
835
984
 
985
+ // Resolve agent: team mapping → plugin default
986
+ const defaultAgentId = (pluginConfig?.defaultAgentId as string) ?? "default";
987
+ let workerAgentId = defaultAgentId;
988
+ if (workerTeamKey) {
989
+ const teamDefault = workerTeamMappings?.[workerTeamKey]?.defaultAgent;
990
+ if (typeof teamDefault === "string") {
991
+ workerAgentId = teamDefault;
992
+ api.logger.info(`${TAG} worker agent routed to ${workerAgentId} via team mapping (${workerTeamKey})`);
993
+ }
994
+ }
995
+
836
996
  const workerStartTime = Date.now();
837
997
  const result = await runAgent({
838
998
  api,
839
- agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
999
+ agentId: workerAgentId,
840
1000
  sessionId: workerSessionId,
841
1001
  message: `${workerPrompt.system}\n\n${workerPrompt.task}`,
842
1002
  streaming: dispatch.agentSessionId
@@ -846,7 +1006,7 @@ export async function spawnWorker(
846
1006
 
847
1007
  // Save worker output to .claw/
848
1008
  const workerElapsed = Date.now() - workerStartTime;
849
- const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
1009
+ const agentId = workerAgentId;
850
1010
  try { saveWorkerOutput(dispatch.worktreePath, dispatch.attempt, result.output); } catch {}
851
1011
  try {
852
1012
  appendLog(dispatch.worktreePath, {
@@ -897,6 +1057,13 @@ export async function spawnWorker(
897
1057
  }
898
1058
  }
899
1059
 
1060
+ // Linear state transition: move to "Triage" so it shows up for human review
1061
+ const wdIssueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
1062
+ await transitionLinearIssueState(
1063
+ linearApi, dispatch.issueId, wdIssueDetails?.team?.id,
1064
+ "triage", undefined, api.logger,
1065
+ );
1066
+
900
1067
  await linearApi.createComment(
901
1068
  dispatch.issueId,
902
1069
  `## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
@@ -963,7 +963,7 @@ describe("AgentSessionEvent.prompted full flow", () => {
963
963
 
964
964
  expect(result.status).toBe(200);
965
965
  const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
966
- expect(infoCalls.some((msg: string) => msg.includes("agent active, ignoring (feedback)"))).toBe(true);
966
+ expect(infoCalls.some((msg: string) => msg.includes("agent active, no tmux, ignoring (feedback)"))).toBe(true);
967
967
  });
968
968
 
969
969
  it("deduplicates by webhookId", async () => {