@calltelemetry/openclaw-linear 0.4.1 → 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.
package/index.ts CHANGED
@@ -5,8 +5,11 @@ import { registerCli } from "./src/cli.js";
5
5
  import { createLinearTools } from "./src/tools.js";
6
6
  import { handleLinearWebhook } from "./src/webhook.js";
7
7
  import { handleOAuthCallback } from "./src/oauth-callback.js";
8
- import { resolveLinearToken } from "./src/linear-api.js";
8
+ import { LinearAgentApi, resolveLinearToken } from "./src/linear-api.js";
9
9
  import { createDispatchService } from "./src/dispatch-service.js";
10
+ import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/dispatch-state.js";
11
+ import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline.js";
12
+ import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./src/notify.js";
10
13
 
11
14
  export default function register(api: OpenClawPluginApi) {
12
15
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
@@ -60,6 +63,106 @@ export default function register(api: OpenClawPluginApi) {
60
63
  // Register dispatch monitor service (stale detection, session hydration, cleanup)
61
64
  api.registerService(createDispatchService(api));
62
65
 
66
+ // ---------------------------------------------------------------------------
67
+ // Dispatch pipeline v2: notifier + agent_end lifecycle hook
68
+ // ---------------------------------------------------------------------------
69
+
70
+ // Instantiate notifier (Discord if configured, otherwise noop)
71
+ const discordBotToken = (() => {
72
+ try {
73
+ const config = JSON.parse(
74
+ require("node:fs").readFileSync(
75
+ require("node:path").join(process.env.HOME ?? "/home/claw", ".openclaw", "openclaw.json"),
76
+ "utf8",
77
+ ),
78
+ );
79
+ return config?.channels?.discord?.token as string | undefined;
80
+ } catch { return undefined; }
81
+ })();
82
+ const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
83
+
84
+ const notify: NotifyFn = (discordBotToken && flowDiscordChannel)
85
+ ? createDiscordNotifier(discordBotToken, flowDiscordChannel)
86
+ : createNoopNotifier();
87
+
88
+ if (flowDiscordChannel && discordBotToken) {
89
+ api.logger.info(`Linear dispatch: Discord notifications enabled (channel: ${flowDiscordChannel})`);
90
+ }
91
+
92
+ // Register agent_end hook — safety net for sessions_spawn sub-agents.
93
+ // In the current implementation, the worker→audit→verdict flow runs inline
94
+ // via spawnWorker() in pipeline.ts. This hook catches sessions_spawn agents
95
+ // (future upgrade path) and serves as a recovery mechanism.
96
+ api.on("agent_end", async (event: any, ctx: any) => {
97
+ try {
98
+ const sessionKey = ctx?.sessionKey ?? "";
99
+ if (!sessionKey) return;
100
+
101
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
102
+ const state = await readDispatchState(statePath);
103
+ const mapping = lookupSessionMapping(state, sessionKey);
104
+ if (!mapping) return; // Not a dispatch sub-agent
105
+
106
+ const dispatch = getActiveDispatch(state, mapping.dispatchId);
107
+ if (!dispatch) {
108
+ api.logger.info(`agent_end: dispatch ${mapping.dispatchId} no longer active`);
109
+ return;
110
+ }
111
+
112
+ // Stale event rejection — only process if attempt matches
113
+ if (dispatch.attempt !== mapping.attempt) {
114
+ api.logger.info(
115
+ `agent_end: stale event for ${mapping.dispatchId} ` +
116
+ `(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
117
+ );
118
+ return;
119
+ }
120
+
121
+ // Create Linear API for hook context
122
+ const tokenInfo = resolveLinearToken(pluginConfig);
123
+ if (!tokenInfo.accessToken) {
124
+ api.logger.error("agent_end: no Linear access token — cannot process dispatch event");
125
+ return;
126
+ }
127
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
128
+ refreshToken: tokenInfo.refreshToken,
129
+ expiresAt: tokenInfo.expiresAt,
130
+ });
131
+
132
+ const hookCtx: HookContext = {
133
+ api,
134
+ linearApi,
135
+ notify,
136
+ pluginConfig,
137
+ configPath: statePath,
138
+ };
139
+
140
+ // Extract output from event
141
+ const output = typeof event?.output === "string"
142
+ ? event.output
143
+ : (event?.messages ?? [])
144
+ .filter((m: any) => m?.role === "assistant")
145
+ .map((m: any) => typeof m?.content === "string" ? m.content : "")
146
+ .join("\n") || "";
147
+
148
+ if (mapping.phase === "worker") {
149
+ api.logger.info(`agent_end: worker completed for ${mapping.dispatchId} — triggering audit`);
150
+ await triggerAudit(hookCtx, dispatch, {
151
+ success: event?.success ?? true,
152
+ output,
153
+ }, sessionKey);
154
+ } else if (mapping.phase === "audit") {
155
+ api.logger.info(`agent_end: audit completed for ${mapping.dispatchId} — processing verdict`);
156
+ await processVerdict(hookCtx, dispatch, {
157
+ success: event?.success ?? true,
158
+ output,
159
+ }, sessionKey);
160
+ }
161
+ } catch (err) {
162
+ api.logger.error(`agent_end hook error: ${err}`);
163
+ }
164
+ });
165
+
63
166
  // Narration Guard: catch short "Let me explore..." responses that narrate intent
64
167
  // without actually calling tools, and append a warning for the user.
65
168
  const NARRATION_PATTERNS = [
@@ -19,7 +19,10 @@
19
19
  "codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
20
20
  "enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
21
21
  "worktreeBaseDir": { "type": "string", "description": "Base directory for persistent git worktrees (default: ~/.openclaw/worktrees)" },
22
- "dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" }
22
+ "dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" },
23
+ "flowDiscordChannel": { "type": "string", "description": "Discord channel ID for dispatch lifecycle notifications (omit to disable)" },
24
+ "promptsPath": { "type": "string", "description": "Override path for prompts.yaml (default: ships with plugin)" },
25
+ "maxReworkAttempts": { "type": "number", "description": "Max audit failures before escalation", "default": 2 }
23
26
  }
24
27
  }
25
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  "index.ts",
23
23
  "src/",
24
24
  "openclaw.plugin.json",
25
+ "prompts.yaml",
25
26
  "README.md"
26
27
  ],
27
28
  "publishConfig": {
@@ -35,5 +36,8 @@
35
36
  "extensions": [
36
37
  "./index.ts"
37
38
  ]
39
+ },
40
+ "dependencies": {
41
+ "yaml": "^2.8.2"
38
42
  }
39
43
  }
package/prompts.yaml ADDED
@@ -0,0 +1,61 @@
1
+ # prompts.yaml — Externalized phase prompts for the Linear dispatch pipeline.
2
+ #
3
+ # Template variables: {{identifier}}, {{title}}, {{description}},
4
+ # {{worktreePath}}, {{gaps}}, {{tier}}, {{attempt}}
5
+ #
6
+ # Edit these to customize worker/audit behavior without rebuilding the plugin.
7
+ # Override path via `promptsPath` in plugin config.
8
+
9
+ worker:
10
+ system: |
11
+ You are implementing a Linear issue. Your job is to plan and code the solution.
12
+ Post an implementation summary as a Linear comment when done.
13
+ DO NOT mark the issue as Done — that is handled by the audit system.
14
+ DO NOT attempt to change the issue status or labels.
15
+ task: |
16
+ Implement issue {{identifier}}: {{title}}
17
+
18
+ Issue body:
19
+ {{description}}
20
+
21
+ Worktree: {{worktreePath}}
22
+
23
+ Instructions:
24
+ 1. Read the issue body carefully — it defines what needs to be done
25
+ 2. Plan your approach
26
+ 3. Implement the solution in the worktree
27
+ 4. Run tests to verify your changes
28
+ 5. Post an implementation summary as a comment on the Linear issue
29
+ 6. Include what was changed, what tests were run, and any notes
30
+
31
+ audit:
32
+ system: |
33
+ You are an independent auditor. Your job is to verify that work was completed correctly.
34
+ The Linear issue body is the SOURCE OF TRUTH for what "done" means.
35
+ Worker comments are secondary evidence of what was attempted.
36
+ You must be thorough and objective. Do not rubber-stamp.
37
+ task: |
38
+ Audit issue {{identifier}}: {{title}}
39
+
40
+ Issue body (source of truth):
41
+ {{description}}
42
+
43
+ Worktree: {{worktreePath}}
44
+
45
+ Checklist:
46
+ 1. Identify ALL acceptance criteria from the issue body
47
+ 2. Read worker comments on the issue (use linearis)
48
+ 3. Verify each acceptance criterion is addressed in the code
49
+ 4. Run tests in the worktree — verify they pass
50
+ 5. Check test coverage if expectations are stated in the issue
51
+ 6. Review the code diff for quality and correctness
52
+
53
+ You MUST return a JSON verdict as the last line of your response:
54
+ {"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
55
+
56
+ rework:
57
+ addendum: |
58
+ PREVIOUS AUDIT FAILED (attempt {{attempt}}). The auditor found these gaps:
59
+ {{gaps}}
60
+
61
+ Address these specific issues in your rework. Focus on the gaps listed above.
@@ -84,7 +84,7 @@ export async function hydrateFromDispatchState(configPath?: string): Promise<num
84
84
  let restored = 0;
85
85
 
86
86
  for (const [, dispatch] of Object.entries(active)) {
87
- if (dispatch.status === "dispatched" || dispatch.status === "running") {
87
+ if (dispatch.status === "dispatched" || dispatch.status === "working") {
88
88
  sessions.set(dispatch.issueId, {
89
89
  agentSessionId: dispatch.agentSessionId ?? "",
90
90
  issueIdentifier: dispatch.issueIdentifier,
package/src/cli.ts CHANGED
@@ -6,9 +6,13 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
6
  import { createInterface } from "node:readline";
7
7
  import { exec } from "node:child_process";
8
8
  import { readFileSync, writeFileSync } from "node:fs";
9
+ import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
9
12
  import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
10
13
  import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
11
14
  import { listWorktrees } from "./codex-worktree.js";
15
+ import { loadPrompts, clearPromptCache } from "./pipeline.js";
12
16
 
13
17
  function prompt(question: string): Promise<string> {
14
18
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -239,4 +243,103 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
239
243
 
240
244
  console.log(`\nTo remove one: openclaw openclaw-linear worktrees --prune <path>\n`);
241
245
  });
246
+
247
+ // --- openclaw openclaw-linear prompts ---
248
+ const prompts = linear
249
+ .command("prompts")
250
+ .description("Manage pipeline prompt templates (prompts.yaml)");
251
+
252
+ prompts
253
+ .command("show")
254
+ .description("Print current prompts.yaml content")
255
+ .action(async () => {
256
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
257
+ const customPath = pluginConfig?.promptsPath as string | undefined;
258
+
259
+ let resolvedPath: string;
260
+ if (customPath) {
261
+ resolvedPath = customPath.startsWith("~")
262
+ ? customPath.replace("~", process.env.HOME ?? "")
263
+ : customPath;
264
+ } else {
265
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
266
+ resolvedPath = join(pluginRoot, "prompts.yaml");
267
+ }
268
+
269
+ console.log(`\nPrompts file: ${resolvedPath}\n`);
270
+
271
+ try {
272
+ const content = readFileSyncFs(resolvedPath, "utf-8");
273
+ console.log(content);
274
+ } catch {
275
+ console.log("(file not found — using built-in defaults)\n");
276
+ // Show the loaded defaults
277
+ clearPromptCache();
278
+ const loaded = loadPrompts(pluginConfig);
279
+ console.log(JSON.stringify(loaded, null, 2));
280
+ }
281
+ });
282
+
283
+ prompts
284
+ .command("path")
285
+ .description("Print the resolved prompts.yaml file path")
286
+ .action(async () => {
287
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
288
+ const customPath = pluginConfig?.promptsPath as string | undefined;
289
+
290
+ let resolvedPath: string;
291
+ if (customPath) {
292
+ resolvedPath = customPath.startsWith("~")
293
+ ? customPath.replace("~", process.env.HOME ?? "")
294
+ : customPath;
295
+ } else {
296
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
297
+ resolvedPath = join(pluginRoot, "prompts.yaml");
298
+ }
299
+
300
+ const exists = existsSync(resolvedPath);
301
+ console.log(`${resolvedPath} ${exists ? "(exists)" : "(not found — using defaults)"}`);
302
+ });
303
+
304
+ prompts
305
+ .command("validate")
306
+ .description("Validate prompts.yaml structure")
307
+ .action(async () => {
308
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
309
+ clearPromptCache();
310
+
311
+ try {
312
+ const loaded = loadPrompts(pluginConfig);
313
+ const errors: string[] = [];
314
+
315
+ if (!loaded.worker?.system) errors.push("Missing worker.system");
316
+ if (!loaded.worker?.task) errors.push("Missing worker.task");
317
+ if (!loaded.audit?.system) errors.push("Missing audit.system");
318
+ if (!loaded.audit?.task) errors.push("Missing audit.task");
319
+ if (!loaded.rework?.addendum) errors.push("Missing rework.addendum");
320
+
321
+ // Check for template variables
322
+ const requiredVars = ["{{identifier}}", "{{title}}", "{{description}}", "{{worktreePath}}"];
323
+ for (const v of requiredVars) {
324
+ if (!loaded.worker.task.includes(v)) {
325
+ errors.push(`worker.task missing template variable: ${v}`);
326
+ }
327
+ if (!loaded.audit.task.includes(v)) {
328
+ errors.push(`audit.task missing template variable: ${v}`);
329
+ }
330
+ }
331
+
332
+ if (errors.length > 0) {
333
+ console.log("\nValidation FAILED:\n");
334
+ for (const e of errors) console.log(` - ${e}`);
335
+ console.log();
336
+ process.exitCode = 1;
337
+ } else {
338
+ console.log("\nValidation PASSED — all sections and template variables present.\n");
339
+ }
340
+ } catch (err) {
341
+ console.error(`\nFailed to load prompts: ${err}\n`);
342
+ process.exitCode = 1;
343
+ }
344
+ });
242
345
  }
@@ -16,6 +16,9 @@ import { hydrateFromDispatchState } from "./active-session.js";
16
16
  import {
17
17
  readDispatchState,
18
18
  listStaleDispatches,
19
+ listRecoverableDispatches,
20
+ transitionDispatch,
21
+ TransitionError,
19
22
  removeActiveDispatch,
20
23
  pruneCompleted,
21
24
  } from "./dispatch-state.js";
@@ -49,6 +52,27 @@ export function createDispatchService(api: OpenClawPluginApi) {
49
52
  ctx.logger.warn(`linear-dispatch: hydration failed: ${err}`);
50
53
  }
51
54
 
55
+ // Recovery scan: find dispatches stuck in "working" with a workerSessionKey
56
+ // but no auditSessionKey (worker completed but audit wasn't triggered before crash)
57
+ try {
58
+ const state = await readDispatchState(statePath);
59
+ const recoverable = listRecoverableDispatches(state);
60
+ for (const d of recoverable) {
61
+ ctx.logger.warn(
62
+ `linear-dispatch: recoverable dispatch ${d.issueIdentifier} ` +
63
+ `(status: ${d.status}, attempt: ${d.attempt}, workerKey: ${d.workerSessionKey}, auditKey: ${d.auditSessionKey ?? "none"})`,
64
+ );
65
+ // Mark as stuck for manual review — automated recovery requires
66
+ // re-triggering audit which needs the full HookContext (Linear API, notifier).
67
+ // The dispatch monitor logs a warning; operator can re-dispatch.
68
+ }
69
+ if (recoverable.length > 0) {
70
+ ctx.logger.warn(`linear-dispatch: ${recoverable.length} dispatch(es) need recovery — consider re-dispatching`);
71
+ }
72
+ } catch (err) {
73
+ ctx.logger.warn(`linear-dispatch: recovery scan failed: ${err}`);
74
+ }
75
+
52
76
  ctx.logger.info(`linear-dispatch: service started (interval: ${INTERVAL_MS / 1000}s)`);
53
77
 
54
78
  intervalId = setInterval(() => runTick(ctx), INTERVAL_MS);
@@ -71,9 +95,14 @@ export function createDispatchService(api: OpenClawPluginApi) {
71
95
  // Skip tick if nothing to do
72
96
  if (activeCount === 0 && Object.keys(state.dispatches.completed).length === 0) return;
73
97
 
74
- // 1. Stale dispatch detection
98
+ // 1. Stale dispatch detection — transition truly stale dispatches to "stuck"
75
99
  const stale = listStaleDispatches(state, STALE_THRESHOLD_MS);
76
100
  for (const dispatch of stale) {
101
+ // Skip terminal states
102
+ if (dispatch.status === "done" || dispatch.status === "failed" || dispatch.status === "stuck") {
103
+ continue;
104
+ }
105
+
77
106
  // Check if worktree still exists and has progress
78
107
  if (existsSync(dispatch.worktreePath)) {
79
108
  const status = getWorktreeStatus(dispatch.worktreePath);
@@ -82,10 +111,29 @@ export function createDispatchService(api: OpenClawPluginApi) {
82
111
  continue;
83
112
  }
84
113
  }
114
+
85
115
  ctx.logger.warn(
86
116
  `linear-dispatch: stale dispatch ${dispatch.issueIdentifier} ` +
87
- `(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status})`
117
+ `(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status}) — transitioning to stuck`,
88
118
  );
119
+
120
+ // Try to transition to stuck
121
+ try {
122
+ await transitionDispatch(
123
+ dispatch.issueIdentifier,
124
+ dispatch.status,
125
+ "stuck",
126
+ { stuckReason: `stale_${Math.round((Date.now() - new Date(dispatch.dispatchedAt).getTime()) / 3_600_000)}h` },
127
+ statePath,
128
+ );
129
+ ctx.logger.info(`linear-dispatch: ${dispatch.issueIdentifier} marked as stuck`);
130
+ } catch (err) {
131
+ if (err instanceof TransitionError) {
132
+ ctx.logger.info(`linear-dispatch: CAS failed for stale transition: ${(err as TransitionError).message}`);
133
+ } else {
134
+ ctx.logger.error(`linear-dispatch: stale transition error: ${err}`);
135
+ }
136
+ }
89
137
  }
90
138
 
91
139
  // 2. Worktree health — verify active dispatches have valid worktrees