@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.
package/index.ts CHANGED
@@ -5,7 +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
+ 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";
9
13
 
10
14
  export default function register(api: OpenClawPluginApi) {
11
15
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
@@ -56,6 +60,109 @@ export default function register(api: OpenClawPluginApi) {
56
60
  },
57
61
  });
58
62
 
63
+ // Register dispatch monitor service (stale detection, session hydration, cleanup)
64
+ api.registerService(createDispatchService(api));
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
+
59
166
  // Narration Guard: catch short "Let me explore..." responses that narrate intent
60
167
  // without actually calling tools, and append a warning for the user.
61
168
  const NARRATION_PATTERNS = [
@@ -17,7 +17,12 @@
17
17
  "codexBaseRepo": { "type": "string", "description": "Path to git repo for Codex worktrees", "default": "/home/claw/ai-workspace" },
18
18
  "codexModel": { "type": "string", "description": "Default Codex model (optional — uses Codex default if omitted)" },
19
19
  "codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
20
- "enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true }
20
+ "enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
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)" },
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 }
21
26
  }
22
27
  }
23
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.4.0",
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.
@@ -7,8 +7,14 @@
7
7
  *
8
8
  * This runs in the gateway process. Tool execution also happens in the gateway,
9
9
  * so tools can read from this registry directly.
10
+ *
11
+ * The in-memory Map is the fast-path for tool lookups. On startup, the
12
+ * dispatch service calls hydrateFromDispatchState() to rebuild it from
13
+ * the persistent dispatch-state.json file.
10
14
  */
11
15
 
16
+ import { readDispatchState } from "./dispatch-state.js";
17
+
12
18
  export interface ActiveSession {
13
19
  agentSessionId: string;
14
20
  issueIdentifier: string;
@@ -64,3 +70,37 @@ export function getCurrentSession(): ActiveSession | null {
64
70
  }
65
71
  return null;
66
72
  }
73
+
74
+ /**
75
+ * Hydrate the in-memory session Map from dispatch-state.json.
76
+ * Called on startup by the dispatch service to restore sessions
77
+ * that were active before a gateway restart.
78
+ *
79
+ * Returns the number of sessions restored.
80
+ */
81
+ export async function hydrateFromDispatchState(configPath?: string): Promise<number> {
82
+ const state = await readDispatchState(configPath);
83
+ const active = state.dispatches.active;
84
+ let restored = 0;
85
+
86
+ for (const [, dispatch] of Object.entries(active)) {
87
+ if (dispatch.status === "dispatched" || dispatch.status === "working") {
88
+ sessions.set(dispatch.issueId, {
89
+ agentSessionId: dispatch.agentSessionId ?? "",
90
+ issueIdentifier: dispatch.issueIdentifier,
91
+ issueId: dispatch.issueId,
92
+ startedAt: new Date(dispatch.dispatchedAt).getTime(),
93
+ });
94
+ restored++;
95
+ }
96
+ }
97
+
98
+ return restored;
99
+ }
100
+
101
+ /**
102
+ * Get the count of currently tracked sessions.
103
+ */
104
+ export function getSessionCount(): number {
105
+ return sessions.size;
106
+ }
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
  }
package/src/code-tool.ts CHANGED
@@ -30,7 +30,7 @@ interface BackendConfig {
30
30
  aliases?: string[];
31
31
  }
32
32
 
33
- interface CodingToolsConfig {
33
+ export interface CodingToolsConfig {
34
34
  codingTool?: string;
35
35
  agentCodingTools?: Record<string, string>;
36
36
  backends?: Record<string, BackendConfig>;
@@ -40,7 +40,7 @@ interface CodingToolsConfig {
40
40
  * Load coding tool config from the plugin's coding-tools.json file.
41
41
  * Falls back to empty config if the file doesn't exist or is invalid.
42
42
  */
43
- function loadCodingConfig(): CodingToolsConfig {
43
+ export function loadCodingConfig(): CodingToolsConfig {
44
44
  try {
45
45
  // Resolve relative to the plugin root (one level up from src/)
46
46
  const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");