@renseiai/agentfactory-linear 0.8.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/src/agent-client-project-repo.test.d.ts +2 -0
  4. package/dist/src/agent-client-project-repo.test.d.ts.map +1 -0
  5. package/dist/src/agent-client-project-repo.test.js +153 -0
  6. package/dist/src/agent-client.d.ts +261 -0
  7. package/dist/src/agent-client.d.ts.map +1 -0
  8. package/dist/src/agent-client.js +902 -0
  9. package/dist/src/agent-session.d.ts +303 -0
  10. package/dist/src/agent-session.d.ts.map +1 -0
  11. package/dist/src/agent-session.js +969 -0
  12. package/dist/src/checkbox-utils.d.ts +88 -0
  13. package/dist/src/checkbox-utils.d.ts.map +1 -0
  14. package/dist/src/checkbox-utils.js +120 -0
  15. package/dist/src/circuit-breaker.d.ts +76 -0
  16. package/dist/src/circuit-breaker.d.ts.map +1 -0
  17. package/dist/src/circuit-breaker.js +229 -0
  18. package/dist/src/circuit-breaker.test.d.ts +2 -0
  19. package/dist/src/circuit-breaker.test.d.ts.map +1 -0
  20. package/dist/src/circuit-breaker.test.js +292 -0
  21. package/dist/src/constants.d.ts +87 -0
  22. package/dist/src/constants.d.ts.map +1 -0
  23. package/dist/src/constants.js +101 -0
  24. package/dist/src/defaults/auto-trigger.d.ts +35 -0
  25. package/dist/src/defaults/auto-trigger.d.ts.map +1 -0
  26. package/dist/src/defaults/auto-trigger.js +36 -0
  27. package/dist/src/defaults/index.d.ts +12 -0
  28. package/dist/src/defaults/index.d.ts.map +1 -0
  29. package/dist/src/defaults/index.js +11 -0
  30. package/dist/src/defaults/priority.d.ts +20 -0
  31. package/dist/src/defaults/priority.d.ts.map +1 -0
  32. package/dist/src/defaults/priority.js +37 -0
  33. package/dist/src/defaults/prompts.d.ts +42 -0
  34. package/dist/src/defaults/prompts.d.ts.map +1 -0
  35. package/dist/src/defaults/prompts.js +310 -0
  36. package/dist/src/defaults/prompts.test.d.ts +2 -0
  37. package/dist/src/defaults/prompts.test.d.ts.map +1 -0
  38. package/dist/src/defaults/prompts.test.js +263 -0
  39. package/dist/src/defaults/work-type-detection.d.ts +19 -0
  40. package/dist/src/defaults/work-type-detection.d.ts.map +1 -0
  41. package/dist/src/defaults/work-type-detection.js +93 -0
  42. package/dist/src/errors.d.ts +91 -0
  43. package/dist/src/errors.d.ts.map +1 -0
  44. package/dist/src/errors.js +173 -0
  45. package/dist/src/frontend-adapter.d.ts +168 -0
  46. package/dist/src/frontend-adapter.d.ts.map +1 -0
  47. package/dist/src/frontend-adapter.js +314 -0
  48. package/dist/src/frontend-adapter.test.d.ts +2 -0
  49. package/dist/src/frontend-adapter.test.d.ts.map +1 -0
  50. package/dist/src/frontend-adapter.test.js +545 -0
  51. package/dist/src/index.d.ts +28 -0
  52. package/dist/src/index.d.ts.map +1 -0
  53. package/dist/src/index.js +30 -0
  54. package/dist/src/issue-tracker-proxy.d.ts +140 -0
  55. package/dist/src/issue-tracker-proxy.d.ts.map +1 -0
  56. package/dist/src/issue-tracker-proxy.js +10 -0
  57. package/dist/src/platform-adapter.d.ts +132 -0
  58. package/dist/src/platform-adapter.d.ts.map +1 -0
  59. package/dist/src/platform-adapter.js +260 -0
  60. package/dist/src/platform-adapter.test.d.ts +2 -0
  61. package/dist/src/platform-adapter.test.d.ts.map +1 -0
  62. package/dist/src/platform-adapter.test.js +468 -0
  63. package/dist/src/proxy-client.d.ts +103 -0
  64. package/dist/src/proxy-client.d.ts.map +1 -0
  65. package/dist/src/proxy-client.js +191 -0
  66. package/dist/src/rate-limiter.d.ts +64 -0
  67. package/dist/src/rate-limiter.d.ts.map +1 -0
  68. package/dist/src/rate-limiter.js +163 -0
  69. package/dist/src/rate-limiter.test.d.ts +2 -0
  70. package/dist/src/rate-limiter.test.d.ts.map +1 -0
  71. package/dist/src/rate-limiter.test.js +217 -0
  72. package/dist/src/retry.d.ts +59 -0
  73. package/dist/src/retry.d.ts.map +1 -0
  74. package/dist/src/retry.js +82 -0
  75. package/dist/src/types.d.ts +492 -0
  76. package/dist/src/types.d.ts.map +1 -0
  77. package/dist/src/types.js +143 -0
  78. package/dist/src/utils.d.ts +52 -0
  79. package/dist/src/utils.d.ts.map +1 -0
  80. package/dist/src/utils.js +277 -0
  81. package/dist/src/webhook-types.d.ts +308 -0
  82. package/dist/src/webhook-types.d.ts.map +1 -0
  83. package/dist/src/webhook-types.js +46 -0
  84. package/package.json +70 -0
@@ -0,0 +1,969 @@
1
+ import { WORK_TYPE_START_STATUS, WORK_TYPE_COMPLETE_STATUS, WORK_TYPE_FAIL_STATUS, TERMINAL_STATUSES, } from './types.js';
2
+ import { LinearSessionError, LinearActivityError, LinearPlanError, } from './errors.js';
3
+ import { buildCompletionComments } from './utils.js';
4
+ import { getDefaultTeamId, LINEAR_PROJECTS, LINEAR_LABELS, } from './constants.js';
5
+ import { parseCheckboxes, updateCheckboxes, } from './checkbox-utils.js';
6
+ function generatePlanItemId() {
7
+ return `plan-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
8
+ }
9
+ /**
10
+ * Agent Session Handler
11
+ * Manages the lifecycle of an agent working on a Linear issue
12
+ */
13
+ export class AgentSession {
14
+ client;
15
+ issueId;
16
+ autoTransition;
17
+ workType;
18
+ sessionId = null;
19
+ state = 'pending';
20
+ currentPlan = { items: [] };
21
+ issue = null;
22
+ activityLog = [];
23
+ constructor(config) {
24
+ this.client = config.client;
25
+ this.issueId = config.issueId;
26
+ this.sessionId = config.sessionId ?? null;
27
+ this.autoTransition = config.autoTransition ?? true;
28
+ this.workType = config.workType ?? 'development';
29
+ }
30
+ get currentState() {
31
+ return this.state;
32
+ }
33
+ get id() {
34
+ return this.sessionId;
35
+ }
36
+ get plan() {
37
+ return { ...this.currentPlan };
38
+ }
39
+ get activities() {
40
+ return [...this.activityLog];
41
+ }
42
+ /**
43
+ * Add or update an external URL for the session
44
+ * External URLs appear in the Linear issue view, linking to dashboards, logs, or PRs
45
+ *
46
+ * @param label - Display label for the URL (e.g., "Pull Request", "Logs")
47
+ * @param url - The URL to link to
48
+ */
49
+ async addExternalUrl(label, url) {
50
+ if (!this.sessionId) {
51
+ throw new LinearSessionError('Cannot add external URL without a session ID. Call start() first or provide sessionId in config.', undefined, this.issueId);
52
+ }
53
+ try {
54
+ await this.client.updateAgentSession({
55
+ sessionId: this.sessionId,
56
+ externalUrls: [{ label, url }],
57
+ });
58
+ }
59
+ catch (error) {
60
+ throw new LinearSessionError(`Failed to add external URL: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId, this.issueId);
61
+ }
62
+ }
63
+ /**
64
+ * Set the pull request URL for this session
65
+ * This unlocks additional PR-related features in Linear
66
+ *
67
+ * @param prUrl - The GitHub pull request URL
68
+ * @see https://linear.app/developers/agents
69
+ */
70
+ async setPullRequestUrl(prUrl) {
71
+ await this.addExternalUrl('Pull Request', prUrl);
72
+ }
73
+ /**
74
+ * Start the agent session
75
+ *
76
+ * Transitions issue status based on work type:
77
+ * - development: Backlog -> Started
78
+ * - Other work types: No transition on start (issue stays in current status)
79
+ */
80
+ async start() {
81
+ try {
82
+ this.issue = await this.client.getIssue(this.issueId);
83
+ if (!this.sessionId) {
84
+ this.sessionId = `session-${this.issueId}-${Date.now()}`;
85
+ }
86
+ this.state = 'active';
87
+ // Transition based on work type — guard against terminal states
88
+ const startStatus = WORK_TYPE_START_STATUS[this.workType];
89
+ if (this.autoTransition && startStatus) {
90
+ const currentState = await this.issue.state;
91
+ const currentStateName = currentState?.name;
92
+ if (currentStateName && TERMINAL_STATUSES.includes(currentStateName)) {
93
+ console.warn(`[AgentSession] Refusing to transition issue ${this.issueId} from terminal state "${currentStateName}" to "${startStatus}"`);
94
+ }
95
+ else {
96
+ await this.client.updateIssueStatus(this.issueId, startStatus);
97
+ }
98
+ }
99
+ return { success: true, sessionId: this.sessionId };
100
+ }
101
+ catch (error) {
102
+ this.state = 'error';
103
+ throw new LinearSessionError(`Failed to start session: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined, this.issueId);
104
+ }
105
+ }
106
+ /**
107
+ * Mark session as awaiting user input
108
+ */
109
+ async awaitInput(prompt) {
110
+ this.state = 'awaitingInput';
111
+ await this.emitActivity({
112
+ type: 'response',
113
+ content: { text: `Awaiting input: ${prompt}` },
114
+ });
115
+ }
116
+ /**
117
+ * Complete the session successfully
118
+ *
119
+ * Transitions issue status based on work type:
120
+ * - development/inflight: Started -> Finished
121
+ * - qa: Finished -> Delivered (only if workResult === 'passed')
122
+ * - acceptance: Delivered -> Accepted (only if workResult === 'passed')
123
+ * - refinement: Rejected -> Backlog
124
+ * - research: No transition (user decides when to move to Backlog)
125
+ *
126
+ * @param summary - Optional completion summary to post as a comment
127
+ * @param workResult - For QA/acceptance: 'passed' promotes, 'failed' transitions to fail status, undefined skips transition
128
+ */
129
+ async complete(summary, workResult) {
130
+ try {
131
+ this.state = 'complete';
132
+ this.currentPlan.items = this.currentPlan.items.map((item) => ({
133
+ ...item,
134
+ state: item.state === 'pending' || item.state === 'inProgress'
135
+ ? 'completed'
136
+ : item.state,
137
+ }));
138
+ if (summary) {
139
+ await this.postCompletionComment(summary);
140
+ }
141
+ // Sync all remaining description checkboxes to complete
142
+ try {
143
+ const checkboxes = await this.getDescriptionCheckboxes();
144
+ const unchecked = checkboxes.filter((cb) => !cb.checked);
145
+ if (unchecked.length > 0) {
146
+ await this.updateDescriptionCheckboxes(unchecked.map((cb) => ({ textPattern: cb.text, checked: true })));
147
+ }
148
+ }
149
+ catch (error) {
150
+ // Log but don't fail completion - checkbox sync is non-critical
151
+ console.warn('[AgentSession] Failed to sync description checkboxes on complete:', error instanceof Error ? error.message : String(error));
152
+ }
153
+ // Transition based on work type — guard against terminal states
154
+ if (this.autoTransition) {
155
+ // Re-fetch current state to guard against transitioning out of terminal states
156
+ let isTerminal = false;
157
+ try {
158
+ const freshIssue = await this.client.getIssue(this.issueId);
159
+ const currentState = await freshIssue.state;
160
+ const currentStateName = currentState?.name;
161
+ if (currentStateName && TERMINAL_STATUSES.includes(currentStateName)) {
162
+ isTerminal = true;
163
+ console.warn(`[AgentSession] Refusing to transition issue ${this.issueId} from terminal state "${currentStateName}" on completion`);
164
+ }
165
+ }
166
+ catch (err) {
167
+ console.warn('[AgentSession] Failed to check terminal state before completion transition:', err);
168
+ }
169
+ if (!isTerminal) {
170
+ const isResultSensitive = this.workType === 'qa' || this.workType === 'acceptance' || this.workType === 'coordination' || this.workType === 'qa-coordination' || this.workType === 'acceptance-coordination';
171
+ if (isResultSensitive) {
172
+ // For QA/acceptance: only transition if workResult is explicitly set
173
+ if (workResult === 'passed') {
174
+ const completeStatus = WORK_TYPE_COMPLETE_STATUS[this.workType];
175
+ if (completeStatus) {
176
+ await this.client.updateIssueStatus(this.issueId, completeStatus);
177
+ }
178
+ }
179
+ else if (workResult === 'failed') {
180
+ const failStatus = WORK_TYPE_FAIL_STATUS[this.workType];
181
+ if (failStatus) {
182
+ await this.client.updateIssueStatus(this.issueId, failStatus);
183
+ }
184
+ }
185
+ // undefined workResult -> skip transition (safe default)
186
+ }
187
+ else {
188
+ // Non-QA/acceptance: unchanged behavior
189
+ const completeStatus = WORK_TYPE_COMPLETE_STATUS[this.workType];
190
+ if (completeStatus) {
191
+ await this.client.updateIssueStatus(this.issueId, completeStatus);
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return { success: true, sessionId: this.sessionId ?? undefined };
197
+ }
198
+ catch (error) {
199
+ throw new LinearSessionError(`Failed to complete session: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined, this.issueId);
200
+ }
201
+ }
202
+ /**
203
+ * Mark session as failed
204
+ * Emits an error activity (auto-generates comment) if session ID is available,
205
+ * otherwise falls back to creating a comment directly.
206
+ */
207
+ async fail(errorMessage) {
208
+ try {
209
+ this.state = 'error';
210
+ this.currentPlan.items = this.currentPlan.items.map((item) => ({
211
+ ...item,
212
+ state: item.state === 'inProgress'
213
+ ? 'canceled'
214
+ : item.state,
215
+ }));
216
+ // Use error activity if we have a session ID (auto-generates comment)
217
+ // Otherwise fall back to direct comment
218
+ if (this.sessionId) {
219
+ await this.createActivity({
220
+ type: 'error',
221
+ body: `**Agent Error**\n\n${errorMessage}\n\n---\n*Session ID: ${this.sessionId}*`,
222
+ }, false // not ephemeral - errors should persist
223
+ );
224
+ }
225
+ else {
226
+ await this.client.createComment(this.issueId, `## Agent Error\n\n${errorMessage}\n\n---\n*Session ID: ${this.sessionId}*`);
227
+ }
228
+ return { success: true, sessionId: this.sessionId ?? undefined };
229
+ }
230
+ catch (error) {
231
+ throw new LinearSessionError(`Failed to mark session as failed: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined, this.issueId);
232
+ }
233
+ }
234
+ /**
235
+ * Emit a generic activity (legacy method for backward compatibility)
236
+ * @deprecated Use createActivity for native Linear Agent API
237
+ */
238
+ async emitActivity(options) {
239
+ try {
240
+ this.activityLog.push({
241
+ type: options.type,
242
+ timestamp: new Date(),
243
+ content: options.content.text,
244
+ });
245
+ if (!options.ephemeral && options.type === 'response') {
246
+ await this.client.createComment(this.issueId, this.formatActivityAsComment(options));
247
+ }
248
+ }
249
+ catch (error) {
250
+ throw new LinearActivityError(`Failed to emit activity: ${error instanceof Error ? error.message : 'Unknown error'}`, options.type, this.sessionId ?? undefined);
251
+ }
252
+ }
253
+ /**
254
+ * Create an activity using the native Linear Agent API
255
+ *
256
+ * @param content - The activity content payload
257
+ * @param ephemeral - Whether the activity should disappear after the next activity
258
+ * @param signal - Optional modifier for how the activity should be interpreted
259
+ * @returns Result containing success status and activity ID
260
+ */
261
+ async createActivity(content, ephemeral = false, signal) {
262
+ if (!this.sessionId) {
263
+ throw new LinearActivityError('Cannot create activity without a session ID. Call start() first or provide sessionId in config.', content.type, undefined);
264
+ }
265
+ try {
266
+ const contentText = content.type === 'action'
267
+ ? `${content.action}: ${content.parameter}`
268
+ : content.body;
269
+ this.activityLog.push({
270
+ type: content.type,
271
+ timestamp: new Date(),
272
+ content: contentText,
273
+ });
274
+ const result = await this.client.createAgentActivity({
275
+ agentSessionId: this.sessionId,
276
+ content,
277
+ ephemeral,
278
+ signal,
279
+ });
280
+ return result;
281
+ }
282
+ catch (error) {
283
+ throw new LinearActivityError(`Failed to create activity: ${error instanceof Error ? error.message : 'Unknown error'}`, content.type, this.sessionId);
284
+ }
285
+ }
286
+ /**
287
+ * Emit a thought activity (persistent by default for visibility in Linear)
288
+ */
289
+ async emitThought(text, ephemeral = false) {
290
+ if (this.sessionId) {
291
+ await this.createActivity({ type: 'thought', body: text }, ephemeral);
292
+ }
293
+ else {
294
+ await this.emitActivity({
295
+ type: 'thought',
296
+ content: { text },
297
+ ephemeral,
298
+ });
299
+ }
300
+ }
301
+ /**
302
+ * Emit an action activity (tool call)
303
+ */
304
+ async emitAction(toolName, input, ephemeral = true) {
305
+ if (this.sessionId) {
306
+ await this.createActivity({
307
+ type: 'action',
308
+ action: toolName,
309
+ parameter: JSON.stringify(input),
310
+ }, ephemeral);
311
+ }
312
+ else {
313
+ await this.emitActivity({
314
+ type: 'action',
315
+ content: {
316
+ text: `Calling ${toolName}`,
317
+ metadata: { toolName, input },
318
+ },
319
+ ephemeral,
320
+ signals: { toolName, toolInput: input },
321
+ });
322
+ }
323
+ }
324
+ /**
325
+ * Emit a tool result activity
326
+ */
327
+ async emitToolResult(toolName, output, ephemeral = true) {
328
+ if (this.sessionId) {
329
+ await this.createActivity({
330
+ type: 'action',
331
+ action: toolName,
332
+ parameter: 'result',
333
+ result: typeof output === 'string' ? output : JSON.stringify(output, null, 2),
334
+ }, ephemeral);
335
+ }
336
+ else {
337
+ await this.emitActivity({
338
+ type: 'action',
339
+ content: {
340
+ text: `Result from ${toolName}`,
341
+ metadata: { toolName, output },
342
+ },
343
+ ephemeral,
344
+ signals: { toolName, toolOutput: output },
345
+ });
346
+ }
347
+ }
348
+ /**
349
+ * Emit a response activity (persisted)
350
+ */
351
+ async emitResponse(text) {
352
+ if (this.sessionId) {
353
+ await this.createActivity({ type: 'response', body: text }, false);
354
+ }
355
+ else {
356
+ await this.emitActivity({
357
+ type: 'response',
358
+ content: { text },
359
+ ephemeral: false,
360
+ });
361
+ }
362
+ }
363
+ /**
364
+ * Emit an error activity using native API
365
+ */
366
+ async emitError(error) {
367
+ if (this.sessionId) {
368
+ await this.createActivity({
369
+ type: 'error',
370
+ body: `**${error.name}**: ${error.message}${error.stack ? `\n\n\`\`\`\n${error.stack}\n\`\`\`` : ''}`,
371
+ }, false);
372
+ }
373
+ else {
374
+ await this.emitActivity({
375
+ type: 'response',
376
+ content: {
377
+ text: `Error: ${error.message}`,
378
+ metadata: {
379
+ errorName: error.name,
380
+ errorStack: error.stack,
381
+ },
382
+ },
383
+ ephemeral: false,
384
+ signals: {
385
+ error: {
386
+ message: error.message,
387
+ stack: error.stack,
388
+ },
389
+ },
390
+ });
391
+ }
392
+ }
393
+ /**
394
+ * Emit an elicitation activity - asking for clarification from the user
395
+ */
396
+ async emitElicitation(text, ephemeral = false) {
397
+ if (this.sessionId) {
398
+ return this.createActivity({ type: 'elicitation', body: text }, ephemeral);
399
+ }
400
+ else {
401
+ await this.emitActivity({
402
+ type: 'response',
403
+ content: { text: `Awaiting clarification: ${text}` },
404
+ ephemeral,
405
+ });
406
+ }
407
+ }
408
+ /**
409
+ * Emit a prompt activity - prompts/instructions for the user
410
+ */
411
+ async emitPrompt(text, ephemeral = false) {
412
+ if (this.sessionId) {
413
+ return this.createActivity({ type: 'prompt', body: text }, ephemeral);
414
+ }
415
+ else {
416
+ await this.emitActivity({
417
+ type: 'response',
418
+ content: { text },
419
+ ephemeral,
420
+ });
421
+ }
422
+ }
423
+ /**
424
+ * Emit an authentication required activity
425
+ * Shows an authentication prompt to the user with a link to authorize
426
+ *
427
+ * @param authUrl - The URL the user should visit to authenticate
428
+ * @param providerName - Optional name of the auth provider (e.g., "GitHub", "Google")
429
+ * @param body - Optional custom message body
430
+ * @returns Activity result with ID
431
+ *
432
+ * @see https://linear.app/developers/agent-signals
433
+ */
434
+ async emitAuthRequired(authUrl, providerName, body) {
435
+ if (!this.sessionId) {
436
+ throw new LinearActivityError('Cannot emit auth activity without a session ID. Call start() first or provide sessionId in config.', 'elicitation', undefined);
437
+ }
438
+ const messageBody = body
439
+ ?? `Authentication required${providerName ? ` with ${providerName}` : ''}. Please [click here](${authUrl}) to authorize.`;
440
+ return this.client.createAgentActivity({
441
+ agentSessionId: this.sessionId,
442
+ content: { type: 'elicitation', body: messageBody },
443
+ ephemeral: false,
444
+ signal: 'auth',
445
+ });
446
+ }
447
+ /**
448
+ * Emit a selection prompt activity
449
+ * Shows a multiple choice selection to the user
450
+ *
451
+ * @param prompt - The question or prompt for the user
452
+ * @param options - Array of option strings the user can select from
453
+ * @returns Activity result with ID
454
+ *
455
+ * @see https://linear.app/developers/agent-signals
456
+ */
457
+ async emitSelect(prompt, options) {
458
+ if (!this.sessionId) {
459
+ throw new LinearActivityError('Cannot emit select activity without a session ID. Call start() first or provide sessionId in config.', 'elicitation', undefined);
460
+ }
461
+ // Format options as numbered list in the body
462
+ const optionsList = options.map((opt, i) => `${i + 1}. ${opt}`).join('\n');
463
+ const body = `${prompt}\n\n${optionsList}`;
464
+ return this.client.createAgentActivity({
465
+ agentSessionId: this.sessionId,
466
+ content: { type: 'elicitation', body },
467
+ ephemeral: false,
468
+ signal: 'select',
469
+ });
470
+ }
471
+ /**
472
+ * Report an environment issue for self-improvement.
473
+ * Creates a bug in the Agent project backlog to track infrastructure improvements.
474
+ *
475
+ * This is a best-effort operation - failures are logged but don't propagate.
476
+ *
477
+ * @param title - Short description of the issue
478
+ * @param description - Detailed explanation of what happened
479
+ * @param context - Additional context about the issue
480
+ * @returns The created issue, or null if creation failed
481
+ */
482
+ async reportEnvironmentIssue(title, description, context) {
483
+ try {
484
+ const fullDescription = `## Environment Issue Report
485
+
486
+ ${description}
487
+
488
+ ### Context
489
+
490
+ | Field | Value |
491
+ |-------|-------|
492
+ | Source Issue | ${context?.sourceIssueId ?? this.issueId} |
493
+ | Session ID | ${this.sessionId ?? 'N/A'} |
494
+ | Issue Type | ${context?.issueType ?? 'unknown'} |
495
+ | Timestamp | ${new Date().toISOString()} |
496
+
497
+ ${context?.additionalContext ? `### Additional Details\n\n\`\`\`json\n${JSON.stringify(context.additionalContext, null, 2)}\n\`\`\`` : ''}
498
+
499
+ ${context?.errorStack ? `### Error Stack\n\n\`\`\`\n${context.errorStack}\n\`\`\`` : ''}
500
+
501
+ ---
502
+ *Auto-generated by agent self-improvement system*`;
503
+ const issue = await this.client.createIssue({
504
+ title: `Bug: [Agent Environment] ${title}`,
505
+ description: fullDescription,
506
+ teamId: getDefaultTeamId(),
507
+ projectId: LINEAR_PROJECTS.AGENT,
508
+ labelIds: [LINEAR_LABELS.BUG],
509
+ });
510
+ return {
511
+ id: issue.id,
512
+ identifier: issue.identifier,
513
+ url: issue.url,
514
+ };
515
+ }
516
+ catch (error) {
517
+ // Log but don't throw - this is a best-effort feature
518
+ console.error('[AgentSession] Failed to report environment issue:', error instanceof Error ? error.message : String(error));
519
+ return null;
520
+ }
521
+ }
522
+ /**
523
+ * Report a human-needed blocker issue.
524
+ * Creates an issue in Icebox with "Needs Human" label, linked as blocking the current issue.
525
+ *
526
+ * This is a best-effort operation - failures are logged but don't propagate.
527
+ *
528
+ * @param title - Short description of what the human needs to do
529
+ * @param description - Detailed steps or context for the human
530
+ * @param options - Optional overrides for project and assignee
531
+ * @returns The created blocker issue, or null if creation failed
532
+ */
533
+ async reportHumanBlocker(title, description, options) {
534
+ try {
535
+ // Resolve project from source issue if not provided
536
+ let projectId = options?.projectId;
537
+ if (!projectId) {
538
+ if (!this.issue) {
539
+ this.issue = await this.client.getIssue(this.issueId);
540
+ }
541
+ const project = await this.issue.project;
542
+ projectId = project?.id;
543
+ }
544
+ const fullDescription = `${description}\n\n---\n*Source issue: ${this.issueId}*\n*Session ID: ${this.sessionId ?? 'N/A'}*\n*Auto-generated by agent blocker system*`;
545
+ // Get Icebox state for the team
546
+ if (!this.issue) {
547
+ this.issue = await this.client.getIssue(this.issueId);
548
+ }
549
+ const team = await this.issue.team;
550
+ let stateId;
551
+ if (team) {
552
+ const statuses = await this.client.getTeamStatuses(team.id);
553
+ stateId = statuses['Icebox'];
554
+ }
555
+ const createInput = {
556
+ title,
557
+ description: fullDescription,
558
+ teamId: team?.id ?? getDefaultTeamId(),
559
+ labelIds: LINEAR_LABELS.NEEDS_HUMAN ? [LINEAR_LABELS.NEEDS_HUMAN] : [],
560
+ };
561
+ if (projectId) {
562
+ createInput.projectId = projectId;
563
+ }
564
+ if (stateId) {
565
+ createInput.stateId = stateId;
566
+ }
567
+ const blockerIssue = await this.client.createIssue(createInput);
568
+ // Assign if requested (createIssue doesn't support assigneeId directly)
569
+ if (options?.assigneeId) {
570
+ await this.client.updateIssue(blockerIssue.id, {
571
+ assigneeId: options.assigneeId,
572
+ });
573
+ }
574
+ // Create blocking relation: blocker blocks source
575
+ await this.client.createIssueRelation({
576
+ issueId: blockerIssue.id,
577
+ relatedIssueId: this.issueId,
578
+ type: 'blocks',
579
+ });
580
+ // Post comment on source issue
581
+ await this.client.createComment(this.issueId, `\u{1F6A7} Human blocker created: [${blockerIssue.identifier}](${blockerIssue.url}) — ${title}`);
582
+ return {
583
+ id: blockerIssue.id,
584
+ identifier: blockerIssue.identifier,
585
+ url: blockerIssue.url,
586
+ };
587
+ }
588
+ catch (error) {
589
+ console.error('[AgentSession] Failed to report human blocker:', error instanceof Error ? error.message : String(error));
590
+ return null;
591
+ }
592
+ }
593
+ // ============================================================================
594
+ // SUB-ISSUE SESSION METHODS (for coordination work type)
595
+ // ============================================================================
596
+ /**
597
+ * Create an agent session on a sub-issue for activity reporting
598
+ *
599
+ * The coordinator uses this to emit activities to individual sub-issue threads,
600
+ * making sub-agent progress visible on each sub-issue in Linear.
601
+ *
602
+ * @param subIssueId - The sub-issue ID (UUID) to create a session on
603
+ * @returns The session ID for the sub-issue, or null if creation failed
604
+ */
605
+ async createSubIssueSession(subIssueId) {
606
+ try {
607
+ const result = await this.client.createAgentSessionOnIssue({
608
+ issueId: subIssueId,
609
+ });
610
+ if (result.success && result.sessionId) {
611
+ return result.sessionId;
612
+ }
613
+ console.warn(`[AgentSession] Failed to create sub-issue session for ${subIssueId}:`, result);
614
+ return null;
615
+ }
616
+ catch (error) {
617
+ console.warn('[AgentSession] Error creating sub-issue session:', error instanceof Error ? error.message : String(error));
618
+ return null;
619
+ }
620
+ }
621
+ /**
622
+ * Emit an activity to a sub-issue's agent session
623
+ *
624
+ * Used by the coordinator to report progress on individual sub-issues.
625
+ * Falls back to creating a comment if the activity emission fails.
626
+ *
627
+ * @param subIssueSessionId - The agent session ID for the sub-issue
628
+ * @param content - The activity content to emit
629
+ * @param ephemeral - Whether the activity is ephemeral (default: false)
630
+ */
631
+ async emitSubIssueActivity(subIssueSessionId, content, ephemeral = false) {
632
+ try {
633
+ await this.client.createAgentActivity({
634
+ agentSessionId: subIssueSessionId,
635
+ content,
636
+ ephemeral,
637
+ });
638
+ }
639
+ catch (error) {
640
+ console.warn('[AgentSession] Failed to emit sub-issue activity:', error instanceof Error ? error.message : String(error));
641
+ }
642
+ }
643
+ // ============================================================================
644
+ // DESCRIPTION CHECKBOX METHODS
645
+ // ============================================================================
646
+ /**
647
+ * Get the current issue description
648
+ * Refreshes the issue data from Linear if needed
649
+ */
650
+ async getDescription() {
651
+ if (!this.issue) {
652
+ this.issue = await this.client.getIssue(this.issueId);
653
+ }
654
+ return this.issue?.description ?? undefined;
655
+ }
656
+ /**
657
+ * Parse checkboxes from the issue description
658
+ *
659
+ * @returns Array of checkbox items, or empty array if no description
660
+ */
661
+ async getDescriptionCheckboxes() {
662
+ const description = await this.getDescription();
663
+ if (!description)
664
+ return [];
665
+ return parseCheckboxes(description);
666
+ }
667
+ /**
668
+ * Update checkboxes in the issue description
669
+ *
670
+ * @param updates - Array of updates to apply
671
+ * @returns The updated issue, or null if no changes were made
672
+ */
673
+ async updateDescriptionCheckboxes(updates) {
674
+ const description = await this.getDescription();
675
+ if (!description)
676
+ return null;
677
+ const newDescription = updateCheckboxes(description, updates);
678
+ if (newDescription === description)
679
+ return null; // No changes
680
+ const updatedIssue = await this.client.updateIssue(this.issueId, {
681
+ description: newDescription,
682
+ });
683
+ // Update local cache
684
+ this.issue = updatedIssue;
685
+ return updatedIssue;
686
+ }
687
+ /**
688
+ * Mark a specific task as complete in the issue description
689
+ *
690
+ * @param textPattern - String or regex to match the task text
691
+ * @returns The updated issue, or null if task not found
692
+ */
693
+ async completeDescriptionTask(textPattern) {
694
+ return this.updateDescriptionCheckboxes([{ textPattern, checked: true }]);
695
+ }
696
+ /**
697
+ * Mark a specific task as incomplete in the issue description
698
+ *
699
+ * @param textPattern - String or regex to match the task text
700
+ * @returns The updated issue, or null if task not found
701
+ */
702
+ async uncompleteDescriptionTask(textPattern) {
703
+ return this.updateDescriptionCheckboxes([{ textPattern, checked: false }]);
704
+ }
705
+ // ============================================================================
706
+ // ISSUE RELATION CONVENIENCE METHODS
707
+ // ============================================================================
708
+ /**
709
+ * Link this issue as related to another issue
710
+ *
711
+ * @param relatedIssueId - The issue ID or identifier to link to
712
+ * @returns Result with relation ID, or null if relation already exists
713
+ */
714
+ async linkRelatedIssue(relatedIssueId) {
715
+ // Check if relation already exists
716
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
717
+ const alreadyLinked = existingRelations.relations.some((r) => r.type === 'related' &&
718
+ (r.relatedIssueId === relatedIssueId ||
719
+ r.relatedIssueIdentifier === relatedIssueId));
720
+ if (alreadyLinked) {
721
+ return null; // Already linked
722
+ }
723
+ return this.client.createIssueRelation({
724
+ issueId: this.issueId,
725
+ relatedIssueId,
726
+ type: 'related',
727
+ });
728
+ }
729
+ /**
730
+ * Mark this issue as blocked by another issue
731
+ *
732
+ * @param blockingIssueId - The issue ID or identifier that blocks this one
733
+ * @returns Result with relation ID, or null if relation already exists
734
+ */
735
+ async markAsBlockedBy(blockingIssueId) {
736
+ // Check if relation already exists
737
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
738
+ const alreadyBlocked = existingRelations.inverseRelations.some((r) => r.type === 'blocks' &&
739
+ (r.issueId === blockingIssueId || r.issueIdentifier === blockingIssueId));
740
+ if (alreadyBlocked) {
741
+ return null; // Already blocked by this issue
742
+ }
743
+ // The blocking issue blocks this issue
744
+ return this.client.createIssueRelation({
745
+ issueId: blockingIssueId,
746
+ relatedIssueId: this.issueId,
747
+ type: 'blocks',
748
+ });
749
+ }
750
+ /**
751
+ * Mark this issue as blocking another issue
752
+ *
753
+ * @param blockedIssueId - The issue ID or identifier that this issue blocks
754
+ * @returns Result with relation ID, or null if relation already exists
755
+ */
756
+ async markAsBlocking(blockedIssueId) {
757
+ // Check if relation already exists
758
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
759
+ const alreadyBlocking = existingRelations.relations.some((r) => r.type === 'blocks' &&
760
+ (r.relatedIssueId === blockedIssueId ||
761
+ r.relatedIssueIdentifier === blockedIssueId));
762
+ if (alreadyBlocking) {
763
+ return null; // Already blocking this issue
764
+ }
765
+ // This issue blocks the other issue
766
+ return this.client.createIssueRelation({
767
+ issueId: this.issueId,
768
+ relatedIssueId: blockedIssueId,
769
+ type: 'blocks',
770
+ });
771
+ }
772
+ /**
773
+ * Mark this issue as a duplicate of another issue
774
+ *
775
+ * @param originalIssueId - The original issue ID or identifier
776
+ * @returns Result with relation ID, or null if relation already exists
777
+ */
778
+ async markAsDuplicateOf(originalIssueId) {
779
+ // Check if relation already exists
780
+ const existingRelations = await this.client.getIssueRelations(this.issueId);
781
+ const alreadyDuplicate = existingRelations.relations.some((r) => r.type === 'duplicate' &&
782
+ (r.relatedIssueId === originalIssueId ||
783
+ r.relatedIssueIdentifier === originalIssueId));
784
+ if (alreadyDuplicate) {
785
+ return null; // Already marked as duplicate
786
+ }
787
+ // This issue is a duplicate of the original
788
+ return this.client.createIssueRelation({
789
+ issueId: this.issueId,
790
+ relatedIssueId: originalIssueId,
791
+ type: 'duplicate',
792
+ });
793
+ }
794
+ /**
795
+ * Get issues that are blocking this issue
796
+ *
797
+ * @returns Array of relation info for blocking issues
798
+ */
799
+ async getBlockers() {
800
+ const relations = await this.client.getIssueRelations(this.issueId);
801
+ // Blockers are inverse relations where another issue blocks this one
802
+ return relations.inverseRelations.filter((r) => r.type === 'blocks');
803
+ }
804
+ /**
805
+ * Check if this issue is blocked by any other issues
806
+ *
807
+ * @returns True if blocked, false otherwise
808
+ */
809
+ async isBlocked() {
810
+ const blockers = await this.getBlockers();
811
+ return blockers.length > 0;
812
+ }
813
+ /**
814
+ * Update the agent's plan (full replacement)
815
+ *
816
+ * Uses Linear's native agentSessionUpdate mutation to display the plan
817
+ * as checkboxes in the Linear UI. Also maintains internal plan state
818
+ * for checkbox sync and completion tracking.
819
+ */
820
+ async updatePlan(items) {
821
+ try {
822
+ // Store internal plan with IDs for backward compatibility and checkbox sync
823
+ this.currentPlan = {
824
+ items: items.map((item) => ({
825
+ ...item,
826
+ id: generatePlanItemId(),
827
+ children: item.children?.map((child) => ({
828
+ ...child,
829
+ id: generatePlanItemId(),
830
+ })),
831
+ })),
832
+ };
833
+ // Flatten the plan for Linear's native API (no nested children)
834
+ const linearPlan = this.flattenPlanItems(items);
835
+ // Only update via API if we have a session ID
836
+ if (this.sessionId) {
837
+ await this.client.updateAgentSession({
838
+ sessionId: this.sessionId,
839
+ plan: linearPlan,
840
+ });
841
+ }
842
+ }
843
+ catch (error) {
844
+ throw new LinearPlanError(`Failed to update plan: ${error instanceof Error ? error.message : 'Unknown error'}`, this.sessionId ?? undefined);
845
+ }
846
+ }
847
+ /**
848
+ * Flatten nested plan items into Linear's flat format
849
+ * Converts: { title, state, children } -> { content, status }
850
+ */
851
+ flattenPlanItems(items) {
852
+ const result = [];
853
+ for (const item of items) {
854
+ // Add parent item
855
+ result.push({
856
+ content: item.details ? `${item.title} - ${item.details}` : item.title,
857
+ status: item.state,
858
+ });
859
+ // Add children as indented items (Linear shows as sub-tasks)
860
+ if (item.children && item.children.length > 0) {
861
+ for (const child of item.children) {
862
+ result.push({
863
+ content: child.details ? ` ${child.title} - ${child.details}` : ` ${child.title}`,
864
+ status: child.state,
865
+ });
866
+ }
867
+ }
868
+ }
869
+ return result;
870
+ }
871
+ /**
872
+ * Update a single plan item's state
873
+ * Also updates the plan in Linear's native API and syncs description checkboxes
874
+ */
875
+ async updatePlanItemState(itemId, state) {
876
+ // Find the item to get its title for checkbox sync
877
+ let itemTitle;
878
+ const updateItemState = (items) => {
879
+ return items.map((item) => {
880
+ if (item.id === itemId) {
881
+ itemTitle = item.title;
882
+ return { ...item, state };
883
+ }
884
+ if (item.children) {
885
+ const updatedChildren = updateItemState(item.children);
886
+ // Check if we found the item in children
887
+ if (!itemTitle) {
888
+ const foundChild = item.children.find((c) => c.id === itemId);
889
+ if (foundChild) {
890
+ itemTitle = foundChild.title;
891
+ }
892
+ }
893
+ return { ...item, children: updatedChildren };
894
+ }
895
+ return item;
896
+ });
897
+ };
898
+ this.currentPlan.items = updateItemState(this.currentPlan.items);
899
+ // Push updated plan to Linear
900
+ if (this.sessionId) {
901
+ try {
902
+ const linearPlan = this.flattenPlanItems(this.currentPlan.items);
903
+ await this.client.updateAgentSession({
904
+ sessionId: this.sessionId,
905
+ plan: linearPlan,
906
+ });
907
+ }
908
+ catch (error) {
909
+ // Log but don't throw - individual state updates are non-critical
910
+ console.warn('[AgentSession] Failed to update plan state in Linear:', error instanceof Error ? error.message : String(error));
911
+ }
912
+ }
913
+ // Sync description checkboxes when plan item completes
914
+ if (state === 'completed' && itemTitle) {
915
+ try {
916
+ await this.completeDescriptionTask(itemTitle);
917
+ }
918
+ catch (error) {
919
+ // Log but don't throw - checkbox sync is non-critical
920
+ console.warn('[AgentSession] Failed to sync description checkbox:', error instanceof Error ? error.message : String(error));
921
+ }
922
+ }
923
+ }
924
+ /**
925
+ * Create a plan item helper
926
+ */
927
+ createPlanItem(title, state = 'pending', details) {
928
+ return { title, state, details };
929
+ }
930
+ formatActivityAsComment(options) {
931
+ const typeEmoji = {
932
+ thought: '\u{1F4AD}',
933
+ action: '\u{26A1}',
934
+ response: '\u{1F4AC}',
935
+ elicitation: '\u{2753}',
936
+ error: '\u{274C}',
937
+ prompt: '\u{1F4DD}',
938
+ };
939
+ const emoji = typeEmoji[options.type] ?? '\u{1F4AC}';
940
+ return `${emoji} **${options.type.charAt(0).toUpperCase() + options.type.slice(1)}**\n\n${options.content.text}`;
941
+ }
942
+ async postCompletionComment(summary) {
943
+ const planItems = this.currentPlan.items.map((item) => ({
944
+ state: item.state,
945
+ title: item.title,
946
+ }));
947
+ const comments = buildCompletionComments(summary, planItems, this.sessionId);
948
+ // Post comments sequentially to maintain order
949
+ for (const chunk of comments) {
950
+ try {
951
+ await this.client.createComment(this.issueId, chunk.body);
952
+ // Small delay between comments to ensure ordering
953
+ if (chunk.partNumber < chunk.totalParts) {
954
+ await new Promise(resolve => setTimeout(resolve, 100));
955
+ }
956
+ }
957
+ catch (error) {
958
+ // Log and continue with remaining comments
959
+ console.error(`[AgentSession] Failed to post completion comment part ${chunk.partNumber}/${chunk.totalParts}:`, error instanceof Error ? error.message : String(error));
960
+ }
961
+ }
962
+ }
963
+ }
964
+ /**
965
+ * Create a new agent session
966
+ */
967
+ export function createAgentSession(config) {
968
+ return new AgentSession(config);
969
+ }