@calltelemetry/openclaw-linear 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +834 -536
  2. package/index.ts +1 -1
  3. package/openclaw.plugin.json +3 -2
  4. package/package.json +1 -1
  5. package/prompts.yaml +46 -6
  6. package/src/__test__/fixtures/linear-responses.ts +75 -0
  7. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  8. package/src/__test__/helpers.ts +133 -0
  9. package/src/agent/agent.test.ts +192 -0
  10. package/src/agent/agent.ts +26 -1
  11. package/src/api/linear-api.test.ts +93 -1
  12. package/src/api/linear-api.ts +37 -1
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/infra/cli.ts +176 -1
  15. package/src/infra/commands.test.ts +276 -0
  16. package/src/infra/doctor.test.ts +19 -0
  17. package/src/infra/doctor.ts +30 -25
  18. package/src/infra/multi-repo.test.ts +163 -0
  19. package/src/infra/multi-repo.ts +29 -0
  20. package/src/infra/notify.test.ts +155 -16
  21. package/src/infra/notify.ts +26 -15
  22. package/src/infra/observability.test.ts +85 -0
  23. package/src/pipeline/artifacts.test.ts +26 -3
  24. package/src/pipeline/dispatch-state.ts +1 -0
  25. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  26. package/src/pipeline/e2e-planning.test.ts +478 -0
  27. package/src/pipeline/intent-classify.test.ts +285 -0
  28. package/src/pipeline/intent-classify.ts +259 -0
  29. package/src/pipeline/pipeline.test.ts +69 -0
  30. package/src/pipeline/pipeline.ts +47 -18
  31. package/src/pipeline/planner.test.ts +159 -40
  32. package/src/pipeline/planner.ts +108 -60
  33. package/src/pipeline/tier-assess.test.ts +89 -0
  34. package/src/pipeline/webhook.ts +424 -251
  35. package/src/tools/claude-tool.ts +6 -0
  36. package/src/tools/cli-shared.test.ts +155 -0
  37. package/src/tools/code-tool.test.ts +210 -0
  38. package/src/tools/code-tool.ts +2 -2
  39. package/src/tools/dispatch-history-tool.test.ts +315 -0
  40. package/src/tools/planner-tools.test.ts +1 -1
  41. package/src/tools/planner-tools.ts +10 -2
@@ -8,12 +8,14 @@ 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";
13
- import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
14
- import { initiatePlanningSession, handlePlannerTurn } from "./planner.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";
14
+ import { readPlanningState, isInPlanningMode, getPlanningSession, endPlanningSession } from "./planning-state.js";
15
+ import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
15
16
  import { startProjectDispatch } from "./dag-dispatch.js";
16
17
  import { emitDiagnostic } from "../infra/observability.js";
18
+ import { classifyIntent } from "./intent-classify.js";
17
19
 
18
20
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
19
21
  interface AgentProfile {
@@ -262,7 +264,7 @@ export async function handleLinearWebhook(
262
264
 
263
265
  const responseBody = result.success
264
266
  ? result.output
265
- : `I encountered an error processing this request. Please try again.`;
267
+ : `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
268
 
267
269
  // 5. Post branded comment (fallback to prefix)
268
270
  const brandingOpts = avatarUrl
@@ -438,7 +440,7 @@ export async function handleLinearWebhook(
438
440
 
439
441
  const responseBody = result.success
440
442
  ? result.output
441
- : `I encountered an error processing this request. Please try again.`;
443
+ : `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
444
 
443
445
  // Post as comment
444
446
  const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
@@ -616,7 +618,7 @@ export async function handleLinearWebhook(
616
618
 
617
619
  const responseBody = result.success
618
620
  ? result.output
619
- : `I encountered an error processing this request. Please try again.`;
621
+ : `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
622
 
621
623
  const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
622
624
  const brandingOpts = avatarUrl
@@ -658,7 +660,7 @@ export async function handleLinearWebhook(
658
660
  return true;
659
661
  }
660
662
 
661
- // ── Comment.create — @mention routing to agents ─────────────────
663
+ // ── Comment.create — intent-based routing ────────────────────────
662
664
  if (payload.type === "Comment" && payload.action === "create") {
663
665
  res.statusCode = 200;
664
666
  res.end("ok");
@@ -667,257 +669,211 @@ export async function handleLinearWebhook(
667
669
  const commentBody = comment?.body ?? "";
668
670
  const commentor = comment?.user?.name ?? "Unknown";
669
671
  const issue = comment?.issue ?? payload.issue;
670
-
671
- // ── Planning mode intercept ──────────────────────────────────
672
672
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
673
673
 
674
- if (issue?.id) {
675
- const linearApiForPlanning = createLinearApi(api);
676
- if (linearApiForPlanning) {
677
- try {
678
- const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
679
- const projectId = enriched?.project?.id;
680
- const planStatePath = pluginConfig?.planningStatePath as string | undefined;
681
-
682
- if (projectId) {
683
- const planState = await readPlanningState(planStatePath);
684
-
685
- // Check if this is a plan initiation request
686
- const isPlanRequest = /\b(plan|planning)\s+(this\s+)?(project|out)\b/i.test(commentBody);
687
- if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
688
- api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
689
- void initiatePlanningSession(
690
- { api, linearApi: linearApiForPlanning, pluginConfig },
691
- projectId,
692
- { id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
693
- ).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
694
- return true;
695
- }
696
-
697
- // Route to planner if project is in planning mode
698
- if (isInPlanningMode(planState, projectId)) {
699
- const session = getPlanningSession(planState, projectId);
700
- if (session && comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
701
- api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
702
- void handlePlannerTurn(
703
- { api, linearApi: linearApiForPlanning, pluginConfig },
704
- session,
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
- },
720
- ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
721
- }
722
- return true;
723
- }
724
- }
725
- } catch (err) {
726
- api.logger.warn(`Planning mode check failed: ${err}`);
727
- }
728
- }
729
- }
730
-
731
- // Load agent profiles and build mention pattern dynamically.
732
- // Default agent (app mentions) is handled by AgentSessionEvent — never here.
733
- const profiles = loadAgentProfiles();
734
- const mentionPattern = buildMentionPattern(profiles);
735
- if (!mentionPattern) {
736
- api.logger.info("Comment webhook: no sub-agent profiles configured, ignoring");
737
- return true;
738
- }
739
-
740
- const matches = commentBody.match(mentionPattern);
741
- if (!matches || matches.length === 0) {
742
- api.logger.info("Comment webhook: no sub-agent mentions found, ignoring");
743
- return true;
744
- }
745
-
746
- const alias = matches[0].replace("@", "");
747
- const resolved = resolveAgentFromAlias(alias, profiles);
748
- if (!resolved) {
749
- api.logger.info(`Comment webhook: alias "${alias}" not found in profiles, ignoring`);
750
- return true;
751
- }
752
-
753
- const mentionedAgent = resolved.agentId;
754
-
755
674
  if (!issue?.id) {
756
675
  api.logger.error("Comment webhook: missing issue data");
757
676
  return true;
758
677
  }
759
678
 
760
- const linearApi = createLinearApi(api);
761
- if (!linearApi) {
762
- api.logger.error("No Linear access token — cannot process comment mention");
763
- return true;
764
- }
765
-
766
- // Dedup on comment ID — prevent processing same comment twice
679
+ // Dedup on comment ID
767
680
  if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
768
681
  api.logger.info(`Comment ${comment.id} already processed — skipping`);
769
682
  return true;
770
683
  }
771
684
 
772
- api.logger.info(`Comment mention: @${mentionedAgent} on ${issue.identifier ?? issue.id} by ${commentor}`);
773
-
774
- // Guard: skip if an agent run is already active for this issue
775
- // (prevents dual-dispatch when both Comment.create and AgentSessionEvent fire)
776
- if (activeRuns.has(issue.id)) {
777
- api.logger.info(`Comment mention: agent already running for ${issue.identifier ?? issue.id} — skipping`);
685
+ const linearApi = createLinearApi(api);
686
+ if (!linearApi) {
687
+ api.logger.error("No Linear access token cannot process comment");
778
688
  return true;
779
689
  }
780
- activeRuns.add(issue.id);
781
690
 
782
- // React with eyes to acknowledge the comment
783
- if (comment?.id) {
784
- linearApi.createReaction(comment.id, "eyes").catch(() => {});
691
+ // Skip bot's own comments
692
+ try {
693
+ const viewerId = await linearApi.getViewerId();
694
+ if (viewerId && comment?.user?.id === viewerId) {
695
+ api.logger.info(`Comment webhook: skipping our own comment on ${issue.identifier ?? issue.id}`);
696
+ return true;
697
+ }
698
+ } catch { /* proceed if viewerId check fails */ }
699
+
700
+ // Load agent profiles
701
+ const profiles = loadAgentProfiles();
702
+ const agentNames = Object.keys(profiles);
703
+
704
+ // ── @mention fast path — skip classifier ────────────────────
705
+ const mentionPattern = buildMentionPattern(profiles);
706
+ const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
707
+ if (mentionMatches && mentionMatches.length > 0) {
708
+ const alias = mentionMatches[0].replace("@", "");
709
+ const resolved = resolveAgentFromAlias(alias, profiles);
710
+ if (resolved) {
711
+ api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
712
+ void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
713
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
714
+ return true;
715
+ }
785
716
  }
786
717
 
787
- // Fetch full issue details from Linear API for richer context
788
- let enrichedIssue = issue;
789
- let recentComments = "";
718
+ // ── Intent classification ─────────────────────────────────────
719
+ // Fetch issue details for context
720
+ let enrichedIssue: any = issue;
790
721
  try {
791
- const full = await linearApi.getIssueDetails(issue.id);
792
- enrichedIssue = { ...issue, ...full };
793
- // Include last few comments for context (excluding the triggering comment)
794
- const comments = full.comments?.nodes ?? [];
795
- const relevant = comments
796
- .filter((c: any) => c.body !== commentBody)
797
- .slice(-3)
798
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body.slice(0, 200)}`)
799
- .join("\n");
800
- if (relevant) recentComments = `\n**Recent Comments:**\n${relevant}\n`;
722
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
801
723
  } catch (err) {
802
724
  api.logger.warn(`Could not fetch issue details: ${err}`);
803
725
  }
804
726
 
805
- const priority = ["No Priority", "Urgent (P1)", "High (P2)", "Medium (P3)", "Low (P4)"][enrichedIssue.priority] ?? "Unknown";
806
- const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name).join(", ") || "None";
807
- const state = enrichedIssue.state?.name ?? "Unknown";
808
- const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
809
-
810
- const taskMessage = [
811
- `You are an orchestrator responding to a Linear issue comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
812
- ``,
813
- `**Tool access:**`,
814
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${enrichedIssue.identifier ?? "API-XXX"}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
815
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
816
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
817
- ``,
818
- `**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
819
- ``,
820
- `**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
821
- `**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
822
- `**URL:** ${enrichedIssue.url ?? "N/A"}`,
823
- ``,
824
- enrichedIssue.description ? `**Description:**\n${enrichedIssue.description}\n` : "",
825
- recentComments,
826
- `**${commentor} wrote:**`,
827
- `> ${commentBody}`,
828
- ``,
829
- `Respond to their message. Be concise and direct. For work requests, dispatch via \`code_run\` and summarize the result.`,
830
- ].filter(Boolean).join("\n");
831
-
832
- // Dispatch to agent with full session lifecycle (non-blocking)
833
- void (async () => {
834
- const label = resolved.label;
835
- const profile = profiles[mentionedAgent];
836
- let agentSessionId: string | null = null;
727
+ const projectId = enrichedIssue?.project?.id;
728
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
837
729
 
730
+ // Determine planning state
731
+ let isPlanning = false;
732
+ let planSession: any = null;
733
+ if (projectId) {
838
734
  try {
839
- // 1. Create agent session (non-fatal if fails)
840
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
841
- agentSessionId = sessionResult.sessionId;
842
- if (agentSessionId) {
843
- api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
844
- // Register active session so code_run can resolve it automatically
845
- setActiveSession({
846
- agentSessionId,
847
- issueIdentifier: enrichedIssue.identifier ?? issue.id,
848
- issueId: issue.id,
849
- agentId: mentionedAgent,
850
- startedAt: Date.now(),
851
- });
852
- } else {
853
- api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
854
- }
855
-
856
- // 2. Emit thought
857
- if (agentSessionId) {
858
- await linearApi.emitActivity(agentSessionId, {
859
- type: "thought",
860
- body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
861
- }).catch(() => {});
735
+ const planState = await readPlanningState(planStatePath);
736
+ isPlanning = isInPlanningMode(planState, projectId);
737
+ if (isPlanning) {
738
+ planSession = getPlanningSession(planState, projectId);
862
739
  }
740
+ } catch { /* proceed without planning context */ }
741
+ }
863
742
 
864
- // 3. Run agent subprocess with streaming
865
- const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
866
- const { runAgent } = await import("../agent/agent.js");
867
- const result = await runAgent({
868
- api,
869
- agentId: mentionedAgent,
870
- sessionId,
871
- message: taskMessage,
872
- timeoutMs: 3 * 60_000,
873
- streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
874
- });
743
+ const intentResult = await classifyIntent(api, {
744
+ commentBody,
745
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
746
+ issueStatus: enrichedIssue?.state?.name,
747
+ isPlanning,
748
+ agentNames,
749
+ hasProject: !!projectId,
750
+ }, pluginConfig);
875
751
 
876
- const responseBody = result.success
877
- ? result.output
878
- : `I encountered an error processing this request. Please try again or check the logs.`;
752
+ api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
879
753
 
880
- // 5. Post branded comment (fall back to [Label] prefix if branding fails)
881
- const brandingOpts = profile?.avatarUrl
882
- ? { createAsUser: label, displayIconUrl: profile.avatarUrl }
883
- : undefined;
754
+ // ── Route by intent ────────────────────────────────────────────
884
755
 
885
- try {
886
- if (brandingOpts) {
887
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
888
- } else {
889
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
756
+ switch (intentResult.intent) {
757
+ case "plan_start": {
758
+ if (!projectId) {
759
+ api.logger.info("Comment intent plan_start but no project — ignoring");
760
+ break;
761
+ }
762
+ if (isPlanning) {
763
+ api.logger.info("Comment intent plan_start but already planning — treating as plan_continue");
764
+ // Fall through to plan_continue
765
+ if (planSession) {
766
+ void handlePlannerTurn(
767
+ { api, linearApi, pluginConfig },
768
+ planSession,
769
+ { issueId: issue.id, commentBody, commentorName: commentor },
770
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
890
771
  }
891
- } catch (brandErr) {
892
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
893
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
772
+ break;
894
773
  }
774
+ api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
775
+ void initiatePlanningSession(
776
+ { api, linearApi, pluginConfig },
777
+ projectId,
778
+ { id: issue.id, identifier: enrichedIssue.identifier, title: enrichedIssue.title, team: enrichedIssue.team },
779
+ ).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
780
+ break;
781
+ }
895
782
 
896
- // 6. Emit response activity (closes the session)
897
- if (agentSessionId) {
898
- const truncated = responseBody.length > 2000
899
- ? responseBody.slice(0, 2000) + "\u2026"
900
- : responseBody;
901
- await linearApi.emitActivity(agentSessionId, {
902
- type: "response",
903
- body: truncated,
904
- }).catch(() => {});
783
+ case "plan_finalize": {
784
+ if (!isPlanning || !planSession) {
785
+ api.logger.info("Comment intent plan_finalize but not in planning mode — ignoring");
786
+ break;
905
787
  }
788
+ if (planSession.status === "plan_review") {
789
+ // Already passed audit + cross-model review — approve directly
790
+ api.logger.info(`Planning: approving plan for ${planSession.projectName} (from plan_review)`);
791
+ void (async () => {
792
+ try {
793
+ await endPlanningSession(planSession.projectId, "approved", planStatePath);
794
+ await linearApi.createComment(
795
+ planSession.rootIssueId,
796
+ `## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
797
+ );
798
+ // Trigger DAG dispatch
799
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
800
+ const hookCtx: HookContext = {
801
+ api, linearApi, notify, pluginConfig,
802
+ configPath: pluginConfig?.dispatchStatePath as string | undefined,
803
+ };
804
+ await startProjectDispatch(hookCtx, planSession.projectId);
805
+ } catch (err) {
806
+ api.logger.error(`Plan approval error: ${err}`);
807
+ }
808
+ })();
809
+ } else {
810
+ // Still interviewing — run audit (which transitions to plan_review)
811
+ void runPlanAudit(
812
+ { api, linearApi, pluginConfig },
813
+ planSession,
814
+ ).catch((err) => api.logger.error(`Plan audit error: ${err}`));
815
+ }
816
+ break;
817
+ }
906
818
 
907
- api.logger.info(`Posted @${mentionedAgent} response to ${issue.identifier ?? issue.id}`);
908
- } catch (err) {
909
- api.logger.error(`Comment mention handler error: ${err}`);
910
- if (agentSessionId) {
911
- await linearApi.emitActivity(agentSessionId, {
912
- type: "error",
913
- body: `Failed to process mention: ${String(err).slice(0, 500)}`,
914
- }).catch(() => {});
819
+ case "plan_abandon": {
820
+ if (!isPlanning || !planSession) {
821
+ api.logger.info("Comment intent plan_abandon but not in planning mode — ignoring");
822
+ break;
915
823
  }
916
- } finally {
917
- clearActiveSession(issue.id);
918
- activeRuns.delete(issue.id);
824
+ void (async () => {
825
+ try {
826
+ await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
827
+ await linearApi.createComment(
828
+ planSession.rootIssueId,
829
+ `Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
830
+ );
831
+ api.logger.info(`Planning: session abandoned for ${planSession.projectName}`);
832
+ } catch (err) {
833
+ api.logger.error(`Plan abandon error: ${err}`);
834
+ }
835
+ })();
836
+ break;
837
+ }
838
+
839
+ case "plan_continue": {
840
+ if (!isPlanning || !planSession) {
841
+ // Not in planning mode — treat as general
842
+ api.logger.info("Comment intent plan_continue but not in planning mode — dispatching to default agent");
843
+ void dispatchCommentToAgent(api, linearApi, profiles, resolveAgentId(api), issue, comment, commentBody, commentor, pluginConfig)
844
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
845
+ break;
846
+ }
847
+ void handlePlannerTurn(
848
+ { api, linearApi, pluginConfig },
849
+ planSession,
850
+ { issueId: issue.id, commentBody, commentorName: commentor },
851
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
852
+ break;
853
+ }
854
+
855
+ case "ask_agent": {
856
+ const targetAgent = intentResult.agentId ?? resolveAgentId(api);
857
+ api.logger.info(`Comment intent ask_agent: routing to ${targetAgent}`);
858
+ void dispatchCommentToAgent(api, linearApi, profiles, targetAgent, issue, comment, commentBody, commentor, pluginConfig)
859
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
860
+ break;
861
+ }
862
+
863
+ case "request_work":
864
+ case "question": {
865
+ const defaultAgent = resolveAgentId(api);
866
+ api.logger.info(`Comment intent ${intentResult.intent}: routing to default agent ${defaultAgent}`);
867
+ void dispatchCommentToAgent(api, linearApi, profiles, defaultAgent, issue, comment, commentBody, commentor, pluginConfig)
868
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
869
+ break;
919
870
  }
920
- })();
871
+
872
+ case "general":
873
+ default:
874
+ api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
875
+ break;
876
+ }
921
877
 
922
878
  return true;
923
879
  }
@@ -1000,6 +956,7 @@ export async function handleLinearWebhook(
1000
956
 
1001
957
  api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
1002
958
 
959
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
1003
960
  const linearApi = createLinearApi(api);
1004
961
  if (!linearApi) {
1005
962
  api.logger.error("No Linear access token — cannot triage new issue");
@@ -1029,6 +986,27 @@ export async function handleLinearWebhook(
1029
986
  api.logger.warn(`Could not fetch issue details for triage: ${err}`);
1030
987
  }
1031
988
 
989
+ // Skip triage for issues in projects that are actively being planned —
990
+ // the planner creates issues and triage would overwrite its estimates/labels.
991
+ const triageProjectId = enrichedIssue?.project?.id;
992
+ if (triageProjectId) {
993
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
994
+ try {
995
+ const planState = await readPlanningState(planStatePath);
996
+ if (isInPlanningMode(planState, triageProjectId)) {
997
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} belongs to project in planning mode — skipping triage`);
998
+ return;
999
+ }
1000
+ } catch { /* proceed with triage if planning state check fails */ }
1001
+ }
1002
+
1003
+ // Skip triage for issues created by our own bot user
1004
+ const viewerId = await linearApi.getViewerId();
1005
+ if (viewerId && issue.creatorId === viewerId) {
1006
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} created by our bot — skipping triage`);
1007
+ return;
1008
+ }
1009
+
1032
1010
  const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1033
1011
  const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
1034
1012
  const currentLabels = enrichedIssue?.labels?.nodes ?? [];
@@ -1113,7 +1091,7 @@ export async function handleLinearWebhook(
1113
1091
 
1114
1092
  const responseBody = result.success
1115
1093
  ? result.output
1116
- : `I encountered an error triaging this issue. Please triage manually.`;
1094
+ : `Something went wrong while triaging this issue. You may need to set the estimate and labels manually.`;
1117
1095
 
1118
1096
  // Parse triage JSON and apply to issue
1119
1097
  let commentBody = responseBody;
@@ -1207,6 +1185,154 @@ export async function handleLinearWebhook(
1207
1185
  return true;
1208
1186
  }
1209
1187
 
1188
+ // ── Comment dispatch helper ───────────────────────────────────────
1189
+ //
1190
+ // Dispatches a comment to a specific agent. Used by intent-based routing
1191
+ // and @mention fast path.
1192
+
1193
+ async function dispatchCommentToAgent(
1194
+ api: OpenClawPluginApi,
1195
+ linearApi: LinearAgentApi,
1196
+ profiles: Record<string, AgentProfile>,
1197
+ agentId: string,
1198
+ issue: any,
1199
+ comment: any,
1200
+ commentBody: string,
1201
+ commentor: string,
1202
+ pluginConfig?: Record<string, unknown>,
1203
+ ): Promise<void> {
1204
+ const profile = profiles[agentId];
1205
+ const label = profile?.label ?? agentId;
1206
+ const avatarUrl = profile?.avatarUrl;
1207
+
1208
+ // Guard: prevent concurrent runs on same issue
1209
+ if (activeRuns.has(issue.id)) {
1210
+ api.logger.info(`dispatchCommentToAgent: ${issue.identifier ?? issue.id} has active run — skipping`);
1211
+ return;
1212
+ }
1213
+
1214
+ // Fetch full issue details
1215
+ let enrichedIssue: any = issue;
1216
+ try {
1217
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1218
+ } catch (err) {
1219
+ api.logger.warn(`Could not fetch issue details: ${err}`);
1220
+ }
1221
+
1222
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1223
+ const comments = enrichedIssue?.comments?.nodes ?? [];
1224
+ const commentSummary = comments
1225
+ .slice(-5)
1226
+ .map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 200)}`)
1227
+ .join("\n");
1228
+
1229
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1230
+ const message = [
1231
+ `You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
1232
+ ``,
1233
+ `**Tool access:**`,
1234
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
1235
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1236
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
1237
+ ``,
1238
+ `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
1239
+ ``,
1240
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1241
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1242
+ ``,
1243
+ `**Description:**`,
1244
+ description,
1245
+ commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
1246
+ `\n**${commentor} says:**\n> ${commentBody}`,
1247
+ ``,
1248
+ `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1249
+ ].filter(Boolean).join("\n");
1250
+
1251
+ // Dispatch with session lifecycle
1252
+ activeRuns.add(issue.id);
1253
+ let agentSessionId: string | null = null;
1254
+
1255
+ try {
1256
+ // Create agent session (non-fatal)
1257
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1258
+ agentSessionId = sessionResult.sessionId;
1259
+ if (agentSessionId) {
1260
+ wasRecentlyProcessed(`session:${agentSessionId}`);
1261
+ setActiveSession({
1262
+ agentSessionId,
1263
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
1264
+ issueId: issue.id,
1265
+ agentId,
1266
+ startedAt: Date.now(),
1267
+ });
1268
+ }
1269
+
1270
+ // Emit thought
1271
+ if (agentSessionId) {
1272
+ await linearApi.emitActivity(agentSessionId, {
1273
+ type: "thought",
1274
+ body: `Processing comment on ${issueRef}...`,
1275
+ }).catch(() => {});
1276
+ }
1277
+
1278
+ // Run agent
1279
+ const sessionId = `linear-comment-${agentId}-${Date.now()}`;
1280
+ const { runAgent } = await import("../agent/agent.js");
1281
+ const result = await runAgent({
1282
+ api,
1283
+ agentId,
1284
+ sessionId,
1285
+ message,
1286
+ timeoutMs: 3 * 60_000,
1287
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
1288
+ });
1289
+
1290
+ const responseBody = result.success
1291
+ ? result.output
1292
+ : `Something went wrong while processing this. The system will retry automatically if possible.`;
1293
+
1294
+ // Post branded comment
1295
+ const brandingOpts = avatarUrl
1296
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1297
+ : undefined;
1298
+
1299
+ try {
1300
+ if (brandingOpts) {
1301
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
1302
+ } else {
1303
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1304
+ }
1305
+ } catch (brandErr) {
1306
+ api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1307
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1308
+ }
1309
+
1310
+ // Emit response (closes session)
1311
+ if (agentSessionId) {
1312
+ const truncated = responseBody.length > 2000
1313
+ ? responseBody.slice(0, 2000) + "…"
1314
+ : responseBody;
1315
+ await linearApi.emitActivity(agentSessionId, {
1316
+ type: "response",
1317
+ body: truncated,
1318
+ }).catch(() => {});
1319
+ }
1320
+
1321
+ api.logger.info(`Posted ${agentId} response to ${issueRef}`);
1322
+ } catch (err) {
1323
+ api.logger.error(`dispatchCommentToAgent error: ${err}`);
1324
+ if (agentSessionId) {
1325
+ await linearApi.emitActivity(agentSessionId, {
1326
+ type: "error",
1327
+ body: `Failed to process comment: ${String(err).slice(0, 500)}`,
1328
+ }).catch(() => {});
1329
+ }
1330
+ } finally {
1331
+ clearActiveSession(issue.id);
1332
+ activeRuns.delete(issue.id);
1333
+ }
1334
+ }
1335
+
1210
1336
  // ── @dispatch handler ─────────────────────────────────────────────
1211
1337
  //
1212
1338
  // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
@@ -1237,7 +1363,7 @@ async function handleDispatch(
1237
1363
  api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1238
1364
  await linearApi.createComment(
1239
1365
  issue.id,
1240
- "This project is in planning mode. Finalize the plan before dispatching implementation.",
1366
+ `**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
1367
  );
1242
1368
  return;
1243
1369
  }
@@ -1260,7 +1386,7 @@ async function handleDispatch(
1260
1386
  api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1261
1387
  await linearApi.createComment(
1262
1388
  issue.id,
1263
- `Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
1389
+ `**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
1390
  );
1265
1391
  return;
1266
1392
  }
@@ -1292,6 +1418,9 @@ async function handleDispatch(
1292
1418
  const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1293
1419
  const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
1294
1420
 
1421
+ // Resolve repos for this dispatch (issue body markers, labels, or config default)
1422
+ const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
1423
+
1295
1424
  // 4. Assess complexity tier
1296
1425
  const assessment = await assessTier(api, {
1297
1426
  identifier,
@@ -1304,28 +1433,51 @@ async function handleDispatch(
1304
1433
  api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1305
1434
  emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
1306
1435
 
1307
- // 5. Create persistent worktree
1308
- let worktree;
1436
+ // 5. Create persistent worktree(s)
1437
+ let worktreePath: string;
1438
+ let worktreeBranch: string;
1439
+ let worktreeResumed: boolean;
1440
+ let worktrees: Array<{ repoName: string; path: string; branch: string }> | undefined;
1441
+
1309
1442
  try {
1310
- worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1311
- api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
1443
+ if (isMultiRepo(repoResolution)) {
1444
+ const multi = createMultiWorktree(identifier, repoResolution.repos, { baseDir: worktreeBaseDir });
1445
+ worktreePath = multi.parentPath;
1446
+ worktreeBranch = `codex/${identifier}`;
1447
+ worktreeResumed = multi.worktrees.some(w => w.resumed);
1448
+ worktrees = multi.worktrees.map(w => ({ repoName: w.repoName, path: w.path, branch: w.branch }));
1449
+ api.logger.info(`@dispatch: multi-repo worktrees ${worktreeResumed ? "resumed" : "created"} at ${worktreePath} (${repoResolution.repos.map(r => r.name).join(", ")})`);
1450
+ } else {
1451
+ const single = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1452
+ worktreePath = single.path;
1453
+ worktreeBranch = single.branch;
1454
+ worktreeResumed = single.resumed;
1455
+ api.logger.info(`@dispatch: worktree ${worktreeResumed ? "resumed" : "created"} at ${worktreePath}`);
1456
+ }
1312
1457
  } catch (err) {
1313
1458
  api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1314
1459
  await linearApi.createComment(
1315
1460
  issue.id,
1316
- `Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
1461
+ `**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
1462
  );
1318
1463
  return;
1319
1464
  }
1320
1465
 
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("; ")}`);
1466
+ // 5b. Prepare workspace(s)
1467
+ if (worktrees) {
1468
+ for (const wt of worktrees) {
1469
+ const prep = prepareWorkspace(wt.path, wt.branch);
1470
+ if (prep.errors.length > 0) {
1471
+ api.logger.warn(`@dispatch: workspace prep for ${wt.repoName} had errors: ${prep.errors.join("; ")}`);
1472
+ }
1473
+ }
1325
1474
  } else {
1326
- api.logger.info(
1327
- `@dispatch: workspace prepared pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
1328
- );
1475
+ const prep = prepareWorkspace(worktreePath, worktreeBranch);
1476
+ if (prep.errors.length > 0) {
1477
+ api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
1478
+ } else {
1479
+ api.logger.info(`@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`);
1480
+ }
1329
1481
  }
1330
1482
 
1331
1483
  // 6. Create agent session on Linear
@@ -1339,16 +1491,16 @@ async function handleDispatch(
1339
1491
 
1340
1492
  // 6b. Initialize .claw/ artifact directory
1341
1493
  try {
1342
- ensureClawDir(worktree.path);
1343
- writeManifest(worktree.path, {
1494
+ ensureClawDir(worktreePath);
1495
+ writeManifest(worktreePath, {
1344
1496
  issueIdentifier: identifier,
1345
1497
  issueTitle: enrichedIssue.title ?? "(untitled)",
1346
1498
  issueId: issue.id,
1347
1499
  tier: assessment.tier,
1348
1500
  model: assessment.model,
1349
1501
  dispatchedAt: new Date().toISOString(),
1350
- worktreePath: worktree.path,
1351
- branch: worktree.branch,
1502
+ worktreePath,
1503
+ branch: worktreeBranch,
1352
1504
  attempts: 0,
1353
1505
  status: "dispatched",
1354
1506
  plugin: "openclaw-linear",
@@ -1363,8 +1515,8 @@ async function handleDispatch(
1363
1515
  issueId: issue.id,
1364
1516
  issueIdentifier: identifier,
1365
1517
  issueTitle: enrichedIssue.title ?? "(untitled)",
1366
- worktreePath: worktree.path,
1367
- branch: worktree.branch,
1518
+ worktreePath,
1519
+ branch: worktreeBranch,
1368
1520
  tier: assessment.tier,
1369
1521
  model: assessment.model,
1370
1522
  status: "dispatched",
@@ -1372,6 +1524,7 @@ async function handleDispatch(
1372
1524
  agentSessionId,
1373
1525
  attempt: 0,
1374
1526
  project: enrichedIssue?.project?.id,
1527
+ worktrees,
1375
1528
  }, statePath);
1376
1529
 
1377
1530
  // 8. Register active session for tool resolution
@@ -1384,16 +1537,24 @@ async function handleDispatch(
1384
1537
  });
1385
1538
 
1386
1539
  // 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})`;
1540
+ const worktreeDesc = worktrees
1541
+ ? worktrees.map(wt => `\`${wt.repoName}\`: \`${wt.path}\``).join("\n")
1542
+ : `\`${worktreePath}\``;
1390
1543
  const statusComment = [
1391
1544
  `**Dispatched** as **${assessment.tier}** (${assessment.model})`,
1392
1545
  `> ${assessment.reasoning}`,
1393
1546
  ``,
1394
- `Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
1395
- `Branch: \`${worktree.branch}\``,
1396
- prepStatus,
1547
+ worktrees
1548
+ ? `Worktrees ${worktreeResumed ? "(resumed)" : "(fresh)"}:\n${worktreeDesc}`
1549
+ : `Worktree: ${worktreeDesc} ${worktreeResumed ? "(resumed)" : "(fresh)"}`,
1550
+ `Branch: \`${worktreeBranch}\``,
1551
+ ``,
1552
+ `**Status:** Worker is starting now. An independent audit runs automatically after implementation.`,
1553
+ ``,
1554
+ `**While you wait:**`,
1555
+ `- Check progress: \`/dispatch status ${identifier}\``,
1556
+ `- Cancel: \`/dispatch escalate ${identifier} "reason"\``,
1557
+ `- All dispatches: \`/dispatch list\``,
1397
1558
  ].join("\n");
1398
1559
 
1399
1560
  await linearApi.createComment(issue.id, statusComment);
@@ -1425,7 +1586,7 @@ async function handleDispatch(
1425
1586
  activeRuns.add(issue.id);
1426
1587
 
1427
1588
  // Instantiate notifier (Discord, Slack, or both — config-driven)
1428
- const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
1589
+ const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
1429
1590
 
1430
1591
  const hookCtx: HookContext = {
1431
1592
  api,
@@ -1450,6 +1611,18 @@ async function handleDispatch(
1450
1611
  .catch(async (err) => {
1451
1612
  api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
1452
1613
  await updateDispatchStatus(identifier, "failed", statePath);
1614
+ // Write memory for failed dispatches so they're searchable in dispatch history
1615
+ try {
1616
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
1617
+ writeDispatchMemory(identifier, `Pipeline failed: ${String(err).slice(0, 500)}`, wsDir, {
1618
+ title: enrichedIssue.title ?? identifier,
1619
+ tier: assessment.tier,
1620
+ status: "failed",
1621
+ project: enrichedIssue?.project?.id,
1622
+ attempts: 1,
1623
+ model: assessment.model,
1624
+ });
1625
+ } catch { /* best effort */ }
1453
1626
  })
1454
1627
  .finally(() => {
1455
1628
  activeRuns.delete(issue.id);