@calltelemetry/openclaw-linear 0.4.0 → 0.4.1

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/src/webhook.ts CHANGED
@@ -3,9 +3,11 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
5
  import { LinearAgentApi, resolveLinearToken } from "./linear-api.js";
6
- // Pipeline is used from Issue.update delegation handler (not from agent session chat)
7
- // import { runFullPipeline, resumePipeline, type PipelineContext } from "./pipeline.js";
6
+ import { runFullPipeline, type PipelineContext } from "./pipeline.js";
8
7
  import { setActiveSession, clearActiveSession } from "./active-session.js";
8
+ import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
9
+ import { assessTier } from "./tier-assess.js";
10
+ import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
9
11
 
10
12
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
11
13
  interface AgentProfile {
@@ -858,7 +860,7 @@ export async function handleLinearWebhook(
858
860
  }
859
861
 
860
862
  const trigger = isDelegatedToUs ? "delegated" : "assigned";
861
- api.logger.info(`Issue ${trigger} to our app user (${viewerId}), processing`);
863
+ api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
862
864
 
863
865
  // Dedup on assignment/delegation
864
866
  const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
@@ -867,200 +869,11 @@ export async function handleLinearWebhook(
867
869
  return true;
868
870
  }
869
871
 
870
- const agentId = resolveAgentId(api);
871
-
872
- // Fetch full issue details + team labels for triage
873
- let enrichedIssue: any = issue;
874
- let teamLabels: Array<{ id: string; name: string }> = [];
875
- try {
876
- enrichedIssue = await linearApi.getIssueDetails(issue.id);
877
- if (enrichedIssue?.team?.id) {
878
- teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
879
- }
880
- } catch (err) {
881
- api.logger.warn(`Could not fetch issue details: ${err}`);
882
- }
883
-
884
- const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
885
- const comments = enrichedIssue?.comments?.nodes ?? [];
886
- const commentSummary = comments
887
- .slice(-5)
888
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
889
- .join("\n");
890
-
891
- const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
892
- const currentLabels = enrichedIssue?.labels?.nodes ?? [];
893
- const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
894
- const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
895
-
896
- const message = [
897
- `IMPORTANT: You are triaging a delegated Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
898
- ``,
899
- `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
900
- `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
901
- ``,
902
- `**Description:**`,
903
- description,
904
- commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
905
- ``,
906
- `## Your Triage Tasks`,
907
- ``,
908
- `1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
909
- `2. **Labels** — Select appropriate labels from the team's available labels`,
910
- `3. **Assessment** — Brief analysis of what this issue needs`,
911
- ``,
912
- `## Available Labels`,
913
- availableLabelList || " (no labels configured)",
914
- ``,
915
- `## Response Format`,
916
- ``,
917
- `You MUST start your response with a JSON block, then follow with your assessment:`,
918
- ``,
919
- '```json',
920
- `{`,
921
- ` "estimate": <number>,`,
922
- ` "labelIds": ["<id1>", "<id2>"],`,
923
- ` "assessment": "<one-line summary of your sizing rationale>"`,
924
- `}`,
925
- '```',
926
- ``,
927
- `Then write your full assessment as markdown below the JSON block.`,
928
- ].filter(Boolean).join("\n");
929
-
930
- // Dispatch agent with session lifecycle (non-blocking)
931
- void (async () => {
932
- const profiles = loadAgentProfiles();
933
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
934
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
935
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
936
- let agentSessionId: string | null = null;
937
-
938
- try {
939
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
940
- agentSessionId = sessionResult.sessionId;
941
- if (agentSessionId) {
942
- wasRecentlyProcessed(`session:${agentSessionId}`);
943
- api.logger.info(`Created agent session ${agentSessionId} for ${trigger}`);
944
- setActiveSession({
945
- agentSessionId,
946
- issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
947
- issueId: issue.id,
948
- agentId,
949
- startedAt: Date.now(),
950
- });
951
- } else {
952
- api.logger.warn(`Could not create agent session for assignment: ${sessionResult.error ?? "unknown"}`);
953
- }
954
-
955
- if (agentSessionId) {
956
- await linearApi.emitActivity(agentSessionId, {
957
- type: "thought",
958
- body: `Reviewing assigned issue ${enrichedIssue?.identifier ?? issue.id}...`,
959
- }).catch(() => {});
960
- }
961
-
962
- if (agentSessionId) {
963
- await linearApi.emitActivity(agentSessionId, {
964
- type: "action",
965
- action: "Triaging",
966
- parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling, sizing`,
967
- }).catch(() => {});
968
- }
969
-
970
- const sessionId = `linear-assign-${issue.id}-${Date.now()}`;
971
- const { runAgent } = await import("./agent.js");
972
- const result = await runAgent({
973
- api,
974
- agentId,
975
- sessionId,
976
- message,
977
- timeoutMs: 3 * 60_000,
978
- streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
979
- });
980
-
981
- const responseBody = result.success
982
- ? result.output
983
- : `I encountered an error reviewing this assignment. Please try again.`;
984
-
985
- // Parse triage JSON from agent response and apply to issue
986
- let commentBody = responseBody;
987
- if (result.success) {
988
- const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
989
- if (jsonMatch) {
990
- try {
991
- const triage = JSON.parse(jsonMatch[1]);
992
- const updateInput: Record<string, unknown> = {};
993
-
994
- if (typeof triage.estimate === "number") {
995
- updateInput.estimate = triage.estimate;
996
- }
997
- if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
998
- // Merge with existing labels
999
- const existingIds = currentLabels.map((l: any) => l.id);
1000
- const allIds = [...new Set([...existingIds, ...triage.labelIds])];
1001
- updateInput.labelIds = allIds;
1002
- }
1003
-
1004
- if (Object.keys(updateInput).length > 0) {
1005
- await linearApi.updateIssue(issue.id, updateInput);
1006
- api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
1007
-
1008
- if (agentSessionId) {
1009
- await linearApi.emitActivity(agentSessionId, {
1010
- type: "action",
1011
- action: "Applied triage",
1012
- result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0} added`,
1013
- }).catch(() => {});
1014
- }
1015
- }
1016
-
1017
- // Strip the JSON block from the comment — post only the assessment
1018
- commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
1019
- } catch (parseErr) {
1020
- api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
1021
- }
1022
- }
1023
- }
1024
-
1025
- // Post comment with assessment
1026
- const brandingOpts = avatarUrl
1027
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1028
- : undefined;
1029
-
1030
- try {
1031
- if (brandingOpts) {
1032
- await linearApi.createComment(issue.id, commentBody, brandingOpts);
1033
- } else {
1034
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1035
- }
1036
- } catch (brandErr) {
1037
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1038
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1039
- }
1040
-
1041
- if (agentSessionId) {
1042
- const truncated = commentBody.length > 2000
1043
- ? commentBody.slice(0, 2000) + "…"
1044
- : commentBody;
1045
- await linearApi.emitActivity(agentSessionId, {
1046
- type: "response",
1047
- body: truncated,
1048
- }).catch(() => {});
1049
- }
1050
-
1051
- api.logger.info(`Posted assignment response to ${enrichedIssue?.identifier ?? issue.id}`);
1052
- } catch (err) {
1053
- api.logger.error(`Issue assignment handler error: ${err}`);
1054
- if (agentSessionId) {
1055
- await linearApi.emitActivity(agentSessionId, {
1056
- type: "error",
1057
- body: `Failed to process assignment: ${String(err).slice(0, 500)}`,
1058
- }).catch(() => {});
1059
- }
1060
- } finally {
1061
- clearActiveSession(issue.id);
1062
- }
1063
- })();
872
+ // Assignment triggers the full dispatch pipeline:
873
+ // tier assessment → worktree → plan → implement → audit
874
+ void handleDispatch(api, linearApi, issue).catch((err) => {
875
+ api.logger.error(`Dispatch pipeline error for ${issue.identifier ?? issue.id}: ${err}`);
876
+ });
1064
877
 
1065
878
  return true;
1066
879
  }
@@ -1290,3 +1103,216 @@ export async function handleLinearWebhook(
1290
1103
  res.end("ok");
1291
1104
  return true;
1292
1105
  }
1106
+
1107
+ // ── @dispatch handler ─────────────────────────────────────────────
1108
+ //
1109
+ // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
1110
+ // creates a persistent worktree, registers the dispatch in state, and
1111
+ // launches the pipeline (plan → implement → audit).
1112
+
1113
+ async function handleDispatch(
1114
+ api: OpenClawPluginApi,
1115
+ linearApi: LinearAgentApi,
1116
+ issue: any,
1117
+ ): Promise<void> {
1118
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
1119
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
1120
+ const worktreeBaseDir = pluginConfig?.worktreeBaseDir as string | undefined;
1121
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
1122
+ const identifier = issue.identifier ?? issue.id;
1123
+
1124
+ api.logger.info(`@dispatch: processing ${identifier}`);
1125
+
1126
+ // 1. Check for existing active dispatch — reclaim if stale
1127
+ const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
1128
+ const state = await readDispatchState(statePath);
1129
+ const existing = getActiveDispatch(state, identifier);
1130
+ if (existing) {
1131
+ const ageMs = Date.now() - new Date(existing.dispatchedAt).getTime();
1132
+ const isStale = ageMs > STALE_DISPATCH_MS;
1133
+ const inMemory = activeRuns.has(issue.id);
1134
+
1135
+ if (!isStale && inMemory) {
1136
+ // Truly still running in this gateway process
1137
+ api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1138
+ await linearApi.createComment(
1139
+ issue.id,
1140
+ `Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
1141
+ );
1142
+ return;
1143
+ }
1144
+
1145
+ // Stale or not in memory (gateway restarted) — reclaim
1146
+ api.logger.info(
1147
+ `dispatch: ${identifier} reclaiming stale dispatch (status: ${existing.status}, ` +
1148
+ `age: ${Math.round(ageMs / 1000)}s, inMemory: ${inMemory}, stale: ${isStale})`,
1149
+ );
1150
+ await removeActiveDispatch(identifier, statePath);
1151
+ activeRuns.delete(issue.id);
1152
+ }
1153
+
1154
+ // 2. Prevent concurrent runs on same issue
1155
+ if (activeRuns.has(issue.id)) {
1156
+ api.logger.info(`@dispatch: ${identifier} has active agent run — skipping`);
1157
+ return;
1158
+ }
1159
+
1160
+ // 3. Fetch full issue details for tier assessment
1161
+ let enrichedIssue: any;
1162
+ try {
1163
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1164
+ } catch (err) {
1165
+ api.logger.error(`@dispatch: failed to fetch issue details: ${err}`);
1166
+ enrichedIssue = issue;
1167
+ }
1168
+
1169
+ const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1170
+ const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
1171
+
1172
+ // 4. Assess complexity tier
1173
+ const assessment = await assessTier(api, {
1174
+ identifier,
1175
+ title: enrichedIssue.title ?? "(untitled)",
1176
+ description: enrichedIssue.description,
1177
+ labels,
1178
+ commentCount,
1179
+ });
1180
+
1181
+ api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1182
+
1183
+ // 5. Create persistent worktree
1184
+ let worktree;
1185
+ try {
1186
+ worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1187
+ api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
1188
+ } catch (err) {
1189
+ api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1190
+ await linearApi.createComment(
1191
+ issue.id,
1192
+ `Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
1193
+ );
1194
+ return;
1195
+ }
1196
+
1197
+ // 5b. Prepare workspace: pull latest from origin + init submodules
1198
+ const prep = prepareWorkspace(worktree.path, worktree.branch);
1199
+ if (prep.errors.length > 0) {
1200
+ api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
1201
+ } else {
1202
+ api.logger.info(
1203
+ `@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
1204
+ );
1205
+ }
1206
+
1207
+ // 6. Create agent session on Linear
1208
+ let agentSessionId: string | undefined;
1209
+ try {
1210
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1211
+ agentSessionId = sessionResult.sessionId ?? undefined;
1212
+ } catch (err) {
1213
+ api.logger.warn(`@dispatch: could not create agent session: ${err}`);
1214
+ }
1215
+
1216
+ // 7. Register dispatch in persistent state
1217
+ const now = new Date().toISOString();
1218
+ await registerDispatch(identifier, {
1219
+ issueId: issue.id,
1220
+ issueIdentifier: identifier,
1221
+ worktreePath: worktree.path,
1222
+ branch: worktree.branch,
1223
+ tier: assessment.tier,
1224
+ model: assessment.model,
1225
+ status: "dispatched",
1226
+ dispatchedAt: now,
1227
+ agentSessionId,
1228
+ }, statePath);
1229
+
1230
+ // 8. Register active session for tool resolution
1231
+ setActiveSession({
1232
+ agentSessionId: agentSessionId ?? "",
1233
+ issueIdentifier: identifier,
1234
+ issueId: issue.id,
1235
+ agentId: resolveAgentId(api),
1236
+ startedAt: Date.now(),
1237
+ });
1238
+
1239
+ // 9. Post dispatch confirmation comment
1240
+ const prepStatus = prep.errors.length > 0
1241
+ ? `Workspace prep: partial (${prep.errors.join("; ")})`
1242
+ : `Workspace prep: OK (pulled=${prep.pulled}, submodules=${prep.submodulesInitialized})`;
1243
+ const statusComment = [
1244
+ `**Dispatched** as **${assessment.tier}** (${assessment.model})`,
1245
+ `> ${assessment.reasoning}`,
1246
+ ``,
1247
+ `Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
1248
+ `Branch: \`${worktree.branch}\``,
1249
+ prepStatus,
1250
+ ].join("\n");
1251
+
1252
+ await linearApi.createComment(issue.id, statusComment);
1253
+
1254
+ if (agentSessionId) {
1255
+ await linearApi.emitActivity(agentSessionId, {
1256
+ type: "thought",
1257
+ body: `Dispatching ${identifier} as ${assessment.tier}...`,
1258
+ }).catch(() => {});
1259
+ }
1260
+
1261
+ // 10. Apply tier label (best effort)
1262
+ try {
1263
+ if (enrichedIssue.team?.id) {
1264
+ const teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
1265
+ const tierLabel = teamLabels.find((l: any) => l.name === `developer:${assessment.tier}`);
1266
+ if (tierLabel) {
1267
+ const currentLabelIds = enrichedIssue.labels?.nodes?.map((l: any) => l.id) ?? [];
1268
+ await linearApi.updateIssue(issue.id, {
1269
+ labelIds: [...currentLabelIds, tierLabel.id],
1270
+ });
1271
+ }
1272
+ }
1273
+ } catch (err) {
1274
+ api.logger.warn(`@dispatch: could not apply tier label: ${err}`);
1275
+ }
1276
+
1277
+ // 11. Run pipeline (non-blocking)
1278
+ const agentId = resolveAgentId(api);
1279
+ const pipelineCtx: PipelineContext = {
1280
+ api,
1281
+ linearApi,
1282
+ agentSessionId: agentSessionId ?? `dispatch-${identifier}-${Date.now()}`,
1283
+ agentId,
1284
+ issue: {
1285
+ id: issue.id,
1286
+ identifier,
1287
+ title: enrichedIssue.title ?? "(untitled)",
1288
+ description: enrichedIssue.description,
1289
+ },
1290
+ worktreePath: worktree.path,
1291
+ codexBranch: worktree.branch,
1292
+ tier: assessment.tier,
1293
+ model: assessment.model,
1294
+ };
1295
+
1296
+ activeRuns.add(issue.id);
1297
+
1298
+ // Update status to running
1299
+ await updateDispatchStatus(identifier, "running", statePath);
1300
+
1301
+ runFullPipeline(pipelineCtx)
1302
+ .then(async () => {
1303
+ await completeDispatch(identifier, {
1304
+ tier: assessment.tier,
1305
+ status: "done",
1306
+ completedAt: new Date().toISOString(),
1307
+ }, statePath);
1308
+ api.logger.info(`@dispatch: pipeline completed for ${identifier}`);
1309
+ })
1310
+ .catch(async (err) => {
1311
+ api.logger.error(`@dispatch: pipeline failed for ${identifier}: ${err}`);
1312
+ await updateDispatchStatus(identifier, "failed", statePath);
1313
+ })
1314
+ .finally(() => {
1315
+ activeRuns.delete(issue.id);
1316
+ clearActiveSession(issue.id);
1317
+ });
1318
+ }