@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,282 @@
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
+ ): Promise<void> {
153
+ const { api, linearApi, pluginConfig } = ctx;
154
+ const configPath = pluginConfig?.planningStatePath as string | undefined;
155
+
156
+ // Detect finalization intent
157
+ if (FINALIZE_PATTERN.test(input.commentBody)) {
158
+ await runPlanAudit(ctx, session);
159
+ return;
160
+ }
161
+
162
+ // Detect abandon intent
163
+ if (ABANDON_PATTERN.test(input.commentBody)) {
164
+ await endPlanningSession(session.projectId, "abandoned", configPath);
165
+ await linearApi.createComment(
166
+ session.rootIssueId,
167
+ `Planning mode ended for **${session.projectName}**. Session abandoned.`,
168
+ );
169
+ api.logger.info(`Planning: session abandoned for ${session.projectName}`);
170
+ return;
171
+ }
172
+
173
+ // Increment turn count
174
+ const newTurnCount = session.turnCount + 1;
175
+ await updatePlanningSession(session.projectId, { turnCount: newTurnCount }, configPath);
176
+
177
+ // Build plan snapshot
178
+ const issues = await linearApi.getProjectIssues(session.projectId);
179
+ const planSnapshot = buildPlanSnapshot(issues);
180
+
181
+ // Build comment history (last 10 comments on root issue)
182
+ let commentHistory = "";
183
+ try {
184
+ const details = await linearApi.getIssueDetails(session.rootIssueId);
185
+ commentHistory = details.comments?.nodes
186
+ ?.map((c) => `**${c.user?.name ?? "Unknown"}:** ${c.body.slice(0, 300)}`)
187
+ .join("\n\n") ?? "";
188
+ } catch { /* best effort */ }
189
+
190
+ // Build prompt
191
+ const prompts = loadPlannerPrompts(pluginConfig);
192
+ const taskPrompt = renderTemplate(prompts.interview, {
193
+ projectName: session.projectName,
194
+ rootIdentifier: session.rootIdentifier,
195
+ planSnapshot,
196
+ turnCount: String(newTurnCount),
197
+ commentHistory,
198
+ userMessage: input.commentBody,
199
+ });
200
+
201
+ // Set planner context for tools
202
+ setActivePlannerContext({
203
+ linearApi,
204
+ projectId: session.projectId,
205
+ teamId: session.teamId,
206
+ });
207
+
208
+ try {
209
+ const sessionId = `planner-${session.rootIdentifier}-turn-${newTurnCount}`;
210
+ const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
211
+
212
+ api.logger.info(`Planning: turn ${newTurnCount} for ${session.projectName}`);
213
+
214
+ const result = await runAgent({
215
+ api,
216
+ agentId,
217
+ sessionId,
218
+ message: `${prompts.system}\n\n${taskPrompt}`,
219
+ });
220
+
221
+ // Post agent response as comment
222
+ if (result.output) {
223
+ await linearApi.createComment(session.rootIssueId, result.output);
224
+ }
225
+ } finally {
226
+ clearActivePlannerContext();
227
+ }
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Plan audit
232
+ // ---------------------------------------------------------------------------
233
+
234
+ export async function runPlanAudit(
235
+ ctx: PlannerContext,
236
+ session: PlanningSession,
237
+ ): Promise<void> {
238
+ const { api, linearApi, pluginConfig } = ctx;
239
+ const configPath = pluginConfig?.planningStatePath as string | undefined;
240
+
241
+ api.logger.info(`Planning: running audit for ${session.projectName}`);
242
+
243
+ // Run deterministic audit
244
+ const issues = await linearApi.getProjectIssues(session.projectId);
245
+ const result = auditPlan(issues);
246
+
247
+ if (result.pass) {
248
+ // Build final summary
249
+ const snapshot = buildPlanSnapshot(issues);
250
+ const warningsList = result.warnings.length > 0
251
+ ? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
252
+ : "";
253
+
254
+ await linearApi.createComment(
255
+ session.rootIssueId,
256
+ `## Plan Approved\n\n` +
257
+ `The plan for **${session.projectName}** passed all checks.\n\n` +
258
+ `**${issues.length} issues** created with valid dependency graph.${warningsList}\n\n` +
259
+ `### Final Plan\n${snapshot}\n\n` +
260
+ `---\n*Planning mode complete. Project is ready for implementation dispatch.*`,
261
+ );
262
+
263
+ await endPlanningSession(session.projectId, "approved", configPath);
264
+ api.logger.info(`Planning: session approved for ${session.projectName}`);
265
+ } else {
266
+ // Post problems and keep planning
267
+ const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
268
+ const warningsList = result.warnings.length > 0
269
+ ? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
270
+ : "";
271
+
272
+ await linearApi.createComment(
273
+ session.rootIssueId,
274
+ `## Plan Audit Failed\n\n` +
275
+ `The following issues need attention before the plan can be approved:\n\n` +
276
+ `**Problems:**\n${problemsList}${warningsList}\n\n` +
277
+ `Please address these issues, then say "finalize plan" again.`,
278
+ );
279
+
280
+ api.logger.info(`Planning: audit failed for ${session.projectName} (${result.problems.length} problems)`);
281
+ }
282
+ }
@@ -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
+ });