@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.
- package/README.md +24 -12
- package/dist/batch-source.d.ts +63 -0
- package/dist/batch-source.d.ts.map +1 -0
- package/dist/batch-source.js +149 -0
- package/dist/batch-source.js.map +1 -0
- package/dist/comments.d.ts +36 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +104 -0
- package/dist/comments.js.map +1 -0
- package/dist/documents.d.ts +1 -15
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +37 -27
- package/dist/documents.js.map +1 -1
- package/dist/format-action.d.ts +25 -0
- package/dist/format-action.d.ts.map +1 -0
- package/dist/format-action.js +250 -0
- package/dist/format-action.js.map +1 -0
- package/dist/index.d.ts +119 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +231 -59
- package/dist/index.js.map +1 -1
- package/dist/issue-graph.d.ts +37 -0
- package/dist/issue-graph.d.ts.map +1 -0
- package/dist/issue-graph.js +125 -0
- package/dist/issue-graph.js.map +1 -0
- package/dist/issues.d.ts +27 -2
- package/dist/issues.d.ts.map +1 -1
- package/dist/issues.js +33 -10
- package/dist/issues.js.map +1 -1
- package/dist/labels.d.ts +48 -1
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +72 -24
- package/dist/labels.js.map +1 -1
- package/dist/linear-client.d.ts +51 -0
- package/dist/linear-client.d.ts.map +1 -1
- package/dist/linear-client.js +233 -34
- package/dist/linear-client.js.map +1 -1
- package/dist/oauth.d.ts +52 -12
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +91 -34
- package/dist/oauth.js.map +1 -1
- package/dist/recording-client.d.ts +20 -2
- package/dist/recording-client.d.ts.map +1 -1
- package/dist/recording-client.js +39 -1
- package/dist/recording-client.js.map +1 -1
- package/dist/responding-client.d.ts +49 -0
- package/dist/responding-client.d.ts.map +1 -0
- package/dist/responding-client.js +47 -0
- package/dist/responding-client.js.map +1 -0
- package/dist/teams.d.ts +20 -0
- package/dist/teams.d.ts.map +1 -0
- package/dist/teams.js +32 -0
- package/dist/teams.js.map +1 -0
- package/package.json +8 -10
- package/src/batch-source.ts +208 -0
- package/src/comments.ts +126 -0
- package/src/documents.ts +162 -0
- package/src/format-action.ts +279 -0
- package/src/index.ts +617 -0
- package/src/issue-graph.ts +169 -0
- package/src/issues.ts +142 -0
- package/src/labels.ts +254 -0
- package/src/linear-client.ts +448 -0
- package/src/oauth.ts +255 -0
- package/src/recording-client.ts +141 -0
- package/src/responding-client.ts +106 -0
- 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";
|