@aivi/tracker-linear 0.0.0-stage → 1.0.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 CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ # @aivi/tracker-linear
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Linear module: one app receiving every webhook, the assistant for people,
4
+ workers in git worktrees, and the Linear MCP proxy. Agent sessions run on
5
+ the host's channel machinery — a delegation becomes one bound conversation
6
+ and one worker turn of the lane's agent in its own worktree.
7
+
8
+ ## Exports
9
+
10
+ | Path | Contents |
11
+ | --- | --- |
12
+ | `@aivi/tracker-linear` | `createLinearModule`, `openLinearStore`, `describeWorkers`, `LINEAR` |
13
+ | `./config` | the `plugin` declaration: module id, config schema, project sections |
14
+ | `./setup` | `aivi add @aivi/tracker-linear`'s setup guide |
15
+ | `./setupProject` | the `tracker`-role project contributor: writes a project's teams and lanes |
16
+ | `./cli` | the `aivi linear` command |
17
+
18
+ ## Model
19
+
20
+ - **One app, one persona, lanes pick agents.** The primary app carries the
21
+ workspace's data feed and every agent-session webhook on one route.
22
+ - **A repository is a Linear team.** Routing reads the issue's team only;
23
+ lanes, branch names, and labels live per team.
24
+ - **Two ids, on purpose.** The module id is this package's short name —
25
+ `tracker-linear` — and so `plugins.tracker-linear`, the `/status` id, the log
26
+ category and the project section's key. The *platform* id stays `linear`: the
27
+ SQLite prefix, the session and message id prefixes, the lease owner, the
28
+ webhook URL Linear's dashboard holds and the `aivi linear` command. Those say
29
+ who a conversation is on, not which package speaks for it, and renaming a
30
+ prefix would orphan every conversation already bound to it.
31
+ - **Stop means stop.** The turn is discarded, capacity released, worktree
32
+ and session kept; only an unverifiable stop is `blocked`.
33
+
34
+ ## Dependencies
35
+
36
+ `@aivi/host` as a peer (runs in-process with webhook routes on the same
37
+ listener).
38
+
39
+ ## Docs
40
+
41
+ [linear](../../docs/linear.md) ·
42
+ [plan (what is left)](../../docs/plans/linear.md)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** The Linear module's operator commands, mounted into the server CLI through
2
+ * the `./cli` subpath contract (@aivi/plugin's `(ctx) => Command`): status and
3
+ * resolve, over the same conversation store the module runs on. */
4
+ import type { PluginCliContext } from '@aivi/plugin';
5
+ import { Command } from 'commander';
6
+ export default _default;
7
+ declare function _default(ctx: PluginCliContext): Command;
package/dist/cli.js ADDED
@@ -0,0 +1,34 @@
1
+ import { resolveBlocked } from '@aivi/plugin';
2
+ import { Command } from 'commander';
3
+ const enabled = async (ctx) => {
4
+ const loaded = await ctx.loaded();
5
+ const config = loaded.config.plugins['tracker-linear'];
6
+ if (!config)
7
+ throw new Error('Linear is not configured in config.json (no plugins.tracker-linear block)');
8
+ return config;
9
+ };
10
+ export default (ctx) => {
11
+ const linear = new Command('linear').description('the Linear module');
12
+ linear
13
+ .command('status')
14
+ .description('Inspect Linear conversations (workers and the assistant)')
15
+ .action(async () => {
16
+ await enabled(ctx);
17
+ const { describeWorkers, openLinearStore } = await import('./index.js');
18
+ await ctx.withStore(store => {
19
+ const inbox = openLinearStore(store);
20
+ ctx.print({ conversations: describeWorkers(inbox) });
21
+ });
22
+ });
23
+ linear
24
+ .command('resolve <id>')
25
+ .description('Release a blocked worker')
26
+ .requiredOption('--reason <text>', 'what was found and done')
27
+ .requiredOption('--confirm-stopped', 'the external side has stopped')
28
+ .action(async (id, options) => {
29
+ await enabled(ctx);
30
+ const { openLinearStore } = await import('./index.js');
31
+ await resolveBlocked(ctx, id, options.reason, store => openLinearStore(store));
32
+ });
33
+ return linear;
34
+ };
@@ -0,0 +1,277 @@
1
+ import type { Logger } from '@aivi/core';
2
+ /** What one Linear app needs to talk to the API: a client-credentials token, nothing persisted. */
3
+ export interface LinearCredentials {
4
+ clientId: string;
5
+ clientSecret: string;
6
+ }
7
+ export interface LinearClientOptions {
8
+ /** `https://api.linear.app` unless a test points elsewhere. */
9
+ baseUrl?: string | undefined;
10
+ /** Called with string URLs only; exactly what the setup context can give. */
11
+ fetch?: ((url: string, init?: RequestInit) => Promise<Response>) | undefined;
12
+ log?: Logger | undefined;
13
+ /** Scopes requested with the token; the agent scopes are what make the app delegable and mentionable. */
14
+ scope?: string | undefined;
15
+ /** Refresh this long before `expires_in` says the token is gone (default one hour). */
16
+ refreshSkewMs?: number | undefined;
17
+ }
18
+ /** One of the five activity types an app may emit; `prompt` is what people send and never ours. */
19
+ export type AgentActivityContent = {
20
+ type: 'thought';
21
+ body: string;
22
+ } | {
23
+ type: 'elicitation';
24
+ body: string;
25
+ } | {
26
+ type: 'action';
27
+ action: string;
28
+ parameter: string;
29
+ result?: string;
30
+ } | {
31
+ type: 'response';
32
+ body: string;
33
+ } | {
34
+ type: 'error';
35
+ body: string;
36
+ };
37
+ export interface AgentActivityInput {
38
+ agentSessionId: string;
39
+ content: AgentActivityContent;
40
+ /** Shown until the next activity replaces it; only `thought` and `action` may be ephemeral. */
41
+ ephemeral?: boolean;
42
+ signal?: 'auth' | 'select';
43
+ signalMetadata?: Record<string, unknown>;
44
+ }
45
+ /** The issue fields eligibility and routing read. */
46
+ export interface LinearIssue {
47
+ id: string;
48
+ identifier: string;
49
+ title: string;
50
+ description: string | null;
51
+ branchName: string;
52
+ url: string;
53
+ state: {
54
+ id: string;
55
+ name: string;
56
+ type: string;
57
+ };
58
+ team: {
59
+ id: string;
60
+ key: string;
61
+ };
62
+ labels: {
63
+ id: string;
64
+ name: string;
65
+ }[];
66
+ delegate: {
67
+ id: string;
68
+ } | null;
69
+ assignee: {
70
+ id: string;
71
+ name: string;
72
+ } | null;
73
+ /** ISO instant the issue was archived — Linear's delete — null while it
74
+ * lives. A dead ticket gets nothing from a session: there is no work. */
75
+ archivedAt: string | null;
76
+ /** Issues blocking this one; a blocker not in a finished state holds the listener back. */
77
+ blockedBy: {
78
+ id: string;
79
+ state: {
80
+ id: string;
81
+ name: string;
82
+ type: string;
83
+ };
84
+ }[];
85
+ /** Linear's rank — 1 Urgent through 4 Low, 0 for unranked: what sorts a
86
+ * board top to bottom, unranked last. */
87
+ priority: number;
88
+ /** ISO instant of creation; the tie-break under priority, oldest first. */
89
+ createdAt: string;
90
+ }
91
+ export interface LinearAgentSession {
92
+ id: string;
93
+ status: string;
94
+ issue: {
95
+ id: string;
96
+ } | null;
97
+ }
98
+ /**
99
+ * What the delegate mutation answers: whether it worked, and the issue's
100
+ * agent sessions as Linear sees them in that very moment. Making the app
101
+ * the delegate creates the session itself, and it appears here — the
102
+ * listener starts the worker from this answer, not from a webhook.
103
+ */
104
+ export interface DelegateAnswer {
105
+ success: boolean;
106
+ issue?: {
107
+ identifier: string;
108
+ agentSessions: {
109
+ nodes: {
110
+ id: string;
111
+ status: string;
112
+ startedAt: string | null;
113
+ url: string | null;
114
+ }[];
115
+ };
116
+ } | undefined;
117
+ }
118
+ /** A team in the workspace: the `id` is what aivi's config holds, the `key`
119
+ * is what Linear's URLs and issue identifiers show. States drive the lane
120
+ * picker; `type` completed or canceled lanes are never offered for work. */
121
+ export interface LinearTeam {
122
+ id: string;
123
+ key: string;
124
+ name: string;
125
+ states: {
126
+ id: string;
127
+ name: string;
128
+ type: string;
129
+ }[];
130
+ }
131
+ export declare class LinearApiError extends Error {
132
+ readonly status: number;
133
+ /** GraphQL error codes (`extensions.code`), kept apart because the HTTP
134
+ * status says nothing about them — Linear answers entity-not-found with a
135
+ * 200. Empty for transport and token errors. */
136
+ readonly codes: readonly string[];
137
+ constructor(message: string, status: number, codes?: readonly string[]);
138
+ /** Linear's word for "that entity is gone", whatever shape it comes in: a
139
+ * deleted issue takes its agent session with it, and every later call
140
+ * names it. The message is the stable part (`Entity not found: …`); the
141
+ * codes only say *how* Linear refused (`INPUT_ERROR` for a lookup of a
142
+ * dead session, live 2026-10-06), never *what*, so no code may stand in
143
+ * for the words. */
144
+ static isGone(error: unknown): boolean;
145
+ }
146
+ export declare class LinearClient {
147
+ private readonly credentials;
148
+ private readonly baseUrl;
149
+ private readonly fetchImpl;
150
+ private readonly log;
151
+ private readonly scope;
152
+ private readonly skew;
153
+ private token;
154
+ private refreshing;
155
+ constructor(credentials: LinearCredentials, options?: LinearClientOptions);
156
+ /** A valid token, fetching or refreshing when needed; concurrent callers share one request. */
157
+ accessToken(force?: boolean): Promise<string>;
158
+ private fetchToken;
159
+ /** Run one GraphQL operation; a 401 refreshes the token and retries once. */
160
+ graphql<T>(query: string, variables?: Record<string, unknown>, retried?: boolean): Promise<T>;
161
+ /** The app user's own id and its workspace in one round; also proves the
162
+ * credentials work. The tracker adapter learns `userId`/`orgId` here. */
163
+ viewer(): Promise<{
164
+ id: string;
165
+ organizationId: string;
166
+ }>;
167
+ /** The app user's own id in this workspace; also proves the credentials work. */
168
+ viewerId(): Promise<string>;
169
+ /** The teams this app can see with their workflow states, archived excluded:
170
+ * the answer to "which id is PEC?" and "which lanes does PEC have?".
171
+ * Linear returns `states` as a connection; its `nodes` are flattened here. */
172
+ listTeams(): Promise<LinearTeam[]>;
173
+ createActivity(input: AgentActivityInput): Promise<string>;
174
+ /** Move an issue to a workflow state by its id — how a lane move is
175
+ * performed. (schema: `issueUpdate(id: String!, input: IssueUpdateInput!)`,
176
+ * the state carried as `input.stateId`.) */
177
+ transitionIssue(issueId: string, stateId: string): Promise<void>;
178
+ /** The label id a name stands for on a team: the team's own label wins over
179
+ * the workspace's when both carry the name (the API applies any label to
180
+ * any issue, so the disambiguation lives here), and a name no label
181
+ * answers is **created on the team** — the human label must be applicable
182
+ * without a visit to settings first. (schema:
183
+ * `issueLabels(filter: {name: {eq}})`, `issueLabelCreate(input:)`.) */
184
+ labelIdForTeam(teamId: string, name: string): Promise<string>;
185
+ /** Put a label on an issue — how the human label marks a ticket: a stop,
186
+ * a failed closing, a stop the runtime would not answer. (schema:
187
+ * `issueAddLabel(id: String!, labelId: String!)`.) */
188
+ addLabel(issueId: string, labelId: string): Promise<void>;
189
+ /** Take a label off an issue — the lift that lets the walk work a marked
190
+ * ticket again. (schema: `issueRemoveLabel(id: String!, labelId: String!)`.) */
191
+ removeLabel(issueId: string, labelId: string): Promise<void>;
192
+ /** A comment on the **issue** itself, not the agent session's activity:
193
+ * the closing note a person reads without opening the session. Posted
194
+ * with the app's token, so it stands as the app's comment. */
195
+ createComment(issueId: string, body: string): Promise<void>;
196
+ /** The ticket's comment trail, oldest first — what `aivi_ticket_comments`
197
+ * reads back. The author is the writer's display name when Linear gives
198
+ * one; the app's own comments carry the app's name like any other. */
199
+ issueComments(issueId: string): Promise<{
200
+ author: string | null;
201
+ body: string;
202
+ at: string;
203
+ }[]>;
204
+ /** Rewrite the ticket's own words — what `aivi_ticket_edit` performs.
205
+ * Only the fields named are touched; Linear answers with the ticket as
206
+ * it now stands. */
207
+ issueUpdate(issueId: string, changes: {
208
+ title?: string;
209
+ description?: string;
210
+ }): Promise<LinearIssue>;
211
+ /** The team's label catalogue — the names a worker may add or remove. */
212
+ teamLabelNames(teamId: string): Promise<string[]>;
213
+ /** What a closing note asks Linear for in one round: the issue's comment
214
+ * bodies (the idempotence check — a note already standing is never
215
+ * posted twice) and the agent sessions with their web urls (the note's
216
+ * marker and the link a person clicks to read the whole trail). The
217
+ * comment page is deliberately wide: the check must see a note older
218
+ * than any retry horizon. */
219
+ closingNoteFacts(issueId: string): Promise<{
220
+ comments: {
221
+ body: string;
222
+ }[];
223
+ sessions: {
224
+ id: string;
225
+ url: string | null;
226
+ }[];
227
+ }>;
228
+ /** Whether Linear has closed this agent session: `endedAt` is its own word
229
+ * for "the result was shown", which is what a follower's retry asks
230
+ * before it would render a result twice. */
231
+ agentSessionEnded(agentSessionId: string): Promise<boolean>;
232
+ /** Replace the session's plan wholesale — Linear takes the full array
233
+ * every time (docs/agent-interaction, Agent Plans: partial updates are
234
+ * explicitly not a thing). `plan` is a JSONObject: the steps array as
235
+ * `{content, status}`. */
236
+ setPlan(agentSessionId: string, steps: {
237
+ content: string;
238
+ status: string;
239
+ }[]): Promise<void>;
240
+ /** Tickets sitting in one state of one team — the eligibility walk's
241
+ * top-to-bottom reading. Linear's public query cannot sort (its `sort`
242
+ * parameter is internal), so the board's order is computed here:
243
+ * Linear's own priority sort — Urgent first, unranked last — and oldest
244
+ * first within a rank. Archived issues never show: the connection
245
+ * leaves them out unless asked. `truncated` says more than 100 sit in
246
+ * the lane; the walk is told rather than shown a partial board in
247
+ * silence. */
248
+ issuesIn(teamId: string, stateId: string): Promise<{
249
+ issues: LinearIssue[];
250
+ truncated: boolean;
251
+ }>;
252
+ issue(id: string): Promise<LinearIssue>;
253
+ /** Make (or unmake, with `null`) this app the issue's delegate; the human
254
+ * assignee stays. Making the app the delegate is what creates the agent
255
+ * session — Linear returns it in this very answer (live, 2026-09-26). */
256
+ setDelegate(issueId: string, delegateId: string | null): Promise<DelegateAnswer>;
257
+ /** Create an issue in a team — the installer's throwaway ticket and the
258
+ * worker's escape hatch. `stateId` lands it in a named workflow state;
259
+ * omitted, Linear's own default (the team's triage) takes it. */
260
+ createIssue(input: {
261
+ teamId: string;
262
+ title: string;
263
+ description?: string;
264
+ stateId?: string;
265
+ }): Promise<LinearIssue>;
266
+ /** Archive an issue — Linear's delete, off the board. The installer tidies
267
+ * its own throwaway ticket with this. */
268
+ deleteIssue(issueId: string): Promise<void>;
269
+ }
270
+ /**
271
+ * Turn what an operator pastes — a team key from a Linear URL or a raw team id —
272
+ * into the ids the config must hold. An exact id matches first, then a key
273
+ * regardless of case; duplicates collapse and the given order is kept. An
274
+ * unknown token is an error naming it and listing the teams the app can see:
275
+ * a private team the app has not joined shows up here as unknown.
276
+ */
277
+ export declare function resolveTeams(teams: LinearTeam[], tokens: string[]): string[];