@calltelemetry/openclaw-linear 0.6.1 → 0.7.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -17
  3. package/index.ts +57 -22
  4. package/openclaw.plugin.json +37 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +47 -0
  7. package/src/api/linear-api.test.ts +494 -0
  8. package/src/api/linear-api.ts +193 -19
  9. package/src/gateway/dispatch-methods.ts +243 -0
  10. package/src/infra/cli.ts +284 -29
  11. package/src/infra/codex-worktree.ts +83 -0
  12. package/src/infra/commands.ts +156 -0
  13. package/src/infra/doctor.test.ts +4 -4
  14. package/src/infra/doctor.ts +7 -29
  15. package/src/infra/file-lock.test.ts +61 -0
  16. package/src/infra/file-lock.ts +49 -0
  17. package/src/infra/multi-repo.ts +85 -0
  18. package/src/infra/notify.test.ts +357 -108
  19. package/src/infra/notify.ts +222 -43
  20. package/src/infra/observability.ts +48 -0
  21. package/src/infra/resilience.test.ts +94 -0
  22. package/src/infra/resilience.ts +101 -0
  23. package/src/pipeline/artifacts.ts +38 -2
  24. package/src/pipeline/dag-dispatch.test.ts +553 -0
  25. package/src/pipeline/dag-dispatch.ts +390 -0
  26. package/src/pipeline/dispatch-service.ts +48 -1
  27. package/src/pipeline/dispatch-state.ts +2 -42
  28. package/src/pipeline/pipeline.ts +91 -17
  29. package/src/pipeline/planner.test.ts +334 -0
  30. package/src/pipeline/planner.ts +287 -0
  31. package/src/pipeline/planning-state.test.ts +236 -0
  32. package/src/pipeline/planning-state.ts +178 -0
  33. package/src/pipeline/tier-assess.test.ts +175 -0
  34. package/src/pipeline/webhook.ts +90 -17
  35. package/src/tools/dispatch-history-tool.ts +201 -0
  36. package/src/tools/orchestration-tools.test.ts +158 -0
  37. package/src/tools/planner-tools.test.ts +535 -0
  38. package/src/tools/planner-tools.ts +450 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * planner.ts — Orchestration for the project planning pipeline.
3
+ *
4
+ * Manages the interview-style planning flow:
5
+ * - initiatePlanningSession: enters planning mode for a project
6
+ * - handlePlannerTurn: processes each user comment during planning
7
+ * - runPlanAudit: validates the plan before finalizing
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { parse as parseYaml } from "yaml";
13
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
+ import type { LinearAgentApi } from "../api/linear-api.js";
15
+ import { runAgent } from "../agent/agent.js";
16
+ import {
17
+ type PlanningSession,
18
+ registerPlanningSession,
19
+ updatePlanningSession,
20
+ endPlanningSession,
21
+ setPlanningCache,
22
+ } from "./planning-state.js";
23
+ import {
24
+ setActivePlannerContext,
25
+ clearActivePlannerContext,
26
+ buildPlanSnapshot,
27
+ auditPlan,
28
+ } from "../tools/planner-tools.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface PlannerContext {
35
+ api: OpenClawPluginApi;
36
+ linearApi: LinearAgentApi;
37
+ pluginConfig?: Record<string, unknown>;
38
+ }
39
+
40
+ interface PlannerPrompts {
41
+ system: string;
42
+ interview: string;
43
+ audit_prompt: string;
44
+ welcome: string;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Prompt loading
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerPrompts {
52
+ const defaults: PlannerPrompts = {
53
+ system: "You are a product planning specialist. Interview the user about features and create Linear issues.",
54
+ interview: "Project: {{projectName}}\n\nPlan:\n{{planSnapshot}}\n\nUser said: {{userMessage}}\n\nContinue planning.",
55
+ audit_prompt: "Run audit_plan for {{projectName}}.",
56
+ welcome: "Entering planning mode for **{{projectName}}**. What are the main feature areas?",
57
+ };
58
+
59
+ try {
60
+ const customPath = pluginConfig?.promptsPath as string | undefined;
61
+ let raw: string;
62
+
63
+ if (customPath) {
64
+ const resolved = customPath.startsWith("~")
65
+ ? customPath.replace("~", process.env.HOME ?? "")
66
+ : customPath;
67
+ raw = readFileSync(resolved, "utf-8");
68
+ } else {
69
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
70
+ raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
71
+ }
72
+
73
+ const parsed = parseYaml(raw) as any;
74
+ if (parsed?.planner) {
75
+ return {
76
+ system: parsed.planner.system ?? defaults.system,
77
+ interview: parsed.planner.interview ?? defaults.interview,
78
+ audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
79
+ welcome: parsed.planner.welcome ?? defaults.welcome,
80
+ };
81
+ }
82
+ } catch { /* use defaults */ }
83
+
84
+ return defaults;
85
+ }
86
+
87
+ function renderTemplate(template: string, vars: Record<string, string>): string {
88
+ let result = template;
89
+ for (const [key, value] of Object.entries(vars)) {
90
+ result = result.replaceAll(`{{${key}}}`, value);
91
+ }
92
+ return result;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Planning initiation
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export async function initiatePlanningSession(
100
+ ctx: PlannerContext,
101
+ projectId: string,
102
+ rootIssue: { id: string; identifier: string; title: string; team?: { id: string } },
103
+ ): Promise<void> {
104
+ const { api, linearApi, pluginConfig } = ctx;
105
+ const configPath = pluginConfig?.planningStatePath as string | undefined;
106
+
107
+ // Fetch project metadata
108
+ const project = await linearApi.getProject(projectId);
109
+ const teamId = rootIssue.team?.id ?? project.teams?.nodes?.[0]?.id;
110
+ if (!teamId) throw new Error(`Cannot determine team for project ${projectId}`);
111
+
112
+ // Fetch team states for reference
113
+ await linearApi.getTeamStates(teamId);
114
+
115
+ // Register session
116
+ const session: PlanningSession = {
117
+ projectId,
118
+ projectName: project.name,
119
+ rootIssueId: rootIssue.id,
120
+ rootIdentifier: rootIssue.identifier,
121
+ teamId,
122
+ status: "interviewing",
123
+ startedAt: new Date().toISOString(),
124
+ turnCount: 0,
125
+ };
126
+
127
+ await registerPlanningSession(projectId, session, configPath);
128
+ setPlanningCache(session);
129
+
130
+ api.logger.info(`Planning: initiated session for ${project.name} (${rootIssue.identifier})`);
131
+
132
+ // Post welcome comment
133
+ const prompts = loadPlannerPrompts(pluginConfig);
134
+ const welcomeMsg = renderTemplate(prompts.welcome, {
135
+ projectName: project.name,
136
+ rootIdentifier: rootIssue.identifier,
137
+ });
138
+ await linearApi.createComment(rootIssue.id, welcomeMsg);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Interview turn
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const FINALIZE_PATTERN = /\b(finalize\s+plan|finalize|done\s+planning|approve\s+plan|plan\s+looks\s+good)\b/i;
146
+ const ABANDON_PATTERN = /\b(abandon|cancel\s+planning|stop\s+planning|exit\s+planning)\b/i;
147
+
148
+ export async function handlePlannerTurn(
149
+ ctx: PlannerContext,
150
+ session: PlanningSession,
151
+ input: { issueId: string; commentBody: string; commentorName: string },
152
+ opts?: { onApproved?: (projectId: string) => void },
153
+ ): Promise<void> {
154
+ const { api, linearApi, pluginConfig } = ctx;
155
+ const configPath = pluginConfig?.planningStatePath as string | undefined;
156
+
157
+ // Detect finalization intent
158
+ if (FINALIZE_PATTERN.test(input.commentBody)) {
159
+ await runPlanAudit(ctx, session, { onApproved: opts?.onApproved });
160
+ return;
161
+ }
162
+
163
+ // Detect abandon intent
164
+ if (ABANDON_PATTERN.test(input.commentBody)) {
165
+ await endPlanningSession(session.projectId, "abandoned", configPath);
166
+ await linearApi.createComment(
167
+ session.rootIssueId,
168
+ `Planning mode ended for **${session.projectName}**. Session abandoned.`,
169
+ );
170
+ api.logger.info(`Planning: session abandoned for ${session.projectName}`);
171
+ return;
172
+ }
173
+
174
+ // Increment turn count
175
+ const newTurnCount = session.turnCount + 1;
176
+ await updatePlanningSession(session.projectId, { turnCount: newTurnCount }, configPath);
177
+
178
+ // Build plan snapshot
179
+ const issues = await linearApi.getProjectIssues(session.projectId);
180
+ const planSnapshot = buildPlanSnapshot(issues);
181
+
182
+ // Build comment history (last 10 comments on root issue)
183
+ let commentHistory = "";
184
+ try {
185
+ const details = await linearApi.getIssueDetails(session.rootIssueId);
186
+ commentHistory = details.comments?.nodes
187
+ ?.map((c) => `**${c.user?.name ?? "Unknown"}:** ${c.body.slice(0, 300)}`)
188
+ .join("\n\n") ?? "";
189
+ } catch { /* best effort */ }
190
+
191
+ // Build prompt
192
+ const prompts = loadPlannerPrompts(pluginConfig);
193
+ const taskPrompt = renderTemplate(prompts.interview, {
194
+ projectName: session.projectName,
195
+ rootIdentifier: session.rootIdentifier,
196
+ planSnapshot,
197
+ turnCount: String(newTurnCount),
198
+ commentHistory,
199
+ userMessage: input.commentBody,
200
+ });
201
+
202
+ // Set planner context for tools
203
+ setActivePlannerContext({
204
+ linearApi,
205
+ projectId: session.projectId,
206
+ teamId: session.teamId,
207
+ });
208
+
209
+ try {
210
+ const sessionId = `planner-${session.rootIdentifier}-turn-${newTurnCount}`;
211
+ const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
212
+
213
+ api.logger.info(`Planning: turn ${newTurnCount} for ${session.projectName}`);
214
+
215
+ const result = await runAgent({
216
+ api,
217
+ agentId,
218
+ sessionId,
219
+ message: `${prompts.system}\n\n${taskPrompt}`,
220
+ });
221
+
222
+ // Post agent response as comment
223
+ if (result.output) {
224
+ await linearApi.createComment(session.rootIssueId, result.output);
225
+ }
226
+ } finally {
227
+ clearActivePlannerContext();
228
+ }
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Plan audit
233
+ // ---------------------------------------------------------------------------
234
+
235
+ export async function runPlanAudit(
236
+ ctx: PlannerContext,
237
+ session: PlanningSession,
238
+ opts?: { onApproved?: (projectId: string) => void },
239
+ ): Promise<void> {
240
+ const { api, linearApi, pluginConfig } = ctx;
241
+ const configPath = pluginConfig?.planningStatePath as string | undefined;
242
+
243
+ api.logger.info(`Planning: running audit for ${session.projectName}`);
244
+
245
+ // Run deterministic audit
246
+ const issues = await linearApi.getProjectIssues(session.projectId);
247
+ const result = auditPlan(issues);
248
+
249
+ if (result.pass) {
250
+ // Build final summary
251
+ const snapshot = buildPlanSnapshot(issues);
252
+ const warningsList = result.warnings.length > 0
253
+ ? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
254
+ : "";
255
+
256
+ await linearApi.createComment(
257
+ session.rootIssueId,
258
+ `## Plan Approved\n\n` +
259
+ `The plan for **${session.projectName}** passed all checks.\n\n` +
260
+ `**${issues.length} issues** created with valid dependency graph.${warningsList}\n\n` +
261
+ `### Final Plan\n${snapshot}\n\n` +
262
+ `---\n*Planning mode complete. Project is ready for implementation dispatch.*`,
263
+ );
264
+
265
+ await endPlanningSession(session.projectId, "approved", configPath);
266
+ api.logger.info(`Planning: session approved for ${session.projectName}`);
267
+
268
+ // Trigger DAG-based dispatch if callback provided
269
+ opts?.onApproved?.(session.projectId);
270
+ } else {
271
+ // Post problems and keep planning
272
+ const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
273
+ const warningsList = result.warnings.length > 0
274
+ ? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
275
+ : "";
276
+
277
+ await linearApi.createComment(
278
+ session.rootIssueId,
279
+ `## Plan Audit Failed\n\n` +
280
+ `The following issues need attention before the plan can be approved:\n\n` +
281
+ `**Problems:**\n${problemsList}${warningsList}\n\n` +
282
+ `Please address these issues, then say "finalize plan" again.`,
283
+ );
284
+
285
+ api.logger.info(`Planning: audit failed for ${session.projectName} (${result.problems.length} problems)`);
286
+ }
287
+ }
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ readPlanningState,
7
+ writePlanningState,
8
+ registerPlanningSession,
9
+ updatePlanningSession,
10
+ getPlanningSession,
11
+ endPlanningSession,
12
+ isInPlanningMode,
13
+ setPlanningCache,
14
+ clearPlanningCache,
15
+ getActivePlanningByProjectId,
16
+ type PlanningSession,
17
+ type PlanningState,
18
+ } from "./planning-state.js";
19
+
20
+ function tmpStatePath(): string {
21
+ const dir = mkdtempSync(join(tmpdir(), "claw-ps-"));
22
+ return join(dir, "state.json");
23
+ }
24
+
25
+ function makePlanningSession(overrides?: Partial<PlanningSession>): PlanningSession {
26
+ return {
27
+ projectId: "proj-1",
28
+ projectName: "Test Project",
29
+ rootIssueId: "issue-uuid-1",
30
+ rootIdentifier: "PLAN-100",
31
+ teamId: "team-uuid-1",
32
+ status: "interviewing",
33
+ startedAt: new Date().toISOString(),
34
+ turnCount: 0,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Read / Write
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe("readPlanningState", () => {
44
+ it("returns empty state when file missing", async () => {
45
+ const p = tmpStatePath();
46
+ const state = await readPlanningState(p);
47
+ expect(state.sessions).toEqual({});
48
+ expect(state.processedEvents).toEqual([]);
49
+ });
50
+
51
+ it("reads back written state", async () => {
52
+ const p = tmpStatePath();
53
+ const session = makePlanningSession();
54
+ const data: PlanningState = {
55
+ sessions: { "proj-1": session },
56
+ processedEvents: ["evt-1"],
57
+ };
58
+ await writePlanningState(data, p);
59
+ const state = await readPlanningState(p);
60
+ expect(state.sessions["proj-1"]).toBeDefined();
61
+ expect(state.sessions["proj-1"].projectId).toBe("proj-1");
62
+ expect(state.processedEvents).toEqual(["evt-1"]);
63
+ });
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Register
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("registerPlanningSession", () => {
71
+ it("registers and reads back", async () => {
72
+ const p = tmpStatePath();
73
+ const session = makePlanningSession();
74
+ await registerPlanningSession("proj-1", session, p);
75
+ const state = await readPlanningState(p);
76
+ const found = getPlanningSession(state, "proj-1");
77
+ expect(found).not.toBeNull();
78
+ expect(found!.projectId).toBe("proj-1");
79
+ expect(found!.projectName).toBe("Test Project");
80
+ expect(found!.status).toBe("interviewing");
81
+ });
82
+
83
+ it("overwrites existing session for same projectId", async () => {
84
+ const p = tmpStatePath();
85
+ const session1 = makePlanningSession({ projectName: "First" });
86
+ const session2 = makePlanningSession({ projectName: "Second" });
87
+ await registerPlanningSession("proj-1", session1, p);
88
+ await registerPlanningSession("proj-1", session2, p);
89
+ const state = await readPlanningState(p);
90
+ const found = getPlanningSession(state, "proj-1");
91
+ expect(found).not.toBeNull();
92
+ expect(found!.projectName).toBe("Second");
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Update
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe("updatePlanningSession", () => {
101
+ it("increments turnCount", async () => {
102
+ const p = tmpStatePath();
103
+ await registerPlanningSession("proj-1", makePlanningSession({ turnCount: 2 }), p);
104
+ const updated = await updatePlanningSession("proj-1", { turnCount: 3 }, p);
105
+ expect(updated.turnCount).toBe(3);
106
+ const state = await readPlanningState(p);
107
+ expect(getPlanningSession(state, "proj-1")!.turnCount).toBe(3);
108
+ });
109
+
110
+ it("updates status", async () => {
111
+ const p = tmpStatePath();
112
+ await registerPlanningSession("proj-1", makePlanningSession(), p);
113
+ const updated = await updatePlanningSession("proj-1", { status: "plan_review" }, p);
114
+ expect(updated.status).toBe("plan_review");
115
+ const state = await readPlanningState(p);
116
+ expect(getPlanningSession(state, "proj-1")!.status).toBe("plan_review");
117
+ });
118
+
119
+ it("throws on missing session", async () => {
120
+ const p = tmpStatePath();
121
+ await expect(
122
+ updatePlanningSession("no-such-project", { turnCount: 1 }, p),
123
+ ).rejects.toThrow("No planning session for project no-such-project");
124
+ });
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Get
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe("getPlanningSession", () => {
132
+ it("returns session by projectId", async () => {
133
+ const p = tmpStatePath();
134
+ await registerPlanningSession("proj-1", makePlanningSession(), p);
135
+ const state = await readPlanningState(p);
136
+ const session = getPlanningSession(state, "proj-1");
137
+ expect(session).not.toBeNull();
138
+ expect(session!.projectId).toBe("proj-1");
139
+ });
140
+
141
+ it("returns null for unknown projectId", async () => {
142
+ const p = tmpStatePath();
143
+ const state = await readPlanningState(p);
144
+ const session = getPlanningSession(state, "unknown-proj");
145
+ expect(session).toBeNull();
146
+ });
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // End
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe("endPlanningSession", () => {
154
+ it("sets status to approved", async () => {
155
+ const p = tmpStatePath();
156
+ await registerPlanningSession("proj-1", makePlanningSession(), p);
157
+ await endPlanningSession("proj-1", "approved", p);
158
+ const state = await readPlanningState(p);
159
+ const session = getPlanningSession(state, "proj-1");
160
+ expect(session).not.toBeNull();
161
+ expect(session!.status).toBe("approved");
162
+ });
163
+
164
+ it("sets status to abandoned", async () => {
165
+ const p = tmpStatePath();
166
+ await registerPlanningSession("proj-1", makePlanningSession(), p);
167
+ await endPlanningSession("proj-1", "abandoned", p);
168
+ const state = await readPlanningState(p);
169
+ const session = getPlanningSession(state, "proj-1");
170
+ expect(session).not.toBeNull();
171
+ expect(session!.status).toBe("abandoned");
172
+ });
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // isInPlanningMode
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe("isInPlanningMode", () => {
180
+ it("true for interviewing", async () => {
181
+ const p = tmpStatePath();
182
+ await registerPlanningSession("proj-1", makePlanningSession({ status: "interviewing" }), p);
183
+ const state = await readPlanningState(p);
184
+ expect(isInPlanningMode(state, "proj-1")).toBe(true);
185
+ });
186
+
187
+ it("true for plan_review", async () => {
188
+ const p = tmpStatePath();
189
+ await registerPlanningSession("proj-1", makePlanningSession({ status: "plan_review" }), p);
190
+ const state = await readPlanningState(p);
191
+ expect(isInPlanningMode(state, "proj-1")).toBe(true);
192
+ });
193
+
194
+ it("false for approved", async () => {
195
+ const p = tmpStatePath();
196
+ await registerPlanningSession("proj-1", makePlanningSession({ status: "approved" }), p);
197
+ const state = await readPlanningState(p);
198
+ expect(isInPlanningMode(state, "proj-1")).toBe(false);
199
+ });
200
+
201
+ it("false for abandoned", async () => {
202
+ const p = tmpStatePath();
203
+ await registerPlanningSession("proj-1", makePlanningSession({ status: "abandoned" }), p);
204
+ const state = await readPlanningState(p);
205
+ expect(isInPlanningMode(state, "proj-1")).toBe(false);
206
+ });
207
+
208
+ it("false for missing project", async () => {
209
+ const p = tmpStatePath();
210
+ const state = await readPlanningState(p);
211
+ expect(isInPlanningMode(state, "no-such-project")).toBe(false);
212
+ });
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // In-memory cache
217
+ // ---------------------------------------------------------------------------
218
+
219
+ describe("setPlanningCache / getActivePlanningByProjectId", () => {
220
+ beforeEach(() => {
221
+ clearPlanningCache("cache-proj-1");
222
+ });
223
+
224
+ it("round-trip in-memory + clearPlanningCache", () => {
225
+ const session = makePlanningSession({ projectId: "cache-proj-1" });
226
+ setPlanningCache(session);
227
+ const cached = getActivePlanningByProjectId("cache-proj-1");
228
+ expect(cached).not.toBeNull();
229
+ expect(cached!.projectId).toBe("cache-proj-1");
230
+ expect(cached!.projectName).toBe("Test Project");
231
+
232
+ clearPlanningCache("cache-proj-1");
233
+ const cleared = getActivePlanningByProjectId("cache-proj-1");
234
+ expect(cleared).toBeNull();
235
+ });
236
+ });
@@ -0,0 +1,178 @@
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 (shared utility)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ import { acquireLock, releaseLock } from "../infra/file-lock.js";
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Read / Write
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function emptyState(): PlanningState {
61
+ return { sessions: {}, processedEvents: [] };
62
+ }
63
+
64
+ export async function readPlanningState(configPath?: string): Promise<PlanningState> {
65
+ const filePath = resolveStatePath(configPath);
66
+ try {
67
+ const raw = await fs.readFile(filePath, "utf-8");
68
+ const parsed = JSON.parse(raw) as PlanningState;
69
+ if (!parsed.sessions) parsed.sessions = {};
70
+ if (!parsed.processedEvents) parsed.processedEvents = [];
71
+ return parsed;
72
+ } catch (err: any) {
73
+ if (err.code === "ENOENT") return emptyState();
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ export async function writePlanningState(data: PlanningState, configPath?: string): Promise<void> {
79
+ const filePath = resolveStatePath(configPath);
80
+ const dir = path.dirname(filePath);
81
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
82
+ if (data.processedEvents.length > MAX_PROCESSED_EVENTS) {
83
+ data.processedEvents = data.processedEvents.slice(-MAX_PROCESSED_EVENTS);
84
+ }
85
+ const tmpPath = filePath + ".tmp";
86
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
87
+ await fs.rename(tmpPath, filePath);
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Session operations
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export async function registerPlanningSession(
95
+ projectId: string,
96
+ session: PlanningSession,
97
+ configPath?: string,
98
+ ): Promise<void> {
99
+ const filePath = resolveStatePath(configPath);
100
+ await acquireLock(filePath);
101
+ try {
102
+ const data = await readPlanningState(configPath);
103
+ data.sessions[projectId] = session;
104
+ await writePlanningState(data, configPath);
105
+ } finally {
106
+ await releaseLock(filePath);
107
+ }
108
+ }
109
+
110
+ export async function updatePlanningSession(
111
+ projectId: string,
112
+ updates: Partial<PlanningSession>,
113
+ configPath?: string,
114
+ ): Promise<PlanningSession> {
115
+ const filePath = resolveStatePath(configPath);
116
+ await acquireLock(filePath);
117
+ try {
118
+ const data = await readPlanningState(configPath);
119
+ const session = data.sessions[projectId];
120
+ if (!session) throw new Error(`No planning session for project ${projectId}`);
121
+ Object.assign(session, updates);
122
+ await writePlanningState(data, configPath);
123
+ return session;
124
+ } finally {
125
+ await releaseLock(filePath);
126
+ }
127
+ }
128
+
129
+ export function getPlanningSession(
130
+ state: PlanningState,
131
+ projectId: string,
132
+ ): PlanningSession | null {
133
+ return state.sessions[projectId] ?? null;
134
+ }
135
+
136
+ export async function endPlanningSession(
137
+ projectId: string,
138
+ status: "approved" | "abandoned",
139
+ configPath?: string,
140
+ ): Promise<void> {
141
+ const filePath = resolveStatePath(configPath);
142
+ await acquireLock(filePath);
143
+ try {
144
+ const data = await readPlanningState(configPath);
145
+ const session = data.sessions[projectId];
146
+ if (session) {
147
+ session.status = status;
148
+ await writePlanningState(data, configPath);
149
+ }
150
+ clearPlanningCache(projectId);
151
+ } finally {
152
+ await releaseLock(filePath);
153
+ }
154
+ }
155
+
156
+ export function isInPlanningMode(state: PlanningState, projectId: string): boolean {
157
+ const session = state.sessions[projectId];
158
+ if (!session) return false;
159
+ return session.status === "interviewing" || session.status === "plan_review";
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // In-memory cache for fast webhook routing
164
+ // ---------------------------------------------------------------------------
165
+
166
+ const planningCache = new Map<string, PlanningSession>();
167
+
168
+ export function setPlanningCache(session: PlanningSession): void {
169
+ planningCache.set(session.projectId, session);
170
+ }
171
+
172
+ export function clearPlanningCache(projectId: string): void {
173
+ planningCache.delete(projectId);
174
+ }
175
+
176
+ export function getActivePlanningByProjectId(projectId: string): PlanningSession | null {
177
+ return planningCache.get(projectId) ?? null;
178
+ }