@aivi/tracker-linear 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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,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;
|
package/dist/webhook.js
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|