@dahrk/linear 0.1.0 → 0.1.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 (62) hide show
  1. package/README.md +24 -12
  2. package/dist/comments.d.ts +36 -0
  3. package/dist/comments.d.ts.map +1 -0
  4. package/dist/comments.js +104 -0
  5. package/dist/comments.js.map +1 -0
  6. package/dist/documents.d.ts +1 -15
  7. package/dist/documents.d.ts.map +1 -1
  8. package/dist/documents.js +37 -27
  9. package/dist/documents.js.map +1 -1
  10. package/dist/format-action.d.ts +25 -0
  11. package/dist/format-action.d.ts.map +1 -0
  12. package/dist/format-action.js +250 -0
  13. package/dist/format-action.js.map +1 -0
  14. package/dist/index.d.ts +113 -11
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +182 -58
  17. package/dist/index.js.map +1 -1
  18. package/dist/issue-graph.d.ts +37 -0
  19. package/dist/issue-graph.d.ts.map +1 -0
  20. package/dist/issue-graph.js +125 -0
  21. package/dist/issue-graph.js.map +1 -0
  22. package/dist/issues.d.ts +3 -2
  23. package/dist/issues.d.ts.map +1 -1
  24. package/dist/issues.js +5 -10
  25. package/dist/issues.js.map +1 -1
  26. package/dist/labels.d.ts +47 -0
  27. package/dist/labels.d.ts.map +1 -1
  28. package/dist/labels.js +71 -23
  29. package/dist/labels.js.map +1 -1
  30. package/dist/linear-client.d.ts +51 -0
  31. package/dist/linear-client.d.ts.map +1 -1
  32. package/dist/linear-client.js +234 -34
  33. package/dist/linear-client.js.map +1 -1
  34. package/dist/oauth.d.ts +11 -10
  35. package/dist/oauth.d.ts.map +1 -1
  36. package/dist/oauth.js +35 -32
  37. package/dist/oauth.js.map +1 -1
  38. package/dist/recording-client.d.ts +20 -2
  39. package/dist/recording-client.d.ts.map +1 -1
  40. package/dist/recording-client.js +39 -1
  41. package/dist/recording-client.js.map +1 -1
  42. package/dist/responding-client.d.ts +49 -0
  43. package/dist/responding-client.d.ts.map +1 -0
  44. package/dist/responding-client.js +47 -0
  45. package/dist/responding-client.js.map +1 -0
  46. package/dist/teams.d.ts +20 -0
  47. package/dist/teams.d.ts.map +1 -0
  48. package/dist/teams.js +32 -0
  49. package/dist/teams.js.map +1 -0
  50. package/package.json +8 -10
  51. package/src/comments.ts +126 -0
  52. package/src/documents.ts +162 -0
  53. package/src/format-action.ts +279 -0
  54. package/src/index.ts +556 -0
  55. package/src/issue-graph.ts +169 -0
  56. package/src/issues.ts +93 -0
  57. package/src/labels.ts +254 -0
  58. package/src/linear-client.ts +449 -0
  59. package/src/oauth.ts +194 -0
  60. package/src/recording-client.ts +141 -0
  61. package/src/responding-client.ts +106 -0
  62. package/src/teams.ts +44 -0
package/src/index.ts ADDED
@@ -0,0 +1,556 @@
1
+ /**
2
+ * @dahrk/linear - the Linear-native control surface (build spec section 14).
3
+ *
4
+ * Two responsibilities:
5
+ * 1. Intake-side (used by the hub): verify the webhook signature, and normalise a
6
+ * raw Linear webhook into the internal LinearEvent. tenantId/workspaceId are
7
+ * resolved from the authenticated connection, never from the payload.
8
+ * 2. Session-side (used by hub + edge): drive the Agent Session API natively -
9
+ * post activities (thought/action/response), render the stage graph as the
10
+ * agent-plan checklist, raise elicitation gates, take prompted-drive turns,
11
+ * handle the stop signal, set externalUrls.
12
+ *
13
+ * Re-implemented directly against the Agent Session API; cyrus's posting code is a
14
+ * reference, not a dependency. Intended runtime dep: the Linear TypeScript SDK
15
+ * (added at M2/M5).
16
+ */
17
+ import type { LinearEvent } from "@dahrk/contracts";
18
+ import { LinearWebhookClient } from "@linear/sdk/webhooks";
19
+ import type {
20
+ LinearWebhookPayload,
21
+ AgentSessionEventWebhookPayload,
22
+ AppUserNotificationWebhookPayloadWithNotification,
23
+ EntityWebhookPayloadWithIssueData,
24
+ } from "@linear/sdk/webhooks";
25
+
26
+ /**
27
+ * Verify a Linear webhook signature and timestamp freshness, then return the parsed
28
+ * payload, or throw. Delegates to LinearWebhookClient.parseData which uses
29
+ * HMAC-SHA256 + timingSafeEqual. The webhookTimestamp field is extracted from the
30
+ * raw body so the SDK's built-in ±60 s replay window applies; a missing or
31
+ * non-numeric timestamp skips the freshness check. Throws "Invalid webhook
32
+ * signature" on bad HMAC and "Invalid webhook timestamp" on a stale delivery.
33
+ */
34
+ export function verifyWebhook(rawBody: Buffer, signature: string, secret: string): unknown {
35
+ // Extract webhookTimestamp so parseData can enforce the ±60 s replay window.
36
+ // If the body is not valid JSON or the field is absent, we omit the argument and
37
+ // parseData skips the check (same as a missing timestamp in the old hand-rolled path).
38
+ let ts: number | undefined;
39
+ try {
40
+ const raw = (JSON.parse(rawBody.toString("utf8")) as { webhookTimestamp?: unknown }).webhookTimestamp;
41
+ if (typeof raw === "number") ts = raw;
42
+ else if (typeof raw === "string") {
43
+ const n = Number(raw);
44
+ if (Number.isFinite(n)) ts = n;
45
+ }
46
+ } catch {
47
+ /* non-JSON body – parseData will re-parse and also throw */
48
+ }
49
+ return new LinearWebhookClient(secret).parseData(rawBody, signature, ts);
50
+ }
51
+
52
+ /**
53
+ * Strip a leading @mention token from a triggering comment body (DHK-322), returning the bare
54
+ * instruction (trimmed). Two spellings are handled: a plain-text `@name` prefix (the structured
55
+ * `agentSession.comment.body` form) and a leading Linear `<user id="...">name</user>` element (the
56
+ * `promptContext` primary-directive-thread form). A body that is only the mention yields "". Pure, so
57
+ * the instruction parsing is unit-testable and free of an LLM.
58
+ */
59
+ function stripLeadingMention(body: string): string {
60
+ let text = body.replace(/^\s+/, "");
61
+ const userMatch = /^<user\b[^>]*>[\s\S]*?<\/user>/.exec(text);
62
+ if (userMatch) text = text.slice(userMatch[0].length);
63
+ else text = text.replace(/^@\S+/, "");
64
+ return text.trim();
65
+ }
66
+
67
+ /**
68
+ * Parse the triggering comment out of a `promptContext` primary-directive-thread (DHK-322): the
69
+ * `comment-id` attribute plus the last `<comment>` body (the triggering message), with its leading
70
+ * mention stripped. The fallback path when the webhook carries no structured `agentSession.comment`.
71
+ * Pure.
72
+ */
73
+ function directiveFromPromptContext(promptContext: string): { commentId?: string; instruction?: string } {
74
+ const block = /<primary-directive-thread\b([^>]*)>([\s\S]*?)<\/primary-directive-thread>/.exec(promptContext);
75
+ if (!block) return {};
76
+ const commentId = /\bcomment-id="([^"]*)"/.exec(block[1] ?? "")?.[1]?.trim();
77
+ const comments = [...(block[2] ?? "").matchAll(/<comment\b[^>]*>([\s\S]*?)<\/comment>/g)];
78
+ const lastBody = comments.at(-1)?.[1];
79
+ const instruction = lastBody !== undefined ? stripLeadingMention(lastBody) : undefined;
80
+ return {
81
+ ...(commentId ? { commentId } : {}),
82
+ ...(instruction ? { instruction } : {}),
83
+ };
84
+ }
85
+
86
+ /** Fixed 👍/👎 -> sentiment lookup. A reaction's sentiment comes from this table, never an LLM, so the
87
+ * determinism boundary holds. Returns undefined for any emoji we do not treat as feedback. */
88
+ function reactionSentiment(emoji: string | undefined): "positive" | "negative" | undefined {
89
+ if (!emoji) return undefined;
90
+ if (emoji === "👍" || emoji === "+1" || emoji === "thumbsup") return "positive";
91
+ if (emoji === "👎" || emoji === "-1" || emoji === "thumbsdown") return "negative";
92
+ return undefined;
93
+ }
94
+
95
+ /**
96
+ * Normalise a verified webhook into the internal LinearEvent. `workspaceId` is read
97
+ * from the payload but only trusted once `authenticate` checks it against the
98
+ * connection's registered set. `tenantId` is supplied by the caller from the owning
99
+ * Connection (the single source of truth for a workspace's tenant), never inferred here.
100
+ */
101
+ export function normalise(payload: LinearWebhookPayload, connectionId: string, tenantId: string): LinearEvent {
102
+ const ts = new Date(payload.webhookTimestamp).toISOString();
103
+ const base = {
104
+ tenantId,
105
+ connectionId,
106
+ workspaceId: payload.organizationId,
107
+ linearPayload: payload,
108
+ ts,
109
+ } as const;
110
+
111
+ if (payload.type === "AgentSessionEvent" || payload.type === "AgentSession") {
112
+ // Narrow to the AgentSessionEvent shape; "AgentSession" is a legacy alias Linear still emits.
113
+ const p = payload as AgentSessionEventWebhookPayload;
114
+ // Linear's webhook body nests the issue under `agentSession.issue.id`; the SDK type models it
115
+ // flattened as `agentSession.issueId`. Tolerate both so the issue id survives for routing.
116
+ const agentSession = p.agentSession as {
117
+ id: string;
118
+ issueId?: string | null;
119
+ issue?: { id?: string; delegateId?: string | null; delegate?: { id?: string } | null } | null;
120
+ appUser?: { id?: string } | null;
121
+ comment?: { id?: string; body?: string } | null;
122
+ };
123
+ const agentSessionId = agentSession.id;
124
+ const issueId = agentSession.issueId ?? agentSession.issue?.id ?? undefined;
125
+ if (p.action === "created") {
126
+ // DHK-322: classify the trigger modality deterministically from the payload alone. An @mention
127
+ // leaves the issue delegated to someone other than the app; a delegate/assign delegates it to the
128
+ // app itself (mirrors `shouldSetDelegate`). When the app user id cannot be resolved, default to
129
+ // `delegate` - the safe default is today's labelled pipeline, never mis-routing a real assignment
130
+ // to converse. No LLM: this is a pure id comparison.
131
+ const appUserId = agentSession.appUser?.id ?? undefined;
132
+ const issueDelegateId = agentSession.issue?.delegateId ?? agentSession.issue?.delegate?.id ?? undefined;
133
+ const trigger: "delegate" | "mention" =
134
+ appUserId !== undefined && issueDelegateId !== appUserId ? "mention" : "delegate";
135
+ // The instruction rides the triggering comment: prefer the structured `agentSession.comment`,
136
+ // else parse it out of the promptContext primary-directive-thread. The leading @mention is
137
+ // stripped so a bare mention yields no instruction.
138
+ const structured = agentSession.comment;
139
+ const promptContext = (payload as { promptContext?: unknown }).promptContext;
140
+ const directive = typeof promptContext === "string" ? directiveFromPromptContext(promptContext) : {};
141
+ const structuredInstruction =
142
+ structured?.body !== undefined ? stripLeadingMention(structured.body) : undefined;
143
+ const instruction =
144
+ (structuredInstruction && structuredInstruction.length > 0 ? structuredInstruction : undefined) ??
145
+ directive.instruction;
146
+ const commentId = structured?.id ?? directive.commentId;
147
+ // Carry the issue id so the hub can map issue -> run (for later Inbox-notification routing).
148
+ return {
149
+ ...base,
150
+ type: "agent-session-created",
151
+ trigger,
152
+ subject: { agentSessionId, ...(issueId ? { issueId } : {}), ...(commentId ? { commentId } : {}) },
153
+ ...(instruction ? { instruction } : {}),
154
+ };
155
+ }
156
+ if (p.action === "prompted") {
157
+ return { ...base, type: "agent-session-prompted", subject: { agentSessionId } };
158
+ }
159
+ if (p.action === "stopped" || p.action === "stop") {
160
+ return { ...base, type: "signal-stop", subject: { agentSessionId } };
161
+ }
162
+ // The session was archived or deleted (the human tore the agent off the issue). Linear does not
163
+ // guarantee an Inbox notification for this, but when it sends the raw AgentSession action we can
164
+ // stop the run it drives directly. Carry the issue id when present so the run can also be found by
165
+ // issue if the session map is stale.
166
+ if (p.action === "archived" || p.action === "deleted") {
167
+ return {
168
+ ...base,
169
+ type: "session-archived",
170
+ subject: { agentSessionId, ...(issueId ? { issueId } : {}) },
171
+ };
172
+ }
173
+ }
174
+
175
+ if (payload.type === "AppUserNotification") {
176
+ const p = payload as AppUserNotificationWebhookPayloadWithNotification;
177
+ // The notification-kind discriminator is carried at the top-level `action` on the real webhook
178
+ // body (the SDK only models it as `notification.type`, which the live payload omits). Read from
179
+ // `action`, and tolerate the alternate field spellings the live body uses for issue id, reaction
180
+ // emoji, and status type.
181
+ const action = p.action;
182
+ const n = p.notification as {
183
+ type?: string;
184
+ issueId?: string;
185
+ issue?: { id?: string; state?: { type?: string } } | null;
186
+ issueStatusType?: string;
187
+ reactionEmoji?: string;
188
+ reaction?: { emoji?: string } | null;
189
+ emoji?: string;
190
+ };
191
+ const issueId = n.issueId ?? n.issue?.id ?? "";
192
+
193
+ // Inbox notification: the agent was unassigned from an issue -> disengage (stop its run).
194
+ if (action === "issueUnassignedFromYou") {
195
+ return { ...base, type: "issue-unassigned", subject: { issueId } };
196
+ }
197
+ // Inbox notification: the issue's status changed. Only a move to a *canceled* workflow-state type is
198
+ // a disengagement signal (stop its run); any other status change falls through to ignored.
199
+ if (action === "issueStatusChanged") {
200
+ const stateType = n.issue?.state?.type ?? n.issueStatusType;
201
+ if (stateType === "canceled") {
202
+ return { ...base, type: "issue-canceled", subject: { issueId } };
203
+ }
204
+ }
205
+ // Inbox notification: a 👍/👎 reaction on a Dahrk response -> a feedback signal. A non-thumb emoji
206
+ // falls through to ignored (never mis-dispatched).
207
+ if (action === "issueEmojiReaction" || action === "issueCommentReaction") {
208
+ const reaction = reactionSentiment(n.reactionEmoji ?? n.reaction?.emoji ?? n.emoji);
209
+ if (reaction) {
210
+ return { ...base, type: "issue-reaction", subject: { issueId }, reaction };
211
+ }
212
+ }
213
+ }
214
+
215
+ // The OAuth app install was revoked: the connection is dead, deactivate it. No subject needed.
216
+ if (payload.type === "OAuthApp" && payload.action === "revoked") {
217
+ return { ...base, type: "oauth-revoked", subject: {} };
218
+ }
219
+
220
+ // Permission Changes webhook: the Connection's set of accessible teams changed. Like `oauth-revoked`
221
+ // it is about the Connection, not an issue/session, so the subject is empty; the added/removed team
222
+ // deltas ride on `teamAccess` for the hub to merge into the Connection's tracked set. The SDK's
223
+ // webhook union does not model `PermissionChange`, so cast the payload (same pattern as the
224
+ // AppUserNotification branch above). Defensive: default missing deltas to [] and the flag to false.
225
+ if (payload.type === "PermissionChange" && payload.action === "teamAccessChanged") {
226
+ const p = payload as unknown as {
227
+ addedTeamIds?: string[];
228
+ removedTeamIds?: string[];
229
+ canAccessAllPublicTeams?: boolean;
230
+ };
231
+ return {
232
+ ...base,
233
+ type: "team-access-changed",
234
+ subject: {},
235
+ teamAccess: {
236
+ addedTeamIds: p.addedTeamIds ?? [],
237
+ removedTeamIds: p.removedTeamIds ?? [],
238
+ canAccessAllPublicTeams: p.canAccessAllPublicTeams ?? false,
239
+ },
240
+ };
241
+ }
242
+
243
+ // Issue SLA webhook (DHK-63): a proactive trigger. Only `breached` is actionable (a pre-brief
244
+ // enrichment worth running); `set`/`highRisk` are lifecycle noise and fall through to the throw so
245
+ // they never spawn a session. The SDK's webhook union does not model `IssueSLA`, so cast to read the
246
+ // serialised issue (`issueData`), tolerating the field spellings the live body may use for the id.
247
+ if (payload.type === "IssueSLA" || payload.type === "Issue SLA") {
248
+ const p = payload as unknown as {
249
+ action?: string;
250
+ issueData?: { id?: string } | null;
251
+ issueId?: string;
252
+ issue?: { id?: string } | null;
253
+ data?: { id?: string } | null;
254
+ };
255
+ if (p.action === "breached") {
256
+ const issueId = p.issueData?.id ?? p.issueId ?? p.issue?.id ?? p.data?.id ?? "";
257
+ return { ...base, type: "sla-breach", subject: { issueId } };
258
+ }
259
+ }
260
+
261
+ if (payload.type === "Issue") {
262
+ const p = payload as EntityWebhookPayloadWithIssueData & {
263
+ issue?: { id?: string } | null;
264
+ data?: { state?: { type?: string } | null } | null;
265
+ updatedFrom?: Record<string, unknown> | null;
266
+ };
267
+ // Live "Issue" entity webhooks carry the issue under `data`; tolerate a top-level `issue` too.
268
+ const issueId = p.data?.id ?? p.issue?.id ?? "";
269
+ // An archive is a soft-delete (`archivedAt` set, or an explicit `archived`/`remove` action): treat
270
+ // it as disengagement so any run parked on the issue is stopped. Any other Issue action is a
271
+ // create/update we currently only route as `issue-created`.
272
+ if (p.action === "archived" || p.action === "remove" || p.data?.archivedAt) {
273
+ return { ...base, type: "issue-archived", subject: { issueId } };
274
+ }
275
+ // Triage entry (DHK-63): the issue just MOVED into a triage-typed state - a proactive trigger for
276
+ // pre-brief enrichment. Conservative on purpose: emit only on positive evidence of an entry (the
277
+ // current state is `triage` AND `updatedFrom` shows the state changed, i.e. carries `stateId`), so
278
+ // an ordinary edit on a ticket already in triage still normalises to `issue-created`.
279
+ if (p.data?.state?.type === "triage" && p.updatedFrom && "stateId" in p.updatedFrom) {
280
+ return { ...base, type: "triage-entry", subject: { issueId } };
281
+ }
282
+ return { ...base, type: "issue-created", subject: { issueId } };
283
+ }
284
+
285
+ throw new Error(`unsupported Linear webhook: type=${payload.type} action=${payload.action}`);
286
+ }
287
+
288
+ /** A Linear agent-session state, mirrored from the engine's run-state (build spec section 14). */
289
+ export type SessionState = "active" | "awaitingInput" | "complete" | "error";
290
+
291
+ /** An activity kind posted to the session. `error` rides the same surface as a thought. */
292
+ export type ActivityType = "thought" | "action" | "response" | "error";
293
+
294
+ /** The result of `startIssue`, so a caller can SEE a no-op/failed state move rather than have it
295
+ * silently swallowed (a silent no-op is why the ticket sat in Backlog for a whole run). `moved`
296
+ * is true only when the issue was actually transitioned into a working state; otherwise `reason`
297
+ * says why not (a benign skip like `already-active` vs a real failure like `no-started-state`). */
298
+ export type StartIssueOutcome =
299
+ | { moved: true; stateName: string }
300
+ | { moved: false; reason: StartIssueSkip };
301
+
302
+ /** Why `startIssue` did not move the issue. The first two are benign (nothing to do); the rest are
303
+ * failures a caller should surface. */
304
+ export type StartIssueSkip =
305
+ | "no-issue"
306
+ | "already-active"
307
+ | "no-team"
308
+ | "no-started-state"
309
+ | "update-failed";
310
+
311
+ /** The result of `moveIssueToReview` (DHK-283), so a caller can SEE a no-op instead of the ticket
312
+ * silently sitting In Progress. `moved` is true only when the issue was actually transitioned into a
313
+ * review state; otherwise `reason` says why not - benign (`already-there`, e.g. a closed issue or one
314
+ * already in the review state) vs a real gap (`no-review-state`: this team has no state named
315
+ * review/in review/qa and `SKAKEL_REVIEW_STATE_NAME` is unset/unmatched). */
316
+ export type MoveToReviewOutcome =
317
+ | { moved: true; stateName: string }
318
+ | { moved: false; reason: MoveToReviewSkip };
319
+
320
+ /** Why `moveIssueToReview` did not move the issue. `no-issue`/`already-there` are benign; the rest are
321
+ * gaps a caller should surface as a visible thought. */
322
+ export type MoveToReviewSkip =
323
+ | "no-issue"
324
+ | "already-there"
325
+ | "no-team"
326
+ | "no-review-state"
327
+ | "update-failed";
328
+
329
+ export interface Activity {
330
+ type: ActivityType;
331
+ text: string;
332
+ /** For `action` activities, the tool name. */
333
+ tool?: string;
334
+ /** For `action` activities, the tool's outcome folded under the call (markdown, DHK-385). Set once
335
+ * the call completes so the step reads as one self-contained activity: verb + parameter on top,
336
+ * result underneath. Ignored for non-action types. */
337
+ result?: string;
338
+ /** thought/action only: a transient state, replaced in the UI by the next activity. */
339
+ ephemeral?: boolean;
340
+ }
341
+
342
+ export interface PlanItem {
343
+ stageId: string;
344
+ status: "pending" | "inProgress" | "completed" | "canceled";
345
+ /** Human-readable label shown in Linear's checklist; falls back to `stageId` when absent. */
346
+ title?: string;
347
+ }
348
+
349
+ /** An elicitation choice: a bare value, or a labelled value (the label is what the user sees). */
350
+ export type ElicitOption = string | { label?: string; value: string };
351
+
352
+ /** A candidate repository the agent already has access to, handed to Linear's
353
+ * `issueRepositorySuggestions` so it can rank them. `hostname` is the git host
354
+ * (e.g. "github.com"); `repositoryFullName` is "owner/name". */
355
+ export interface RepoCandidate {
356
+ hostname?: string;
357
+ repositoryFullName: string;
358
+ }
359
+
360
+ /** One ranked suggestion Linear returns for an issue: a candidate plus a confidence score. The order
361
+ * (and the score) is advisory only - it orders a human pick-list and NEVER decides routing. */
362
+ export interface RepoSuggestion {
363
+ repositoryFullName: string;
364
+ hostname?: string;
365
+ confidence: number;
366
+ }
367
+
368
+ /** Account-linking parameters for the `auth` signal (the credential-plane seam). */
369
+ export interface AuthRequest {
370
+ url: string;
371
+ userId?: string;
372
+ providerName?: string;
373
+ }
374
+
375
+ /** A Linear workflow-state category. Mirrors the `type` field on a `WorkflowState`. */
376
+ export type IssueStateType =
377
+ | "triage"
378
+ | "backlog"
379
+ | "unstarted"
380
+ | "started"
381
+ | "completed"
382
+ | "canceled";
383
+
384
+ /** The disengagement ground truth for one Linear issue, read live off the API (DHK-*: stuck
385
+ * awaitingInput backstop). Only the fields the disengagement predicate needs are carried; an issue
386
+ * ABSENT from a `readIssueEngagement` result (hard-deleted) is represented by the map having no
387
+ * entry, not by a value here, so `present` is always true when an `IssueEngagement` exists. */
388
+ export interface IssueEngagement {
389
+ /** Always true for a returned issue; the "not present" (deleted) case is a missing map entry. */
390
+ present: boolean;
391
+ /** The issue has been archived (soft-deleted). Read from `archivedAt`; archived issues are still
392
+ * returned because the read requests `includeArchived`. */
393
+ archived: boolean;
394
+ /** The issue's current workflow-state category (`state.type`), when known. */
395
+ stateType?: IssueStateType;
396
+ /** The current assignee's user id, when set. */
397
+ assigneeId?: string;
398
+ /** The current delegated agent-user id (`delegateId`), when set. For an agent, engagement is
399
+ * delegation, not assignment, so this is the primary identity to test against the app user. */
400
+ delegateId?: string;
401
+ }
402
+
403
+ /** A pull request to attach to the session's issue (idempotent by `url`). */
404
+ export interface PrAttachment {
405
+ url: string;
406
+ title: string;
407
+ subtitle?: string;
408
+ iconUrl?: string;
409
+ }
410
+
411
+ /** A Linear Document to create and link to the session's issue (the `attach-document` action). */
412
+ export interface DocumentInput {
413
+ title: string;
414
+ /** The document body as markdown. */
415
+ content: string;
416
+ }
417
+
418
+ /** The created Linear Document, returned so the caller can surface its link. */
419
+ export interface DocumentRef {
420
+ id: string;
421
+ url: string;
422
+ }
423
+
424
+ /** A labelled external link on a session (the run dashboard, the eventual PR, a document). The label
425
+ * is what Linear shows on the "Open" affordance; the URL must be unique within the session's array. */
426
+ export interface ExternalUrl {
427
+ url: string;
428
+ label: string;
429
+ }
430
+
431
+ /** Keep only the externalUrls Linear accepts: http(s) URLs (internal `skakel://` refs are dropped),
432
+ * with empty/blank URLs removed and duplicates collapsed by `url` (first wins, since Linear requires
433
+ * each URL to be unique within the array). Pure, so it is unit-tested directly. */
434
+ export function filterExternalUrls(urls: ExternalUrl[]): ExternalUrl[] {
435
+ const seen = new Set<string>();
436
+ const kept: ExternalUrl[] = [];
437
+ for (const u of urls) {
438
+ const url = u.url?.trim();
439
+ if (!url || !/^https?:\/\//.test(url) || seen.has(url)) continue;
440
+ seen.add(url);
441
+ kept.push({ url, label: u.label });
442
+ }
443
+ return kept;
444
+ }
445
+
446
+ /**
447
+ * The session-side control surface: drive one Linear agent session. The hub holds the
448
+ * Connection's token and calls this; the edge never does. Two implementations: a
449
+ * credential-free recording client (tests + the offline harness) and the real
450
+ * `@linear/sdk`-backed client (live). See ./recording-client.ts and ./linear-client.ts.
451
+ */
452
+ export interface AgentSessionClient {
453
+ /** Post a progress activity (thought / action / response / error). */
454
+ postActivity(sessionId: string, activity: Activity): Promise<void>;
455
+ /** Render or update the agent-plan checklist (the workflow stage graph). */
456
+ setPlan(sessionId: string, items: PlanItem[]): Promise<void>;
457
+ /** Raise an elicitation gate (optionally with `select` choices; labels render in the UI). */
458
+ raiseElicitation(sessionId: string, prompt: string, options?: ElicitOption[]): Promise<void>;
459
+ /** Rank candidate repositories for an issue via Linear's `issueRepositorySuggestions`. The returned
460
+ * order is advisory: it only orders the repo pick-list, and never decides routing (the determinism
461
+ * boundary). Returns `[]` when Linear has no opinion, so the caller falls back to registry order. */
462
+ suggestRepositories(issueId: string, candidates: RepoCandidate[]): Promise<RepoSuggestion[]>;
463
+ /** Idempotently stamp an issue with a label by NAME (get-or-create the label, then attach it). Used
464
+ * to record the chosen `repo:<name>` so future events for the issue route deterministically. */
465
+ addIssueLabel(issueId: string, name: string): Promise<void>;
466
+ /** Raise an account-linking elicitation carrying the `auth` signal (credential-plane seam). */
467
+ requestAuth(sessionId: string, prompt: string, auth: AuthRequest): Promise<void>;
468
+ /** Move the session's issue into its team's working (`started`, non-review) state and self-delegate
469
+ * (best practice on delegation). Returns an outcome so a caller can surface a no-op/failed move
470
+ * instead of it vanishing (see `StartIssueOutcome`). */
471
+ startIssue(sessionId: string): Promise<StartIssueOutcome>;
472
+ /** Move the session's issue to a review-named state on run completion (configurable via
473
+ * `SKAKEL_REVIEW_STATE_NAME`). Leaves it unchanged when no such state exists - never
474
+ * auto-completes, so a human keeps iterating and can re-summon @Dahrk on the same PR. Returns an
475
+ * outcome so a caller can surface a no-op (no matching state) rather than have the ticket silently
476
+ * stay In Progress (see `MoveToReviewOutcome`). */
477
+ moveIssueToReview(sessionId: string): Promise<MoveToReviewOutcome>;
478
+ /** Post a normal top-level comment on the session's issue (not an agent activity), so a run
479
+ * summary lands in the issue's comment thread rather than only in the agent session panel. */
480
+ commentOnIssue(sessionId: string, body: string): Promise<void>;
481
+ /** Attach a pull request to the session's issue (idempotent by URL) in addition to externalUrls. */
482
+ attachPr(sessionId: string, pr: PrAttachment): Promise<void>;
483
+ /** Create a Linear Document linked to the session's issue (the `attach-document` action's output),
484
+ * returning its id and URL. The deterministic engine calls this; the LLM never does. */
485
+ createDocument(sessionId: string, doc: DocumentInput): Promise<DocumentRef>;
486
+ /** Set external links on the session (run logs/dashboard, the eventual PR), each with a label. The
487
+ * full array is replaced wholesale, so the caller passes the complete desired set; non-http(s)
488
+ * URLs are filtered out before the call. */
489
+ setExternalUrls(sessionId: string, urls: ExternalUrl[]): Promise<void>;
490
+ /** Mirror the run-state to the Linear session state. */
491
+ setState(sessionId: string, state: SessionState): Promise<void>;
492
+ /** Read disengagement ground truth for a batch of issues in one query (rate-limit friendly; the
493
+ * read includes archived issues). The result maps issue id -> `IssueEngagement`; an issue id that
494
+ * is absent from the result was not returned by Linear (hard-deleted). Used by the disengagement
495
+ * sweep and webhook path to decide whether @Dahrk is still engaged on an issue. */
496
+ readIssueEngagement(issueIds: string[]): Promise<Map<string, IssueEngagement>>;
497
+ /** The app user's own id (`viewer.id`) - the id @Dahrk self-delegates to on `startIssue`. Cached.
498
+ * This is the identity a run's issue must still delegate/assign to for the agent to be "engaged". */
499
+ appUserId(): Promise<string>;
500
+ /** Proactively create an agent session ON an issue (DHK-63): the hub was neither delegated nor
501
+ * @mentioned but has useful work to do (a triage/SLA pre-brief). Returns the new session id so the
502
+ * caller can bind a run to it. Wraps Linear's `agentSessionCreateOnIssue`. */
503
+ createSessionOnIssue(issueId: string): Promise<string>;
504
+ /** Proactively create an agent session ON a comment (DHK-63): the comment analogue of
505
+ * `createSessionOnIssue`. Returns the new session id. Wraps `agentSessionCreateOnComment`. */
506
+ createSessionOnComment(commentId: string): Promise<string>;
507
+ }
508
+
509
+ export type { LinearWebhookPayload } from "@linear/sdk/webhooks";
510
+ export { createRecordingClient } from "./recording-client.js";
511
+ export type { RecordingClient, RecordedCall, RecordingClientOptions } from "./recording-client.js";
512
+ export { createRespondingLinearClient } from "./responding-client.js";
513
+ export type {
514
+ RespondingClient,
515
+ RespondingClientOptions,
516
+ SessionProjection,
517
+ GateDecision,
518
+ } from "./responding-client.js";
519
+ export { createLinearClient } from "./linear-client.js";
520
+ export { formatToolAction, formatToolResult, type ToolAction } from "./format-action.js";
521
+ export { authorizeUrl, exchangeCode, refreshTokens, mintAppToken, fetchOrganizationId, probeToken, DEFAULT_AGENT_SCOPES } from "./oauth.js";
522
+ export type { LinearTokens, LinearProbe } from "./oauth.js";
523
+ export {
524
+ provisionLabels,
525
+ provisionWorkflowLabels,
526
+ provisionRepoLabels,
527
+ linearLabelApi,
528
+ projectLabelApi,
529
+ fetchIssueProjectRepoLabels,
530
+ fetchIssueChildCount,
531
+ fetchOpenBlockers,
532
+ isBlockerSettled,
533
+ REPO_LABEL_GROUP,
534
+ } from "./labels.js";
535
+ export type { LabelApi, ProvisionOptions, OpenBlocker } from "./labels.js";
536
+ export { linearTeamsApi, listWorkspaceTeams } from "./teams.js";
537
+ export type { Team, TeamsApi } from "./teams.js";
538
+ export { linearTriageApi, linearClientAuth } from "./issues.js";
539
+ export type { TriageApi } from "./issues.js";
540
+ export {
541
+ fetchAttachedDocuments,
542
+ collectAttachedDocuments,
543
+ linearDocumentSource,
544
+ documentSlugFromUrl,
545
+ documentSlug,
546
+ } from "./documents.js";
547
+ export type { DocumentSource, RawDocument } from "./documents.js";
548
+ export { fetchIssueComments, collectIssueComments, linearCommentSource } from "./comments.js";
549
+ export type { CommentSource, RawComment } from "./comments.js";
550
+ export {
551
+ fetchRelatedIssues,
552
+ collectRelatedIssues,
553
+ linearIssueGraphSource,
554
+ MAX_RELATED_ISSUES,
555
+ } from "./issue-graph.js";
556
+ export type { IssueGraphSource, RawEdge, RawRelatedIssue } from "./issue-graph.js";