@calltelemetry/openclaw-linear 0.6.0 → 0.7.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/README.md +115 -17
- package/index.ts +18 -22
- package/openclaw.plugin.json +35 -2
- package/package.json +1 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.ts +180 -9
- package/src/infra/cli.ts +214 -0
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +759 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +114 -35
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +282 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +216 -0
- package/src/pipeline/webhook.ts +69 -17
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planning-state.ts — File-backed persistent planning session state.
|
|
3
|
+
*
|
|
4
|
+
* Tracks active planning sessions across gateway restarts.
|
|
5
|
+
* Uses file-level locking to prevent concurrent read-modify-write races.
|
|
6
|
+
* Mirrors the dispatch-state.ts pattern.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export type PlanningStatus = "interviewing" | "plan_review" | "approved" | "abandoned";
|
|
18
|
+
|
|
19
|
+
export interface PlanningSession {
|
|
20
|
+
projectId: string;
|
|
21
|
+
projectName: string;
|
|
22
|
+
rootIssueId: string;
|
|
23
|
+
rootIdentifier: string;
|
|
24
|
+
teamId: string;
|
|
25
|
+
agentSessionId?: string;
|
|
26
|
+
status: PlanningStatus;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
turnCount: number;
|
|
29
|
+
planningLabelId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PlanningState {
|
|
33
|
+
sessions: Record<string, PlanningSession>; // keyed by projectId
|
|
34
|
+
processedEvents: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Defaults
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const DEFAULT_STATE_PATH = path.join(homedir(), ".openclaw", "linear-planning-state.json");
|
|
42
|
+
const MAX_PROCESSED_EVENTS = 200;
|
|
43
|
+
|
|
44
|
+
function resolveStatePath(configPath?: string): string {
|
|
45
|
+
if (!configPath) return DEFAULT_STATE_PATH;
|
|
46
|
+
if (configPath.startsWith("~/")) return configPath.replace("~", homedir());
|
|
47
|
+
return configPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// File locking
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const LOCK_STALE_MS = 30_000;
|
|
55
|
+
const LOCK_RETRY_MS = 50;
|
|
56
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
57
|
+
|
|
58
|
+
function lockPath(statePath: string): string {
|
|
59
|
+
return statePath + ".lock";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function acquireLock(statePath: string): Promise<void> {
|
|
63
|
+
const lock = lockPath(statePath);
|
|
64
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
65
|
+
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
|
69
|
+
return;
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
if (err.code !== "EEXIST") throw err;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const content = await fs.readFile(lock, "utf-8");
|
|
75
|
+
const lockTime = Number(content);
|
|
76
|
+
if (Date.now() - lockTime > LOCK_STALE_MS) {
|
|
77
|
+
try { await fs.unlink(lock); } catch { /* race */ }
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
} catch { /* lock disappeared — retry */ }
|
|
81
|
+
|
|
82
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
|
|
87
|
+
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function releaseLock(statePath: string): Promise<void> {
|
|
91
|
+
try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Read / Write
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function emptyState(): PlanningState {
|
|
99
|
+
return { sessions: {}, processedEvents: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readPlanningState(configPath?: string): Promise<PlanningState> {
|
|
103
|
+
const filePath = resolveStatePath(configPath);
|
|
104
|
+
try {
|
|
105
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
106
|
+
const parsed = JSON.parse(raw) as PlanningState;
|
|
107
|
+
if (!parsed.sessions) parsed.sessions = {};
|
|
108
|
+
if (!parsed.processedEvents) parsed.processedEvents = [];
|
|
109
|
+
return parsed;
|
|
110
|
+
} catch (err: any) {
|
|
111
|
+
if (err.code === "ENOENT") return emptyState();
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function writePlanningState(data: PlanningState, configPath?: string): Promise<void> {
|
|
117
|
+
const filePath = resolveStatePath(configPath);
|
|
118
|
+
const dir = path.dirname(filePath);
|
|
119
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
120
|
+
if (data.processedEvents.length > MAX_PROCESSED_EVENTS) {
|
|
121
|
+
data.processedEvents = data.processedEvents.slice(-MAX_PROCESSED_EVENTS);
|
|
122
|
+
}
|
|
123
|
+
const tmpPath = filePath + ".tmp";
|
|
124
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
125
|
+
await fs.rename(tmpPath, filePath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Session operations
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export async function registerPlanningSession(
|
|
133
|
+
projectId: string,
|
|
134
|
+
session: PlanningSession,
|
|
135
|
+
configPath?: string,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const filePath = resolveStatePath(configPath);
|
|
138
|
+
await acquireLock(filePath);
|
|
139
|
+
try {
|
|
140
|
+
const data = await readPlanningState(configPath);
|
|
141
|
+
data.sessions[projectId] = session;
|
|
142
|
+
await writePlanningState(data, configPath);
|
|
143
|
+
} finally {
|
|
144
|
+
await releaseLock(filePath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function updatePlanningSession(
|
|
149
|
+
projectId: string,
|
|
150
|
+
updates: Partial<PlanningSession>,
|
|
151
|
+
configPath?: string,
|
|
152
|
+
): Promise<PlanningSession> {
|
|
153
|
+
const filePath = resolveStatePath(configPath);
|
|
154
|
+
await acquireLock(filePath);
|
|
155
|
+
try {
|
|
156
|
+
const data = await readPlanningState(configPath);
|
|
157
|
+
const session = data.sessions[projectId];
|
|
158
|
+
if (!session) throw new Error(`No planning session for project ${projectId}`);
|
|
159
|
+
Object.assign(session, updates);
|
|
160
|
+
await writePlanningState(data, configPath);
|
|
161
|
+
return session;
|
|
162
|
+
} finally {
|
|
163
|
+
await releaseLock(filePath);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getPlanningSession(
|
|
168
|
+
state: PlanningState,
|
|
169
|
+
projectId: string,
|
|
170
|
+
): PlanningSession | null {
|
|
171
|
+
return state.sessions[projectId] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function endPlanningSession(
|
|
175
|
+
projectId: string,
|
|
176
|
+
status: "approved" | "abandoned",
|
|
177
|
+
configPath?: string,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
const filePath = resolveStatePath(configPath);
|
|
180
|
+
await acquireLock(filePath);
|
|
181
|
+
try {
|
|
182
|
+
const data = await readPlanningState(configPath);
|
|
183
|
+
const session = data.sessions[projectId];
|
|
184
|
+
if (session) {
|
|
185
|
+
session.status = status;
|
|
186
|
+
await writePlanningState(data, configPath);
|
|
187
|
+
}
|
|
188
|
+
clearPlanningCache(projectId);
|
|
189
|
+
} finally {
|
|
190
|
+
await releaseLock(filePath);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function isInPlanningMode(state: PlanningState, projectId: string): boolean {
|
|
195
|
+
const session = state.sessions[projectId];
|
|
196
|
+
if (!session) return false;
|
|
197
|
+
return session.status === "interviewing" || session.status === "plan_review";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// In-memory cache for fast webhook routing
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
const planningCache = new Map<string, PlanningSession>();
|
|
205
|
+
|
|
206
|
+
export function setPlanningCache(session: PlanningSession): void {
|
|
207
|
+
planningCache.set(session.projectId, session);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function clearPlanningCache(projectId: string): void {
|
|
211
|
+
planningCache.delete(projectId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getActivePlanningByProjectId(projectId: string): PlanningSession | null {
|
|
215
|
+
return planningCache.get(projectId) ?? null;
|
|
216
|
+
}
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -6,10 +6,12 @@ import { LinearAgentApi, resolveLinearToken } from "../api/linear-api.js";
|
|
|
6
6
|
import { spawnWorker, type HookContext } from "./pipeline.js";
|
|
7
7
|
import { setActiveSession, clearActiveSession } from "./active-session.js";
|
|
8
8
|
import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
|
|
9
|
-
import {
|
|
9
|
+
import { createNotifierFromConfig, type NotifyFn } from "../infra/notify.js";
|
|
10
10
|
import { assessTier } from "./tier-assess.js";
|
|
11
11
|
import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
12
|
import { ensureClawDir, writeManifest } from "./artifacts.js";
|
|
13
|
+
import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
|
|
14
|
+
import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
|
|
13
15
|
|
|
14
16
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
15
17
|
interface AgentProfile {
|
|
@@ -663,6 +665,50 @@ export async function handleLinearWebhook(
|
|
|
663
665
|
const commentor = comment?.user?.name ?? "Unknown";
|
|
664
666
|
const issue = comment?.issue ?? payload.issue;
|
|
665
667
|
|
|
668
|
+
// ── Planning mode intercept ──────────────────────────────────
|
|
669
|
+
if (issue?.id) {
|
|
670
|
+
const linearApiForPlanning = createLinearApi(api);
|
|
671
|
+
if (linearApiForPlanning) {
|
|
672
|
+
try {
|
|
673
|
+
const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
|
|
674
|
+
const projectId = enriched?.project?.id;
|
|
675
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
676
|
+
|
|
677
|
+
if (projectId) {
|
|
678
|
+
const planState = await readPlanningState(planStatePath);
|
|
679
|
+
|
|
680
|
+
// Check if this is a plan initiation request
|
|
681
|
+
const isPlanRequest = /\b(plan|planning)\s+(this\s+)?(project|out)\b/i.test(commentBody);
|
|
682
|
+
if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
|
|
683
|
+
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
684
|
+
void initiatePlanningSession(
|
|
685
|
+
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
686
|
+
projectId,
|
|
687
|
+
{ id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
|
|
688
|
+
).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Route to planner if project is in planning mode
|
|
693
|
+
if (isInPlanningMode(planState, projectId)) {
|
|
694
|
+
const session = getPlanningSession(planState, projectId);
|
|
695
|
+
if (session && comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
|
|
696
|
+
api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
|
|
697
|
+
void handlePlannerTurn(
|
|
698
|
+
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
699
|
+
session,
|
|
700
|
+
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
701
|
+
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
702
|
+
}
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} catch (err) {
|
|
707
|
+
api.logger.warn(`Planning mode check failed: ${err}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
666
712
|
// Load agent profiles and build mention pattern dynamically.
|
|
667
713
|
// Default agent (app mentions) is handled by AgentSessionEvent — never here.
|
|
668
714
|
const profiles = loadAgentProfiles();
|
|
@@ -1161,6 +1207,26 @@ async function handleDispatch(
|
|
|
1161
1207
|
|
|
1162
1208
|
api.logger.info(`@dispatch: processing ${identifier}`);
|
|
1163
1209
|
|
|
1210
|
+
// 0. Check planning mode — prevent dispatch for issues in planning-mode projects
|
|
1211
|
+
try {
|
|
1212
|
+
const enrichedForPlan = await linearApi.getIssueDetails(issue.id ?? issue);
|
|
1213
|
+
const planProjectId = enrichedForPlan?.project?.id;
|
|
1214
|
+
if (planProjectId) {
|
|
1215
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
1216
|
+
const planState = await readPlanningState(planStatePath);
|
|
1217
|
+
if (isInPlanningMode(planState, planProjectId)) {
|
|
1218
|
+
api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
|
|
1219
|
+
await linearApi.createComment(
|
|
1220
|
+
issue.id,
|
|
1221
|
+
"This project is in planning mode. Finalize the plan before dispatching implementation.",
|
|
1222
|
+
);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
api.logger.warn(`dispatch: planning mode check failed for ${identifier}: ${err}`);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1164
1230
|
// 1. Check for existing active dispatch — reclaim if stale
|
|
1165
1231
|
const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
|
|
1166
1232
|
const state = await readDispatchState(statePath);
|
|
@@ -1337,22 +1403,8 @@ async function handleDispatch(
|
|
|
1337
1403
|
// 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
|
|
1338
1404
|
activeRuns.add(issue.id);
|
|
1339
1405
|
|
|
1340
|
-
// Instantiate notifier
|
|
1341
|
-
const
|
|
1342
|
-
try {
|
|
1343
|
-
const config = JSON.parse(
|
|
1344
|
-
require("node:fs").readFileSync(
|
|
1345
|
-
require("node:path").join(process.env.HOME ?? "/home/claw", ".openclaw", "openclaw.json"),
|
|
1346
|
-
"utf8",
|
|
1347
|
-
),
|
|
1348
|
-
);
|
|
1349
|
-
return config?.channels?.discord?.token as string | undefined;
|
|
1350
|
-
} catch { return undefined; }
|
|
1351
|
-
})();
|
|
1352
|
-
const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
|
|
1353
|
-
const notify: NotifyFn = (discordBotToken && flowDiscordChannel)
|
|
1354
|
-
? createDiscordNotifier(discordBotToken, flowDiscordChannel)
|
|
1355
|
-
: createNoopNotifier();
|
|
1406
|
+
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
1407
|
+
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
1356
1408
|
|
|
1357
1409
|
const hookCtx: HookContext = {
|
|
1358
1410
|
api,
|