@calltelemetry/openclaw-linear 0.9.14 → 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.
package/index.ts CHANGED
@@ -229,6 +229,56 @@ export default function register(api: OpenClawPluginApi) {
229
229
  }
230
230
  });
231
231
 
232
+ // Hard gate: prepend planning-only constraints to code_run when issue is not "started".
233
+ // Even if the orchestrator LLM ignores scope rules, the coding agent receives hard constraints.
234
+ api.on("before_tool_call", async (event: any, _ctx: any) => {
235
+ if (event.toolName !== "code_run") return;
236
+
237
+ const { getCurrentSession } = await import("./src/pipeline/active-session.js");
238
+ const session = getCurrentSession();
239
+ if (!session?.issueId) return; // Non-Linear context, allow
240
+
241
+ // Check issue state
242
+ const hookTokenInfo = resolveLinearToken(pluginConfig);
243
+ if (!hookTokenInfo.accessToken) return;
244
+ const hookLinearApi = new LinearAgentApi(hookTokenInfo.accessToken, {
245
+ refreshToken: hookTokenInfo.refreshToken,
246
+ expiresAt: hookTokenInfo.expiresAt,
247
+ });
248
+
249
+ try {
250
+ const issue = await hookLinearApi.getIssueDetails(session.issueId);
251
+ const stateType = issue?.state?.type ?? "";
252
+ const isStarted = stateType === "started";
253
+
254
+ if (!isStarted) {
255
+ const constraint = [
256
+ "CRITICAL CONSTRAINT — PLANNING MODE ONLY:",
257
+ `This issue (${session.issueIdentifier}) is in "${issue?.state?.name ?? stateType}" state — NOT In Progress.`,
258
+ "You may ONLY:",
259
+ "- Read and explore files to understand the codebase",
260
+ "- Write plan files (PLAN.md, notes, design outlines)",
261
+ "- Search code to inform planning",
262
+ "You MUST NOT:",
263
+ "- Create, modify, or delete source code, config, or infrastructure files",
264
+ "- Run system commands that change state (deploys, installs, migrations)",
265
+ "- Make external API requests that modify data",
266
+ "- Build, implement, or scaffold any application code",
267
+ "Plan and explore ONLY. Do not implement anything.",
268
+ "---",
269
+ ].join("\n");
270
+
271
+ const originalPrompt = event.params?.prompt ?? "";
272
+ return {
273
+ params: { ...event.params, prompt: `${constraint}\n${originalPrompt}` },
274
+ };
275
+ }
276
+ } catch (err) {
277
+ api.logger.warn(`before_tool_call: issue state check failed: ${err}`);
278
+ // Don't block on failure — fall through to allow
279
+ }
280
+ });
281
+
232
282
  // Narration Guard: catch short "Let me explore..." responses that narrate intent
233
283
  // without actually calling tools, and append a warning for the user.
234
284
  const NARRATION_PATTERNS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  ``,
@@ -698,7 +749,7 @@ export async function handleLinearWebhook(
698
749
  : [
699
750
  `**Tool access:**`,
700
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".`,
701
- `- \`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.`,
702
753
  `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
703
754
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
704
755
  ];
@@ -711,6 +762,37 @@ export async function handleLinearWebhook(
711
762
  api.logger.info(`Follow-up guidance injected: ${(guidanceCtxPrompted.guidance ?? "cached").slice(0, 120)}...`);
712
763
  }
713
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
+
714
796
  const message = [
715
797
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
716
798
  ``,
@@ -856,7 +938,7 @@ export async function handleLinearWebhook(
856
938
  const profiles = loadAgentProfiles();
857
939
  const agentNames = Object.keys(profiles);
858
940
 
859
- // ── @mention fast path — skip classifier ────────────────────
941
+ // ── @mention fast path — with intent gate ────────────────────
860
942
  const mentionPattern = buildMentionPattern(profiles);
861
943
  const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
862
944
  if (mentionMatches && mentionMatches.length > 0) {
@@ -864,6 +946,42 @@ export async function handleLinearWebhook(
864
946
  const resolved = resolveAgentFromAlias(alias, profiles);
865
947
  if (resolved) {
866
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
+
867
985
  void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
868
986
  .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
869
987
  return true;
@@ -906,6 +1024,19 @@ export async function handleLinearWebhook(
906
1024
 
907
1025
  api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
908
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
+
909
1040
  // ── Route by intent ────────────────────────────────────────────
910
1041
 
911
1042
  switch (intentResult.intent) {
@@ -1461,7 +1592,7 @@ async function dispatchCommentToAgent(
1461
1592
  : [
1462
1593
  `**Tool access:**`,
1463
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".`,
1464
- `- \`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.`,
1465
1596
  `- Standard tools: exec, read, edit, write, web_search, etc.`,
1466
1597
  ];
1467
1598
 
@@ -1487,7 +1618,14 @@ async function dispatchCommentToAgent(
1487
1618
  ``,
1488
1619
  `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1489
1620
  ``,
1490
- `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.`,
1491
1629
  commentGuidanceAppendix,
1492
1630
  ].filter(Boolean).join("\n");
1493
1631