@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,97 @@
1
+ /** How far a webhook's own timestamp may be from now before it is treated as a replay. */
2
+ export declare const WEBHOOK_MAX_SKEW_MS = 60000;
3
+ export type WebhookVerdict = {
4
+ ok: true;
5
+ payload: LinearWebhook;
6
+ } | {
7
+ ok: false;
8
+ status: number;
9
+ reason: string;
10
+ };
11
+ /** The parts of an `AgentSessionEvent` payload the module reads. Untrusted input. */
12
+ export interface AgentSessionEventPayload {
13
+ type: 'AgentSessionEvent';
14
+ action: 'created' | 'prompted';
15
+ organizationId: string;
16
+ oauthClientId?: string;
17
+ appUserId?: string;
18
+ webhookTimestamp: number;
19
+ agentSession: {
20
+ id: string;
21
+ status?: string;
22
+ issue?: {
23
+ id: string;
24
+ identifier?: string;
25
+ title?: string;
26
+ team?: {
27
+ id: string;
28
+ key?: string;
29
+ };
30
+ } | null;
31
+ comment?: {
32
+ id: string;
33
+ } | null;
34
+ };
35
+ agentActivity?: {
36
+ id: string;
37
+ content?: {
38
+ type?: string;
39
+ body?: string;
40
+ };
41
+ signal?: string | null;
42
+ };
43
+ promptContext?: string;
44
+ guidance?: unknown;
45
+ previousComments?: unknown;
46
+ }
47
+ /** An issue data-change payload; the listener reads state and label changes. */
48
+ export interface IssueEventPayload {
49
+ type: 'Issue';
50
+ action: 'create' | 'update' | 'remove';
51
+ organizationId: string;
52
+ webhookTimestamp: number;
53
+ data: {
54
+ id: string;
55
+ identifier?: string;
56
+ title?: string;
57
+ stateId?: string;
58
+ state?: {
59
+ id: string;
60
+ name: string;
61
+ type?: string;
62
+ };
63
+ labelIds?: string[];
64
+ labels?: {
65
+ id: string;
66
+ name: string;
67
+ }[];
68
+ teamId?: string;
69
+ delegateId?: string | null;
70
+ branchName?: string;
71
+ };
72
+ updatedFrom?: Record<string, unknown>;
73
+ url?: string;
74
+ }
75
+ export interface OtherPayload {
76
+ type: string;
77
+ action: string;
78
+ organizationId?: string;
79
+ webhookTimestamp: number;
80
+ }
81
+ export type LinearWebhook = AgentSessionEventPayload | IssueEventPayload | OtherPayload;
82
+ /**
83
+ * Verify one delivery the way Linear documents it: HMAC-SHA256 of the raw
84
+ * body with the app's webhook signing secret, compared in constant time
85
+ * against `Linear-Signature`, then the payload's own `webhookTimestamp`
86
+ * within a minute of now. The body is parsed only after both hold.
87
+ */
88
+ export declare function verifyWebhook(input: {
89
+ body: Buffer;
90
+ signature: string | string[] | undefined;
91
+ secret: string;
92
+ now?: number;
93
+ }): WebhookVerdict;
94
+ export declare const isAgentSessionEvent: (p: LinearWebhook) => p is AgentSessionEventPayload;
95
+ export declare const isIssueEvent: (p: LinearWebhook) => p is IssueEventPayload;
96
+ /** Sign a body the way Linear does; tests and the live check use it. */
97
+ export declare function signWebhook(body: Buffer | string, secret: string): string;
@@ -0,0 +1,40 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ /** How far a webhook's own timestamp may be from now before it is treated as a replay. */
3
+ export const WEBHOOK_MAX_SKEW_MS = 60_000;
4
+ /**
5
+ * Verify one delivery the way Linear documents it: HMAC-SHA256 of the raw
6
+ * body with the app's webhook signing secret, compared in constant time
7
+ * against `Linear-Signature`, then the payload's own `webhookTimestamp`
8
+ * within a minute of now. The body is parsed only after both hold.
9
+ */
10
+ export function verifyWebhook(input) {
11
+ const header = Array.isArray(input.signature) ? input.signature[0] : input.signature;
12
+ if (!header)
13
+ return { ok: false, status: 401, reason: 'missing Linear-Signature' };
14
+ const expected = createHmac('sha256', input.secret).update(input.body).digest();
15
+ const provided = Buffer.from(header, 'hex');
16
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
17
+ return { ok: false, status: 401, reason: 'signature mismatch' };
18
+ let payload;
19
+ try {
20
+ payload = JSON.parse(input.body.toString('utf8'));
21
+ }
22
+ catch {
23
+ return { ok: false, status: 400, reason: 'body is not JSON' };
24
+ }
25
+ if (!isRecord(payload) || typeof payload.type !== 'string' || typeof payload.action !== 'string')
26
+ return { ok: false, status: 400, reason: 'payload has no type/action' };
27
+ const ts = payload.webhookTimestamp;
28
+ if (typeof ts !== 'number')
29
+ return { ok: false, status: 400, reason: 'payload has no webhookTimestamp' };
30
+ if (Math.abs((input.now ?? Date.now()) - ts) > WEBHOOK_MAX_SKEW_MS)
31
+ return { ok: false, status: 401, reason: 'webhookTimestamp outside the replay window' };
32
+ return { ok: true, payload: payload };
33
+ }
34
+ export const isAgentSessionEvent = (p) => p.type === 'AgentSessionEvent' && (p.action === 'created' || p.action === 'prompted');
35
+ export const isIssueEvent = (p) => p.type === 'Issue';
36
+ const isRecord = (value) => typeof value === 'object' && value !== null;
37
+ /** Sign a body the way Linear does; tests and the live check use it. */
38
+ export function signWebhook(body, secret) {
39
+ return createHmac('sha256', secret).update(body).digest('hex');
40
+ }
package/dist/work.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { Logger } from '@aivi/core';
2
+ import type { AiviServices } from '@aivi/plugin';
3
+ import type { LinearClient } from './client.ts';
4
+ import type { LinearConfig } from './config.ts';
5
+ /** The ticket's own words: what it is, what it says. This is the **summary**
6
+ * `initWork` hands the orchestrator, and the orchestrator composes the
7
+ * worker's first prompt around it — the same dossier for a queue pickup
8
+ * and a plain walk claim: the ticket's text does not depend on how the
9
+ * worker was reached. */
10
+ export declare const issueDossier: (issue: {
11
+ identifier: string;
12
+ title: string;
13
+ description: string | null;
14
+ }) => string;
15
+ /** What the walk reads and what `initWork` opens through: the Linear board,
16
+ * in the installation's own credentials. */
17
+ export interface LinearBoard {
18
+ projects(): string[];
19
+ tickets(projectId: string, lane: string): Promise<{
20
+ id: string;
21
+ blocked: boolean;
22
+ }[]>;
23
+ moveTo(projectId: string, ticketId: string, lane: string): Promise<void>;
24
+ /** The ticket fresh from the board, or null when a person deleted it. */
25
+ issue(ticketId: string): Promise<LinearIssueWords | undefined>;
26
+ }
27
+ /** The fields of a listed issue the tracker's stages speak with. */
28
+ export interface LinearIssueWords {
29
+ identifier: string;
30
+ title: string;
31
+ description: string | null;
32
+ teamId: string;
33
+ stateName: string;
34
+ /** Linear's branch name for the issue: what a worktree lane checks out. */
35
+ branchName: string;
36
+ }
37
+ /**
38
+ * The Linear board as the orchestrator's eligibility walk reads it
39
+ * (docs/orchestrator.md, "How work gets picked"): which projects this
40
+ * installation speaks for, what sits in a lane top-to-bottom, and how a
41
+ * ticket enters a lane.
42
+ *
43
+ * The adapter is conversation-keyed because a webhook arrives through an
44
+ * agent session; the walk has no session and no conversation, so this reads
45
+ * with the installation's own credentials — the primary app, the one that
46
+ * carries workspace data. Reads are one client and one team-and-state
47
+ * board, both fetched once per module start: the states a walk matches
48
+ * lanes against change when a person changes them, and a restart is when
49
+ * aivi learns of that.
50
+ *
51
+ * What comes back **is** eligible work: archived tickets, tickets carrying
52
+ * the human-work label, and tickets that already have a delegate are left
53
+ * out here, because those are Linear's words for "a person owns this" and
54
+ * the orchestrator never learns Linear's words. A delegate rules the ticket
55
+ * out (ruled 2026-10-02): someone already speaks for it, and re-delegating
56
+ * to the user it already has is a mutation no-op — Linear makes no session
57
+ * for it, which was seven silent failures; now the walk simply leaves the
58
+ * ticket alone until a person clears the delegate. A stop is not remembered
59
+ * here (ruled 2026-10-02): a stopped ticket goes back to the board, and
60
+ * "not that one again" is the HITL label's job, not a memory in somebody's
61
+ * database.
62
+ *
63
+ * `openClient` is the loader seam the knowledge service also uses: tests
64
+ * read a fake board with no credentials; production reads the primary app.
65
+ */
66
+ export declare function linearBoard(config: LinearConfig, services: Pick<AiviServices, 'loaded'>, log: Logger, openClient?: (app: string) => LinearClient): LinearBoard;
package/dist/work.js ADDED
@@ -0,0 +1,98 @@
1
+ import { primaryLinearApp } from './config.js';
2
+ import { projectLinear } from './projects.js';
3
+ import { clientFor, isClosed } from './tracker.js';
4
+ /** The ticket's own words: what it is, what it says. This is the **summary**
5
+ * `initWork` hands the orchestrator, and the orchestrator composes the
6
+ * worker's first prompt around it — the same dossier for a queue pickup
7
+ * and a plain walk claim: the ticket's text does not depend on how the
8
+ * worker was reached. */
9
+ export const issueDossier = (issue) => `<issue identifier="${issue.identifier}"><title>${issue.title}</title><description>${issue.description ?? ''}</description></issue>`;
10
+ /**
11
+ * The Linear board as the orchestrator's eligibility walk reads it
12
+ * (docs/orchestrator.md, "How work gets picked"): which projects this
13
+ * installation speaks for, what sits in a lane top-to-bottom, and how a
14
+ * ticket enters a lane.
15
+ *
16
+ * The adapter is conversation-keyed because a webhook arrives through an
17
+ * agent session; the walk has no session and no conversation, so this reads
18
+ * with the installation's own credentials — the primary app, the one that
19
+ * carries workspace data. Reads are one client and one team-and-state
20
+ * board, both fetched once per module start: the states a walk matches
21
+ * lanes against change when a person changes them, and a restart is when
22
+ * aivi learns of that.
23
+ *
24
+ * What comes back **is** eligible work: archived tickets, tickets carrying
25
+ * the human-work label, and tickets that already have a delegate are left
26
+ * out here, because those are Linear's words for "a person owns this" and
27
+ * the orchestrator never learns Linear's words. A delegate rules the ticket
28
+ * out (ruled 2026-10-02): someone already speaks for it, and re-delegating
29
+ * to the user it already has is a mutation no-op — Linear makes no session
30
+ * for it, which was seven silent failures; now the walk simply leaves the
31
+ * ticket alone until a person clears the delegate. A stop is not remembered
32
+ * here (ruled 2026-10-02): a stopped ticket goes back to the board, and
33
+ * "not that one again" is the HITL label's job, not a memory in somebody's
34
+ * database.
35
+ *
36
+ * `openClient` is the loader seam the knowledge service also uses: tests
37
+ * read a fake board with no credentials; production reads the primary app.
38
+ */
39
+ export function linearBoard(config, services, log, openClient = app => clientFor(config, app, log)) {
40
+ let reader;
41
+ const read = () => (reader ??= openClient(primaryLinearApp(config) ?? ''));
42
+ let board;
43
+ const teams = async () => (board ??= await read().listTeams());
44
+ /** A ticket the walk may start: not deleted between the listing and this
45
+ * breath, not a person's to work, and nobody else already speaks for it —
46
+ * not eligible, and never knocked twice. */
47
+ const walkable = (issue) => !issue.archivedAt && !issue.labels.some(l => l.name === config.humanLabel) && !issue.delegate;
48
+ return {
49
+ projects: () => services.loaded.projects.filter(p => !p.removed && projectLinear(services.loaded, p.id)).map(p => p.id),
50
+ tickets: async (projectId, state) => {
51
+ const linear = projectLinear(services.loaded, projectId);
52
+ if (!linear)
53
+ return [];
54
+ const out = [];
55
+ for (const teamId of linear.teams) {
56
+ const team = (await teams()).find(t => t.id === teamId);
57
+ const lane = team?.states.find(s => s.name === state);
58
+ if (!team || !lane)
59
+ continue; // this team's workflow has no such lane: nothing sits there
60
+ const page = await read().issuesIn(team.id, lane.id);
61
+ if (page.truncated)
62
+ log.warn('walk.board.truncated', { project: projectId, lane: state, team: team.key });
63
+ for (const issue of page.issues) {
64
+ if (!walkable(issue))
65
+ continue;
66
+ out.push({ id: issue.id, blocked: issue.blockedBy.some(b => !isClosed(b.state.type)) });
67
+ }
68
+ }
69
+ return out;
70
+ },
71
+ moveTo: async (projectId, ticketId, state) => {
72
+ const issue = await read().issue(ticketId);
73
+ const team = (await teams()).find(t => t.id === issue.team.id);
74
+ const lane = team?.states.find(s => s.name === state);
75
+ if (!team || !lane)
76
+ throw new Error(`Linear team ${issue.team.key} has no state "${state}": ${ticketId} cannot enter project ${projectId}`);
77
+ // Idempotence is the board's promise: the orchestrator re-drives an
78
+ // owed move after a retry and from the next boot, and a move that
79
+ // already landed lands here as nothing — no second word to Linear.
80
+ if (issue.state.name === state)
81
+ return;
82
+ await read().transitionIssue(ticketId, lane.id);
83
+ },
84
+ issue: async (ticketId) => {
85
+ const issue = await read().issue(ticketId);
86
+ if (issue.archivedAt)
87
+ return undefined; // deleted: not work, and the stage says so
88
+ return {
89
+ identifier: issue.identifier,
90
+ title: issue.title,
91
+ description: issue.description ?? null,
92
+ teamId: issue.team.id,
93
+ stateName: issue.state.name,
94
+ branchName: issue.branchName,
95
+ };
96
+ },
97
+ };
98
+ }
package/docs/linear.md ADDED
@@ -0,0 +1,90 @@
1
+ # Linear — the tracker's configuration
2
+
3
+ The `plugins.tracker-linear` block is the Linear module's whole setup; the
4
+ module runs when `@aivi/tracker-linear` stands in the `aivi-plugins` list.
5
+ The key is the package name; the database's prefix, the webhook URL Linear's
6
+ dashboard holds and the `aivi linear` command keep the platform's short
7
+ name, since those say who a conversation is on rather than which package
8
+ speaks for it.
9
+
10
+ ```json
11
+ {
12
+ "plugins": {
13
+ "tracker-linear": {
14
+ "agent": "aivi",
15
+ "primary": "aivi",
16
+ "apps": { "aivi": {}, "reviewer": {} },
17
+ "logMisroutes": true,
18
+ "humanLabel": "needs-human",
19
+ "pool": "default",
20
+ "progress": "tools"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ | Field | Meaning |
27
+ | --- | --- |
28
+ | `agent` | The OpenCode agent that answers people on Linear — comment mentions above all: the **assistant**. A hand delegation is never its to answer: those get the fixed refusal. Default: `assistant` |
29
+ | `primary` | The app that carries the workspace's data feed, signs the bare `LINEAR_*` secrets and authorises the Linear MCP. Default: the one app; required once several apps are configured |
30
+ | `apps.<id>` | A Linear OAuth application acting as an app user. The primary does the receiving; every other app is a **face** — a name and icon in Linear's UI with its own credentials, no routing meaning |
31
+ | `logMisroutes` | `true`: log at warn a webhook delivered to the wrong endpoint — a data change on a face's route. It is dropped either way |
32
+ | `humanLabel` | Issues with this label are never worked automatically — the only "not that one again" there is (a stop remembers nothing); a session created on one is refused with an explanation, and a failed closing or an unkillable worker marks the ticket with it |
33
+ | `pool` | Dispatcher pool a worker turn takes a slot in (must exist in `dispatcher.pools` when pools are configured) |
34
+ | `mcp` | 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; `false` disables it |
35
+ | `progress` | `silent`, `status` or `tools`: what the ephemeral activities show while a worker runs |
36
+
37
+ The project entry routes by Linear **team** (a repository may list several
38
+ teams — one checkout, several teams; a team belongs to at most one project;
39
+ Linear *projects* (epics) play no routing part). The workflow itself is not a
40
+ Linear field: it is **core's** lane array on the project,
41
+ `projects.<id>.lanes` — left to right, the order **is** the priority:
42
+
43
+ ```json
44
+ {
45
+ "projects": {
46
+ "website": {
47
+ "tracker-linear": { "teams": ["linear-team-id"] },
48
+ "lanes": [
49
+ { "name": "Todo", "queue": true },
50
+ { "name": "In Progress", "agent": "dev", "pool": "worker", "worktree": true },
51
+ { "name": "Review", "agent": "reviewer" },
52
+ { "name": "Release" }
53
+ ]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ A lane naming an `agent` is worked; one naming none is worked by humans; a
60
+ tracker state named nowhere in the array is silence. `next` and `previous`
61
+ override where a success and a failure move the ticket — neighbours by
62
+ default, and a stop never moves. `queue: true` marks the workflow's **one**
63
+ queue lane: fresh work waiting for capacity, an extension of the worker lane
64
+ it feeds; the load says so loudly for two queue lanes, a queue lane naming
65
+ an agent, or a queue whose next lane (by order or by `next`) works nothing.
66
+ `pool` names the dispatcher pool the lane's work draws capacity from.
67
+ `worktree: true` gives the worker its own git worktree; default false works
68
+ the project checkout itself, where the agent file's own `edit` deny is the
69
+ only guard. **Every state is asked** (ruled 2026-10-08): the
70
+ wizard asks what kind of lane each state is — Agent, Human, or None. Linear's
71
+ `type` is real API data, so it sets which answer the question starts on (a
72
+ `completed` state starts on Human, `canceled` and `duplicate` on None); the
73
+ person's answer wins. A state answered None is not in the array: aivi never
74
+ reads it and never moves a ticket into it. `aivi projects add` writes the
75
+ array for you — names, kinds, agents, worktrees, and the one queue question.
76
+ `teams` is never defaulted.
77
+ `workspaceId` is optional and only needed when the installation spans Linear
78
+ workspaces; `projectDefaults.tracker-linear.workspaceId` supplies it to every
79
+ project that omits its own. The mapped agent is resolved by OpenCode's
80
+ ordinary discovery for the session's directory; an unknown agent file is
81
+ OpenCode's own error at session start, not a config error.
82
+
83
+ Credentials are never in JSON. The **primary** app reads the bare
84
+ `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET` and `LINEAR_WEBHOOK_SECRET` — the
85
+ bare names mean *the one app*. Every other app follows the convention
86
+ `LINEAR_<APP>_CLIENT_ID`, `LINEAR_<APP>_CLIENT_SECRET` and
87
+ `LINEAR_<APP>_WEBHOOK_SECRET` (`<APP>` is the app id upper-cased with `-` as
88
+ `_`, so `dev-app` reads `LINEAR_DEV_APP_CLIENT_ID`). The Linear MCP, when
89
+ enabled (`plugins.tracker-linear.mcp`), is served by the module itself on
90
+ loopback and authorised with the primary's app-actor token.
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@aivi/tracker-linear",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "development": "./src/index.ts",
8
+ "default": "./dist/index.js"
9
+ },
10
+ "./config": {
11
+ "development": "./src/config.ts",
12
+ "default": "./dist/config.js"
13
+ },
14
+ "./setup": {
15
+ "development": "./src/setup.ts",
16
+ "default": "./dist/setup.js"
17
+ },
18
+ "./setupProject": {
19
+ "development": "./src/setup-project.ts",
20
+ "default": "./dist/setup-project.js"
21
+ },
22
+ "./cli": {
23
+ "development": "./src/cli.ts",
24
+ "default": "./dist/cli.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "docs"
30
+ ],
31
+ "engines": {
32
+ "node": ">=26.1.0 <27"
33
+ },
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/aivi-hq/aivi.git",
38
+ "directory": "packages/tracker-linear"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "prepack": "tsc -b tsconfig.build.json"
45
+ },
46
+ "dependencies": {
47
+ "@aivi/core": "^1.0.0",
48
+ "@aivi/plugin": "^1.0.0",
49
+ "commander": "15.0.0",
50
+ "zod": "4.1.13"
51
+ },
52
+ "peerDependencies": {
53
+ "@aivi/host": "^1.0.0"
54
+ },
55
+ "devDependencies": {
56
+ "@aivi/host": "^1.0.0"
57
+ }
58
+ }