@calltelemetry/openclaw-linear 0.9.14 → 0.9.16

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.
@@ -15,9 +15,12 @@ import { readPlanningState, isInPlanningMode, getPlanningSession, endPlanningSes
15
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
+ import { classifyIntent, type Intent } from "./intent-classify.js";
19
19
  import { extractGuidance, formatGuidanceAppendix, cacheGuidanceForTeam, getCachedGuidanceForTeam, isGuidanceEnabled, _resetGuidanceCacheForTesting } from "./guidance.js";
20
20
  import { loadAgentProfiles, buildMentionPattern, resolveAgentFromAlias, validateProfiles, _resetProfilesCacheForTesting, type AgentProfile } from "../infra/shared-profiles.js";
21
+ import { getActiveTmuxSession } from "../infra/tmux-runner.js";
22
+ import { capturePane } from "../infra/tmux.js";
23
+ import { loadCodingConfig, resolveToolName } from "../tools/code-tool.js";
21
24
 
22
25
  // ── Prompt input sanitization ─────────────────────────────────────
23
26
 
@@ -35,6 +38,28 @@ export function sanitizePromptInput(text: string, maxLength = 4000): string {
35
38
  return sanitized;
36
39
  }
37
40
 
41
+ /**
42
+ * Check if a work request should be blocked based on issue state.
43
+ * Returns a rejection message if blocked, null if allowed.
44
+ */
45
+ function shouldBlockWorkRequest(
46
+ intent: Intent,
47
+ stateType: string,
48
+ stateName: string,
49
+ issueRef: string,
50
+ ): string | null {
51
+ if (intent !== "request_work") return null;
52
+ if (stateType === "started") return null; // In Progress — allow
53
+ return (
54
+ `This issue (${issueRef}) is in **${stateName}** — it needs planning and scoping before implementation.\n\n` +
55
+ `**To move forward:**\n` +
56
+ `1. Update the issue description with requirements and acceptance criteria\n` +
57
+ `2. Move the issue to **In Progress**\n` +
58
+ `3. Then ask me to implement it\n\n` +
59
+ `I can help you scope and plan — just ask questions or discuss the approach.`
60
+ );
61
+ }
62
+
38
63
  // Track issues with active agent runs to prevent concurrent duplicate runs.
39
64
  const activeRuns = new Set<string>();
40
65
 
@@ -74,6 +99,7 @@ function wasRecentlyProcessed(key: string): boolean {
74
99
  export function _resetForTesting(): void {
75
100
  activeRuns.clear();
76
101
  recentlyProcessed.clear();
102
+ recentlyEmittedActivities.clear();
77
103
  _resetProfilesCacheForTesting();
78
104
  linearApiCache = null;
79
105
  lastSweep = Date.now();
@@ -83,6 +109,36 @@ export function _resetForTesting(): void {
83
109
  _resetAffinityForTesting();
84
110
  }
85
111
 
112
+ // ── Feedback loop prevention for steering ─────────────────────────────
113
+ // Track recently emitted activity body hashes to prevent our own emissions
114
+ // from triggering the steering handler.
115
+ const recentlyEmittedActivities = new Map<string, number>();
116
+ const EMITTED_TTL_MS = 30_000;
117
+
118
+ function hashActivityBody(body: string): string {
119
+ // Simple fast hash — not crypto, just dedup
120
+ let hash = 0;
121
+ for (let i = 0; i < Math.min(body.length, 200); i++) {
122
+ hash = ((hash << 5) - hash + body.charCodeAt(i)) | 0;
123
+ }
124
+ return String(hash);
125
+ }
126
+
127
+ export function trackEmittedActivity(body: string): void {
128
+ const hash = hashActivityBody(body);
129
+ recentlyEmittedActivities.set(hash, Date.now());
130
+ // Prune entries older than TTL
131
+ const now = Date.now();
132
+ for (const [k, ts] of recentlyEmittedActivities) {
133
+ if (now - ts > EMITTED_TTL_MS) recentlyEmittedActivities.delete(k);
134
+ }
135
+ }
136
+
137
+ function wasRecentlyEmitted(body: string): boolean {
138
+ const hash = hashActivityBody(body);
139
+ return recentlyEmittedActivities.has(hash);
140
+ }
141
+
86
142
  /** @internal — test-only; add an issue ID to the activeRuns set. */
87
143
  export function _addActiveRunForTesting(issueId: string): void {
88
144
  activeRuns.add(issueId);
@@ -406,9 +462,7 @@ export async function handleLinearWebhook(
406
462
  }
407
463
  }
408
464
 
409
- api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} agent=${agentId} (comments: ${previousComments.length}, guidance: ${guidanceCtx.guidance ? "yes" : "no"})`);
410
-
411
- // Fetch full issue details
465
+ // Fetch full issue details (needed for team routing + description)
412
466
  let enrichedIssue: any = issue;
413
467
  try {
414
468
  enrichedIssue = await linearApi.getIssueDetails(issue.id);
@@ -416,6 +470,19 @@ export async function handleLinearWebhook(
416
470
  api.logger.warn(`Could not fetch issue details: ${err}`);
417
471
  }
418
472
 
473
+ // Team-based agent routing: if no mention or affinity override, try team mapping
474
+ const teamKey = enrichedIssue?.team?.key as string | undefined;
475
+ if (!mentionOverride && agentId === resolveAgentId(api) && teamKey) {
476
+ const teamMappings = (pluginConfig as Record<string, unknown>)?.teamMappings as Record<string, any> | undefined;
477
+ const teamDefault = teamMappings?.[teamKey]?.defaultAgent;
478
+ if (typeof teamDefault === "string") {
479
+ api.logger.info(`AgentSession routed to ${teamDefault} via team mapping (${teamKey})`);
480
+ agentId = teamDefault;
481
+ }
482
+ }
483
+
484
+ api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} agent=${agentId} team=${teamKey ?? "?"} (comments: ${previousComments.length}, guidance: ${guidanceCtx.guidance ? "yes" : "no"})`);
485
+
419
486
  const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
420
487
 
421
488
  // Cache guidance for this team (enables Comment webhook paths)
@@ -434,12 +501,13 @@ export async function handleLinearWebhook(
434
501
  const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
435
502
  const stateType = enrichedIssue?.state?.type ?? "";
436
503
  const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
504
+ const cliTool = resolveToolName(loadCodingConfig(), agentId);
437
505
 
438
506
  const toolAccessLines = isTriaged
439
507
  ? [
440
508
  `**Tool access:**`,
441
509
  `- \`linear_issues\` tool: Full access. Use action="read" with issueId="${issueRef}" to get details, action="create" to create issues (with parentIssueId to create sub-issues for granular work breakdown), action="update" with status/priority/labels/estimate to modify issues, action="comment" to post comments, action="list_states" to see available workflow states.`,
442
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
510
+ `- \`${cliTool}\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
443
511
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
444
512
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
445
513
  ``,
@@ -448,19 +516,48 @@ export async function handleLinearWebhook(
448
516
  : [
449
517
  `**Tool access:**`,
450
518
  `- \`linear_issues\` tool: READ ONLY. Use action="read" with issueId="${issueRef}" to get details, action="list_states"/"list_labels" for metadata. Do NOT use action="update", action="create", or action="comment".`,
451
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text they cannot access linear_issues.`,
519
+ `- \`${cliTool}\`: **Planning mode only.** Workers may explore code and write plan files (PLAN.md, design docs). Workers MUST NOT create, modify, or delete source code, run deployments, or make system changes. Use for codebase exploration and planning only.`,
452
520
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
453
521
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
454
522
  ];
455
523
 
456
524
  const roleLines = isTriaged
457
- ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
458
- : [`**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 comments via linear_issues — the audit system handles lifecycle transitions.`];
525
+ ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`${cliTool}\`. Do NOT post comments yourself — the handler posts your text output.`]
526
+ : [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`${cliTool}\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post comments via linear_issues — the audit system handles lifecycle transitions.`];
459
527
 
460
528
  if (guidanceAppendix) {
461
529
  api.logger.info(`Guidance injected (${guidanceCtx.source}): ${guidanceCtx.guidance?.slice(0, 120)}...`);
462
530
  }
463
531
 
532
+ // ── Intent gate: classify user request and block work requests on untriaged issues ──
533
+ const classifyText = userMessage || promptContext || enrichedIssue?.title || "";
534
+ const projectId = enrichedIssue?.project?.id;
535
+ let isPlanning = false;
536
+ if (projectId) {
537
+ try {
538
+ const planState = await readPlanningState(pluginConfig?.planningStatePath as string | undefined);
539
+ isPlanning = isInPlanningMode(planState, projectId);
540
+ } catch { /* proceed without planning context */ }
541
+ }
542
+
543
+ const intentResult = await classifyIntent(api, {
544
+ commentBody: classifyText,
545
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
546
+ issueStatus: enrichedIssue?.state?.name,
547
+ isPlanning,
548
+ agentNames: Object.keys(profiles),
549
+ hasProject: !!projectId,
550
+ }, pluginConfig as Record<string, unknown> | undefined);
551
+
552
+ api.logger.info(`AgentSession.created intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning}`);
553
+
554
+ const blockMsg = shouldBlockWorkRequest(intentResult.intent, stateType, enrichedIssue?.state?.name ?? "Unknown", issueRef);
555
+ if (blockMsg) {
556
+ api.logger.info(`AgentSession.created: blocking work request on untriaged issue ${issueRef}`);
557
+ await linearApi.emitActivity(session.id, { type: "response", body: blockMsg }).catch(() => {});
558
+ return true;
559
+ }
560
+
464
561
  const message = [
465
562
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
466
563
  ``,
@@ -479,10 +576,10 @@ export async function handleLinearWebhook(
479
576
  ``,
480
577
  `## Scope Rules`,
481
578
  `1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
482
- `2. **\`code_run\` is ONLY for issue-body work.** Only dispatch \`code_run\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT code_run.`,
483
- `3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`code_run\` alone. If a comment requests new implementation, update the issue description first, then build from the issue text.`,
484
- `4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`code_run\` after the plan is clear and grounded in the issue body.`,
485
- `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no code_run.`,
579
+ `2. **\`${cliTool}\` is ONLY for issue-body work.** Only dispatch \`${cliTool}\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT ${cliTool}.`,
580
+ `3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`${cliTool}\` alone. If a comment requests new implementation, update the issue description first, then build from the issue text.`,
581
+ `4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`${cliTool}\` after the plan is clear and grounded in the issue body.`,
582
+ `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no ${cliTool}.`,
486
583
  ``,
487
584
  `Respond within the scope defined above. Be concise and action-oriented.`,
488
585
  ].filter(Boolean).join("\n");
@@ -493,7 +590,7 @@ export async function handleLinearWebhook(
493
590
  const profiles = loadAgentProfiles();
494
591
  const label = profiles[agentId]?.label ?? agentId;
495
592
 
496
- // Register active session for tool resolution (code_run, etc.)
593
+ // Register active session for tool resolution (cli_codex, etc.)
497
594
  // Also eagerly records affinity so follow-ups route to the same agent.
498
595
  setActiveSession({
499
596
  agentSessionId: session.id,
@@ -580,10 +677,45 @@ export async function handleLinearWebhook(
580
677
  return true;
581
678
  }
582
679
 
583
- // If an agent run is already active for this issue, this is feedback from
584
- // our own activity emissions ignore to prevent loops.
680
+ // ── Steering gate: three-way routing during active runs ──
681
+ // 1. Filter our own emitted activities (feedback loop prevention)
682
+ const activityType = activity?.content?.type;
683
+ const activityBody = activity?.content?.body ?? activity?.body ?? "";
684
+ const isOurFeedback =
685
+ activityType === "thought" ||
686
+ activityType === "action" ||
687
+ activityType === "response" ||
688
+ activityType === "error" ||
689
+ activityType === "elicitation" ||
690
+ (typeof activityBody === "string" && wasRecentlyEmitted(activityBody));
691
+ if (isOurFeedback) {
692
+ api.logger.info(`AgentSession prompted: ${session.id} — feedback from own activity (${activityType ?? "hash-match"}), ignoring`);
693
+ return true;
694
+ }
695
+
696
+ // 2. If active dispatch with tmux session → route to steering orchestrator
697
+ const tmuxSession = getActiveTmuxSession(issue.id);
698
+ if (tmuxSession) {
699
+ const userText = activityBody;
700
+ if (!userText || typeof userText !== "string" || !userText.trim()) {
701
+ api.logger.info(`AgentSession prompted: ${session.id} — tmux active but empty user message, ignoring`);
702
+ return true;
703
+ }
704
+
705
+ api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — routing to steering orchestrator (${tmuxSession.backend})`);
706
+ void handleSteeringInput(api, {
707
+ session,
708
+ issue,
709
+ userMessage: userText,
710
+ tmuxSession,
711
+ pluginConfig: pluginConfig as Record<string, unknown> | undefined,
712
+ });
713
+ return true;
714
+ }
715
+
716
+ // 3. No tmux session but active run → existing behavior (ignore feedback)
585
717
  if (activeRuns.has(issue.id)) {
586
- api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, ignoring (feedback)`);
718
+ api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, no tmux, ignoring (feedback)`);
587
719
  return true;
588
720
  }
589
721
 
@@ -684,12 +816,13 @@ export async function handleLinearWebhook(
684
816
  const followUpIssueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
685
817
  const followUpStateType = enrichedIssue?.state?.type ?? "";
686
818
  const followUpIsTriaged = followUpStateType === "started" || followUpStateType === "completed" || followUpStateType === "canceled";
819
+ const followUpCliTool = resolveToolName(loadCodingConfig(), agentId);
687
820
 
688
821
  const followUpToolAccessLines = followUpIsTriaged
689
822
  ? [
690
823
  `**Tool access:**`,
691
824
  `- \`linear_issues\` tool: Full access. Use action="read" with issueId="${followUpIssueRef}" to get details, action="create" to create issues (with parentIssueId to create sub-issues for granular work breakdown), action="update" with status/priority/labels/estimate to modify issues, action="comment" to post comments, action="list_states" to see available workflow states.`,
692
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
825
+ `- \`${followUpCliTool}\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
693
826
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
694
827
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
695
828
  ``,
@@ -698,19 +831,50 @@ export async function handleLinearWebhook(
698
831
  : [
699
832
  `**Tool access:**`,
700
833
  `- \`linear_issues\` tool: READ ONLY. Use action="read" with issueId="${followUpIssueRef}" to get details, action="list_states"/"list_labels" for metadata. Do NOT use action="update", action="create", or action="comment".`,
701
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text they cannot access linear_issues.`,
834
+ `- \`${followUpCliTool}\`: **Planning mode only.** Workers may explore code and write plan files (PLAN.md, design docs). Workers MUST NOT create, modify, or delete source code, run deployments, or make system changes. Use for codebase exploration and planning only.`,
702
835
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
703
836
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
704
837
  ];
705
838
 
706
839
  const followUpRoleLines = followUpIsTriaged
707
- ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
708
- : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
840
+ ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`${followUpCliTool}\`. Do NOT post comments yourself — the handler posts your text output.`]
841
+ : [`**Your role:** Dispatcher. For work requests, use \`${followUpCliTool}\`. You do NOT update issue status — the audit system handles lifecycle.`];
709
842
 
710
843
  if (followUpGuidanceAppendix) {
711
844
  api.logger.info(`Follow-up guidance injected: ${(guidanceCtxPrompted.guidance ?? "cached").slice(0, 120)}...`);
712
845
  }
713
846
 
847
+ // ── Intent gate: classify follow-up and block work requests on untriaged issues ──
848
+ const followUpProjectId = enrichedIssue?.project?.id;
849
+ let followUpIsPlanning = false;
850
+ if (followUpProjectId) {
851
+ try {
852
+ const planState = await readPlanningState(pluginConfig?.planningStatePath as string | undefined);
853
+ followUpIsPlanning = isInPlanningMode(planState, followUpProjectId);
854
+ } catch { /* proceed without planning context */ }
855
+ }
856
+
857
+ const followUpIntentResult = await classifyIntent(api, {
858
+ commentBody: userMessage,
859
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
860
+ issueStatus: enrichedIssue?.state?.name,
861
+ isPlanning: followUpIsPlanning,
862
+ agentNames: Object.keys(profiles),
863
+ hasProject: !!followUpProjectId,
864
+ }, pluginConfig as Record<string, unknown> | undefined);
865
+
866
+ api.logger.info(`AgentSession.prompted intent: ${followUpIntentResult.intent}${followUpIntentResult.agentId ? ` (agent: ${followUpIntentResult.agentId})` : ""} — ${followUpIntentResult.reasoning}`);
867
+
868
+ const followUpBlockMsg = shouldBlockWorkRequest(
869
+ followUpIntentResult.intent, followUpStateType, enrichedIssue?.state?.name ?? "Unknown", followUpIssueRef,
870
+ );
871
+ if (followUpBlockMsg) {
872
+ api.logger.info(`AgentSession.prompted: blocking work request on untriaged issue ${followUpIssueRef}`);
873
+ await linearApi.emitActivity(session.id, { type: "response", body: followUpBlockMsg }).catch(() => {});
874
+ activeRuns.delete(issue.id);
875
+ return;
876
+ }
877
+
714
878
  const message = [
715
879
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
716
880
  ``,
@@ -729,7 +893,7 @@ export async function handleLinearWebhook(
729
893
  ``,
730
894
  `## Scope Rules`,
731
895
  `1. **The issue body is your scope.** Re-read the description above before acting.`,
732
- `2. **Comments explore, issue body builds.** The follow-up may refine understanding or ask questions — NEVER dispatch \`code_run\` from a comment alone. If the user requests implementation, suggest updating the issue description first.`,
896
+ `2. **Comments explore, issue body builds.** The follow-up may refine understanding or ask questions — NEVER dispatch \`${followUpCliTool}\` from a comment alone. If the user requests implementation, suggest updating the issue description first.`,
733
897
  `3. **Match response to request.** Answer questions with answers. Do NOT escalate conversational messages into builds.`,
734
898
  ``,
735
899
  `Respond to the follow-up within the scope defined above. Be concise and action-oriented.`,
@@ -856,7 +1020,7 @@ export async function handleLinearWebhook(
856
1020
  const profiles = loadAgentProfiles();
857
1021
  const agentNames = Object.keys(profiles);
858
1022
 
859
- // ── @mention fast path — skip classifier ────────────────────
1023
+ // ── @mention fast path — with intent gate ────────────────────
860
1024
  const mentionPattern = buildMentionPattern(profiles);
861
1025
  const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
862
1026
  if (mentionMatches && mentionMatches.length > 0) {
@@ -864,6 +1028,42 @@ export async function handleLinearWebhook(
864
1028
  const resolved = resolveAgentFromAlias(alias, profiles);
865
1029
  if (resolved) {
866
1030
  api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
1031
+
1032
+ // Classify intent even on @mention path to gate work requests
1033
+ let enrichedForGate: any = issue;
1034
+ try { enrichedForGate = await linearApi.getIssueDetails(issue.id); } catch {}
1035
+ const mentionStateType = enrichedForGate?.state?.type ?? "";
1036
+ const mentionProjectId = enrichedForGate?.project?.id;
1037
+ let mentionIsPlanning = false;
1038
+ if (mentionProjectId) {
1039
+ try {
1040
+ const planState = await readPlanningState(pluginConfig?.planningStatePath as string | undefined);
1041
+ mentionIsPlanning = isInPlanningMode(planState, mentionProjectId);
1042
+ } catch {}
1043
+ }
1044
+
1045
+ const mentionIntentResult = await classifyIntent(api, {
1046
+ commentBody,
1047
+ issueTitle: enrichedForGate?.title ?? "(untitled)",
1048
+ issueStatus: enrichedForGate?.state?.name,
1049
+ isPlanning: mentionIsPlanning,
1050
+ agentNames,
1051
+ hasProject: !!mentionProjectId,
1052
+ }, pluginConfig);
1053
+
1054
+ api.logger.info(`Comment @mention intent: ${mentionIntentResult.intent} — ${mentionIntentResult.reasoning}`);
1055
+
1056
+ const mentionBlockMsg = shouldBlockWorkRequest(
1057
+ mentionIntentResult.intent, mentionStateType,
1058
+ enrichedForGate?.state?.name ?? "Unknown",
1059
+ enrichedForGate?.identifier ?? issue.identifier ?? issue.id,
1060
+ );
1061
+ if (mentionBlockMsg) {
1062
+ api.logger.info(`Comment @mention: blocking work request on untriaged issue ${enrichedForGate?.identifier ?? issue.identifier ?? issue.id}`);
1063
+ try { await createCommentWithDedup(linearApi, issue.id, mentionBlockMsg); } catch {}
1064
+ return true;
1065
+ }
1066
+
867
1067
  void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
868
1068
  .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
869
1069
  return true;
@@ -906,6 +1106,19 @@ export async function handleLinearWebhook(
906
1106
 
907
1107
  api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
908
1108
 
1109
+ // ── Gate work requests on untriaged issues ────────────────────
1110
+ const commentStateType = enrichedIssue?.state?.type ?? "";
1111
+ const commentBlockMsg = shouldBlockWorkRequest(
1112
+ intentResult.intent, commentStateType,
1113
+ enrichedIssue?.state?.name ?? "Unknown",
1114
+ enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
1115
+ );
1116
+ if (commentBlockMsg) {
1117
+ api.logger.info(`Comment: blocking work request on untriaged issue ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id}`);
1118
+ try { await createCommentWithDedup(linearApi, issue.id, commentBlockMsg); } catch {}
1119
+ return true;
1120
+ }
1121
+
909
1122
  // ── Route by intent ────────────────────────────────────────────
910
1123
 
911
1124
  switch (intentResult.intent) {
@@ -1448,12 +1661,13 @@ async function dispatchCommentToAgent(
1448
1661
  const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1449
1662
  const stateType = enrichedIssue?.state?.type ?? "";
1450
1663
  const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
1664
+ const cliTool = resolveToolName(loadCodingConfig(), agentId);
1451
1665
 
1452
1666
  const toolAccessLines = isTriaged
1453
1667
  ? [
1454
1668
  `**Tool access:**`,
1455
1669
  `- \`linear_issues\` tool: Full access. Use action="read" with issueId="${issueRef}" to get details, action="create" to create issues (with parentIssueId to create sub-issues for granular work breakdown), action="update" with status/priority/labels/estimate to modify issues, action="comment" to post comments, action="list_states" to see available workflow states.`,
1456
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
1670
+ `- \`${cliTool}\`: Dispatch coding work to a worker. Workers return text — they cannot access linear_issues.`,
1457
1671
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
1458
1672
  ``,
1459
1673
  `**Sub-issue guidance:** When a task is too large or has multiple distinct parts, break it into sub-issues using action="create" with parentIssueId="${issueRef}". Each sub-issue should be an atomic, independently testable unit of work with its own acceptance criteria. This enables parallel dispatch and clearer progress tracking.`,
@@ -1461,13 +1675,13 @@ async function dispatchCommentToAgent(
1461
1675
  : [
1462
1676
  `**Tool access:**`,
1463
1677
  `- \`linear_issues\` tool: READ ONLY. Use action="read" with issueId="${issueRef}" to get details, action="list_states"/"list_labels" for metadata. Do NOT use action="update", action="create", or action="comment".`,
1464
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text they cannot access linear_issues.`,
1678
+ `- \`${cliTool}\`: **Planning mode only.** Workers may explore code and write plan files (PLAN.md, design docs). Workers MUST NOT create, modify, or delete source code, run deployments, or make system changes. Use for codebase exploration and planning only.`,
1465
1679
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
1466
1680
  ];
1467
1681
 
1468
1682
  const roleLines = isTriaged
1469
- ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
1470
- : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
1683
+ ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`${cliTool}\`. Do NOT post comments yourself — the handler posts your text output.`]
1684
+ : [`**Your role:** Dispatcher. For work requests, use \`${cliTool}\`. You do NOT update issue status — the audit system handles lifecycle.`];
1471
1685
 
1472
1686
  const message = [
1473
1687
  `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).`,
@@ -1487,7 +1701,14 @@ async function dispatchCommentToAgent(
1487
1701
  ``,
1488
1702
  `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1489
1703
  ``,
1490
- `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1704
+ `## Scope Rules`,
1705
+ `1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
1706
+ `2. **\`${cliTool}\` is ONLY for issue-body work.** Only dispatch \`${cliTool}\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT ${cliTool}.`,
1707
+ `3. **Comments explore, issue body builds.** The comment above may explore scope or ask questions but NEVER trigger \`${cliTool}\` from a comment alone. If the comment requests new implementation, suggest updating the issue description or creating a new issue.`,
1708
+ `4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`${cliTool}\` after the plan is clear and grounded in the issue body.`,
1709
+ `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements in the issue body → no ${cliTool}.`,
1710
+ ``,
1711
+ `Respond within the scope defined above. Be concise and action-oriented.`,
1491
1712
  commentGuidanceAppendix,
1492
1713
  ].filter(Boolean).join("\n");
1493
1714
 
@@ -1835,9 +2056,11 @@ async function handleDispatch(
1835
2056
 
1836
2057
  const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1837
2058
  const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
2059
+ const dispatchTeamKey = enrichedIssue?.team?.key as string | undefined;
1838
2060
 
1839
- // Resolve repos for this dispatch (issue body markers, labels, or config default)
1840
- const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
2061
+ // Resolve repos for this dispatch (issue body markers labels team mapping → config default)
2062
+ const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig, dispatchTeamKey);
2063
+ api.logger.info(`@dispatch: ${identifier} team=${dispatchTeamKey ?? "none"} repos=${repoResolution.repos.map(r => r.name).join(",")} source=${repoResolution.source}`);
1841
2064
 
1842
2065
  // 4. Assess complexity tier
1843
2066
  const assessment = await assessTier(api, {
@@ -1954,6 +2177,20 @@ async function handleDispatch(
1954
2177
  worktrees,
1955
2178
  }, statePath);
1956
2179
 
2180
+ // 7b. Linear state transition: set issue to "In Progress" (best-effort)
2181
+ if (enrichedIssue?.team?.id) {
2182
+ try {
2183
+ const teamStates = await linearApi.getTeamStates(enrichedIssue.team.id);
2184
+ const inProgress = teamStates.find((s: any) => s.type === "started");
2185
+ if (inProgress) {
2186
+ await linearApi.updateIssue(issue.id, { stateId: inProgress.id });
2187
+ api.logger.info(`@dispatch: ${identifier} → ${inProgress.name} (In Progress)`);
2188
+ }
2189
+ } catch (err) {
2190
+ api.logger.warn(`@dispatch: ${identifier} — failed to set In Progress state: ${err}`);
2191
+ }
2192
+ }
2193
+
1957
2194
  // 8. Register active session for tool resolution
1958
2195
  setActiveSession({
1959
2196
  agentSessionId: agentSessionId ?? "",
@@ -2056,3 +2293,145 @@ async function handleDispatch(
2056
2293
  clearActiveSession(issue.id);
2057
2294
  });
2058
2295
  }
2296
+
2297
+ // ── Steering handler ──────────────────────────────────────────────
2298
+ //
2299
+ // Handle user input during an active tmux-wrapped dispatch.
2300
+ // Routes to a short orchestrator agent session that can steer, capture, or abort.
2301
+
2302
+ async function handleSteeringInput(
2303
+ api: OpenClawPluginApi,
2304
+ ctx: {
2305
+ session: any;
2306
+ issue: any;
2307
+ userMessage: string;
2308
+ tmuxSession: { sessionName: string; backend: string; issueIdentifier: string; issueId: string; steeringMode: string };
2309
+ pluginConfig?: Record<string, unknown>;
2310
+ },
2311
+ ): Promise<void> {
2312
+ const { session, issue, userMessage, tmuxSession, pluginConfig } = ctx;
2313
+
2314
+ const linearApi = createLinearApi(api);
2315
+ if (!linearApi) {
2316
+ api.logger.error("handleSteeringInput: no Linear API");
2317
+ return;
2318
+ }
2319
+
2320
+ // 1. Capture recent coding agent output for context
2321
+ let agentOutput = "";
2322
+ try {
2323
+ agentOutput = capturePane(tmuxSession.sessionName, 50);
2324
+ } catch (err) {
2325
+ api.logger.warn(`handleSteeringInput: capturePane failed: ${err}`);
2326
+ }
2327
+
2328
+ // 2. Read dispatch state for context
2329
+ let dispatchCtx = "";
2330
+ try {
2331
+ const state = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
2332
+ const dispatch = getActiveDispatch(state, tmuxSession.issueIdentifier);
2333
+ if (dispatch) {
2334
+ dispatchCtx = [
2335
+ `Worktree: ${dispatch.worktreePath}`,
2336
+ `Attempt: ${dispatch.attempt} | Status: ${dispatch.status}`,
2337
+ `Tier: ${dispatch.tier}`,
2338
+ ].join("\n");
2339
+ }
2340
+ } catch { /* proceed without dispatch context */ }
2341
+
2342
+ // 3. Build steering prompt
2343
+ const prompt = [
2344
+ `You are a steering orchestrator. A ${tmuxSession.backend} coding agent is currently ` +
2345
+ `working on issue ${tmuxSession.issueIdentifier}. The user just sent a message in the Linear session.`,
2346
+ ``,
2347
+ `## Issue Context`,
2348
+ `**${tmuxSession.issueIdentifier}**: ${issue?.title ?? "(untitled)"}`,
2349
+ dispatchCtx || `Backend: ${tmuxSession.backend}`,
2350
+ `Steering mode: ${tmuxSession.steeringMode}`,
2351
+ ``,
2352
+ `## Recent Agent Output (last 50 lines)`,
2353
+ "```",
2354
+ agentOutput || "(no output captured)",
2355
+ "```",
2356
+ ``,
2357
+ `## User's Message`,
2358
+ `> ${sanitizePromptInput(userMessage, 2000)}`,
2359
+ ``,
2360
+ `## Your Decision`,
2361
+ `Analyze the user's message in the context of what the coding agent is doing.`,
2362
+ ``,
2363
+ `**Use \`steer_agent\`** (issueId="${issue.id}") if the user is:`,
2364
+ `- Providing information the agent needs (docs location, tool name, API details)`,
2365
+ `- Answering a question the agent asked`,
2366
+ `- Redirecting the agent's approach ("focus on X first", "use library Y")`,
2367
+ `→ Craft a PRECISE, actionable message. Don't forward raw user text.`,
2368
+ ` Translate vague input into clear instructions with file paths, code refs, etc.`,
2369
+ tmuxSession.steeringMode === "one-shot"
2370
+ ? `⚠️ WARNING: ${tmuxSession.backend} is in ONE-SHOT mode — steer_agent will fail. You can only abort or respond directly.`
2371
+ : "",
2372
+ ``,
2373
+ `**Use \`capture_agent_output\`** (issueId="${issue.id}") if you need more context before deciding.`,
2374
+ ``,
2375
+ `**Use \`abort_agent\`** (issueId="${issue.id}") if the user wants to stop/cancel the run.`,
2376
+ ``,
2377
+ `**Respond directly (just output text)** if the user is:`,
2378
+ `- Asking a status question ("what's it doing?", "how far along?")`,
2379
+ `- Making a request for you, not the coding agent`,
2380
+ ``,
2381
+ `Be fast and decisive. This is a mid-task steering call, not a conversation.`,
2382
+ ].filter(Boolean).join("\n");
2383
+
2384
+ // 4. Emit acknowledgment
2385
+ const ackBody = `Processing your input while ${tmuxSession.backend} agent is working...`;
2386
+ trackEmittedActivity(ackBody);
2387
+ await linearApi.emitActivity(session.id, {
2388
+ type: "thought",
2389
+ body: ackBody,
2390
+ }).catch(() => {});
2391
+
2392
+ // 5. Run SHORT orchestrator session (60s timeout)
2393
+ try {
2394
+ const { runAgent } = await import("../agent/agent.js");
2395
+ const result = await runAgent({
2396
+ api,
2397
+ agentId: resolveAgentId(api),
2398
+ sessionId: `linear-steer-${session.id}-${Date.now()}`,
2399
+ message: prompt,
2400
+ timeoutMs: 60_000,
2401
+ streaming: { linearApi, agentSessionId: session.id },
2402
+ toolsDeny: [
2403
+ "cli_codex",
2404
+ "cli_claude",
2405
+ "cli_gemini",
2406
+ "dispatch_history",
2407
+ "plan_audit",
2408
+ "plan_create_issue",
2409
+ "plan_link_issues",
2410
+ "plan_update_issue",
2411
+ "write",
2412
+ "edit",
2413
+ "apply_patch",
2414
+ "spawn_agent",
2415
+ "ask_agent",
2416
+ ],
2417
+ });
2418
+
2419
+ // 6. Post response if orchestrator responded directly (not via tool)
2420
+ if (result.success && result.output.trim()) {
2421
+ const responseBody = result.output;
2422
+ trackEmittedActivity(responseBody);
2423
+ await linearApi.emitActivity(session.id, {
2424
+ type: "response",
2425
+ body: responseBody,
2426
+ }).catch(() => {});
2427
+ }
2428
+ } catch (err) {
2429
+ api.logger.error(`handleSteeringInput error: ${err}`);
2430
+ const errBody = `Steering failed: ${String(err).slice(0, 300)}`;
2431
+ trackEmittedActivity(errBody);
2432
+ await linearApi.emitActivity(session.id, {
2433
+ type: "error",
2434
+ body: errBody,
2435
+ }).catch(() => {});
2436
+ }
2437
+ }