@aivi/tracker-linear 0.0.0-stage → 0.4.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.
package/dist/client.js ADDED
@@ -0,0 +1,362 @@
1
+ import { errorMessage, getLogger } from '@aivi/core';
2
+ import { MODULE_ID } from './config.js';
3
+ export class LinearApiError extends Error {
4
+ status;
5
+ /** GraphQL error codes (`extensions.code`), kept apart because the HTTP
6
+ * status says nothing about them — Linear answers entity-not-found with a
7
+ * 200. Empty for transport and token errors. */
8
+ codes;
9
+ constructor(message, status, codes = []) {
10
+ super(message);
11
+ this.name = 'LinearApiError';
12
+ this.status = status;
13
+ this.codes = codes;
14
+ }
15
+ /** Linear's word for "that entity is gone", whatever shape it comes in: a
16
+ * deleted issue takes its agent session with it, and every later call
17
+ * names it. The message is the stable part (`Entity not found: …`); the
18
+ * codes only say *how* Linear refused (`INPUT_ERROR` for a lookup of a
19
+ * dead session, live 2026-10-06), never *what*, so no code may stand in
20
+ * for the words. */
21
+ static isGone(error) {
22
+ return error instanceof LinearApiError && /entity not found/i.test(error.message);
23
+ }
24
+ }
25
+ const DEFAULT_SCOPE = 'read,write,app:assignable,app:mentionable';
26
+ const ISSUE_FIELDS = `
27
+ id identifier title description branchName url
28
+ state { id name type }
29
+ team { id key }
30
+ labels { nodes { id name } }
31
+ delegate { id }
32
+ assignee { id name }
33
+ archivedAt
34
+ priority
35
+ createdAt
36
+ inverseRelations { nodes { type issue { id state { id name type } } } }
37
+ `;
38
+ const issueOf = ({ labels, inverseRelations, ...rest }) => ({
39
+ ...rest,
40
+ labels: labels.nodes,
41
+ // The live Linear schema has no `blockedBy` on Issue: relations are
42
+ // directional, and in `inverseRelations` the relation's source (`issue`) is
43
+ // the blocker of our issue, with `type` the lowercase string `blocks`.
44
+ blockedBy: inverseRelations.nodes.filter(r => r.type === 'blocks').map(r => r.issue),
45
+ });
46
+ /**
47
+ * A small GraphQL client for one Linear app. The token is obtained with the
48
+ * `client_credentials` grant (an `app` actor token valid for 30 days), kept in
49
+ * memory, refreshed ahead of its expiry and re-fetched once on a 401. There is
50
+ * no `@linear/sdk`: six operations do not justify it.
51
+ */
52
+ /** Linear's workflow state groups in board order. `position` is scoped
53
+ * **within a type group** (SDL: "States are displayed in ascending order
54
+ * of position within their type group"), so sorting by position alone
55
+ * interleaves the groups: Done and Canceled each rank near 0 of their own
56
+ * group and would sort before a late `started` lane. Group first, then
57
+ * position — that is the board. */
58
+ const stateGroupOrder = ['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled', 'duplicate'];
59
+ const byBoardOrder = (a, b) => {
60
+ const group = stateGroupOrder.indexOf(a.type) - stateGroupOrder.indexOf(b.type);
61
+ return group !== 0 ? group : a.position - b.position;
62
+ };
63
+ export class LinearClient {
64
+ credentials;
65
+ baseUrl;
66
+ fetchImpl;
67
+ log;
68
+ scope;
69
+ skew;
70
+ token = null;
71
+ refreshing = null;
72
+ constructor(credentials, options = {}) {
73
+ this.credentials = credentials;
74
+ this.baseUrl = (options.baseUrl ?? 'https://api.linear.app').replace(/\/$/, '');
75
+ this.fetchImpl = options.fetch ?? fetch;
76
+ this.log = options.log ?? getLogger(['aivi', MODULE_ID]);
77
+ this.scope = options.scope ?? DEFAULT_SCOPE;
78
+ this.skew = options.refreshSkewMs ?? 3_600_000;
79
+ }
80
+ /** A valid token, fetching or refreshing when needed; concurrent callers share one request. */
81
+ async accessToken(force = false) {
82
+ if (!force && this.token && this.token.expiresAt - this.skew > Date.now())
83
+ return this.token.value;
84
+ if (!this.refreshing) {
85
+ this.refreshing = this.fetchToken().finally(() => {
86
+ this.refreshing = null;
87
+ });
88
+ }
89
+ return this.refreshing;
90
+ }
91
+ async fetchToken() {
92
+ const body = new URLSearchParams({ grant_type: 'client_credentials', scope: this.scope });
93
+ const basic = Buffer.from(`${this.credentials.clientId}:${this.credentials.clientSecret}`).toString('base64');
94
+ const response = await this.fetchImpl(`${this.baseUrl}/oauth/token`, {
95
+ method: 'POST',
96
+ headers: { authorization: `Basic ${basic}`, 'content-type': 'application/x-www-form-urlencoded' },
97
+ body,
98
+ });
99
+ const text = await response.text();
100
+ if (!response.ok) {
101
+ throw new LinearApiError(`Linear token request failed (${response.status}): ${describe(text)}`, response.status);
102
+ }
103
+ const parsed = JSON.parse(text);
104
+ if (!parsed.access_token)
105
+ throw new LinearApiError('Linear token response had no access_token', 502);
106
+ this.token = { value: parsed.access_token, expiresAt: Date.now() + (parsed.expires_in ?? 86_400) * 1000 };
107
+ // A token fetch per API burst is debug-level detail, not something a setup prompt should interleave.
108
+ this.log.debug('linear.token', { expiresIn: parsed.expires_in ?? null });
109
+ return this.token.value;
110
+ }
111
+ /** Run one GraphQL operation; a 401 refreshes the token and retries once. */
112
+ async graphql(query, variables = {}, retried = false) {
113
+ const token = await this.accessToken();
114
+ const response = await this.fetchImpl(`${this.baseUrl}/graphql`, {
115
+ method: 'POST',
116
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
117
+ body: JSON.stringify({ query, variables }),
118
+ });
119
+ if (response.status === 401 && !retried) {
120
+ await this.accessToken(true);
121
+ return this.graphql(query, variables, true);
122
+ }
123
+ const text = await response.text();
124
+ if (!response.ok)
125
+ throw new LinearApiError(`Linear API ${response.status}: ${describe(text)}`, response.status);
126
+ const parsed = JSON.parse(text);
127
+ if (parsed.errors?.length)
128
+ throw new LinearApiError(parsed.errors.map(e => e.message).join('; '), 200, parsed.errors.map(e => e.extensions?.code ?? ''));
129
+ if (parsed.data === undefined)
130
+ throw new LinearApiError('Linear API returned no data', 502);
131
+ return parsed.data;
132
+ }
133
+ /** The app user's own id and its workspace in one round; also proves the
134
+ * credentials work. The tracker adapter learns `userId`/`orgId` here. */
135
+ async viewer() {
136
+ const data = await this.graphql('query { viewer { id organization { id } } }');
137
+ return { id: data.viewer.id, organizationId: data.viewer.organization.id };
138
+ }
139
+ /** The app user's own id in this workspace; also proves the credentials work. */
140
+ async viewerId() {
141
+ return (await this.viewer()).id;
142
+ }
143
+ /** The teams this app can see with their workflow states, archived excluded:
144
+ * the answer to "which id is PEC?" and "which lanes does PEC have?".
145
+ * Linear returns `states` as a connection; its `nodes` are flattened here. */
146
+ async listTeams() {
147
+ const data = await this.graphql('query { teams { nodes { id key name states { nodes { id name type position } } } } }');
148
+ // The connection's own order is not the board's; the group order and
149
+ // the per-group `position` are.
150
+ return data.teams.nodes.map(team => ({
151
+ id: team.id,
152
+ key: team.key,
153
+ name: team.name,
154
+ states: team.states.nodes.sort(byBoardOrder).map(({ position: _position, ...state }) => state),
155
+ }));
156
+ }
157
+ async createActivity(input) {
158
+ const data = await this.graphql(`mutation($input: AgentActivityCreateInput!) { agentActivityCreate(input: $input) { success agentActivity { id } } }`, { input });
159
+ if (!data.agentActivityCreate.success)
160
+ throw new LinearApiError('agentActivityCreate was not successful', 200);
161
+ return data.agentActivityCreate.agentActivity.id;
162
+ }
163
+ /** Move an issue to a workflow state by its id — how a lane move is
164
+ * performed. (schema: `issueUpdate(id: String!, input: IssueUpdateInput!)`,
165
+ * the state carried as `input.stateId`.) */
166
+ async transitionIssue(issueId, stateId) {
167
+ const data = await this.graphql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`, { id: issueId, input: { stateId } });
168
+ if (!data.issueUpdate.success)
169
+ throw new LinearApiError('issueUpdate was not successful', 200);
170
+ }
171
+ /** The label id a name stands for on a team: the team's own label wins over
172
+ * the workspace's when both carry the name (the API applies any label to
173
+ * any issue, so the disambiguation lives here), and a name no label
174
+ * answers is **created on the team** — the human label must be applicable
175
+ * without a visit to settings first. (schema:
176
+ * `issueLabels(filter: {name: {eq}})`, `issueLabelCreate(input:)`.) */
177
+ async labelIdForTeam(teamId, name) {
178
+ const data = await this.graphql(`query($name: String!) { issueLabels(filter: { name: { eq: $name } }, first: 25) { nodes { id team { id } } } }`, { name });
179
+ const eligible = data.issueLabels.nodes.filter(l => l.team === null || l.team.id === teamId);
180
+ const existing = eligible.find(l => l.team?.id === teamId) ?? eligible[0];
181
+ if (existing)
182
+ return existing.id;
183
+ const created = await this.graphql(`mutation($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { success issueLabel { id } } }`, { input: { name, teamId } });
184
+ if (!created.issueLabelCreate.success)
185
+ throw new LinearApiError(`label "${name}" was not created`, 200);
186
+ return created.issueLabelCreate.issueLabel.id;
187
+ }
188
+ /** Put a label on an issue — how the human label marks a ticket: a stop,
189
+ * a failed closing, a stop the runtime would not answer. (schema:
190
+ * `issueAddLabel(id: String!, labelId: String!)`.) */
191
+ async addLabel(issueId, labelId) {
192
+ const data = await this.graphql(`mutation($id: String!, $labelId: String!) { issueAddLabel(id: $id, labelId: $labelId) { success } }`, { id: issueId, labelId });
193
+ if (!data.issueAddLabel.success)
194
+ throw new LinearApiError('issueAddLabel was not successful', 200);
195
+ }
196
+ /** Take a label off an issue — the lift that lets the walk work a marked
197
+ * ticket again. (schema: `issueRemoveLabel(id: String!, labelId: String!)`.) */
198
+ async removeLabel(issueId, labelId) {
199
+ const data = await this.graphql(`mutation($id: String!, $labelId: String!) { issueRemoveLabel(id: $id, labelId: $labelId) { success } }`, { id: issueId, labelId });
200
+ if (!data.issueRemoveLabel.success)
201
+ throw new LinearApiError('issueRemoveLabel was not successful', 200);
202
+ }
203
+ /** A comment on the **issue** itself, not the agent session's activity:
204
+ * the closing note a person reads without opening the session. Posted
205
+ * with the app's token, so it stands as the app's comment. */
206
+ async createComment(issueId, body) {
207
+ const data = await this.graphql(`mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`, { input: { issueId, body } });
208
+ if (!data.commentCreate.success)
209
+ throw new LinearApiError('commentCreate was not successful', 200);
210
+ }
211
+ /** The ticket's comment trail, oldest first — what `aivi_ticket_comments`
212
+ * reads back. The author is the writer's display name when Linear gives
213
+ * one; the app's own comments carry the app's name like any other. */
214
+ async issueComments(issueId) {
215
+ const data = await this.graphql(`query($id: String!) { issue(id: $id) { comments(first: 100) { nodes { body createdAt user { name } } } } }`, {
216
+ id: issueId,
217
+ });
218
+ if (!data.issue)
219
+ throw new LinearApiError('Entity not found: Issue', 200);
220
+ return data.issue.comments.nodes
221
+ .map(c => ({ author: c.user?.name ?? null, body: c.body, at: c.createdAt }))
222
+ .sort((a, b) => a.at.localeCompare(b.at));
223
+ }
224
+ /** Rewrite the ticket's own words — what `aivi_ticket_edit` performs.
225
+ * Only the fields named are touched; Linear answers with the ticket as
226
+ * it now stands. */
227
+ async issueUpdate(issueId, changes) {
228
+ const data = await this.graphql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { id: issueId, input: changes });
229
+ if (!data.issueUpdate.success || !data.issueUpdate.issue)
230
+ throw new LinearApiError('issueUpdate was not successful', 200);
231
+ return issueOf(data.issueUpdate.issue);
232
+ }
233
+ /** The team's label catalogue — the names a worker may add or remove. */
234
+ async teamLabelNames(teamId) {
235
+ const data = await this.graphql(`query($id: String!) { team(id: $id) { labels(first: 50) { nodes { name } } } }`, { id: teamId });
236
+ if (!data.team)
237
+ throw new LinearApiError(`team ${teamId} not found`, 200);
238
+ return data.team.labels.nodes.map(l => l.name);
239
+ }
240
+ /** What a closing note asks Linear for in one round: the issue's comment
241
+ * bodies (the idempotence check — a note already standing is never
242
+ * posted twice) and the agent sessions with their web urls (the note's
243
+ * marker and the link a person clicks to read the whole trail). The
244
+ * comment page is deliberately wide: the check must see a note older
245
+ * than any retry horizon. */
246
+ async closingNoteFacts(issueId) {
247
+ const data = await this.graphql(`query($id: String!) { issue(id: $id) { comments(first: 100) { nodes { body } } agentSessions { nodes { id url } } } }`, { id: issueId });
248
+ if (!data.issue)
249
+ throw new LinearApiError(`issue ${issueId} not found`, 200);
250
+ return { comments: data.issue.comments.nodes, sessions: data.issue.agentSessions.nodes };
251
+ }
252
+ /** Whether Linear has closed this agent session: `endedAt` is its own word
253
+ * for "the result was shown", which is what a follower's retry asks
254
+ * before it would render a result twice. */
255
+ async agentSessionEnded(agentSessionId) {
256
+ const data = await this.graphql('query($id: String!) { agentSession(id: $id) { endedAt } }', { id: agentSessionId });
257
+ return data.agentSession.endedAt !== null;
258
+ }
259
+ /** Replace the session's plan wholesale — Linear takes the full array
260
+ * every time (docs/agent-interaction, Agent Plans: partial updates are
261
+ * explicitly not a thing). `plan` is a JSONObject: the steps array as
262
+ * `{content, status}`. */
263
+ async setPlan(agentSessionId, steps) {
264
+ const data = await this.graphql(`mutation($id: String!, $input: AgentSessionUpdateInput!) { agentSessionUpdate(id: $id, input: $input) { success } }`, { id: agentSessionId, input: { plan: steps } });
265
+ if (!data.agentSessionUpdate.success)
266
+ throw new LinearApiError('agentSessionUpdate was not successful', 200);
267
+ }
268
+ /** Tickets sitting in one state of one team — the eligibility walk's
269
+ * top-to-bottom reading. Linear's public query cannot sort (its `sort`
270
+ * parameter is internal), so the board's order is computed here:
271
+ * Linear's own priority sort — Urgent first, unranked last — and oldest
272
+ * first within a rank. Archived issues never show: the connection
273
+ * leaves them out unless asked. `truncated` says more than 100 sit in
274
+ * the lane; the walk is told rather than shown a partial board in
275
+ * silence. */
276
+ async issuesIn(teamId, stateId) {
277
+ const data = await this.graphql(
278
+ // ID! not String!: Linear's id filters are typed ID and answer a
279
+ // String variable with a 400 (live, 2026-10-02 — the fake board could
280
+ // never catch this, so the walk met it first). Direct id arguments
281
+ // (`issue(id: $id)`) do accept String; filter comparisons do not.
282
+ `query($teamId: ID!, $stateId: ID!) {
283
+ issues(
284
+ filter: { team: { id: { eq: $teamId } }, state: { id: { eq: $stateId } } }
285
+ orderBy: createdAt
286
+ first: 100
287
+ ) { nodes { ${ISSUE_FIELDS} } pageInfo { hasNextPage } }
288
+ }`, { teamId, stateId });
289
+ const rank = (priority) => (priority === 0 ? 5 : priority);
290
+ const issues = data.issues.nodes
291
+ .map(issueOf)
292
+ .sort((a, b) => rank(a.priority) - rank(b.priority) || a.createdAt.localeCompare(b.createdAt));
293
+ return { issues, truncated: data.issues.pageInfo.hasNextPage };
294
+ }
295
+ async issue(id) {
296
+ const data = await this.graphql(`query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`, { id });
297
+ // A deleted issue answers as missing, in words the closing already
298
+ // knows: without this a null would crash the caller instead of saying
299
+ // what it means.
300
+ if (!data.issue)
301
+ throw new LinearApiError('Entity not found: Issue', 200);
302
+ return issueOf(data.issue);
303
+ }
304
+ /** Make (or unmake, with `null`) this app the issue's delegate; the human
305
+ * assignee stays. Making the app the delegate is what creates the agent
306
+ * session — Linear returns it in this very answer (live, 2026-09-26). */
307
+ async setDelegate(issueId, delegateId) {
308
+ const data = await this.graphql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { identifier agentSessions { nodes { id status startedAt url } } } } }`, { id: issueId, input: { delegateId } });
309
+ if (!data.issueUpdate.success)
310
+ throw new LinearApiError('issueUpdate(delegateId) failed', 200);
311
+ return data.issueUpdate;
312
+ }
313
+ /** Create an issue in a team — the installer's throwaway ticket and the
314
+ * worker's escape hatch. `stateId` lands it in a named workflow state;
315
+ * omitted, Linear's own default (the team's triage) takes it. */
316
+ async createIssue(input) {
317
+ const data = await this.graphql(`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input });
318
+ if (!data.issueCreate.success)
319
+ throw new LinearApiError('issueCreate failed', 200);
320
+ return issueOf(data.issueCreate.issue);
321
+ }
322
+ /** Archive an issue — Linear's delete, off the board. The installer tidies
323
+ * its own throwaway ticket with this. */
324
+ async deleteIssue(issueId) {
325
+ const data = await this.graphql(`mutation($id: String!) { issueDelete(id: $id) { success } }`, { id: issueId });
326
+ if (!data.issueDelete.success)
327
+ throw new LinearApiError('issueDelete failed', 200);
328
+ }
329
+ }
330
+ /**
331
+ * Turn what an operator pastes — a team key from a Linear URL or a raw team id —
332
+ * into the ids the config must hold. An exact id matches first, then a key
333
+ * regardless of case; duplicates collapse and the given order is kept. An
334
+ * unknown token is an error naming it and listing the teams the app can see:
335
+ * a private team the app has not joined shows up here as unknown.
336
+ */
337
+ export function resolveTeams(teams, tokens) {
338
+ const byId = new Map(teams.map(t => [t.id, t.id]));
339
+ const byKey = new Map(teams.map(t => [t.key.toLowerCase(), t.id]));
340
+ const out = [];
341
+ for (const token of tokens) {
342
+ const id = byId.get(token) ?? byKey.get(token.toLowerCase());
343
+ if (!id) {
344
+ const visible = teams.map(t => `${t.key} (${t.name})`).join(', ') || 'none';
345
+ throw new Error(`${token} is not a team this app can see (visible: ${visible}). A private team needs the app added to it.`);
346
+ }
347
+ if (!out.includes(id))
348
+ out.push(id);
349
+ }
350
+ return out;
351
+ }
352
+ const describe = (text) => {
353
+ try {
354
+ const parsed = JSON.parse(text);
355
+ if (parsed.errors?.length)
356
+ return parsed.errors.map(e => e.message).join('; ');
357
+ return [parsed.error, parsed.error_description].filter(Boolean).join(': ') || text.slice(0, 200);
358
+ }
359
+ catch (error) {
360
+ return text.slice(0, 200) || errorMessage(error);
361
+ }
362
+ };
@@ -0,0 +1,94 @@
1
+ import type { AiviPlugin } from '@aivi/plugin';
2
+ import { z } from 'zod';
3
+ /**
4
+ * The Linear module. One app does the work: it carries the workspace's
5
+ * **Issues** data feed on its webhook route, receives every agent-session
6
+ * event, and its token authorises the Linear MCP. Extra apps are *faces* — a
7
+ * name and icon in Linear's UI, their own credentials (`LINEAR_<APP>_*`) and
8
+ * webhook route, no routing meaning. Lanes are core's
9
+ * own (`projects.<id>.lanes`, ordered); this block maps teams to the project. The block
10
+ * lives at `plugins.tracker-linear` in config.json — the module id is the
11
+ * package name, ruled 2026-09-30 — and the `aivi-plugins` list in
12
+ * app/package.json says whether the module runs.
13
+ */
14
+ /** The module id, and with it the key of this plugin's block in `config.json`
15
+ * (`plugins.tracker-linear`), its `/status` id and its log category: the module
16
+ * id is the package name (`@aivi/tracker-linear`), ruled 2026-09-30 for every
17
+ * plugin, so a person writes the word they already typed into `aivi add` and
18
+ * never opens a source file to learn what to call a plugin. What deliberately
19
+ * keeps the shorter `linear` is everything naming the **platform** rather than
20
+ * this package: the SQLite prefix and session-id prefix (renaming those would
21
+ * orphan every conversation in an existing database for no gain), the webhook
22
+ * URL Linear's dashboard holds, and the `aivi linear` command.
23
+ */
24
+ export declare const MODULE_ID = "tracker-linear";
25
+ export declare const linearSchema: z.ZodObject<{
26
+ agent: z.ZodOptional<z.ZodString>;
27
+ primary: z.ZodOptional<z.ZodString>;
28
+ apps: z.ZodRecord<z.ZodString, z.ZodObject<{}, z.core.$strict>>;
29
+ logMisroutes: z.ZodDefault<z.ZodBoolean>;
30
+ humanLabel: z.ZodDefault<z.ZodString>;
31
+ pool: z.ZodDefault<z.ZodString>;
32
+ mcp: z.ZodDefault<z.ZodUnion<readonly [z.ZodObject<{
33
+ port: z.ZodDefault<z.ZodNumber>;
34
+ }, z.core.$strict>, z.ZodLiteral<false>]>>;
35
+ progress: z.ZodDefault<z.ZodEnum<{
36
+ silent: "silent";
37
+ status: "status";
38
+ tools: "tools";
39
+ }>>;
40
+ }, z.core.$strict>;
41
+ export type LinearConfig = z.infer<typeof linearSchema>;
42
+ /** The environment names of the one app: the primary's credentials, no app segment. */
43
+ export declare const linearPrimarySecretNames: {
44
+ readonly clientId: 'LINEAR_CLIENT_ID';
45
+ readonly clientSecret: 'LINEAR_CLIENT_SECRET';
46
+ readonly webhookSecret: 'LINEAR_WEBHOOK_SECRET';
47
+ };
48
+ /**
49
+ * Environment variable names for one app's credentials. The **primary** app
50
+ * uses the bare `linearPrimarySecretNames`; this prefixed convention is for
51
+ * every other app (a face) — `<APP>` = the id upper-cased, `-` → `_`.
52
+ */
53
+ export declare const linearSecretNames: (app: string) => {
54
+ clientId: string;
55
+ clientSecret: string;
56
+ webhookSecret: string;
57
+ };
58
+ /** The app that carries the workspace data feed and the bare secrets: `linear.primary`, else the one configured app. */
59
+ export declare function primaryLinearApp(linear: LinearConfig | undefined): string | undefined;
60
+ /** The assistant's agent name: `linear.agent`, else the one assistant everyone gets. */
61
+ export declare function assistantAgent(linear: LinearConfig | undefined): string;
62
+ /** The plugin's own `linear` section of a project entry (`projects.<id>.linear`),
63
+ * contributed to the composed schema the way the `plugins` block contributes
64
+ * its own. Core reads none of it. */
65
+ export declare const linearProjectSchema: z.ZodObject<{
66
+ workspaceId: z.ZodOptional<z.ZodString>;
67
+ teams: z.ZodArray<z.ZodString>;
68
+ createLane: z.ZodOptional<z.ZodString>;
69
+ }, z.core.$strict>;
70
+ export type LinearProjectEntry = z.infer<typeof linearProjectSchema>;
71
+ /** The plugin's own section under `projectDefaults`. */
72
+ export declare const linearProjectDefaultsSchema: z.ZodObject<{
73
+ workspaceId: z.ZodOptional<z.ZodString>;
74
+ createLane: z.ZodOptional<z.ZodString>;
75
+ }, z.core.$strict>;
76
+ export type LinearProjectDefaults = z.infer<typeof linearProjectDefaultsSchema>;
77
+ /** The project's Linear routing as it takes effect: which teams speak for it
78
+ * and in which workspace. The lanes are the project's own core fact, read
79
+ * from `Project.lanes` — this type stopped carrying them on purpose. */
80
+ export interface ProjectLinear {
81
+ workspaceId?: string;
82
+ teams: string[];
83
+ /** Where `aivi_ticket_create` lands a new ticket, as it takes effect: the
84
+ * project's own `createLane`, else `projectDefaults`', else absent — and
85
+ * absent means the project's first configured lane, read from the core
86
+ * `Project`, not from this section. */
87
+ createLane?: string;
88
+ }
89
+ /** The registry entry: the block holds no paths (credentials live in the
90
+ * environment, lanes name agents), so `home` finds nothing to resolve here;
91
+ * the module code loads lazily. The `linear` sections of a project and of
92
+ * `projectDefaults` are contributed here — core passes them through and this
93
+ * plugin reads them back. */
94
+ export declare const plugin: AiviPlugin<LinearConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,171 @@
1
+ /** The Linear plugin's registry declaration, at its `./config` subpath: the
2
+ * module id, the block's own schema (with the cross-field rules that belong to
3
+ * it), and the bridge to the module code. The CLI imports this for every
4
+ * command, so nothing here pulls the Linear client. */
5
+ import { PROJECT_ID } from '@aivi/core';
6
+ import { z } from 'zod';
7
+ const id = z.string().regex(PROJECT_ID);
8
+ /**
9
+ * The Linear module. One app does the work: it carries the workspace's
10
+ * **Issues** data feed on its webhook route, receives every agent-session
11
+ * event, and its token authorises the Linear MCP. Extra apps are *faces* — a
12
+ * name and icon in Linear's UI, their own credentials (`LINEAR_<APP>_*`) and
13
+ * webhook route, no routing meaning. Lanes are core's
14
+ * own (`projects.<id>.lanes`, ordered); this block maps teams to the project. The block
15
+ * lives at `plugins.tracker-linear` in config.json — the module id is the
16
+ * package name, ruled 2026-09-30 — and the `aivi-plugins` list in
17
+ * app/package.json says whether the module runs.
18
+ */
19
+ /** The module id, and with it the key of this plugin's block in `config.json`
20
+ * (`plugins.tracker-linear`), its `/status` id and its log category: the module
21
+ * id is the package name (`@aivi/tracker-linear`), ruled 2026-09-30 for every
22
+ * plugin, so a person writes the word they already typed into `aivi add` and
23
+ * never opens a source file to learn what to call a plugin. What deliberately
24
+ * keeps the shorter `linear` is everything naming the **platform** rather than
25
+ * this package: the SQLite prefix and session-id prefix (renaming those would
26
+ * orphan every conversation in an existing database for no gain), the webhook
27
+ * URL Linear's dashboard holds, and the `aivi linear` command.
28
+ */
29
+ export const MODULE_ID = 'tracker-linear';
30
+ export const linearSchema = z
31
+ .strictObject({
32
+ agent: z
33
+ .string()
34
+ .min(1)
35
+ .optional()
36
+ .describe('The OpenCode agent that answers people on Linear — comment mentions and delegations that no lane claims: the assistant. Default: the aivi name.'),
37
+ primary: z
38
+ .string()
39
+ .min(1)
40
+ .optional()
41
+ .describe('The app that carries the workspace data feed, signs the bare LINEAR_* secrets and authorises the Linear MCP; default the one app. Required once several apps are configured.'),
42
+ apps: z
43
+ .record(id, z
44
+ .strictObject({})
45
+ .describe('Empty today: credentials come from the environment; the id is the app identity and its webhook route.'))
46
+ .describe('Linear apps by id. The primary (see `primary`) carries the data feed; every other app is a face — a name and icon in Linear’s UI with its own credentials, no routing meaning.'),
47
+ logMisroutes: z
48
+ .boolean()
49
+ .default(true)
50
+ .describe('Log at warn a webhook delivered to the wrong endpoint (a data change on a face’s route); it is dropped either way.'),
51
+ humanLabel: z
52
+ .string()
53
+ .min(1)
54
+ .default('needs-human')
55
+ .describe('Issues carrying this label are never worked automatically; a hand delegation is refused with an explanation.'),
56
+ pool: id.default('default').describe('Dispatcher pool a worker turn takes a slot in.'),
57
+ mcp: z
58
+ .union([
59
+ z.strictObject({
60
+ port: z.number().int().min(0).max(65535).default(4101),
61
+ }),
62
+ z.literal(false),
63
+ ])
64
+ .default({ port: 4101 })
65
+ .describe("On by default: the module serves Linear's hosted MCP on loopback (default port 4101), authorised with the app-actor token, so agents can act in Linear and writes attribute to the app; OpenCode connects as a remote MCP at http://127.0.0.1:<port>/mcp. `false` disables it. Loopback only."),
66
+ progress: z
67
+ .enum(['silent', 'status', 'tools'])
68
+ .default('tools')
69
+ .describe('What the ephemeral activities in the agent session show while a worker runs.'),
70
+ })
71
+ .superRefine((config, ctx) => {
72
+ // Several apps need a named primary, and the primary must be one of the configured apps.
73
+ const appIds = Object.keys(config.apps);
74
+ if (appIds.length > 1 && !config.primary)
75
+ ctx.addIssue({
76
+ code: 'custom',
77
+ path: ['primary'],
78
+ message: 'Required once several apps are configured: which app carries the data feed and the bare LINEAR_* secrets',
79
+ });
80
+ if (config.primary && !appIds.includes(config.primary))
81
+ ctx.addIssue({ code: 'custom', path: ['primary'], message: 'Not a configured app' });
82
+ });
83
+ /** The environment names of the one app: the primary's credentials, no app segment. */
84
+ export const linearPrimarySecretNames = {
85
+ clientId: 'LINEAR_CLIENT_ID',
86
+ clientSecret: 'LINEAR_CLIENT_SECRET',
87
+ webhookSecret: 'LINEAR_WEBHOOK_SECRET',
88
+ };
89
+ /**
90
+ * Environment variable names for one app's credentials. The **primary** app
91
+ * uses the bare `linearPrimarySecretNames`; this prefixed convention is for
92
+ * every other app (a face) — `<APP>` = the id upper-cased, `-` → `_`.
93
+ */
94
+ export const linearSecretNames = (app) => {
95
+ const key = app.toUpperCase().replaceAll('-', '_');
96
+ return {
97
+ clientId: `LINEAR_${key}_CLIENT_ID`,
98
+ clientSecret: `LINEAR_${key}_CLIENT_SECRET`,
99
+ webhookSecret: `LINEAR_${key}_WEBHOOK_SECRET`,
100
+ };
101
+ };
102
+ /** The app that carries the workspace data feed and the bare secrets: `linear.primary`, else the one configured app. */
103
+ export function primaryLinearApp(linear) {
104
+ if (!linear)
105
+ return undefined;
106
+ if (linear.primary)
107
+ return linear.primary;
108
+ const ids = Object.keys(linear.apps);
109
+ return ids.length === 1 ? ids[0] : undefined;
110
+ }
111
+ /** The assistant's agent name: `linear.agent`, else the one assistant everyone gets. */
112
+ export function assistantAgent(linear) {
113
+ return linear?.agent ?? 'assistant';
114
+ }
115
+ /*
116
+ * Lanes used to live here, in Linear's own section. They do not anymore
117
+ * (ruled 2026-10-01): **lanes are core's concept** — the ordered workflow of
118
+ * a project, configured at `projects.<id>.lanes` and read by the host
119
+ * orchestrator, which decides every move from the order and asks a tracker
120
+ * only to perform one. What remains in this section is Linear's own routing:
121
+ * which teams feed the project, and which workspace.
122
+ */
123
+ /** The plugin's own `linear` section of a project entry (`projects.<id>.linear`),
124
+ * contributed to the composed schema the way the `plugins` block contributes
125
+ * its own. Core reads none of it. */
126
+ export const linearProjectSchema = z.strictObject({
127
+ workspaceId: z
128
+ .string()
129
+ .min(1)
130
+ .optional()
131
+ .describe('Linear organization id; only needed with more than one workspace.'),
132
+ teams: z
133
+ .array(z.string().min(1))
134
+ .min(1)
135
+ .describe('Linear team ids whose issues belong to this project; a team maps to at most one project.'),
136
+ createLane: z
137
+ .string()
138
+ .min(1)
139
+ .optional()
140
+ .describe('Where `aivi_ticket_create` lands a new ticket in this project — a lane name from the team’s states. Default: the project’s first configured lane; with none matching, Linear’s own default state.'),
141
+ });
142
+ /** The plugin's own section under `projectDefaults`. */
143
+ export const linearProjectDefaultsSchema = z.strictObject({
144
+ workspaceId: z
145
+ .string()
146
+ .min(1)
147
+ .optional()
148
+ .describe('Linear organization id every project gets unless it names its own.'),
149
+ createLane: z
150
+ .string()
151
+ .min(1)
152
+ .optional()
153
+ .describe('Where `aivi_ticket_create` lands a new ticket in every project unless it names its own — a lane name from the team’s states.'),
154
+ });
155
+ /** The registry entry: the block holds no paths (credentials live in the
156
+ * environment, lanes name agents), so `home` finds nothing to resolve here;
157
+ * the module code loads lazily. The `linear` sections of a project and of
158
+ * `projectDefaults` are contributed here — core passes them through and this
159
+ * plugin reads them back. */
160
+ export const plugin = {
161
+ id: MODULE_ID,
162
+ configSchema: linearSchema,
163
+ projectSchema: linearProjectSchema,
164
+ projectDefaultsSchema: linearProjectDefaultsSchema,
165
+ // The lazy load is the design: the CLI reads this file's schema without
166
+ // pulling the host's world in, and the module arrives only when the host
167
+ // actually builds. The cycle back into this file (the module imports its
168
+ // own schema) is that design, not a tangle.
169
+ // fallow-ignore-next-line circular-dependency
170
+ createModule: config => import('./module.js').then(m => m.createLinearModule(config)),
171
+ };
package/dist/desk.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Linear's answer to the ticket desk (the `TicketDesk` contract at
3
+ * `@aivi/plugin/tracker`). The host owns the `aivi_ticket_*` tools and the
4
+ * ground truth of the call — this file answers what each one *is* on Linear:
5
+ * which app's feed a plain comment lands on, which team a new ticket is born
6
+ * in, what the team's label catalogue means. The desk speaks through the
7
+ * **primary** app: it holds the credentials, and a comment posted there is
8
+ * the app's own.
9
+ */
10
+ import type { LoadedConfig } from '@aivi/core';
11
+ import type { Platform, TicketDesk } from '@aivi/plugin/tracker';
12
+ import type { LinearConfig } from './config.ts';
13
+ export declare function createLinearDesk(loaded: LoadedConfig, config: LinearConfig, platform: Platform): TicketDesk;