@calltelemetry/openclaw-linear 0.9.12 → 0.9.15

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.
@@ -1261,7 +1261,7 @@ describe("Comment.create intent routing", () => {
1261
1261
  identifier: "ENG-RW",
1262
1262
  title: "Request Work",
1263
1263
  description: "desc",
1264
- state: { name: "Backlog", type: "backlog" },
1264
+ state: { name: "In Progress", type: "started" },
1265
1265
  team: { id: "team-rw" },
1266
1266
  comments: { nodes: [] },
1267
1267
  });
@@ -2219,7 +2219,7 @@ describe("dispatchCommentToAgent via Comment.create intents", () => {
2219
2219
  identifier: "ENG-DE",
2220
2220
  title: "Dispatch Error",
2221
2221
  description: "desc",
2222
- state: { name: "Backlog", type: "backlog" },
2222
+ state: { name: "In Progress", type: "started" },
2223
2223
  team: { id: "team-de" },
2224
2224
  comments: { nodes: [] },
2225
2225
  });
@@ -2709,7 +2709,7 @@ describe("postAgentComment edge cases", () => {
2709
2709
  identifier: "ENG-IF",
2710
2710
  title: "Identity Fail",
2711
2711
  description: "desc",
2712
- state: { name: "Backlog", type: "backlog" },
2712
+ state: { name: "In Progress", type: "started" },
2713
2713
  team: { id: "team-if" },
2714
2714
  comments: { nodes: [] },
2715
2715
  });
@@ -3405,7 +3405,7 @@ describe("Comment.create .catch callbacks on fire-and-forget dispatches", () =>
3405
3405
  identifier: "ENG-RWC",
3406
3406
  title: "Request Work Catch",
3407
3407
  description: "desc",
3408
- state: { name: "Backlog", type: "backlog" },
3408
+ state: { name: "In Progress", type: "started" },
3409
3409
  team: { id: "team-rwc" },
3410
3410
  comments: { nodes: [] },
3411
3411
  });
@@ -3599,7 +3599,7 @@ describe("dispatchCommentToAgent internal .catch callbacks", () => {
3599
3599
  identifier: "ENG-DCE",
3600
3600
  title: "DCA Error",
3601
3601
  description: "desc",
3602
- state: { name: "Backlog", type: "backlog" },
3602
+ state: { name: "In Progress", type: "started" },
3603
3603
  team: { id: "team-dce" },
3604
3604
  comments: { nodes: [] },
3605
3605
  });
@@ -4865,7 +4865,7 @@ describe("session affinity routing", () => {
4865
4865
  identifier: "ENG-AFF-RW",
4866
4866
  title: "Affinity Request Work",
4867
4867
  description: "desc",
4868
- state: { name: "Backlog", type: "backlog" },
4868
+ state: { name: "In Progress", type: "started" },
4869
4869
  team: { id: "team-aff" },
4870
4870
  comments: { nodes: [] },
4871
4871
  });
@@ -4899,7 +4899,7 @@ describe("session affinity routing", () => {
4899
4899
  identifier: "ENG-NO-AFF",
4900
4900
  title: "No Affinity",
4901
4901
  description: "desc",
4902
- state: { name: "Backlog", type: "backlog" },
4902
+ state: { name: "In Progress", type: "started" },
4903
4903
  team: { id: "team-noaff" },
4904
4904
  comments: { nodes: [] },
4905
4905
  });
@@ -15,7 +15,7 @@ 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
21
 
@@ -35,6 +35,28 @@ export function sanitizePromptInput(text: string, maxLength = 4000): string {
35
35
  return sanitized;
36
36
  }
37
37
 
38
+ /**
39
+ * Check if a work request should be blocked based on issue state.
40
+ * Returns a rejection message if blocked, null if allowed.
41
+ */
42
+ function shouldBlockWorkRequest(
43
+ intent: Intent,
44
+ stateType: string,
45
+ stateName: string,
46
+ issueRef: string,
47
+ ): string | null {
48
+ if (intent !== "request_work") return null;
49
+ if (stateType === "started") return null; // In Progress — allow
50
+ return (
51
+ `This issue (${issueRef}) is in **${stateName}** — it needs planning and scoping before implementation.\n\n` +
52
+ `**To move forward:**\n` +
53
+ `1. Update the issue description with requirements and acceptance criteria\n` +
54
+ `2. Move the issue to **In Progress**\n` +
55
+ `3. Then ask me to implement it\n\n` +
56
+ `I can help you scope and plan — just ask questions or discuss the approach.`
57
+ );
58
+ }
59
+
38
60
  // Track issues with active agent runs to prevent concurrent duplicate runs.
39
61
  const activeRuns = new Set<string>();
40
62
 
@@ -448,7 +470,7 @@ export async function handleLinearWebhook(
448
470
  : [
449
471
  `**Tool access:**`,
450
472
  `- \`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.`,
473
+ `- \`code_run\`: **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
474
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
453
475
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
454
476
  ];
@@ -461,6 +483,35 @@ export async function handleLinearWebhook(
461
483
  api.logger.info(`Guidance injected (${guidanceCtx.source}): ${guidanceCtx.guidance?.slice(0, 120)}...`);
462
484
  }
463
485
 
486
+ // ── Intent gate: classify user request and block work requests on untriaged issues ──
487
+ const classifyText = userMessage || promptContext || enrichedIssue?.title || "";
488
+ const projectId = enrichedIssue?.project?.id;
489
+ let isPlanning = false;
490
+ if (projectId) {
491
+ try {
492
+ const planState = await readPlanningState(pluginConfig?.planningStatePath as string | undefined);
493
+ isPlanning = isInPlanningMode(planState, projectId);
494
+ } catch { /* proceed without planning context */ }
495
+ }
496
+
497
+ const intentResult = await classifyIntent(api, {
498
+ commentBody: classifyText,
499
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
500
+ issueStatus: enrichedIssue?.state?.name,
501
+ isPlanning,
502
+ agentNames: Object.keys(profiles),
503
+ hasProject: !!projectId,
504
+ }, pluginConfig as Record<string, unknown> | undefined);
505
+
506
+ api.logger.info(`AgentSession.created intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning}`);
507
+
508
+ const blockMsg = shouldBlockWorkRequest(intentResult.intent, stateType, enrichedIssue?.state?.name ?? "Unknown", issueRef);
509
+ if (blockMsg) {
510
+ api.logger.info(`AgentSession.created: blocking work request on untriaged issue ${issueRef}`);
511
+ await linearApi.emitActivity(session.id, { type: "response", body: blockMsg }).catch(() => {});
512
+ return true;
513
+ }
514
+
464
515
  const message = [
465
516
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
466
517
  ``,
@@ -477,7 +528,14 @@ export async function handleLinearWebhook(
477
528
  commentContext ? `\n**Conversation:**\n${commentContext}` : "",
478
529
  userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
479
530
  ``,
480
- `Respond to the user's request. For work requests, dispatch via \`code_run\` and summarize the result. Be concise and action-oriented.`,
531
+ `## Scope Rules`,
532
+ `1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
533
+ `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.`,
534
+ `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.`,
535
+ `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.`,
536
+ `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no code_run.`,
537
+ ``,
538
+ `Respond within the scope defined above. Be concise and action-oriented.`,
481
539
  ].filter(Boolean).join("\n");
482
540
 
483
541
  // Run agent directly (non-blocking)
@@ -691,7 +749,7 @@ export async function handleLinearWebhook(
691
749
  : [
692
750
  `**Tool access:**`,
693
751
  `- \`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".`,
694
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text they cannot access linear_issues.`,
752
+ `- \`code_run\`: **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.`,
695
753
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
696
754
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
697
755
  ];
@@ -704,6 +762,37 @@ export async function handleLinearWebhook(
704
762
  api.logger.info(`Follow-up guidance injected: ${(guidanceCtxPrompted.guidance ?? "cached").slice(0, 120)}...`);
705
763
  }
706
764
 
765
+ // ── Intent gate: classify follow-up and block work requests on untriaged issues ──
766
+ const followUpProjectId = enrichedIssue?.project?.id;
767
+ let followUpIsPlanning = false;
768
+ if (followUpProjectId) {
769
+ try {
770
+ const planState = await readPlanningState(pluginConfig?.planningStatePath as string | undefined);
771
+ followUpIsPlanning = isInPlanningMode(planState, followUpProjectId);
772
+ } catch { /* proceed without planning context */ }
773
+ }
774
+
775
+ const followUpIntentResult = await classifyIntent(api, {
776
+ commentBody: userMessage,
777
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
778
+ issueStatus: enrichedIssue?.state?.name,
779
+ isPlanning: followUpIsPlanning,
780
+ agentNames: Object.keys(profiles),
781
+ hasProject: !!followUpProjectId,
782
+ }, pluginConfig as Record<string, unknown> | undefined);
783
+
784
+ api.logger.info(`AgentSession.prompted intent: ${followUpIntentResult.intent}${followUpIntentResult.agentId ? ` (agent: ${followUpIntentResult.agentId})` : ""} — ${followUpIntentResult.reasoning}`);
785
+
786
+ const followUpBlockMsg = shouldBlockWorkRequest(
787
+ followUpIntentResult.intent, followUpStateType, enrichedIssue?.state?.name ?? "Unknown", followUpIssueRef,
788
+ );
789
+ if (followUpBlockMsg) {
790
+ api.logger.info(`AgentSession.prompted: blocking work request on untriaged issue ${followUpIssueRef}`);
791
+ await linearApi.emitActivity(session.id, { type: "response", body: followUpBlockMsg }).catch(() => {});
792
+ activeRuns.delete(issue.id);
793
+ return;
794
+ }
795
+
707
796
  const message = [
708
797
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
709
798
  ``,
@@ -720,7 +809,12 @@ export async function handleLinearWebhook(
720
809
  commentContext ? `\n**Recent conversation:**\n${commentContext}` : "",
721
810
  `\n**User's follow-up message:**\n> ${userMessage}`,
722
811
  ``,
723
- `Respond to the user's follow-up. For work requests, dispatch via \`code_run\`. Be concise and action-oriented.`,
812
+ `## Scope Rules`,
813
+ `1. **The issue body is your scope.** Re-read the description above before acting.`,
814
+ `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.`,
815
+ `3. **Match response to request.** Answer questions with answers. Do NOT escalate conversational messages into builds.`,
816
+ ``,
817
+ `Respond to the follow-up within the scope defined above. Be concise and action-oriented.`,
724
818
  ].filter(Boolean).join("\n");
725
819
 
726
820
  setActiveSession({
@@ -844,7 +938,7 @@ export async function handleLinearWebhook(
844
938
  const profiles = loadAgentProfiles();
845
939
  const agentNames = Object.keys(profiles);
846
940
 
847
- // ── @mention fast path — skip classifier ────────────────────
941
+ // ── @mention fast path — with intent gate ────────────────────
848
942
  const mentionPattern = buildMentionPattern(profiles);
849
943
  const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
850
944
  if (mentionMatches && mentionMatches.length > 0) {
@@ -852,6 +946,42 @@ export async function handleLinearWebhook(
852
946
  const resolved = resolveAgentFromAlias(alias, profiles);
853
947
  if (resolved) {
854
948
  api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
949
+
950
+ // Classify intent even on @mention path to gate work requests
951
+ let enrichedForGate: any = issue;
952
+ try { enrichedForGate = await linearApi.getIssueDetails(issue.id); } catch {}
953
+ const mentionStateType = enrichedForGate?.state?.type ?? "";
954
+ const mentionProjectId = enrichedForGate?.project?.id;
955
+ let mentionIsPlanning = false;
956
+ if (mentionProjectId) {
957
+ try {
958
+ const planState = await readPlanningState(pluginConfig?.planningStatePath as string | undefined);
959
+ mentionIsPlanning = isInPlanningMode(planState, mentionProjectId);
960
+ } catch {}
961
+ }
962
+
963
+ const mentionIntentResult = await classifyIntent(api, {
964
+ commentBody,
965
+ issueTitle: enrichedForGate?.title ?? "(untitled)",
966
+ issueStatus: enrichedForGate?.state?.name,
967
+ isPlanning: mentionIsPlanning,
968
+ agentNames,
969
+ hasProject: !!mentionProjectId,
970
+ }, pluginConfig);
971
+
972
+ api.logger.info(`Comment @mention intent: ${mentionIntentResult.intent} — ${mentionIntentResult.reasoning}`);
973
+
974
+ const mentionBlockMsg = shouldBlockWorkRequest(
975
+ mentionIntentResult.intent, mentionStateType,
976
+ enrichedForGate?.state?.name ?? "Unknown",
977
+ enrichedForGate?.identifier ?? issue.identifier ?? issue.id,
978
+ );
979
+ if (mentionBlockMsg) {
980
+ api.logger.info(`Comment @mention: blocking work request on untriaged issue ${enrichedForGate?.identifier ?? issue.identifier ?? issue.id}`);
981
+ try { await createCommentWithDedup(linearApi, issue.id, mentionBlockMsg); } catch {}
982
+ return true;
983
+ }
984
+
855
985
  void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
856
986
  .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
857
987
  return true;
@@ -894,6 +1024,19 @@ export async function handleLinearWebhook(
894
1024
 
895
1025
  api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
896
1026
 
1027
+ // ── Gate work requests on untriaged issues ────────────────────
1028
+ const commentStateType = enrichedIssue?.state?.type ?? "";
1029
+ const commentBlockMsg = shouldBlockWorkRequest(
1030
+ intentResult.intent, commentStateType,
1031
+ enrichedIssue?.state?.name ?? "Unknown",
1032
+ enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
1033
+ );
1034
+ if (commentBlockMsg) {
1035
+ api.logger.info(`Comment: blocking work request on untriaged issue ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id}`);
1036
+ try { await createCommentWithDedup(linearApi, issue.id, commentBlockMsg); } catch {}
1037
+ return true;
1038
+ }
1039
+
897
1040
  // ── Route by intent ────────────────────────────────────────────
898
1041
 
899
1042
  switch (intentResult.intent) {
@@ -1449,7 +1592,7 @@ async function dispatchCommentToAgent(
1449
1592
  : [
1450
1593
  `**Tool access:**`,
1451
1594
  `- \`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".`,
1452
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text they cannot access linear_issues.`,
1595
+ `- \`code_run\`: **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.`,
1453
1596
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
1454
1597
  ];
1455
1598
 
@@ -1475,7 +1618,14 @@ async function dispatchCommentToAgent(
1475
1618
  ``,
1476
1619
  `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1477
1620
  ``,
1478
- `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1621
+ `## Scope Rules`,
1622
+ `1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
1623
+ `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.`,
1624
+ `3. **Comments explore, issue body builds.** The comment above may explore scope or ask questions but NEVER trigger \`code_run\` from a comment alone. If the comment requests new implementation, suggest updating the issue description or creating a new issue.`,
1625
+ `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.`,
1626
+ `5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements in the issue body → no code_run.`,
1627
+ ``,
1628
+ `Respond within the scope defined above. Be concise and action-oriented.`,
1479
1629
  commentGuidanceAppendix,
1480
1630
  ].filter(Boolean).join("\n");
1481
1631
 
@@ -178,6 +178,11 @@ export function createCodeTool(
178
178
  required: ["prompt"],
179
179
  },
180
180
  execute: async (toolCallId: string, params: CliToolParams & { backend?: string }, ...rest: unknown[]) => {
181
+ // Extract onUpdate callback for progress reporting to Linear
182
+ const onUpdate = typeof rest[1] === "function"
183
+ ? rest[1] as (update: Record<string, unknown>) => void
184
+ : undefined;
185
+
181
186
  // Resolve backend: explicit alias → per-agent config → global default
182
187
  const currentSession = getCurrentSession();
183
188
  const agentId = currentSession?.agentId;
@@ -189,6 +194,13 @@ export function createCodeTool(
189
194
 
190
195
  api.logger.info(`code_run: backend=${backend} agent=${agentId ?? "unknown"}`);
191
196
 
197
+ // Emit prompt summary so Linear users see what's being built
198
+ const promptSummary = (params.prompt ?? "").slice(0, 200);
199
+ api.logger.info(`code_run prompt: [${backend}] ${promptSummary}`);
200
+ if (onUpdate) {
201
+ try { onUpdate({ status: "running", summary: `[${backend}] ${promptSummary}` }); } catch {}
202
+ }
203
+
192
204
  const result = await runner(api, params, pluginConfig);
193
205
 
194
206
  return jsonResult({