@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.
@@ -0,0 +1,178 @@
1
+ /** Linear's **platform**: the `Platform` contract of `@aivi/plugin/tracker`
2
+ * spelled in Linear's words. It owns everything Linear-shaped below the
3
+ * neutral surface — the apps and their credentials, the webhook endpoints
4
+ * and their signatures, the GraphQL reads and mutations, the agent-session
5
+ * activity types — and translates at the border: platform events become
6
+ * `TrackerEvent`s, neutral updates become Linear mutations. The module's
7
+ * decision code imports **no** Linear types through this file; if it needs
8
+ * something Linear-shaped, the neutral contract grows (or the answer stays
9
+ * here), never the other way around.
10
+ *
11
+ * `createStates` and `candidates` come with the orchestrator extraction's
12
+ * dispatch work (the lane-map fetch and the pull-shaped queue read); the
13
+ * module never polled today, so the adapter does not pretend to.
14
+ */
15
+ import { type Logger } from '@aivi/core';
16
+ import { type PublicRoutes } from '@aivi/plugin';
17
+ import type { RunPlan, RunQuestion } from '@aivi/plugin/run';
18
+ import type { Platform, TrackerComment, TrackerCommentKind, TrackerEvent, TrackerIssue, TrackerProgressLine, TrackerState, TrackerUpdate } from '@aivi/plugin/tracker';
19
+ import { LinearClient } from './client.ts';
20
+ import type { LinearConfig } from './config.ts';
21
+ /** One Linear app, ready to speak: its client, its webhook secret, and who
22
+ * aivi is there (`userId`) and in which workspace (`orgId`), learned at
23
+ * `ready` from the credentials themselves. */
24
+ export interface LinearAppRuntime {
25
+ id: string;
26
+ client: LinearClient;
27
+ webhookSecret: string;
28
+ userId: string;
29
+ orgId: string;
30
+ }
31
+ /** Credentials for every configured app, from the environment; a missing one
32
+ * is the operator's to fix. The primary app (the one app, or `linear.primary`)
33
+ * keeps the bare `LINEAR_*` names; every other app — a face — uses
34
+ * `LINEAR_<APP>_*`. */
35
+ export declare function requireLinearSecrets(config: LinearConfig, env?: NodeJS.ProcessEnv): {
36
+ id: string;
37
+ clientId: string;
38
+ clientSecret: string;
39
+ webhookSecret: string;
40
+ }[];
41
+ /** The client to ask for teams: the one configured app, or the one `--app` names.
42
+ * `requireLinearSecrets` names the missing LINEAR_* variables when secrets are absent. */
43
+ export declare function clientFor(config: LinearConfig, app: string | undefined, log: Logger): LinearClient;
44
+ /** Linear's verdict of *finished as far as work goes*: `completed` (Done) and
45
+ * `canceled` (Canceled, Won't Fix, Could not reproduce) both end the ticket's
46
+ * claim on anyone's attention. The words live here and nowhere above — the
47
+ * neutral contract carries only the verdict (`completed`), which is what the
48
+ * decision code and the orchestrator ever see. */
49
+ export declare const isClosed: (stateType: string) => boolean;
50
+ /** Translate a fresh Linear issue into the neutral facts the decision code
51
+ * reads. This is the only place Linear field names survive. */
52
+ export declare function neutralIssue(issue: Awaited<ReturnType<LinearClient['issue']>>): TrackerIssue;
53
+ export declare class LinearPlatform implements Platform {
54
+ readonly id = "tracker-linear";
55
+ private readonly routes;
56
+ private readonly apps;
57
+ private readonly primaryId;
58
+ private readonly logMisroutes;
59
+ private readonly log;
60
+ /** `clients` is a test seam keyed by app id; real clients come from the
61
+ * environment. Construction checks the config's shape; `ready` and
62
+ * `events` reach the network and the host's routes. */
63
+ constructor(config: LinearConfig, routes: PublicRoutes, clients?: Map<string, LinearClient>, log?: Logger);
64
+ /** Learn who aivi is in each app's workspace; bad credentials are a setup
65
+ * error the operator fixes, not a retry. `createLinearPlatform` awaits
66
+ * this; it is public so a caller may re-learn the identities. */
67
+ ready(): Promise<void>;
68
+ /** The app a conversation belongs to. A conversation is `<app>:<agent
69
+ * session id>`; a bare app id is the app's own feed — a ticket's data
70
+ * change arrives before any session of its own exists. */
71
+ private of;
72
+ /** A session the platform names arrives on the primary's endpoint — a
73
+ * face's agent sessions reach aivi through the primary's data feed — so
74
+ * its conversation carries the primary's namespace. */
75
+ idFor(sessionId: string): string;
76
+ parts(conversation: string): {
77
+ app: string;
78
+ session: string;
79
+ };
80
+ orgOf(conversation: string): Promise<string>;
81
+ ownerOf(conversation: string): string;
82
+ issue(conversation: string, issueId: string): Promise<TrackerIssue>;
83
+ laneStates(conversation: string, teamId: string): Promise<TrackerState[]>;
84
+ assign(conversation: string, issueId: string, userId: string): Promise<void>;
85
+ unassign(conversation: string, issueId: string): Promise<void>;
86
+ /** Making the app the delegate creates the agent session itself, in the
87
+ * mutation's own answer (live, 2026-09-26); the `created` webhook that
88
+ * follows is a redelivery the module dedupes. */
89
+ startSession(conversation: string, issueId: string): Promise<string | null>;
90
+ /** The agent session a conversation names. A bare app id — the ticket's
91
+ * own feed — has no session to speak into: only a worker or the
92
+ * assistant, which always arrive with one, may comment. */
93
+ private sessionOf;
94
+ /** The neutral kinds, rendered as Linear's agent-session activities: an
95
+ * answer is a visible response, an outcome the visible error, progress
96
+ * the ephemeral thought replaced as work moves, a note a visible thought. */
97
+ comment(conversation: string, text: string, kind: TrackerCommentKind): Promise<string | undefined>;
98
+ /** The progress stream, in Linear's own activities (docs/linear.md): a
99
+ * running tool is an ephemeral `action` naming it, anything else an
100
+ * ephemeral `thought`. Ephemeral is Linear's word for *replaced*: the
101
+ * person sees the worker's current moment, never a trail of lines. */
102
+ progress(conversation: string, line: TrackerProgressLine): Promise<void>;
103
+ /** A death before any session exists has no agent session to speak in:
104
+ * this is the plain ticket comment that keeps it visible (ruled
105
+ * 2026-10-02 after seven silent early failures). The conversation is the
106
+ * app's own feed — a bare app id routes to that app's client. */
107
+ notify(conversation: string, issueId: string, text: string): Promise<void>;
108
+ /**
109
+ * Linear's way with a question: an `elicitation` activity in the agent
110
+ * session, carrying the `select` signal with the options when there are
111
+ * any (docs/agent-signals, live shape). The person may answer by picking —
112
+ * the choice arrives as an ordinary `prompted` event carrying the value —
113
+ * or in free text, which dismisses the elicitation just the same. The
114
+ * session waits in `awaitingInput` for as long as it takes: an elicitation
115
+ * is not on any clock.
116
+ */
117
+ ask(conversation: string, question: RunQuestion): Promise<void>;
118
+ /** Linear's way with a plan: replace the agent session's plan wholesale —
119
+ * the same words Linear's own API uses for it, statuses and all
120
+ * (docs/agent-interaction, Agent Plans). */
121
+ plan(conversation: string, plan: RunPlan): Promise<void>;
122
+ /** The ticket desk, worked on the **issue** directly with the app's own
123
+ * token — these calls carry no agent session, so `conversation` is only
124
+ * the app selector here, as in `notify`. */
125
+ ticketComments(conversation: string, issueId: string): Promise<TrackerComment[]>;
126
+ addComment(conversation: string, issueId: string, text: string): Promise<void>;
127
+ editIssue(conversation: string, issueId: string, changes: {
128
+ title?: string;
129
+ description?: string;
130
+ }): Promise<TrackerIssue>;
131
+ createIssue(conversation: string, input: {
132
+ teamId: string;
133
+ title: string;
134
+ description?: string;
135
+ lane?: string;
136
+ }): Promise<TrackerIssue>;
137
+ teamLabels(conversation: string, teamId: string): Promise<string[]>;
138
+ /** Linear's word for "the result was shown": the response activity that
139
+ * renders a success *completes* the agent session (docs, live), so an
140
+ * ended session has said its piece and a retry must not say it again. */
141
+ resultShown(conversation: string): Promise<boolean>;
142
+ /** The closing note on the **ticket** (ruled 2026-10-02: the answer was
143
+ * only readable by opening the agent session). The person gets the text
144
+ * plus a link to the session that did the work. Idempotence is ours, as
145
+ * the seam demands, and it is a signature aivi controls:
146
+ * the note's own closing trailer, not a bare session id a worker was
147
+ * invited to talk about. Wake retries and the boot pass never post it
148
+ * twice. */
149
+ closingNote(conversation: string, issueId: string, text: string): Promise<void>;
150
+ /**
151
+ * Perform one neutral update. A `move` resolves the lane's *name* against
152
+ * the issue's team — the names come from the project's lane config, which
153
+ * is core's decision; Linear is only ever asked to transition, and only
154
+ * when the issue is not in that state already (the boot pass may re-drive
155
+ * a move that already landed). A `label` resolves the name against the
156
+ * team's labels (created there when none carries it) and adds or lifts
157
+ * (2026-10-03: this branch used to throw "cannot apply label yet" while
158
+ * the module's stop and failed-closing marks asked for one every time —
159
+ * the label never rode in production, only in the fake that had learned
160
+ * to mutate labels; the quality scan's dead-code report exposed it).
161
+ */
162
+ apply(conversation: string, issueId: string, update: TrackerUpdate): Promise<void>;
163
+ /** One public route per app, verified by that app's secret, acknowledged
164
+ * at once (Linear retries anything but a 200), then translated into the
165
+ * normalized events. A data change on a face's route is a misroute:
166
+ * dropped, audible per `linear.logMisroutes`. */
167
+ events(sink: (event: TrackerEvent) => Promise<void>): () => void;
168
+ /** An agent-session event names a conversation: `created` is a session
169
+ * begun on an issue — a session with no issue is noise, said — and later
170
+ * activity is a person's message or a stop signal riding it. */
171
+ private sessionEvent;
172
+ private misroute;
173
+ }
174
+ /** Build Linear's adapter: the config's shape is checked here and the
175
+ * identities are learned before it comes back — a tracker from here is
176
+ * ready to speak. Route registration stays with `events`, which the module
177
+ * calls once its own handlers exist. */
178
+ export declare function createLinearPlatform(config: LinearConfig, routes: PublicRoutes, clients?: Map<string, LinearClient>, log?: Logger): Promise<LinearPlatform>;
@@ -0,0 +1,435 @@
1
+ /** Linear's **platform**: the `Platform` contract of `@aivi/plugin/tracker`
2
+ * spelled in Linear's words. It owns everything Linear-shaped below the
3
+ * neutral surface — the apps and their credentials, the webhook endpoints
4
+ * and their signatures, the GraphQL reads and mutations, the agent-session
5
+ * activity types — and translates at the border: platform events become
6
+ * `TrackerEvent`s, neutral updates become Linear mutations. The module's
7
+ * decision code imports **no** Linear types through this file; if it needs
8
+ * something Linear-shaped, the neutral contract grows (or the answer stays
9
+ * here), never the other way around.
10
+ *
11
+ * `createStates` and `candidates` come with the orchestrator extraction's
12
+ * dispatch work (the lane-map fetch and the pull-shaped queue read); the
13
+ * module never polled today, so the adapter does not pretend to.
14
+ */
15
+ import { getLogger } from '@aivi/core';
16
+ import { ConfigurationError } from '@aivi/plugin';
17
+ import { LinearApiError, LinearClient } from './client.js';
18
+ import { linearPrimarySecretNames, linearSecretNames, MODULE_ID, primaryLinearApp } from './config.js';
19
+ import { registerWebhookRoutes } from './routes.js';
20
+ import { isAgentSessionEvent, isIssueEvent, } from './webhook.js';
21
+ /** Credentials for every configured app, from the environment; a missing one
22
+ * is the operator's to fix. The primary app (the one app, or `linear.primary`)
23
+ * keeps the bare `LINEAR_*` names; every other app — a face — uses
24
+ * `LINEAR_<APP>_*`. */
25
+ export function requireLinearSecrets(config, env = process.env) {
26
+ const primary = primaryLinearApp(config);
27
+ if (!primary)
28
+ throw new ConfigurationError('Linear: with several apps one must carry the data feed — set `linear.primary`');
29
+ const out = [];
30
+ for (const id of Object.keys(config.apps)) {
31
+ const names = id === primary ? linearPrimarySecretNames : linearSecretNames(id);
32
+ const missing = Object.values(names).filter(name => !env[name]);
33
+ if (missing.length)
34
+ throw new ConfigurationError(`Linear app ${id}: set ${missing.join(', ')} in <home>/.env`);
35
+ out.push({
36
+ id,
37
+ clientId: env[names.clientId],
38
+ clientSecret: env[names.clientSecret],
39
+ webhookSecret: env[names.webhookSecret],
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+ /** The client to ask for teams: the one configured app, or the one `--app` names.
45
+ * `requireLinearSecrets` names the missing LINEAR_* variables when secrets are absent. */
46
+ export function clientFor(config, app, log) {
47
+ const ids = Object.keys(config.apps);
48
+ if (!ids.length)
49
+ throw new Error('linear.apps is empty: configure a Linear app before pointing projects at teams');
50
+ if (ids.length > 1 && !app)
51
+ throw new Error(`Several Linear apps are configured (${ids.join(', ')}): pass --app`);
52
+ const id = app ?? ids[0];
53
+ if (!config.apps[id])
54
+ throw new Error(`Unknown Linear app ${id}. Configured: ${ids.join(', ')}`);
55
+ const creds = requireLinearSecrets(config).find(cred => cred.id === id);
56
+ return new LinearClient(creds, { log });
57
+ }
58
+ /** Linear's verdict of *finished as far as work goes*: `completed` (Done) and
59
+ * `canceled` (Canceled, Won't Fix, Could not reproduce) both end the ticket's
60
+ * claim on anyone's attention. The words live here and nowhere above — the
61
+ * neutral contract carries only the verdict (`completed`), which is what the
62
+ * decision code and the orchestrator ever see. */
63
+ export const isClosed = (stateType) => stateType === 'completed' || stateType === 'canceled' || stateType === 'duplicate';
64
+ /** Translate a fresh Linear issue into the neutral facts the decision code
65
+ * reads. This is the only place Linear field names survive. */
66
+ export function neutralIssue(issue) {
67
+ return {
68
+ id: issue.id,
69
+ identifier: issue.identifier,
70
+ title: issue.title,
71
+ description: issue.description,
72
+ branchName: issue.branchName ?? '',
73
+ url: issue.url,
74
+ state: issue.state,
75
+ teamId: issue.team.id,
76
+ labels: issue.labels,
77
+ delegateId: issue.delegate?.id ?? null,
78
+ assignee: issue.assignee,
79
+ archived: issue.archivedAt !== null,
80
+ completed: isClosed(issue.state.type),
81
+ blockedBy: issue.blockedBy.map(b => ({ id: b.id, completed: isClosed(b.state.type) })),
82
+ };
83
+ }
84
+ export class LinearPlatform {
85
+ id = MODULE_ID;
86
+ routes;
87
+ apps = new Map();
88
+ primaryId;
89
+ logMisroutes;
90
+ log;
91
+ /** `clients` is a test seam keyed by app id; real clients come from the
92
+ * environment. Construction checks the config's shape; `ready` and
93
+ * `events` reach the network and the host's routes. */
94
+ constructor(config, routes, clients, log = getLogger(['aivi', MODULE_ID])) {
95
+ this.log = log;
96
+ this.routes = routes;
97
+ this.primaryId = primaryLinearApp(config);
98
+ this.logMisroutes = config.logMisroutes;
99
+ if (!Object.keys(config.apps).length)
100
+ throw new ConfigurationError('linear.apps is empty: configure a Linear app before pointing projects at teams');
101
+ for (const secret of requireLinearSecrets(config))
102
+ this.apps.set(secret.id, {
103
+ id: secret.id,
104
+ client: clients?.get(secret.id) ?? new LinearClient(secret, { log }),
105
+ webhookSecret: secret.webhookSecret,
106
+ userId: '',
107
+ orgId: '',
108
+ });
109
+ }
110
+ /** Learn who aivi is in each app's workspace; bad credentials are a setup
111
+ * error the operator fixes, not a retry. `createLinearPlatform` awaits
112
+ * this; it is public so a caller may re-learn the identities. */
113
+ async ready() {
114
+ for (const runtime of this.apps.values()) {
115
+ try {
116
+ const viewer = await runtime.client.viewer();
117
+ runtime.userId = viewer.id;
118
+ runtime.orgId = viewer.organizationId;
119
+ }
120
+ catch (error) {
121
+ if (error instanceof LinearApiError && (error.status === 400 || error.status === 401))
122
+ throw new ConfigurationError(`Linear app ${runtime.id}: credentials rejected (${error.message})`);
123
+ throw error;
124
+ }
125
+ }
126
+ }
127
+ /** The app a conversation belongs to. A conversation is `<app>:<agent
128
+ * session id>`; a bare app id is the app's own feed — a ticket's data
129
+ * change arrives before any session of its own exists. */
130
+ of(conversation) {
131
+ const runtime = this.apps.get(this.parts(conversation).app);
132
+ if (!runtime)
133
+ throw new Error(`Linear app for conversation ${conversation} is not configured`);
134
+ return runtime;
135
+ }
136
+ /** A session the platform names arrives on the primary's endpoint — a
137
+ * face's agent sessions reach aivi through the primary's data feed — so
138
+ * its conversation carries the primary's namespace. */
139
+ idFor(sessionId) {
140
+ return `${this.primaryId}:${sessionId}`;
141
+ }
142
+ parts(conversation) {
143
+ const at = conversation.indexOf(':');
144
+ return at < 0
145
+ ? { app: conversation, session: '' }
146
+ : { app: conversation.slice(0, at), session: conversation.slice(at + 1) };
147
+ }
148
+ async orgOf(conversation) {
149
+ return this.of(conversation).orgId;
150
+ }
151
+ ownerOf(conversation) {
152
+ return this.of(conversation).userId;
153
+ }
154
+ async issue(conversation, issueId) {
155
+ return neutralIssue(await this.of(conversation).client.issue(issueId));
156
+ }
157
+ async laneStates(conversation, teamId) {
158
+ const teams = await this.of(conversation).client.listTeams();
159
+ const team = teams.find(t => t.id === teamId);
160
+ if (!team)
161
+ throw new Error(`Linear team ${teamId} is not visible to app ${this.of(conversation).id}`);
162
+ return team.states;
163
+ }
164
+ async assign(conversation, issueId, userId) {
165
+ await this.of(conversation).client.setDelegate(issueId, userId);
166
+ }
167
+ async unassign(conversation, issueId) {
168
+ await this.of(conversation).client.setDelegate(issueId, null);
169
+ }
170
+ /** Making the app the delegate creates the agent session itself, in the
171
+ * mutation's own answer (live, 2026-09-26); the `created` webhook that
172
+ * follows is a redelivery the module dedupes. */
173
+ async startSession(conversation, issueId) {
174
+ const app = this.of(conversation);
175
+ const answer = await app.client.setDelegate(issueId, app.userId);
176
+ const made = answer.issue?.agentSessions.nodes.find(s => s.status === 'pending');
177
+ return made?.id ?? null;
178
+ }
179
+ /** The agent session a conversation names. A bare app id — the ticket's
180
+ * own feed — has no session to speak into: only a worker or the
181
+ * assistant, which always arrive with one, may comment. */
182
+ sessionOf(conversation) {
183
+ const { session } = this.parts(conversation);
184
+ if (!session)
185
+ throw new Error(`conversation ${conversation} names no agent session`);
186
+ return session;
187
+ }
188
+ /** The neutral kinds, rendered as Linear's agent-session activities: an
189
+ * answer is a visible response, an outcome the visible error, progress
190
+ * the ephemeral thought replaced as work moves, a note a visible thought. */
191
+ async comment(conversation, text, kind) {
192
+ const content = kind === 'answer'
193
+ ? { type: 'response', body: text }
194
+ : kind === 'outcome'
195
+ ? { type: 'error', body: text }
196
+ : { type: 'thought', body: text };
197
+ return this.of(conversation).client.createActivity({
198
+ agentSessionId: this.sessionOf(conversation),
199
+ content,
200
+ ...(kind === 'progress' ? { ephemeral: true } : {}),
201
+ });
202
+ }
203
+ /** The progress stream, in Linear's own activities (docs/linear.md): a
204
+ * running tool is an ephemeral `action` naming it, anything else an
205
+ * ephemeral `thought`. Ephemeral is Linear's word for *replaced*: the
206
+ * person sees the worker's current moment, never a trail of lines. */
207
+ async progress(conversation, line) {
208
+ await this.of(conversation).client.createActivity({
209
+ agentSessionId: this.sessionOf(conversation),
210
+ content: line.tool
211
+ ? { type: 'action', action: line.tool.name, parameter: line.tool.detail ?? line.text }
212
+ : { type: 'thought', body: line.text },
213
+ ephemeral: true,
214
+ });
215
+ }
216
+ /** A death before any session exists has no agent session to speak in:
217
+ * this is the plain ticket comment that keeps it visible (ruled
218
+ * 2026-10-02 after seven silent early failures). The conversation is the
219
+ * app's own feed — a bare app id routes to that app's client. */
220
+ async notify(conversation, issueId, text) {
221
+ await this.of(conversation).client.createComment(issueId, text);
222
+ }
223
+ /**
224
+ * Linear's way with a question: an `elicitation` activity in the agent
225
+ * session, carrying the `select` signal with the options when there are
226
+ * any (docs/agent-signals, live shape). The person may answer by picking —
227
+ * the choice arrives as an ordinary `prompted` event carrying the value —
228
+ * or in free text, which dismisses the elicitation just the same. The
229
+ * session waits in `awaitingInput` for as long as it takes: an elicitation
230
+ * is not on any clock.
231
+ */
232
+ async ask(conversation, question) {
233
+ await this.of(conversation).client.createActivity({
234
+ agentSessionId: this.sessionOf(conversation),
235
+ content: { type: 'elicitation', body: question.question },
236
+ ...(question.options?.length ? { signal: 'select', signalMetadata: { options: question.options } } : {}),
237
+ });
238
+ }
239
+ /** Linear's way with a plan: replace the agent session's plan wholesale —
240
+ * the same words Linear's own API uses for it, statuses and all
241
+ * (docs/agent-interaction, Agent Plans). */
242
+ async plan(conversation, plan) {
243
+ await this.of(conversation).client.setPlan(this.sessionOf(conversation), plan.steps);
244
+ }
245
+ /** The ticket desk, worked on the **issue** directly with the app's own
246
+ * token — these calls carry no agent session, so `conversation` is only
247
+ * the app selector here, as in `notify`. */
248
+ async ticketComments(conversation, issueId) {
249
+ return this.of(conversation).client.issueComments(issueId);
250
+ }
251
+ async addComment(conversation, issueId, text) {
252
+ await this.of(conversation).client.createComment(issueId, text);
253
+ }
254
+ async editIssue(conversation, issueId, changes) {
255
+ return neutralIssue(await this.of(conversation).client.issueUpdate(issueId, changes));
256
+ }
257
+ async createIssue(conversation, input) {
258
+ const client = this.of(conversation).client;
259
+ const { lane, ...fields } = input;
260
+ let stateId;
261
+ if (lane) {
262
+ const teams = await client.listTeams();
263
+ stateId = teams.find(t => t.id === input.teamId)?.states.find(s => s.name === lane)?.id;
264
+ if (!stateId)
265
+ throw new Error(`Linear team ${input.teamId} has no state named "${lane}" — check createLane in config`);
266
+ }
267
+ return neutralIssue(await client.createIssue({ ...fields, ...(stateId ? { stateId } : {}) }));
268
+ }
269
+ async teamLabels(conversation, teamId) {
270
+ return this.of(conversation).client.teamLabelNames(teamId);
271
+ }
272
+ /** Linear's word for "the result was shown": the response activity that
273
+ * renders a success *completes* the agent session (docs, live), so an
274
+ * ended session has said its piece and a retry must not say it again. */
275
+ async resultShown(conversation) {
276
+ return this.of(conversation).client.agentSessionEnded(this.sessionOf(conversation));
277
+ }
278
+ /** The closing note on the **ticket** (ruled 2026-10-02: the answer was
279
+ * only readable by opening the agent session). The person gets the text
280
+ * plus a link to the session that did the work. Idempotence is ours, as
281
+ * the seam demands, and it is a signature aivi controls:
282
+ * the note's own closing trailer, not a bare session id a worker was
283
+ * invited to talk about. Wake retries and the boot pass never post it
284
+ * twice. */
285
+ async closingNote(conversation, issueId, text) {
286
+ const session = this.sessionOf(conversation);
287
+ const facts = await this.of(conversation).client.closingNoteFacts(issueId);
288
+ const url = facts.sessions.find(s => s.id === session)?.url ?? undefined;
289
+ if (facts.comments.some(c => closingNoteStands(c.body, session, url)))
290
+ return;
291
+ const link = url ? `[agent session](${url})` : `agent session \`${session}\``;
292
+ await this.of(conversation).client.createComment(issueId, `${text}\n\n${CLOSING_MARK}${link}`);
293
+ }
294
+ /**
295
+ * Perform one neutral update. A `move` resolves the lane's *name* against
296
+ * the issue's team — the names come from the project's lane config, which
297
+ * is core's decision; Linear is only ever asked to transition, and only
298
+ * when the issue is not in that state already (the boot pass may re-drive
299
+ * a move that already landed). A `label` resolves the name against the
300
+ * team's labels (created there when none carries it) and adds or lifts
301
+ * (2026-10-03: this branch used to throw "cannot apply label yet" while
302
+ * the module's stop and failed-closing marks asked for one every time —
303
+ * the label never rode in production, only in the fake that had learned
304
+ * to mutate labels; the quality scan's dead-code report exposed it).
305
+ */
306
+ async apply(conversation, issueId, update) {
307
+ if (update.kind === 'comment')
308
+ return void (await this.comment(conversation, update.text, 'note'));
309
+ if (update.kind === 'label') {
310
+ const client = this.of(conversation).client;
311
+ const issue = await client.issue(issueId);
312
+ const labelId = await client.labelIdForTeam(issue.team.id, update.label);
313
+ return update.on ? client.addLabel(issueId, labelId) : client.removeLabel(issueId, labelId);
314
+ }
315
+ if (update.kind !== 'move')
316
+ throw new Error('Linear cannot apply this update yet');
317
+ const client = this.of(conversation).client;
318
+ const issue = await client.issue(issueId);
319
+ const teams = await client.listTeams();
320
+ const state = teams.find(t => t.id === issue.team.id)?.states.find(s => s.name === update.lane);
321
+ if (!state)
322
+ throw new Error(`Linear team ${issue.team.key} has no state named "${update.lane}" — check the project's lanes`);
323
+ if (issue.state.id === state.id)
324
+ return; // already there: the move landed before a crash
325
+ await client.transitionIssue(issueId, state.id);
326
+ }
327
+ /** One public route per app, verified by that app's secret, acknowledged
328
+ * at once (Linear retries anything but a 200), then translated into the
329
+ * normalized events. A data change on a face's route is a misroute:
330
+ * dropped, audible per `linear.logMisroutes`. */
331
+ events(sink) {
332
+ const dispatch = async (appId, payload) => {
333
+ const app = this.apps.get(appId);
334
+ if (!app)
335
+ return this.log.warn('webhook.unknown-app', { app: appId, type: payload.type, action: payload.action });
336
+ if (isAgentSessionEvent(payload))
337
+ return this.sessionEvent(appId, payload, sink);
338
+ if (isIssueEvent(payload)) {
339
+ if (appId !== this.primaryId)
340
+ return this.misroute(appId, payload);
341
+ // A data change always arrives on the primary: its route carries
342
+ // the workspace's feed, so the re-read speaks with the primary.
343
+ return sink({
344
+ kind: 'updated',
345
+ conversation: `${this.primaryId}`,
346
+ issueId: payload.data.id,
347
+ changed: changedFields(payload),
348
+ });
349
+ }
350
+ this.log.debug('webhook.ignored', { app: appId, type: payload.type, action: payload.action });
351
+ };
352
+ return registerWebhookRoutes(this.routes, [...this.apps.values()], dispatch, this.log);
353
+ }
354
+ /** An agent-session event names a conversation: `created` is a session
355
+ * begun on an issue — a session with no issue is noise, said — and later
356
+ * activity is a person's message or a stop signal riding it. */
357
+ async sessionEvent(appId, payload, sink) {
358
+ const conversation = `${appId}:${payload.agentSession.id}`;
359
+ if (payload.action === 'created') {
360
+ const issueId = payload.agentSession.issue?.id;
361
+ if (!issueId)
362
+ return this.log.warn('webhook.session-without-issue', { conversation });
363
+ return sink({
364
+ kind: 'started',
365
+ conversation,
366
+ issueId,
367
+ ...(payload.promptContext ? { promptContext: payload.promptContext } : {}),
368
+ });
369
+ }
370
+ const prompt = payload.agentActivity;
371
+ return sink({
372
+ kind: 'prompted',
373
+ id: prompt?.id ?? `seen:${payload.agentSession.id}`,
374
+ conversation,
375
+ ...(prompt?.content?.body ? { body: prompt.content.body } : {}),
376
+ ...(prompt?.signal !== undefined ? { signal: prompt.signal } : {}),
377
+ });
378
+ }
379
+ misroute(endpoint, payload) {
380
+ const fields = { endpoint, type: payload.type, action: payload.action };
381
+ if (this.logMisroutes)
382
+ this.log.warn('webhook.misrouted', fields);
383
+ else
384
+ this.log.debug('webhook.misrouted', fields);
385
+ }
386
+ }
387
+ /** The issue fields the webhook reports as changed, in the order the module
388
+ * reads them: state, labels, delegate, archive. A field absent from
389
+ * `updatedFrom` did not change — and the list is the whole contract of
390
+ * what a data change can mean to aivi. */
391
+ /** The mark that opens the closing note's trailer — the one piece of its
392
+ * shape aivi controls and reads back. */
393
+ const CLOSING_MARK = '— aivi · ';
394
+ /** Whether the closing note for this session stands on the ticket, read
395
+ * the forge's way: the trailer **closes** the comment, so
396
+ * only its last line is read — a session id quoted in a worker's prose is
397
+ * the worker's text, not the note standing. The note names its session by
398
+ * id or by the url it linked; either one on the trailer line is aivi's own
399
+ * mark. Deliberate forgery of the whole trailer is a person's comment now,
400
+ * not an accident the marker invites. */
401
+ function closingNoteStands(body, session, url) {
402
+ const last = body.trimEnd().split('\n').at(-1)?.trim();
403
+ if (!last?.startsWith(CLOSING_MARK))
404
+ return false;
405
+ return last.includes(session) || (url !== undefined && last.includes(url));
406
+ }
407
+ /** What an issue event changed, in the tracker's words. An update reports the
408
+ * fields its `updatedFrom` carries. A create reports **state**: the issue
409
+ * just entered its state, and a board's front door — Linear's Triage, a
410
+ * lane like any other — must wake the walk the same way a move into it
411
+ * does. A create with no wake was a ticket never picked up until someone
412
+ * nudged it by hand. */
413
+ function changedFields(payload) {
414
+ if (payload.action === 'create')
415
+ return ['state'];
416
+ const changed = [];
417
+ for (const [field, change] of [
418
+ ['stateId', 'state'],
419
+ ['labelIds', 'labels'],
420
+ ['delegateId', 'delegate'],
421
+ ['archivedAt', 'archive'],
422
+ ])
423
+ if (payload.updatedFrom && field in payload.updatedFrom)
424
+ changed.push(change);
425
+ return changed;
426
+ }
427
+ /** Build Linear's adapter: the config's shape is checked here and the
428
+ * identities are learned before it comes back — a tracker from here is
429
+ * ready to speak. Route registration stays with `events`, which the module
430
+ * calls once its own handlers exist. */
431
+ export async function createLinearPlatform(config, routes, clients, log) {
432
+ const tracker = new LinearPlatform(config, routes, clients, log);
433
+ await tracker.ready();
434
+ return tracker;
435
+ }