@calltelemetry/openclaw-linear 0.9.15 → 0.9.17
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 +7 -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 +225 -0
- package/src/infra/tmux.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/pipeline.ts +184 -17
- package/src/pipeline/webhook.test.ts +1 -1
- package/src/pipeline/webhook.ts +271 -30
- 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/planner-tools.ts +1 -0
- package/src/tools/steering-tools.ts +141 -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/pipeline.ts
CHANGED
|
@@ -49,6 +49,80 @@ import {
|
|
|
49
49
|
import { resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
50
50
|
import { emitDiagnostic } from "../infra/observability.js";
|
|
51
51
|
import { renderTemplate } from "../infra/template.js";
|
|
52
|
+
import { getWorktreeStatus, createPullRequest } from "../infra/codex-worktree.js";
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Linear issue state transitions (best-effort)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Transition a Linear issue to a target state by type (e.g., "started", "completed", "triage").
|
|
60
|
+
* Resolves the team's workflow states and finds a matching state.
|
|
61
|
+
* All transitions are best-effort — failures are logged as warnings, never thrown.
|
|
62
|
+
*/
|
|
63
|
+
async function transitionLinearIssueState(
|
|
64
|
+
linearApi: LinearAgentApi,
|
|
65
|
+
issueId: string,
|
|
66
|
+
teamId: string | undefined,
|
|
67
|
+
targetStateType: string,
|
|
68
|
+
opts?: { preferredStateName?: string },
|
|
69
|
+
logger?: { warn: (msg: string) => void; info: (msg: string) => void },
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (!teamId) {
|
|
72
|
+
logger?.warn(`[linear-state] no teamId — skipping state transition to ${targetStateType}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const states = await linearApi.getTeamStates(teamId);
|
|
77
|
+
// Prefer a specific state name if provided, otherwise match by type
|
|
78
|
+
let target = opts?.preferredStateName
|
|
79
|
+
? states.find(s => s.name === opts.preferredStateName) ?? states.find(s => s.type === targetStateType)
|
|
80
|
+
: states.find(s => s.type === targetStateType);
|
|
81
|
+
if (target) {
|
|
82
|
+
await linearApi.updateIssue(issueId, { stateId: target.id });
|
|
83
|
+
logger?.info(`[linear-state] ${issueId} → ${target.name} (type=${target.type})`);
|
|
84
|
+
} else {
|
|
85
|
+
logger?.warn(`[linear-state] no state with type="${targetStateType}" found for team ${teamId}`);
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logger?.warn(`[linear-state] failed to transition ${issueId} to ${targetStateType}: ${err}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Transition a Linear issue to "In Review" if it exists, otherwise "Done".
|
|
94
|
+
* Used after audit pass when a PR was created.
|
|
95
|
+
*/
|
|
96
|
+
async function transitionToReviewOrDone(
|
|
97
|
+
linearApi: LinearAgentApi,
|
|
98
|
+
issueId: string,
|
|
99
|
+
teamId: string | undefined,
|
|
100
|
+
hasPr: boolean,
|
|
101
|
+
logger?: { warn: (msg: string) => void; info: (msg: string) => void },
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
if (!teamId) return;
|
|
104
|
+
try {
|
|
105
|
+
const states = await linearApi.getTeamStates(teamId);
|
|
106
|
+
if (hasPr) {
|
|
107
|
+
// Prefer "In Review" state; fall back to any "started" state named "In Review", then "completed"
|
|
108
|
+
const inReview = states.find(s => s.name === "In Review")
|
|
109
|
+
?? states.find(s => s.type === "started" && s.name.toLowerCase().includes("review"));
|
|
110
|
+
if (inReview) {
|
|
111
|
+
await linearApi.updateIssue(issueId, { stateId: inReview.id });
|
|
112
|
+
logger?.info(`[linear-state] ${issueId} → ${inReview.name} (PR created)`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// No PR or no "In Review" state — mark as Done
|
|
117
|
+
const done = states.find(s => s.type === "completed");
|
|
118
|
+
if (done) {
|
|
119
|
+
await linearApi.updateIssue(issueId, { stateId: done.id });
|
|
120
|
+
logger?.info(`[linear-state] ${issueId} → ${done.name} (completed)`);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger?.warn(`[linear-state] failed to transition ${issueId} to review/done: ${err}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
52
126
|
|
|
53
127
|
// ---------------------------------------------------------------------------
|
|
54
128
|
// Prompt loading
|
|
@@ -218,7 +292,7 @@ export interface IssueContext {
|
|
|
218
292
|
export function buildWorkerTask(
|
|
219
293
|
issue: IssueContext,
|
|
220
294
|
worktreePath: string,
|
|
221
|
-
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string },
|
|
295
|
+
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown>; guidance?: string; teamContext?: string },
|
|
222
296
|
): { system: string; task: string } {
|
|
223
297
|
const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
|
|
224
298
|
const vars: Record<string, string> = {
|
|
@@ -233,6 +307,7 @@ export function buildWorkerTask(
|
|
|
233
307
|
? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
|
|
234
308
|
: "",
|
|
235
309
|
projectContext: buildProjectContext(opts?.pluginConfig),
|
|
310
|
+
teamContext: opts?.teamContext ?? "",
|
|
236
311
|
};
|
|
237
312
|
|
|
238
313
|
let task = renderTemplate(prompts.worker.task, vars);
|
|
@@ -253,7 +328,7 @@ export function buildAuditTask(
|
|
|
253
328
|
issue: IssueContext,
|
|
254
329
|
worktreePath: string,
|
|
255
330
|
pluginConfig?: Record<string, unknown>,
|
|
256
|
-
opts?: { guidance?: string },
|
|
331
|
+
opts?: { guidance?: string; teamContext?: string },
|
|
257
332
|
): { system: string; task: string } {
|
|
258
333
|
const prompts = loadPrompts(pluginConfig, worktreePath);
|
|
259
334
|
const vars: Record<string, string> = {
|
|
@@ -268,6 +343,7 @@ export function buildAuditTask(
|
|
|
268
343
|
? `\n---\n## IMPORTANT — Workspace Guidance (MUST follow)\nThe workspace owner has set the following mandatory instructions. You MUST incorporate these into your response:\n\n${opts.guidance.slice(0, 2000)}\n---`
|
|
269
344
|
: "",
|
|
270
345
|
projectContext: buildProjectContext(pluginConfig),
|
|
346
|
+
teamContext: opts?.teamContext ?? "",
|
|
271
347
|
};
|
|
272
348
|
|
|
273
349
|
return {
|
|
@@ -388,11 +464,19 @@ export async function triggerAudit(
|
|
|
388
464
|
|
|
389
465
|
// Look up cached guidance for audit
|
|
390
466
|
const auditTeamId = issueDetails?.team?.id;
|
|
467
|
+
const auditTeamKey = issueDetails?.team?.key as string | undefined;
|
|
391
468
|
const auditGuidance = (auditTeamId && isGuidanceEnabled(pluginConfig, auditTeamId))
|
|
392
469
|
? getCachedGuidanceForTeam(auditTeamId) ?? undefined
|
|
393
470
|
: undefined;
|
|
394
471
|
|
|
395
|
-
|
|
472
|
+
// Resolve team context from teamMappings config
|
|
473
|
+
const auditTeamMappings = pluginConfig?.teamMappings as Record<string, any> | undefined;
|
|
474
|
+
const auditTeamMapping = auditTeamKey ? auditTeamMappings?.[auditTeamKey] : undefined;
|
|
475
|
+
const auditTeamContext = auditTeamMapping?.context
|
|
476
|
+
? `\n## Team Context (${auditTeamKey})\n${auditTeamMapping.context}\n`
|
|
477
|
+
: "";
|
|
478
|
+
|
|
479
|
+
const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig, { guidance: auditGuidance, teamContext: auditTeamContext });
|
|
396
480
|
|
|
397
481
|
// Set Linear label
|
|
398
482
|
await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
|
|
@@ -424,11 +508,22 @@ export async function triggerAudit(
|
|
|
424
508
|
activeDispatch.auditSessionKey = auditSessionId;
|
|
425
509
|
}
|
|
426
510
|
|
|
511
|
+
// Resolve audit agent: team mapping → plugin default
|
|
512
|
+
const auditDefaultAgentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
513
|
+
let auditAgentId = auditDefaultAgentId;
|
|
514
|
+
if (auditTeamKey) {
|
|
515
|
+
const auditTeamDefault = auditTeamMappings?.[auditTeamKey]?.defaultAgent;
|
|
516
|
+
if (typeof auditTeamDefault === "string") {
|
|
517
|
+
auditAgentId = auditTeamDefault;
|
|
518
|
+
api.logger.info(`${TAG} audit agent routed to ${auditAgentId} via team mapping (${auditTeamKey})`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
427
522
|
api.logger.info(`${TAG} spawning audit agent session=${auditSessionId}`);
|
|
428
523
|
|
|
429
524
|
const result = await runAgent({
|
|
430
525
|
api,
|
|
431
|
-
agentId:
|
|
526
|
+
agentId: auditAgentId,
|
|
432
527
|
sessionId: auditSessionId,
|
|
433
528
|
message: `${auditPrompt.system}\n\n${auditPrompt.task}`,
|
|
434
529
|
streaming: dispatch.agentSessionId
|
|
@@ -558,15 +653,7 @@ async function handleAuditPass(
|
|
|
558
653
|
throw err;
|
|
559
654
|
}
|
|
560
655
|
|
|
561
|
-
//
|
|
562
|
-
await completeDispatch(dispatch.issueIdentifier, {
|
|
563
|
-
tier: dispatch.tier,
|
|
564
|
-
status: "done",
|
|
565
|
-
completedAt: new Date().toISOString(),
|
|
566
|
-
project: dispatch.project,
|
|
567
|
-
}, configPath);
|
|
568
|
-
|
|
569
|
-
// Build summary from .claw/ artifacts and write to memory
|
|
656
|
+
// Build summary from .claw/ artifacts and write to memory (before PR so body can include it)
|
|
570
657
|
let summary: string | null = null;
|
|
571
658
|
try {
|
|
572
659
|
summary = buildSummaryFromArtifacts(dispatch.worktreePath);
|
|
@@ -587,12 +674,58 @@ async function handleAuditPass(
|
|
|
587
674
|
api.logger.warn(`${TAG} failed to write summary/memory: ${err}`);
|
|
588
675
|
}
|
|
589
676
|
|
|
590
|
-
//
|
|
677
|
+
// Auto-PR creation (best-effort, non-fatal)
|
|
678
|
+
let prUrl: string | null = null;
|
|
679
|
+
try {
|
|
680
|
+
const wtStatus = getWorktreeStatus(dispatch.worktreePath);
|
|
681
|
+
if (wtStatus.lastCommit) {
|
|
682
|
+
const prBody = [
|
|
683
|
+
`Fixes ${dispatch.issueIdentifier}`,
|
|
684
|
+
"",
|
|
685
|
+
"## What changed",
|
|
686
|
+
summary?.slice(0, 2000) ?? "(see audit verdict)",
|
|
687
|
+
"",
|
|
688
|
+
"## Audit",
|
|
689
|
+
`- **Criteria:** ${verdict.criteria.join(", ") || "N/A"}`,
|
|
690
|
+
`- **Tests:** ${verdict.testResults || "N/A"}`,
|
|
691
|
+
"",
|
|
692
|
+
`*Auto-generated by OpenClaw dispatch pipeline*`,
|
|
693
|
+
].join("\n");
|
|
694
|
+
const prResult = createPullRequest(
|
|
695
|
+
dispatch.worktreePath,
|
|
696
|
+
`${dispatch.issueIdentifier}: ${dispatch.issueTitle ?? "implementation"}`,
|
|
697
|
+
prBody,
|
|
698
|
+
);
|
|
699
|
+
prUrl = prResult.prUrl;
|
|
700
|
+
api.logger.info(`${TAG} PR created: ${prUrl}`);
|
|
701
|
+
} else {
|
|
702
|
+
api.logger.info(`${TAG} no commits in worktree — skipping PR creation`);
|
|
703
|
+
}
|
|
704
|
+
} catch (err) {
|
|
705
|
+
api.logger.warn(`${TAG} PR creation failed (non-fatal): ${err}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Move to completed (include prUrl if PR was created)
|
|
709
|
+
await completeDispatch(dispatch.issueIdentifier, {
|
|
710
|
+
tier: dispatch.tier,
|
|
711
|
+
status: "done",
|
|
712
|
+
completedAt: new Date().toISOString(),
|
|
713
|
+
project: dispatch.project,
|
|
714
|
+
prUrl: prUrl ?? undefined,
|
|
715
|
+
}, configPath);
|
|
716
|
+
|
|
717
|
+
// Linear state transition: "In Review" if PR was created, otherwise "Done"
|
|
718
|
+
const issueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
719
|
+
const teamId = issueDetails?.team?.id;
|
|
720
|
+
await transitionToReviewOrDone(linearApi, dispatch.issueId, teamId, !!prUrl, api.logger);
|
|
721
|
+
|
|
722
|
+
// Post approval comment (with summary excerpt and PR link if available)
|
|
591
723
|
const criteriaList = verdict.criteria.map((c) => `- ${c}`).join("\n");
|
|
592
724
|
const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
|
|
725
|
+
const prLine = prUrl ? `\n- **Pull request:** ${prUrl}` : "";
|
|
593
726
|
await linearApi.createComment(
|
|
594
727
|
dispatch.issueId,
|
|
595
|
-
`## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw
|
|
728
|
+
`## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}${prLine}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`${prUrl ? "" : "\n- Create a PR from the worktree branch if one wasn't opened automatically"}`,
|
|
596
729
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
597
730
|
|
|
598
731
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
@@ -673,6 +806,13 @@ async function handleAuditFail(
|
|
|
673
806
|
}
|
|
674
807
|
} catch {}
|
|
675
808
|
|
|
809
|
+
// Linear state transition: move to "Triage" so it shows up for human review
|
|
810
|
+
const stuckIssueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
811
|
+
await transitionLinearIssueState(
|
|
812
|
+
linearApi, dispatch.issueId, stuckIssueDetails?.team?.id,
|
|
813
|
+
"triage", undefined, api.logger,
|
|
814
|
+
);
|
|
815
|
+
|
|
676
816
|
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
677
817
|
await linearApi.createComment(
|
|
678
818
|
dispatch.issueId,
|
|
@@ -804,15 +944,24 @@ export async function spawnWorker(
|
|
|
804
944
|
|
|
805
945
|
// Look up cached guidance for the issue's team
|
|
806
946
|
const workerTeamId = issueDetails?.team?.id;
|
|
947
|
+
const workerTeamKey = issueDetails?.team?.key as string | undefined;
|
|
807
948
|
const workerGuidance = (workerTeamId && isGuidanceEnabled(pluginConfig, workerTeamId))
|
|
808
949
|
? getCachedGuidanceForTeam(workerTeamId) ?? undefined
|
|
809
950
|
: undefined;
|
|
810
951
|
|
|
952
|
+
// Resolve team context from teamMappings config
|
|
953
|
+
const workerTeamMappings = pluginConfig?.teamMappings as Record<string, any> | undefined;
|
|
954
|
+
const workerTeamMapping = workerTeamKey ? workerTeamMappings?.[workerTeamKey] : undefined;
|
|
955
|
+
const workerTeamContext = workerTeamMapping?.context
|
|
956
|
+
? `\n## Team Context (${workerTeamKey})\n${workerTeamMapping.context}\n`
|
|
957
|
+
: "";
|
|
958
|
+
|
|
811
959
|
const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
|
|
812
960
|
attempt: dispatch.attempt,
|
|
813
961
|
gaps: opts?.gaps,
|
|
814
962
|
pluginConfig,
|
|
815
963
|
guidance: workerGuidance,
|
|
964
|
+
teamContext: workerTeamContext,
|
|
816
965
|
});
|
|
817
966
|
|
|
818
967
|
const workerSessionId = `linear-worker-${dispatch.issueIdentifier}-${dispatch.attempt}`;
|
|
@@ -833,10 +982,21 @@ export async function spawnWorker(
|
|
|
833
982
|
|
|
834
983
|
api.logger.info(`${TAG} spawning worker agent session=${workerSessionId} (attempt ${dispatch.attempt})`);
|
|
835
984
|
|
|
985
|
+
// Resolve agent: team mapping → plugin default
|
|
986
|
+
const defaultAgentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
987
|
+
let workerAgentId = defaultAgentId;
|
|
988
|
+
if (workerTeamKey) {
|
|
989
|
+
const teamDefault = workerTeamMappings?.[workerTeamKey]?.defaultAgent;
|
|
990
|
+
if (typeof teamDefault === "string") {
|
|
991
|
+
workerAgentId = teamDefault;
|
|
992
|
+
api.logger.info(`${TAG} worker agent routed to ${workerAgentId} via team mapping (${workerTeamKey})`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
836
996
|
const workerStartTime = Date.now();
|
|
837
997
|
const result = await runAgent({
|
|
838
998
|
api,
|
|
839
|
-
agentId:
|
|
999
|
+
agentId: workerAgentId,
|
|
840
1000
|
sessionId: workerSessionId,
|
|
841
1001
|
message: `${workerPrompt.system}\n\n${workerPrompt.task}`,
|
|
842
1002
|
streaming: dispatch.agentSessionId
|
|
@@ -846,7 +1006,7 @@ export async function spawnWorker(
|
|
|
846
1006
|
|
|
847
1007
|
// Save worker output to .claw/
|
|
848
1008
|
const workerElapsed = Date.now() - workerStartTime;
|
|
849
|
-
const agentId =
|
|
1009
|
+
const agentId = workerAgentId;
|
|
850
1010
|
try { saveWorkerOutput(dispatch.worktreePath, dispatch.attempt, result.output); } catch {}
|
|
851
1011
|
try {
|
|
852
1012
|
appendLog(dispatch.worktreePath, {
|
|
@@ -897,6 +1057,13 @@ export async function spawnWorker(
|
|
|
897
1057
|
}
|
|
898
1058
|
}
|
|
899
1059
|
|
|
1060
|
+
// Linear state transition: move to "Triage" so it shows up for human review
|
|
1061
|
+
const wdIssueDetails = await linearApi.getIssueDetails(dispatch.issueId).catch(() => null);
|
|
1062
|
+
await transitionLinearIssueState(
|
|
1063
|
+
linearApi, dispatch.issueId, wdIssueDetails?.team?.id,
|
|
1064
|
+
"triage", undefined, api.logger,
|
|
1065
|
+
);
|
|
1066
|
+
|
|
900
1067
|
await linearApi.createComment(
|
|
901
1068
|
dispatch.issueId,
|
|
902
1069
|
`## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
|
|
@@ -963,7 +963,7 @@ describe("AgentSessionEvent.prompted full flow", () => {
|
|
|
963
963
|
|
|
964
964
|
expect(result.status).toBe(200);
|
|
965
965
|
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
966
|
-
expect(infoCalls.some((msg: string) => msg.includes("agent active, ignoring (feedback)"))).toBe(true);
|
|
966
|
+
expect(infoCalls.some((msg: string) => msg.includes("agent active, no tmux, ignoring (feedback)"))).toBe(true);
|
|
967
967
|
});
|
|
968
968
|
|
|
969
969
|
it("deduplicates by webhookId", async () => {
|