@calltelemetry/openclaw-linear 0.4.0 → 0.5.0

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * tier-assess.ts — LLM-based complexity assessment for Linear issues.
3
+ *
4
+ * Uses runAgent() with the agent's configured model (e.g. kimi-k2.5)
5
+ * to assess issue complexity. The agent model handles orchestration —
6
+ * it never calls coding CLIs directly.
7
+ *
8
+ * Cost: one short agent turn (~500 tokens). Latency: ~2-5s.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
13
+ import type { Tier } from "./dispatch-state.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Tier → Model mapping
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const TIER_MODELS: Record<Tier, string> = {
20
+ junior: "anthropic/claude-haiku-4-5",
21
+ medior: "anthropic/claude-sonnet-4-6",
22
+ senior: "anthropic/claude-opus-4-6",
23
+ };
24
+
25
+ export interface TierAssessment {
26
+ tier: Tier;
27
+ model: string;
28
+ reasoning: string;
29
+ }
30
+
31
+ export interface IssueContext {
32
+ identifier: string;
33
+ title: string;
34
+ description?: string | null;
35
+ labels?: string[];
36
+ commentCount?: number;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Assessment
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const ASSESS_PROMPT = `You are a complexity assessor. Assess this issue and respond ONLY with JSON.
44
+
45
+ Tiers:
46
+ - junior: typos, copy changes, config tweaks, simple CSS, env var additions
47
+ - medior: features, bugfixes, moderate refactoring, adding tests, API changes
48
+ - senior: architecture changes, database migrations, security fixes, multi-service coordination
49
+
50
+ Consider:
51
+ 1. How many files/services are likely affected?
52
+ 2. Does it touch auth, data, or external APIs? (higher risk → higher tier)
53
+ 3. Is the description clear and actionable?
54
+ 4. Are there dependencies or unknowns?
55
+
56
+ Respond ONLY with: {"tier":"junior|medior|senior","reasoning":"one sentence"}`;
57
+
58
+ /**
59
+ * Assess issue complexity using the agent's configured model.
60
+ *
61
+ * Falls back to "medior" if the agent call fails or returns invalid JSON.
62
+ */
63
+ export async function assessTier(
64
+ api: OpenClawPluginApi,
65
+ issue: IssueContext,
66
+ agentId?: string,
67
+ ): Promise<TierAssessment> {
68
+ const issueText = [
69
+ `Issue: ${issue.identifier} — ${issue.title}`,
70
+ issue.description ? `Description: ${issue.description.slice(0, 1500)}` : "",
71
+ issue.labels?.length ? `Labels: ${issue.labels.join(", ")}` : "",
72
+ issue.commentCount != null ? `Comments: ${issue.commentCount}` : "",
73
+ ].filter(Boolean).join("\n");
74
+
75
+ const message = `${ASSESS_PROMPT}\n\n${issueText}`;
76
+
77
+ try {
78
+ const { runAgent } = await import("./agent.js");
79
+ const result = await runAgent({
80
+ api,
81
+ agentId: agentId ?? resolveDefaultAgent(api),
82
+ sessionId: `tier-assess-${issue.identifier}-${Date.now()}`,
83
+ message,
84
+ timeoutMs: 30_000, // 30s — this should be fast
85
+ });
86
+
87
+ // Try to parse assessment from output regardless of success flag.
88
+ // runAgent may report success:false (non-zero exit code) even when
89
+ // the agent produced valid JSON output — e.g. agent exited with
90
+ // signal but wrote the response before terminating.
91
+ if (result.output) {
92
+ const parsed = parseAssessment(result.output);
93
+ if (parsed) {
94
+ api.logger.info(`Tier assessment for ${issue.identifier}: ${parsed.tier} — ${parsed.reasoning} (agent success=${result.success})`);
95
+ return parsed;
96
+ }
97
+ }
98
+
99
+ if (!result.success) {
100
+ api.logger.warn(`Tier assessment agent failed for ${issue.identifier}: ${result.output.slice(0, 200)}`);
101
+ } else {
102
+ api.logger.warn(`Tier assessment for ${issue.identifier}: could not parse response: ${result.output.slice(0, 200)}`);
103
+ }
104
+ } catch (err) {
105
+ api.logger.warn(`Tier assessment error for ${issue.identifier}: ${err}`);
106
+ }
107
+
108
+ // Fallback: medior is the safest default
109
+ const fallback: TierAssessment = {
110
+ tier: "medior",
111
+ model: TIER_MODELS.medior,
112
+ reasoning: "Assessment failed — defaulting to medior",
113
+ };
114
+ api.logger.info(`Tier assessment fallback for ${issue.identifier}: medior`);
115
+ return fallback;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Helpers
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function resolveDefaultAgent(api: OpenClawPluginApi): string {
123
+ // Use the plugin's configured default agent (same one that runs the pipeline)
124
+ const fromConfig = (api as any).pluginConfig?.defaultAgentId;
125
+ if (typeof fromConfig === "string" && fromConfig) return fromConfig;
126
+
127
+ // Fall back to isDefault in agent profiles
128
+ try {
129
+ const profilesPath = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
130
+ const raw = readFileSync(profilesPath, "utf8");
131
+ const profiles = JSON.parse(raw).agents ?? {};
132
+ const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
133
+ if (defaultAgent) return defaultAgent[0];
134
+ } catch { /* fall through */ }
135
+
136
+ return "default";
137
+ }
138
+
139
+ function parseAssessment(raw: string): TierAssessment | null {
140
+ // Extract JSON from the response (may have markdown wrapping)
141
+ const jsonMatch = raw.match(/\{[^}]+\}/);
142
+ if (!jsonMatch) return null;
143
+
144
+ try {
145
+ const parsed = JSON.parse(jsonMatch[0]);
146
+ const tier = parsed.tier as string;
147
+ if (tier !== "junior" && tier !== "medior" && tier !== "senior") return null;
148
+
149
+ return {
150
+ tier: tier as Tier,
151
+ model: TIER_MODELS[tier as Tier],
152
+ reasoning: parsed.reasoning ?? "no reasoning provided",
153
+ };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
package/src/webhook.ts CHANGED
@@ -3,9 +3,12 @@ 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 { spawnWorker, type HookContext } 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 { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./notify.js";
10
+ import { assessTier } from "./tier-assess.js";
11
+ import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
9
12
 
10
13
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
11
14
  interface AgentProfile {
@@ -858,7 +861,7 @@ export async function handleLinearWebhook(
858
861
  }
859
862
 
860
863
  const trigger = isDelegatedToUs ? "delegated" : "assigned";
861
- api.logger.info(`Issue ${trigger} to our app user (${viewerId}), processing`);
864
+ api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
862
865
 
863
866
  // Dedup on assignment/delegation
864
867
  const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
@@ -867,200 +870,11 @@ export async function handleLinearWebhook(
867
870
  return true;
868
871
  }
869
872
 
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
- })();
873
+ // Assignment triggers the full dispatch pipeline:
874
+ // tier assessment → worktree → plan → implement → audit
875
+ void handleDispatch(api, linearApi, issue).catch((err) => {
876
+ api.logger.error(`Dispatch pipeline error for ${issue.identifier ?? issue.id}: ${err}`);
877
+ });
1064
878
 
1065
879
  return true;
1066
880
  }
@@ -1290,3 +1104,224 @@ export async function handleLinearWebhook(
1290
1104
  res.end("ok");
1291
1105
  return true;
1292
1106
  }
1107
+
1108
+ // ── @dispatch handler ─────────────────────────────────────────────
1109
+ //
1110
+ // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
1111
+ // creates a persistent worktree, registers the dispatch in state, and
1112
+ // launches the pipeline (plan → implement → audit).
1113
+
1114
+ async function handleDispatch(
1115
+ api: OpenClawPluginApi,
1116
+ linearApi: LinearAgentApi,
1117
+ issue: any,
1118
+ ): Promise<void> {
1119
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
1120
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
1121
+ const worktreeBaseDir = pluginConfig?.worktreeBaseDir as string | undefined;
1122
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
1123
+ const identifier = issue.identifier ?? issue.id;
1124
+
1125
+ api.logger.info(`@dispatch: processing ${identifier}`);
1126
+
1127
+ // 1. Check for existing active dispatch — reclaim if stale
1128
+ const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
1129
+ const state = await readDispatchState(statePath);
1130
+ const existing = getActiveDispatch(state, identifier);
1131
+ if (existing) {
1132
+ const ageMs = Date.now() - new Date(existing.dispatchedAt).getTime();
1133
+ const isStale = ageMs > STALE_DISPATCH_MS;
1134
+ const inMemory = activeRuns.has(issue.id);
1135
+
1136
+ if (!isStale && inMemory) {
1137
+ // Truly still running in this gateway process
1138
+ api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1139
+ await linearApi.createComment(
1140
+ issue.id,
1141
+ `Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
1142
+ );
1143
+ return;
1144
+ }
1145
+
1146
+ // Stale or not in memory (gateway restarted) — reclaim
1147
+ api.logger.info(
1148
+ `dispatch: ${identifier} reclaiming stale dispatch (status: ${existing.status}, ` +
1149
+ `age: ${Math.round(ageMs / 1000)}s, inMemory: ${inMemory}, stale: ${isStale})`,
1150
+ );
1151
+ await removeActiveDispatch(identifier, statePath);
1152
+ activeRuns.delete(issue.id);
1153
+ }
1154
+
1155
+ // 2. Prevent concurrent runs on same issue
1156
+ if (activeRuns.has(issue.id)) {
1157
+ api.logger.info(`@dispatch: ${identifier} has active agent run — skipping`);
1158
+ return;
1159
+ }
1160
+
1161
+ // 3. Fetch full issue details for tier assessment
1162
+ let enrichedIssue: any;
1163
+ try {
1164
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1165
+ } catch (err) {
1166
+ api.logger.error(`@dispatch: failed to fetch issue details: ${err}`);
1167
+ enrichedIssue = issue;
1168
+ }
1169
+
1170
+ const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
1171
+ const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
1172
+
1173
+ // 4. Assess complexity tier
1174
+ const assessment = await assessTier(api, {
1175
+ identifier,
1176
+ title: enrichedIssue.title ?? "(untitled)",
1177
+ description: enrichedIssue.description,
1178
+ labels,
1179
+ commentCount,
1180
+ });
1181
+
1182
+ api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1183
+
1184
+ // 5. Create persistent worktree
1185
+ let worktree;
1186
+ try {
1187
+ worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
1188
+ api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
1189
+ } catch (err) {
1190
+ api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1191
+ await linearApi.createComment(
1192
+ issue.id,
1193
+ `Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
1194
+ );
1195
+ return;
1196
+ }
1197
+
1198
+ // 5b. Prepare workspace: pull latest from origin + init submodules
1199
+ const prep = prepareWorkspace(worktree.path, worktree.branch);
1200
+ if (prep.errors.length > 0) {
1201
+ api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
1202
+ } else {
1203
+ api.logger.info(
1204
+ `@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
1205
+ );
1206
+ }
1207
+
1208
+ // 6. Create agent session on Linear
1209
+ let agentSessionId: string | undefined;
1210
+ try {
1211
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1212
+ agentSessionId = sessionResult.sessionId ?? undefined;
1213
+ } catch (err) {
1214
+ api.logger.warn(`@dispatch: could not create agent session: ${err}`);
1215
+ }
1216
+
1217
+ // 7. Register dispatch in persistent state
1218
+ const now = new Date().toISOString();
1219
+ await registerDispatch(identifier, {
1220
+ issueId: issue.id,
1221
+ issueIdentifier: identifier,
1222
+ worktreePath: worktree.path,
1223
+ branch: worktree.branch,
1224
+ tier: assessment.tier,
1225
+ model: assessment.model,
1226
+ status: "dispatched",
1227
+ dispatchedAt: now,
1228
+ agentSessionId,
1229
+ attempt: 0,
1230
+ }, statePath);
1231
+
1232
+ // 8. Register active session for tool resolution
1233
+ setActiveSession({
1234
+ agentSessionId: agentSessionId ?? "",
1235
+ issueIdentifier: identifier,
1236
+ issueId: issue.id,
1237
+ agentId: resolveAgentId(api),
1238
+ startedAt: Date.now(),
1239
+ });
1240
+
1241
+ // 9. Post dispatch confirmation comment
1242
+ const prepStatus = prep.errors.length > 0
1243
+ ? `Workspace prep: partial (${prep.errors.join("; ")})`
1244
+ : `Workspace prep: OK (pulled=${prep.pulled}, submodules=${prep.submodulesInitialized})`;
1245
+ const statusComment = [
1246
+ `**Dispatched** as **${assessment.tier}** (${assessment.model})`,
1247
+ `> ${assessment.reasoning}`,
1248
+ ``,
1249
+ `Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
1250
+ `Branch: \`${worktree.branch}\``,
1251
+ prepStatus,
1252
+ ].join("\n");
1253
+
1254
+ await linearApi.createComment(issue.id, statusComment);
1255
+
1256
+ if (agentSessionId) {
1257
+ await linearApi.emitActivity(agentSessionId, {
1258
+ type: "thought",
1259
+ body: `Dispatching ${identifier} as ${assessment.tier}...`,
1260
+ }).catch(() => {});
1261
+ }
1262
+
1263
+ // 10. Apply tier label (best effort)
1264
+ try {
1265
+ if (enrichedIssue.team?.id) {
1266
+ const teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
1267
+ const tierLabel = teamLabels.find((l: any) => l.name === `developer:${assessment.tier}`);
1268
+ if (tierLabel) {
1269
+ const currentLabelIds = enrichedIssue.labels?.nodes?.map((l: any) => l.id) ?? [];
1270
+ await linearApi.updateIssue(issue.id, {
1271
+ labelIds: [...currentLabelIds, tierLabel.id],
1272
+ });
1273
+ }
1274
+ }
1275
+ } catch (err) {
1276
+ api.logger.warn(`@dispatch: could not apply tier label: ${err}`);
1277
+ }
1278
+
1279
+ // 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
1280
+ activeRuns.add(issue.id);
1281
+
1282
+ // Instantiate notifier
1283
+ const discordBotToken = (() => {
1284
+ try {
1285
+ const config = JSON.parse(
1286
+ require("node:fs").readFileSync(
1287
+ require("node:path").join(process.env.HOME ?? "/home/claw", ".openclaw", "openclaw.json"),
1288
+ "utf8",
1289
+ ),
1290
+ );
1291
+ return config?.channels?.discord?.token as string | undefined;
1292
+ } catch { return undefined; }
1293
+ })();
1294
+ const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
1295
+ const notify: NotifyFn = (discordBotToken && flowDiscordChannel)
1296
+ ? createDiscordNotifier(discordBotToken, flowDiscordChannel)
1297
+ : createNoopNotifier();
1298
+
1299
+ const hookCtx: HookContext = {
1300
+ api,
1301
+ linearApi,
1302
+ notify,
1303
+ pluginConfig,
1304
+ configPath: statePath,
1305
+ };
1306
+
1307
+ // Re-read dispatch to get fresh state after registration
1308
+ const freshState = await readDispatchState(statePath);
1309
+ const dispatch = getActiveDispatch(freshState, identifier)!;
1310
+
1311
+ await notify("dispatch", {
1312
+ identifier,
1313
+ title: enrichedIssue.title ?? "(untitled)",
1314
+ status: "dispatched",
1315
+ });
1316
+
1317
+ // spawnWorker handles: dispatched→working→auditing→done/rework/stuck
1318
+ spawnWorker(hookCtx, dispatch)
1319
+ .catch(async (err) => {
1320
+ api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
1321
+ await updateDispatchStatus(identifier, "failed", statePath);
1322
+ })
1323
+ .finally(() => {
1324
+ activeRuns.delete(issue.id);
1325
+ clearActiveSession(issue.id);
1326
+ });
1327
+ }