@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.
@@ -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
+ }
@@ -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 { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "../infra/notify.js";
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 discordBotToken = (() => {
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,