@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/README.md +41 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +277 -0
- package/dist/client.js +362 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +171 -0
- package/dist/desk.d.ts +13 -0
- package/dist/desk.js +70 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/links.d.ts +48 -0
- package/dist/links.js +83 -0
- package/dist/mcp.d.ts +32 -0
- package/dist/mcp.js +126 -0
- package/dist/module.d.ts +38 -0
- package/dist/module.js +767 -0
- package/dist/projects.d.ts +40 -0
- package/dist/projects.js +119 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.js +40 -0
- package/dist/runprogress.d.ts +60 -0
- package/dist/runprogress.js +120 -0
- package/dist/setup-project.d.ts +63 -0
- package/dist/setup-project.js +254 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +526 -0
- package/dist/tracker.d.ts +178 -0
- package/dist/tracker.js +435 -0
- package/dist/webhook.d.ts +97 -0
- package/dist/webhook.js +40 -0
- package/dist/work.d.ts +66 -0
- package/dist/work.js +98 -0
- package/docs/linear.md +90 -0
- package/package.json +56 -4
|
@@ -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>;
|
package/dist/tracker.js
ADDED
|
@@ -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
|
+
}
|