@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.
- package/README.md +104 -48
- package/index.ts +57 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +599 -0
- package/src/infra/tmux.ts +158 -0
- package/src/infra/token-refresh-timer.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/memory-search.ts +40 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/retro.ts +231 -0
- package/src/pipeline/webhook.test.ts +8 -8
- package/src/pipeline/webhook.ts +408 -29
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/steering-tools.ts +176 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
package/src/pipeline/webhook.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`-
|
|
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
|
-
`-
|
|
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
|
|
458
|
-
: [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`
|
|
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.
|
|
483
|
-
`3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`
|
|
484
|
-
`4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`
|
|
485
|
-
`5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no
|
|
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 (
|
|
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
|
-
//
|
|
584
|
-
// our own
|
|
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
|
-
`-
|
|
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
|
-
`-
|
|
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
|
|
708
|
-
: [`**Your role:** Dispatcher. For work requests, use
|
|
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 \`
|
|
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 —
|
|
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
|
-
`-
|
|
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
|
-
`-
|
|
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
|
|
1470
|
-
: [`**Your role:** Dispatcher. For work requests, use
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|