@calltelemetry/openclaw-linear 0.8.0 → 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.
- package/README.md +152 -34
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +27 -1
- package/src/agent/agent.test.ts +49 -0
- package/src/agent/agent.ts +26 -1
- package/src/infra/doctor.ts +2 -2
- package/src/pipeline/e2e-planning.test.ts +77 -54
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +98 -32
- package/src/pipeline/webhook.ts +322 -226
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/code-tool.test.ts +3 -3
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
package/src/pipeline/webhook.ts
CHANGED
|
@@ -11,10 +11,11 @@ import { assessTier } from "./tier-assess.js";
|
|
|
11
11
|
import { createWorktree, createMultiWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
12
|
import { resolveRepos, isMultiRepo } from "../infra/multi-repo.js";
|
|
13
13
|
import { ensureClawDir, writeManifest, writeDispatchMemory, resolveOrchestratorWorkspace } from "./artifacts.js";
|
|
14
|
-
import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
|
|
15
|
-
import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
|
|
14
|
+
import { readPlanningState, isInPlanningMode, getPlanningSession, endPlanningSession } from "./planning-state.js";
|
|
15
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
16
16
|
import { startProjectDispatch } from "./dag-dispatch.js";
|
|
17
17
|
import { emitDiagnostic } from "../infra/observability.js";
|
|
18
|
+
import { classifyIntent } from "./intent-classify.js";
|
|
18
19
|
|
|
19
20
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
20
21
|
interface AgentProfile {
|
|
@@ -659,7 +660,7 @@ export async function handleLinearWebhook(
|
|
|
659
660
|
return true;
|
|
660
661
|
}
|
|
661
662
|
|
|
662
|
-
// ── Comment.create —
|
|
663
|
+
// ── Comment.create — intent-based routing ────────────────────────
|
|
663
664
|
if (payload.type === "Comment" && payload.action === "create") {
|
|
664
665
|
res.statusCode = 200;
|
|
665
666
|
res.end("ok");
|
|
@@ -668,265 +669,211 @@ export async function handleLinearWebhook(
|
|
|
668
669
|
const commentBody = comment?.body ?? "";
|
|
669
670
|
const commentor = comment?.user?.name ?? "Unknown";
|
|
670
671
|
const issue = comment?.issue ?? payload.issue;
|
|
671
|
-
|
|
672
|
-
// ── Planning mode intercept ──────────────────────────────────
|
|
673
672
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
674
673
|
|
|
675
|
-
if (issue?.id) {
|
|
676
|
-
const linearApiForPlanning = createLinearApi(api);
|
|
677
|
-
if (linearApiForPlanning) {
|
|
678
|
-
try {
|
|
679
|
-
const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
|
|
680
|
-
const projectId = enriched?.project?.id;
|
|
681
|
-
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
682
|
-
|
|
683
|
-
if (projectId) {
|
|
684
|
-
const planState = await readPlanningState(planStatePath);
|
|
685
|
-
|
|
686
|
-
// Check if this is a plan initiation request
|
|
687
|
-
const isPlanRequest = /\b(plan|planning)\s+(this\s+)(project|out)\b/i.test(commentBody) || /\bplan\s+this\s+out\b/i.test(commentBody);
|
|
688
|
-
if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
|
|
689
|
-
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
690
|
-
void initiatePlanningSession(
|
|
691
|
-
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
692
|
-
projectId,
|
|
693
|
-
{ id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
|
|
694
|
-
).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
|
|
695
|
-
return true;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Route to planner if project is in planning mode
|
|
699
|
-
if (isInPlanningMode(planState, projectId)) {
|
|
700
|
-
const session = getPlanningSession(planState, projectId);
|
|
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}`)) {
|
|
710
|
-
api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
|
|
711
|
-
void handlePlannerTurn(
|
|
712
|
-
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
713
|
-
session,
|
|
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
|
-
},
|
|
729
|
-
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
730
|
-
}
|
|
731
|
-
return true;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
} catch (err) {
|
|
735
|
-
api.logger.warn(`Planning mode check failed: ${err}`);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Load agent profiles and build mention pattern dynamically.
|
|
741
|
-
// Default agent (app mentions) is handled by AgentSessionEvent — never here.
|
|
742
|
-
const profiles = loadAgentProfiles();
|
|
743
|
-
const mentionPattern = buildMentionPattern(profiles);
|
|
744
|
-
if (!mentionPattern) {
|
|
745
|
-
api.logger.info("Comment webhook: no sub-agent profiles configured, ignoring");
|
|
746
|
-
return true;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
const matches = commentBody.match(mentionPattern);
|
|
750
|
-
if (!matches || matches.length === 0) {
|
|
751
|
-
api.logger.info("Comment webhook: no sub-agent mentions found, ignoring");
|
|
752
|
-
return true;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const alias = matches[0].replace("@", "");
|
|
756
|
-
const resolved = resolveAgentFromAlias(alias, profiles);
|
|
757
|
-
if (!resolved) {
|
|
758
|
-
api.logger.info(`Comment webhook: alias "${alias}" not found in profiles, ignoring`);
|
|
759
|
-
return true;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const mentionedAgent = resolved.agentId;
|
|
763
|
-
|
|
764
674
|
if (!issue?.id) {
|
|
765
675
|
api.logger.error("Comment webhook: missing issue data");
|
|
766
676
|
return true;
|
|
767
677
|
}
|
|
768
678
|
|
|
769
|
-
|
|
770
|
-
if (!linearApi) {
|
|
771
|
-
api.logger.error("No Linear access token — cannot process comment mention");
|
|
772
|
-
return true;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Dedup on comment ID — prevent processing same comment twice
|
|
679
|
+
// Dedup on comment ID
|
|
776
680
|
if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
|
|
777
681
|
api.logger.info(`Comment ${comment.id} already processed — skipping`);
|
|
778
682
|
return true;
|
|
779
683
|
}
|
|
780
684
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
// (prevents dual-dispatch when both Comment.create and AgentSessionEvent fire)
|
|
785
|
-
if (activeRuns.has(issue.id)) {
|
|
786
|
-
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");
|
|
787
688
|
return true;
|
|
788
689
|
}
|
|
789
|
-
activeRuns.add(issue.id);
|
|
790
690
|
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
linearApi.
|
|
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
|
+
}
|
|
794
716
|
}
|
|
795
717
|
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
let
|
|
718
|
+
// ── Intent classification ─────────────────────────────────────
|
|
719
|
+
// Fetch issue details for context
|
|
720
|
+
let enrichedIssue: any = issue;
|
|
799
721
|
try {
|
|
800
|
-
|
|
801
|
-
enrichedIssue = { ...issue, ...full };
|
|
802
|
-
// Include last few comments for context (excluding the triggering comment)
|
|
803
|
-
const comments = full.comments?.nodes ?? [];
|
|
804
|
-
const relevant = comments
|
|
805
|
-
.filter((c: any) => c.body !== commentBody)
|
|
806
|
-
.slice(-3)
|
|
807
|
-
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body.slice(0, 200)}`)
|
|
808
|
-
.join("\n");
|
|
809
|
-
if (relevant) recentComments = `\n**Recent Comments:**\n${relevant}\n`;
|
|
722
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
810
723
|
} catch (err) {
|
|
811
724
|
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
812
725
|
}
|
|
813
726
|
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
const state = enrichedIssue.state?.name ?? "Unknown";
|
|
817
|
-
const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
|
|
818
|
-
|
|
819
|
-
const taskMessage = [
|
|
820
|
-
`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).`,
|
|
821
|
-
``,
|
|
822
|
-
`**Tool access:**`,
|
|
823
|
-
`- \`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.`,
|
|
824
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
825
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
826
|
-
``,
|
|
827
|
-
`**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.`,
|
|
828
|
-
``,
|
|
829
|
-
`**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
|
|
830
|
-
`**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
|
|
831
|
-
`**URL:** ${enrichedIssue.url ?? "N/A"}`,
|
|
832
|
-
``,
|
|
833
|
-
enrichedIssue.description ? `**Description:**\n${enrichedIssue.description}\n` : "",
|
|
834
|
-
recentComments,
|
|
835
|
-
`**${commentor} wrote:**`,
|
|
836
|
-
`> ${commentBody}`,
|
|
837
|
-
``,
|
|
838
|
-
`Respond to their message. Be concise and direct. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
839
|
-
].filter(Boolean).join("\n");
|
|
840
|
-
|
|
841
|
-
// Dispatch to agent with full session lifecycle (non-blocking)
|
|
842
|
-
void (async () => {
|
|
843
|
-
const label = resolved.label;
|
|
844
|
-
const profile = profiles[mentionedAgent];
|
|
845
|
-
let agentSessionId: string | null = null;
|
|
727
|
+
const projectId = enrichedIssue?.project?.id;
|
|
728
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
846
729
|
|
|
730
|
+
// Determine planning state
|
|
731
|
+
let isPlanning = false;
|
|
732
|
+
let planSession: any = null;
|
|
733
|
+
if (projectId) {
|
|
847
734
|
try {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
|
|
853
|
-
// Register active session so code_run can resolve it automatically
|
|
854
|
-
setActiveSession({
|
|
855
|
-
agentSessionId,
|
|
856
|
-
issueIdentifier: enrichedIssue.identifier ?? issue.id,
|
|
857
|
-
issueId: issue.id,
|
|
858
|
-
agentId: mentionedAgent,
|
|
859
|
-
startedAt: Date.now(),
|
|
860
|
-
});
|
|
861
|
-
} else {
|
|
862
|
-
api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
|
|
735
|
+
const planState = await readPlanningState(planStatePath);
|
|
736
|
+
isPlanning = isInPlanningMode(planState, projectId);
|
|
737
|
+
if (isPlanning) {
|
|
738
|
+
planSession = getPlanningSession(planState, projectId);
|
|
863
739
|
}
|
|
740
|
+
} catch { /* proceed without planning context */ }
|
|
741
|
+
}
|
|
864
742
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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);
|
|
872
751
|
|
|
873
|
-
|
|
874
|
-
const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
|
|
875
|
-
const { runAgent } = await import("../agent/agent.js");
|
|
876
|
-
const result = await runAgent({
|
|
877
|
-
api,
|
|
878
|
-
agentId: mentionedAgent,
|
|
879
|
-
sessionId,
|
|
880
|
-
message: taskMessage,
|
|
881
|
-
timeoutMs: 3 * 60_000,
|
|
882
|
-
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
883
|
-
});
|
|
752
|
+
api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
|
|
884
753
|
|
|
885
|
-
|
|
886
|
-
? result.output
|
|
887
|
-
: `Something went wrong while processing this. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
754
|
+
// ── Route by intent ────────────────────────────────────────────
|
|
888
755
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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}`));
|
|
899
771
|
}
|
|
900
|
-
|
|
901
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
902
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
772
|
+
break;
|
|
903
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
|
+
}
|
|
904
782
|
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
783
|
+
case "plan_finalize": {
|
|
784
|
+
if (!isPlanning || !planSession) {
|
|
785
|
+
api.logger.info("Comment intent plan_finalize but not in planning mode — ignoring");
|
|
786
|
+
break;
|
|
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}`));
|
|
914
815
|
}
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
915
818
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
921
|
-
type: "error",
|
|
922
|
-
body: `Failed to process mention: ${String(err).slice(0, 500)}`,
|
|
923
|
-
}).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;
|
|
924
823
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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;
|
|
928
837
|
}
|
|
929
|
-
|
|
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;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
case "general":
|
|
873
|
+
default:
|
|
874
|
+
api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
930
877
|
|
|
931
878
|
return true;
|
|
932
879
|
}
|
|
@@ -1009,6 +956,7 @@ export async function handleLinearWebhook(
|
|
|
1009
956
|
|
|
1010
957
|
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
|
|
1011
958
|
|
|
959
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
1012
960
|
const linearApi = createLinearApi(api);
|
|
1013
961
|
if (!linearApi) {
|
|
1014
962
|
api.logger.error("No Linear access token — cannot triage new issue");
|
|
@@ -1237,6 +1185,154 @@ export async function handleLinearWebhook(
|
|
|
1237
1185
|
return true;
|
|
1238
1186
|
}
|
|
1239
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
|
+
|
|
1240
1336
|
// ── @dispatch handler ─────────────────────────────────────────────
|
|
1241
1337
|
//
|
|
1242
1338
|
// Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
|
package/src/tools/claude-tool.ts
CHANGED
|
@@ -150,6 +150,12 @@ export async function runClaude(
|
|
|
150
150
|
const env = { ...process.env };
|
|
151
151
|
delete env.CLAUDECODE;
|
|
152
152
|
|
|
153
|
+
// Pass Anthropic API key if configured (plugin config takes precedence over env)
|
|
154
|
+
const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
|
|
155
|
+
if (claudeApiKey) {
|
|
156
|
+
env.ANTHROPIC_API_KEY = claudeApiKey;
|
|
157
|
+
}
|
|
158
|
+
|
|
153
159
|
const child = spawn(CLAUDE_BIN, args, {
|
|
154
160
|
stdio: ["ignore", "pipe", "pipe"],
|
|
155
161
|
cwd: workingDir,
|
|
@@ -203,8 +203,8 @@ describe("resolveCodingBackend", () => {
|
|
|
203
203
|
expect(resolveCodingBackend(config)).toBe("gemini");
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
-
it("falls back to
|
|
207
|
-
expect(resolveCodingBackend({})).toBe("
|
|
208
|
-
expect(resolveCodingBackend({}, "anyAgent")).toBe("
|
|
206
|
+
it("falls back to codex when no config provided", () => {
|
|
207
|
+
expect(resolveCodingBackend({})).toBe("codex");
|
|
208
|
+
expect(resolveCodingBackend({}, "anyAgent")).toBe("codex");
|
|
209
209
|
});
|
|
210
210
|
});
|
package/src/tools/code-tool.ts
CHANGED
|
@@ -88,7 +88,7 @@ function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): Codi
|
|
|
88
88
|
* Priority:
|
|
89
89
|
* 1. Per-agent override: config.agentCodingTools[agentId]
|
|
90
90
|
* 2. Global default: config.codingTool
|
|
91
|
-
* 3. Hardcoded fallback: "
|
|
91
|
+
* 3. Hardcoded fallback: "codex"
|
|
92
92
|
*/
|
|
93
93
|
export function resolveCodingBackend(
|
|
94
94
|
config: CodingToolsConfig,
|
|
@@ -104,7 +104,7 @@ export function resolveCodingBackend(
|
|
|
104
104
|
const global = config.codingTool;
|
|
105
105
|
if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
|
|
106
106
|
|
|
107
|
-
return "
|
|
107
|
+
return "codex";
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|