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