@calltelemetry/openclaw-linear 0.4.1 → 0.5.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/README.md +263 -249
- package/index.ts +115 -6
- package/openclaw.plugin.json +4 -1
- package/package.json +5 -1
- package/prompts.yaml +79 -0
- package/src/active-session.ts +1 -1
- package/src/agent.ts +41 -7
- package/src/claude-tool.ts +1 -1
- package/src/cli.ts +103 -0
- package/src/dispatch-service.ts +50 -2
- package/src/dispatch-state.ts +240 -8
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +561 -406
- package/src/tier-assess.ts +1 -1
- package/src/webhook.ts +90 -45
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 = [
|
|
@@ -93,15 +196,21 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
93
196
|
try {
|
|
94
197
|
const raw = execFileSync(bin, ["--version"], {
|
|
95
198
|
encoding: "utf8",
|
|
96
|
-
timeout:
|
|
199
|
+
timeout: 15_000,
|
|
97
200
|
env: { ...process.env, CLAUDECODE: undefined } as any,
|
|
98
201
|
}).trim();
|
|
99
202
|
cliChecks[name] = raw || "unknown";
|
|
100
203
|
} catch {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
204
|
+
// Fallback: check if the file exists (execFileSync can fail in worker contexts)
|
|
205
|
+
try {
|
|
206
|
+
require("node:fs").accessSync(bin, require("node:fs").constants.X_OK);
|
|
207
|
+
cliChecks[name] = "installed (version check skipped)";
|
|
208
|
+
} catch {
|
|
209
|
+
cliChecks[name] = "not found";
|
|
210
|
+
api.logger.warn(
|
|
211
|
+
`${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
105
214
|
}
|
|
106
215
|
}
|
|
107
216
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.5.1",
|
|
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,79 @@
|
|
|
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
|
+
# Access model:
|
|
10
|
+
# Zoe (orchestrator) — linearis READ ONLY (issues read/list/search)
|
|
11
|
+
# Worker — NO linearis access. Return text output only.
|
|
12
|
+
# Auditor — linearis READ + WRITE (can update status, close, comment)
|
|
13
|
+
|
|
14
|
+
worker:
|
|
15
|
+
system: |
|
|
16
|
+
You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary.
|
|
17
|
+
You do NOT have access to linearis or any Linear issue management tools.
|
|
18
|
+
Do NOT attempt to update, close, comment on, or modify the Linear issue in any way.
|
|
19
|
+
Do NOT mark the issue as Done — the audit system handles all issue lifecycle.
|
|
20
|
+
Just write code and return your implementation summary as text.
|
|
21
|
+
task: |
|
|
22
|
+
Implement issue {{identifier}}: {{title}}
|
|
23
|
+
|
|
24
|
+
Issue body:
|
|
25
|
+
{{description}}
|
|
26
|
+
|
|
27
|
+
Worktree: {{worktreePath}}
|
|
28
|
+
|
|
29
|
+
Instructions:
|
|
30
|
+
1. Read the issue body carefully — it defines what needs to be done
|
|
31
|
+
2. Plan your approach
|
|
32
|
+
3. Implement the solution in the worktree
|
|
33
|
+
4. Run tests to verify your changes
|
|
34
|
+
5. Return a text summary of what you changed, what tests you ran, and any notes
|
|
35
|
+
|
|
36
|
+
Your text output will be captured automatically. Do NOT use linearis or attempt to post comments.
|
|
37
|
+
|
|
38
|
+
audit:
|
|
39
|
+
system: |
|
|
40
|
+
You are an independent auditor. Your job is to verify that work was completed correctly
|
|
41
|
+
and then update the Linear issue accordingly.
|
|
42
|
+
The Linear issue body is the SOURCE OF TRUTH for what "done" means.
|
|
43
|
+
Worker output is secondary evidence of what was attempted.
|
|
44
|
+
You must be thorough and objective. Do not rubber-stamp.
|
|
45
|
+
|
|
46
|
+
You have WRITE access to linearis. After auditing, you are responsible for:
|
|
47
|
+
- Posting an audit summary comment on the issue
|
|
48
|
+
- Updating the issue status if the audit passes
|
|
49
|
+
Use `linearis` CLI via exec for these operations.
|
|
50
|
+
task: |
|
|
51
|
+
Audit issue {{identifier}}: {{title}}
|
|
52
|
+
|
|
53
|
+
Issue body (source of truth):
|
|
54
|
+
{{description}}
|
|
55
|
+
|
|
56
|
+
Worktree: {{worktreePath}}
|
|
57
|
+
|
|
58
|
+
Checklist:
|
|
59
|
+
1. Identify ALL acceptance criteria from the issue body
|
|
60
|
+
2. Read worker comments: `linearis issues read {{identifier}}`
|
|
61
|
+
3. Verify each acceptance criterion is addressed in the code
|
|
62
|
+
4. Run tests in the worktree — verify they pass
|
|
63
|
+
5. Check test coverage if expectations are stated in the issue
|
|
64
|
+
6. Review the code diff for quality and correctness
|
|
65
|
+
|
|
66
|
+
After auditing:
|
|
67
|
+
- Post your audit findings as a comment: `linearis comments create {{identifier}} --body "..."`
|
|
68
|
+
- If PASS: update status: `linearis issues update {{identifier}} --status "Done"`
|
|
69
|
+
|
|
70
|
+
You MUST return a JSON verdict as the last line of your response:
|
|
71
|
+
{"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
|
|
72
|
+
|
|
73
|
+
rework:
|
|
74
|
+
addendum: |
|
|
75
|
+
PREVIOUS AUDIT FAILED (attempt {{attempt}}). The auditor found these gaps:
|
|
76
|
+
{{gaps}}
|
|
77
|
+
|
|
78
|
+
Address these specific issues in your rework. Focus on the gaps listed above.
|
|
79
|
+
Remember: you do NOT have linearis access. Just fix the code and return a text summary.
|
package/src/active-session.ts
CHANGED
|
@@ -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 === "
|
|
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/agent.ts
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
5
|
import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
|
|
4
6
|
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Agent directory resolution (config-based, not ext API which ignores agentId)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
interface AgentDirs {
|
|
12
|
+
workspaceDir: string;
|
|
13
|
+
agentDir: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveAgentDirs(agentId: string, config: Record<string, any>): AgentDirs {
|
|
17
|
+
const home = process.env.HOME ?? "/home/claw";
|
|
18
|
+
const agentList = config?.agents?.list as Array<Record<string, any>> | undefined;
|
|
19
|
+
const agentEntry = agentList?.find((a) => a.id === agentId);
|
|
20
|
+
|
|
21
|
+
// Workspace: agent-specific override → agents.defaults.workspace → fallback
|
|
22
|
+
const workspaceDir = agentEntry?.workspace
|
|
23
|
+
?? config?.agents?.defaults?.workspace
|
|
24
|
+
?? join(home, ".openclaw", "workspace");
|
|
25
|
+
|
|
26
|
+
// Agent runtime dir: always ~/.openclaw/agents/{agentId}/agent
|
|
27
|
+
// (matches OpenClaw's internal structure)
|
|
28
|
+
const agentDir = join(home, ".openclaw", "agents", agentId, "agent");
|
|
29
|
+
mkdirSync(agentDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
return { workspaceDir, agentDir };
|
|
32
|
+
}
|
|
33
|
+
|
|
5
34
|
// Import extensionAPI for embedded agent runner (internal, not in public SDK)
|
|
6
35
|
let _extensionAPI: typeof import("/home/claw/.npm-global/lib/node_modules/openclaw/dist/extensionAPI.js") | null = null;
|
|
7
36
|
async function getExtensionAPI() {
|
|
@@ -66,17 +95,22 @@ async function runEmbedded(
|
|
|
66
95
|
): Promise<AgentRunResult> {
|
|
67
96
|
const ext = await getExtensionAPI();
|
|
68
97
|
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
98
|
+
// Load config so we can resolve agent dirs and providers correctly.
|
|
99
|
+
const config = await api.runtime.config.loadConfig();
|
|
100
|
+
const configAny = config as Record<string, any>;
|
|
101
|
+
|
|
102
|
+
// Resolve workspace and agent dirs from config (ext API ignores agentId).
|
|
103
|
+
const dirs = resolveAgentDirs(agentId, configAny);
|
|
104
|
+
const { workspaceDir, agentDir } = dirs;
|
|
72
105
|
const runId = randomUUID();
|
|
73
106
|
|
|
74
|
-
//
|
|
75
|
-
const
|
|
107
|
+
// Build session file path under the correct agent's sessions directory.
|
|
108
|
+
const sessionsDir = join(agentDir, "sessions");
|
|
109
|
+
try { mkdirSync(sessionsDir, { recursive: true }); } catch {}
|
|
110
|
+
const sessionFile = join(sessionsDir, `${sessionId}.jsonl`);
|
|
76
111
|
|
|
77
112
|
// Resolve model/provider from config — default is anthropic which requires
|
|
78
113
|
// a separate API key. Our agents use openrouter.
|
|
79
|
-
const configAny = config as Record<string, any>;
|
|
80
114
|
const agentList = configAny?.agents?.list as Array<Record<string, any>> | undefined;
|
|
81
115
|
const agentEntry = agentList?.find((a) => a.id === agentId);
|
|
82
116
|
const modelRef: string =
|
|
@@ -89,7 +123,7 @@ async function runEmbedded(
|
|
|
89
123
|
const provider = slashIdx > 0 ? modelRef.slice(0, slashIdx) : ext.DEFAULT_PROVIDER;
|
|
90
124
|
const model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
91
125
|
|
|
92
|
-
api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model}`);
|
|
126
|
+
api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model} workspaceDir=${workspaceDir} agentDir=${agentDir}`);
|
|
93
127
|
|
|
94
128
|
const emit = (content: ActivityContent) => {
|
|
95
129
|
streaming.linearApi.emitActivity(streaming.agentSessionId, content).catch((err) => {
|
package/src/claude-tool.ts
CHANGED
|
@@ -138,7 +138,6 @@ export async function runClaude(
|
|
|
138
138
|
if (model ?? pluginConfig?.claudeModel) {
|
|
139
139
|
args.push("--model", (model ?? pluginConfig?.claudeModel) as string);
|
|
140
140
|
}
|
|
141
|
-
args.push("-C", workingDir);
|
|
142
141
|
args.push("-p", prompt);
|
|
143
142
|
|
|
144
143
|
api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
|
|
@@ -150,6 +149,7 @@ export async function runClaude(
|
|
|
150
149
|
|
|
151
150
|
const child = spawn(CLAUDE_BIN, args, {
|
|
152
151
|
stdio: ["ignore", "pipe", "pipe"],
|
|
152
|
+
cwd: workingDir,
|
|
153
153
|
env,
|
|
154
154
|
timeout: 0,
|
|
155
155
|
});
|
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/dispatch-service.ts
CHANGED
|
@@ -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
|