@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,450 @@
1
+ /**
2
+ * planner-tools.ts — Agent tools for the project planning pipeline.
3
+ *
4
+ * These tools are used exclusively by the planner agent during planning mode.
5
+ * They wrap LinearAgentApi methods to create/link/update issues and audit the DAG.
6
+ *
7
+ * Context injection: The planner pipeline sets/clears the active planner context
8
+ * before/after calling runAgent(). Tools read from this module-level context
9
+ * at execution time (same pattern as active-session.ts).
10
+ */
11
+ import type { AnyAgentTool } from "openclaw/plugin-sdk";
12
+ import { jsonResult } from "openclaw/plugin-sdk";
13
+ import type { LinearAgentApi } from "../api/linear-api.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface PlannerToolContext {
20
+ linearApi: LinearAgentApi;
21
+ projectId: string;
22
+ teamId: string;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Context injection (set before runAgent, cleared after)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ let _activePlannerCtx: PlannerToolContext | null = null;
30
+
31
+ export function setActivePlannerContext(ctx: PlannerToolContext): void {
32
+ _activePlannerCtx = ctx;
33
+ }
34
+
35
+ export function clearActivePlannerContext(): void {
36
+ _activePlannerCtx = null;
37
+ }
38
+
39
+ function requireContext(): PlannerToolContext {
40
+ if (!_activePlannerCtx) {
41
+ throw new Error("No active planning session. This tool is only available during planning mode.");
42
+ }
43
+ return _activePlannerCtx;
44
+ }
45
+
46
+ export interface AuditResult {
47
+ pass: boolean;
48
+ problems: string[];
49
+ warnings: string[];
50
+ }
51
+
52
+ type ProjectIssue = Awaited<ReturnType<LinearAgentApi["getProjectIssues"]>>[number];
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Identifier resolution
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function buildIdentifierMap(issues: ProjectIssue[]): Map<string, string> {
59
+ const map = new Map<string, string>();
60
+ for (const issue of issues) {
61
+ map.set(issue.identifier, issue.id);
62
+ }
63
+ return map;
64
+ }
65
+
66
+ function resolveId(idMap: Map<string, string>, identifier: string): string {
67
+ const id = idMap.get(identifier);
68
+ if (!id) throw new Error(`Unknown issue identifier: ${identifier}. Use get_project_plan to see current issues.`);
69
+ return id;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // DAG cycle detection (Kahn's algorithm)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export function detectCycles(issues: ProjectIssue[]): string[] {
77
+ // Build adjacency: "blocks" means an edge from blocker to blocked
78
+ const identifiers = new Set(issues.map((i) => i.identifier));
79
+ const inDegree = new Map<string, number>();
80
+ const adjacency = new Map<string, string[]>();
81
+
82
+ for (const id of identifiers) {
83
+ inDegree.set(id, 0);
84
+ adjacency.set(id, []);
85
+ }
86
+
87
+ for (const issue of issues) {
88
+ for (const rel of issue.relations?.nodes ?? []) {
89
+ const target = rel.relatedIssue?.identifier;
90
+ if (!target || !identifiers.has(target)) continue;
91
+ if (rel.type === "blocks") {
92
+ // issue blocks target → edge from issue to target
93
+ adjacency.get(issue.identifier)!.push(target);
94
+ inDegree.set(target, (inDegree.get(target) ?? 0) + 1);
95
+ } else if (rel.type === "blocked_by") {
96
+ // issue is blocked by target → edge from target to issue
97
+ adjacency.get(target)!.push(issue.identifier);
98
+ inDegree.set(issue.identifier, (inDegree.get(issue.identifier) ?? 0) + 1);
99
+ }
100
+ }
101
+ }
102
+
103
+ // Kahn's algorithm
104
+ const queue: string[] = [];
105
+ for (const [id, deg] of inDegree) {
106
+ if (deg === 0) queue.push(id);
107
+ }
108
+
109
+ let processed = 0;
110
+ while (queue.length > 0) {
111
+ const node = queue.shift()!;
112
+ processed++;
113
+ for (const neighbor of adjacency.get(node) ?? []) {
114
+ const newDeg = (inDegree.get(neighbor) ?? 1) - 1;
115
+ inDegree.set(neighbor, newDeg);
116
+ if (newDeg === 0) queue.push(neighbor);
117
+ }
118
+ }
119
+
120
+ // Nodes not processed are in cycles
121
+ if (processed === identifiers.size) return [];
122
+
123
+ const cycleNodes: string[] = [];
124
+ for (const [id, deg] of inDegree) {
125
+ if (deg > 0) cycleNodes.push(id);
126
+ }
127
+ return cycleNodes;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Audit logic
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export function auditPlan(issues: ProjectIssue[]): AuditResult {
135
+ const problems: string[] = [];
136
+ const warnings: string[] = [];
137
+
138
+ for (const issue of issues) {
139
+ const isEpic = issue.labels?.nodes?.some((l) => l.name.toLowerCase().includes("epic"));
140
+
141
+ // Description check
142
+ if (!issue.description || issue.description.trim().length < 50) {
143
+ problems.push(`${issue.identifier} "${issue.title}": description missing or too short (min 50 chars)`);
144
+ }
145
+
146
+ // Non-epic checks
147
+ if (!isEpic) {
148
+ if (issue.estimate == null) {
149
+ problems.push(`${issue.identifier} "${issue.title}": missing estimate`);
150
+ }
151
+ if (!issue.priority || issue.priority === 0) {
152
+ problems.push(`${issue.identifier} "${issue.title}": missing priority`);
153
+ }
154
+ }
155
+ }
156
+
157
+ // DAG cycle check
158
+ const cycleNodes = detectCycles(issues);
159
+ if (cycleNodes.length > 0) {
160
+ problems.push(`Dependency cycle detected involving: ${cycleNodes.join(", ")}`);
161
+ }
162
+
163
+ // Orphan check: issues with no parent and no relations linking to the rest
164
+ const hasParent = new Set(issues.filter((i) => i.parent).map((i) => i.identifier));
165
+ const hasRelation = new Set<string>();
166
+ for (const issue of issues) {
167
+ for (const rel of issue.relations?.nodes ?? []) {
168
+ hasRelation.add(issue.identifier);
169
+ if (rel.relatedIssue?.identifier) hasRelation.add(rel.relatedIssue.identifier);
170
+ }
171
+ }
172
+ for (const issue of issues) {
173
+ if (!hasParent.has(issue.identifier) && !hasRelation.has(issue.identifier)) {
174
+ warnings.push(`${issue.identifier} "${issue.title}": orphan issue (no parent or dependency links)`);
175
+ }
176
+ }
177
+
178
+ return {
179
+ pass: problems.length === 0,
180
+ problems,
181
+ warnings,
182
+ };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Snapshot formatter
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export function buildPlanSnapshot(issues: ProjectIssue[]): string {
190
+ if (issues.length === 0) return "_No issues created yet._";
191
+
192
+ const lines: string[] = [];
193
+ const childMap = new Map<string, ProjectIssue[]>();
194
+ const topLevel: ProjectIssue[] = [];
195
+
196
+ for (const issue of issues) {
197
+ if (issue.parent) {
198
+ const siblings = childMap.get(issue.parent.identifier) ?? [];
199
+ siblings.push(issue);
200
+ childMap.set(issue.parent.identifier, siblings);
201
+ } else {
202
+ topLevel.push(issue);
203
+ }
204
+ }
205
+
206
+ const priorityLabel = (p: number): string => {
207
+ if (p === 1) return "Urgent";
208
+ if (p === 2) return "High";
209
+ if (p === 3) return "Medium";
210
+ if (p === 4) return "Low";
211
+ return "None";
212
+ };
213
+
214
+ const formatRelations = (issue: ProjectIssue): string => {
215
+ const rels = issue.relations?.nodes ?? [];
216
+ if (rels.length === 0) return "";
217
+ return " " + rels.map((r) => `→ ${r.type} ${r.relatedIssue.identifier}`).join(", ");
218
+ };
219
+
220
+ const formatIssue = (issue: ProjectIssue, indent: string): void => {
221
+ const est = issue.estimate != null ? `est: ${issue.estimate}` : "est: -";
222
+ const pri = `pri: ${priorityLabel(issue.priority)}`;
223
+ const rels = formatRelations(issue);
224
+ lines.push(`${indent}- ${issue.identifier} "${issue.title}" [${est}, ${pri}]${rels}`);
225
+
226
+ const children = childMap.get(issue.identifier) ?? [];
227
+ for (const child of children) {
228
+ formatIssue(child, indent + " ");
229
+ }
230
+ };
231
+
232
+ // Separate epics from standalone issues
233
+ const epics = topLevel.filter((i) => i.labels?.nodes?.some((l) => l.name.toLowerCase().includes("epic")));
234
+ const standalone = topLevel.filter((i) => !i.labels?.nodes?.some((l) => l.name.toLowerCase().includes("epic")));
235
+
236
+ if (epics.length > 0) {
237
+ lines.push(`### Epics (${epics.length})`);
238
+ for (const epic of epics) formatIssue(epic, "");
239
+ }
240
+
241
+ if (standalone.length > 0) {
242
+ lines.push(`### Standalone issues (${standalone.length})`);
243
+ for (const issue of standalone) formatIssue(issue, "");
244
+ }
245
+
246
+ return lines.join("\n");
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Tool factory
251
+ // ---------------------------------------------------------------------------
252
+
253
+ export function createPlannerTools(): AnyAgentTool[] {
254
+ return [
255
+ // ---- create_issue ----
256
+ {
257
+ name: "plan_create_issue",
258
+ label: "Create Issue",
259
+ description:
260
+ "Create a new Linear issue in the current planning project. Use parentIdentifier to create sub-issues under an epic or parent issue.",
261
+ parameters: {
262
+ type: "object",
263
+ properties: {
264
+ title: { type: "string", description: "Issue title" },
265
+ description: { type: "string", description: "Issue description with acceptance criteria (min 50 chars)" },
266
+ parentIdentifier: { type: "string", description: "Parent issue identifier (e.g. PROJ-2) to create as sub-issue" },
267
+ isEpic: { type: "boolean", description: "Mark as epic (high-level feature area)" },
268
+ priority: { type: "number", description: "Priority: 1=Urgent, 2=High, 3=Medium, 4=Low" },
269
+ estimate: { type: "number", description: "Story point estimate" },
270
+ },
271
+ required: ["title", "description"],
272
+ },
273
+ execute: async (_toolCallId: string, params: {
274
+ title: string;
275
+ description: string;
276
+ parentIdentifier?: string;
277
+ isEpic?: boolean;
278
+ priority?: number;
279
+ estimate?: number;
280
+ }) => {
281
+ const { linearApi, projectId, teamId } = requireContext();
282
+
283
+ const input: Record<string, unknown> = {
284
+ teamId,
285
+ projectId,
286
+ title: params.title,
287
+ description: params.description,
288
+ };
289
+
290
+ if (params.priority) input.priority = params.priority;
291
+ if (params.estimate != null) input.estimate = params.estimate;
292
+
293
+ // Resolve parent
294
+ if (params.parentIdentifier) {
295
+ const issues = await linearApi.getProjectIssues(projectId);
296
+ const idMap = buildIdentifierMap(issues);
297
+ input.parentId = resolveId(idMap, params.parentIdentifier);
298
+ }
299
+
300
+ const result = await linearApi.createIssue(input as any);
301
+
302
+ // If epic, try to add "Epic" label
303
+ if (params.isEpic) {
304
+ try {
305
+ const labels = await linearApi.getTeamLabels(teamId);
306
+ const epicLabel = labels.find((l) => l.name.toLowerCase() === "epic");
307
+ if (epicLabel) {
308
+ await linearApi.updateIssueExtended(result.id, { labelIds: [epicLabel.id] });
309
+ }
310
+ } catch { /* best-effort labeling */ }
311
+ }
312
+
313
+ return jsonResult({
314
+ id: result.id,
315
+ identifier: result.identifier,
316
+ title: params.title,
317
+ isEpic: params.isEpic ?? false,
318
+ });
319
+ },
320
+ } as unknown as AnyAgentTool,
321
+
322
+ // ---- link_issues ----
323
+ {
324
+ name: "plan_link_issues",
325
+ label: "Link Issues",
326
+ description:
327
+ "Create a dependency relationship between two issues. Use 'blocks' to indicate ordering: if A must finish before B starts, A blocks B.",
328
+ parameters: {
329
+ type: "object",
330
+ properties: {
331
+ fromIdentifier: { type: "string", description: "Source issue identifier (e.g. PROJ-2)" },
332
+ toIdentifier: { type: "string", description: "Target issue identifier (e.g. PROJ-3)" },
333
+ type: {
334
+ type: "string",
335
+ description: "Relationship type: 'blocks', 'blocked_by', or 'related'",
336
+ },
337
+ },
338
+ required: ["fromIdentifier", "toIdentifier", "type"],
339
+ },
340
+ execute: async (_toolCallId: string, params: {
341
+ fromIdentifier: string;
342
+ toIdentifier: string;
343
+ type: "blocks" | "blocked_by" | "related";
344
+ }) => {
345
+ const { linearApi, projectId } = requireContext();
346
+ const issues = await linearApi.getProjectIssues(projectId);
347
+ const idMap = buildIdentifierMap(issues);
348
+
349
+ const fromId = resolveId(idMap, params.fromIdentifier);
350
+ const toId = resolveId(idMap, params.toIdentifier);
351
+
352
+ const result = await linearApi.createIssueRelation({
353
+ issueId: fromId,
354
+ relatedIssueId: toId,
355
+ type: params.type,
356
+ });
357
+
358
+ return jsonResult({
359
+ id: result.id,
360
+ from: params.fromIdentifier,
361
+ to: params.toIdentifier,
362
+ type: params.type,
363
+ });
364
+ },
365
+ } as unknown as AnyAgentTool,
366
+
367
+ // ---- get_project_plan ----
368
+ {
369
+ name: "plan_get_project",
370
+ label: "Get Project Plan",
371
+ description:
372
+ "Retrieve the current project plan showing all issues organized by hierarchy with dependency relationships.",
373
+ parameters: {
374
+ type: "object",
375
+ properties: {},
376
+ required: [],
377
+ },
378
+ execute: async () => {
379
+ const { linearApi, projectId } = requireContext();
380
+ const issues = await linearApi.getProjectIssues(projectId);
381
+ const snapshot = buildPlanSnapshot(issues);
382
+ return jsonResult({
383
+ issueCount: issues.length,
384
+ plan: snapshot,
385
+ });
386
+ },
387
+ } as unknown as AnyAgentTool,
388
+
389
+ // ---- update_issue ----
390
+ {
391
+ name: "plan_update_issue",
392
+ label: "Update Issue",
393
+ description: "Update an existing issue's description, estimate, priority, or labels.",
394
+ parameters: {
395
+ type: "object",
396
+ properties: {
397
+ identifier: { type: "string", description: "Issue identifier (e.g. PROJ-5)" },
398
+ description: { type: "string", description: "New description" },
399
+ estimate: { type: "number", description: "New estimate" },
400
+ priority: { type: "number", description: "New priority: 1=Urgent, 2=High, 3=Medium, 4=Low" },
401
+ labelIds: {
402
+ type: "array",
403
+ description: "Label IDs to set",
404
+ },
405
+ },
406
+ required: ["identifier"],
407
+ },
408
+ execute: async (_toolCallId: string, params: {
409
+ identifier: string;
410
+ description?: string;
411
+ estimate?: number;
412
+ priority?: number;
413
+ labelIds?: string[];
414
+ }) => {
415
+ const { linearApi, projectId } = requireContext();
416
+ const issues = await linearApi.getProjectIssues(projectId);
417
+ const idMap = buildIdentifierMap(issues);
418
+ const issueId = resolveId(idMap, params.identifier);
419
+
420
+ const updates: Record<string, unknown> = {};
421
+ if (params.description !== undefined) updates.description = params.description;
422
+ if (params.estimate !== undefined) updates.estimate = params.estimate;
423
+ if (params.priority !== undefined) updates.priority = params.priority;
424
+ if (params.labelIds !== undefined) updates.labelIds = params.labelIds;
425
+
426
+ const success = await linearApi.updateIssueExtended(issueId, updates);
427
+ return jsonResult({ identifier: params.identifier, updated: success });
428
+ },
429
+ } as unknown as AnyAgentTool,
430
+
431
+ // ---- audit_plan ----
432
+ {
433
+ name: "plan_audit",
434
+ label: "Audit Plan",
435
+ description:
436
+ "Run a completeness audit on the current project plan. Checks descriptions, estimates, priorities, DAG validity, and orphan issues.",
437
+ parameters: {
438
+ type: "object",
439
+ properties: {},
440
+ required: [],
441
+ },
442
+ execute: async () => {
443
+ const { linearApi, projectId } = requireContext();
444
+ const issues = await linearApi.getProjectIssues(projectId);
445
+ const result = auditPlan(issues);
446
+ return jsonResult(result);
447
+ },
448
+ } as unknown as AnyAgentTool,
449
+ ];
450
+ }