@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,40 @@
|
|
|
1
|
+
import { type LoadedConfig, type Project, type ProjectLaneInput } from '@aivi/core';
|
|
2
|
+
import type { ProjectLinear } from './config.ts';
|
|
3
|
+
/** The project's Linear routing as it takes effect: which teams speak for it
|
|
4
|
+
* and in which workspace. Lanes are not here — they are core's ordered
|
|
5
|
+
* workflow on the `Project` itself (`projects.<id>.lanes`), which the
|
|
6
|
+
* orchestrator reads to decide moves and this plugin only ever performs. */
|
|
7
|
+
export declare function projectLinear(loaded: LoadedConfig, projectId: string): ProjectLinear | undefined;
|
|
8
|
+
/** A checkout receives one issue stream, so a Linear team may belong to
|
|
9
|
+
* exactly one project. Said for every collision, not just the first. */
|
|
10
|
+
export declare function linearTeamCollisions(loaded: LoadedConfig): string[];
|
|
11
|
+
/** A project checkout and the Linear routing that claims issues for it. */
|
|
12
|
+
export interface RoutedProject {
|
|
13
|
+
project: Project;
|
|
14
|
+
linear: ProjectLinear;
|
|
15
|
+
}
|
|
16
|
+
/** Which aivi project an issue belongs to: by the issue's Linear team, and by
|
|
17
|
+
* workspace when one is configured. */
|
|
18
|
+
export declare function projectForIssue(loaded: LoadedConfig, issue: {
|
|
19
|
+
teamId: string;
|
|
20
|
+
organizationId: string;
|
|
21
|
+
}): RoutedProject | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Point a project at Linear teams, and optionally (re)write its lanes: teams
|
|
24
|
+
* go to `projects.<id>.tracker-linear.teams`, lanes to the project's **core**
|
|
25
|
+
* `lanes` array — the ordered workflow, which core validates and the
|
|
26
|
+
* orchestrator decides moves from. Nothing else in the file is touched; an
|
|
27
|
+
* existing `lanes` stays as it was when none is passed. The written file must
|
|
28
|
+
* hold: the core config must still load (which validates the lane order and
|
|
29
|
+
* every `complete`/`return` reference), the section must parse against this
|
|
30
|
+
* plugin's own schema, and no team may belong to two projects — if any check
|
|
31
|
+
* fails, the previous bytes are restored and the error stands.
|
|
32
|
+
*/
|
|
33
|
+
export declare function writeProjectLinear(configPath: string, id: string, options: {
|
|
34
|
+
teams: string[];
|
|
35
|
+
lanes?: ProjectLaneInput[];
|
|
36
|
+
}): Promise<{
|
|
37
|
+
id: string;
|
|
38
|
+
teams: string[];
|
|
39
|
+
lanes?: ProjectLaneInput[];
|
|
40
|
+
}>;
|
package/dist/projects.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Linear's project sections: the write path the `aivi projects` wizard uses,
|
|
2
|
+
* the routing the module consults, and the cross-project checks the plugin
|
|
3
|
+
* owns because core reads nothing inside a contributed section. Teams are
|
|
4
|
+
* Linear's own; the **lanes are core's** — this file writes them into the
|
|
5
|
+
* project's core `lanes` array and reads nothing back from it. */
|
|
6
|
+
import { renameSync, rmSync } from 'node:fs';
|
|
7
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { loadConfig, PROJECT_ID } from '@aivi/core';
|
|
9
|
+
import { linearProjectSchema } from './config.js';
|
|
10
|
+
/** The module id, which is also the key of this plugin's section under every
|
|
11
|
+
* project and under `projectDefaults`: core writes what it is keyed by, so one
|
|
12
|
+
* word names both and a person never guesses which is which. */
|
|
13
|
+
const SECTION = 'tracker-linear';
|
|
14
|
+
/** The `tracker-linear` section of a project entry as written — typed, or
|
|
15
|
+
* undefined. Core passes the section through; reading it is this plugin's
|
|
16
|
+
* business. */
|
|
17
|
+
const writtenLinear = (loaded, projectId) => loaded.config.projects[projectId]?.[SECTION];
|
|
18
|
+
/** The `tracker-linear` section of `projectDefaults` as written. */
|
|
19
|
+
const writtenDefaults = (loaded) => loaded.config.projectDefaults[SECTION];
|
|
20
|
+
/** The project's Linear routing as it takes effect: which teams speak for it
|
|
21
|
+
* and in which workspace. Lanes are not here — they are core's ordered
|
|
22
|
+
* workflow on the `Project` itself (`projects.<id>.lanes`), which the
|
|
23
|
+
* orchestrator reads to decide moves and this plugin only ever performs. */
|
|
24
|
+
export function projectLinear(loaded, projectId) {
|
|
25
|
+
const entry = writtenLinear(loaded, projectId);
|
|
26
|
+
if (!entry)
|
|
27
|
+
return undefined;
|
|
28
|
+
const workspaceId = entry.workspaceId ?? writtenDefaults(loaded)?.workspaceId;
|
|
29
|
+
const createLane = entry.createLane ?? writtenDefaults(loaded)?.createLane;
|
|
30
|
+
return { teams: entry.teams, ...(workspaceId ? { workspaceId } : {}), ...(createLane ? { createLane } : {}) };
|
|
31
|
+
}
|
|
32
|
+
/** A checkout receives one issue stream, so a Linear team may belong to
|
|
33
|
+
* exactly one project. Said for every collision, not just the first. */
|
|
34
|
+
export function linearTeamCollisions(loaded) {
|
|
35
|
+
const owners = new Map();
|
|
36
|
+
const collisions = [];
|
|
37
|
+
for (const project of Object.keys(loaded.config.projects))
|
|
38
|
+
for (const team of writtenLinear(loaded, project)?.teams ?? []) {
|
|
39
|
+
const owner = owners.get(team);
|
|
40
|
+
if (owner && owner !== project)
|
|
41
|
+
collisions.push(`projects.${project}.${SECTION}.teams: this Linear team is already mapped to project ${owner}`);
|
|
42
|
+
owners.set(team, project);
|
|
43
|
+
}
|
|
44
|
+
return collisions;
|
|
45
|
+
}
|
|
46
|
+
/** Which aivi project an issue belongs to: by the issue's Linear team, and by
|
|
47
|
+
* workspace when one is configured. */
|
|
48
|
+
export function projectForIssue(loaded, issue) {
|
|
49
|
+
for (const project of loaded.projects) {
|
|
50
|
+
if (project.removed)
|
|
51
|
+
continue;
|
|
52
|
+
const linear = projectLinear(loaded, project.id);
|
|
53
|
+
if (!linear)
|
|
54
|
+
continue;
|
|
55
|
+
if (linear.teams.includes(issue.teamId) && (!linear.workspaceId || linear.workspaceId === issue.organizationId))
|
|
56
|
+
return { project, linear };
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Point a project at Linear teams, and optionally (re)write its lanes: teams
|
|
62
|
+
* go to `projects.<id>.tracker-linear.teams`, lanes to the project's **core**
|
|
63
|
+
* `lanes` array — the ordered workflow, which core validates and the
|
|
64
|
+
* orchestrator decides moves from. Nothing else in the file is touched; an
|
|
65
|
+
* existing `lanes` stays as it was when none is passed. The written file must
|
|
66
|
+
* hold: the core config must still load (which validates the lane order and
|
|
67
|
+
* every `complete`/`return` reference), the section must parse against this
|
|
68
|
+
* plugin's own schema, and no team may belong to two projects — if any check
|
|
69
|
+
* fails, the previous bytes are restored and the error stands.
|
|
70
|
+
*/
|
|
71
|
+
export async function writeProjectLinear(configPath, id, options) {
|
|
72
|
+
if (!PROJECT_ID.test(id))
|
|
73
|
+
throw new Error(`Project id "${id}" must match ${PROJECT_ID}`);
|
|
74
|
+
if (!options.teams.length)
|
|
75
|
+
throw new Error('Set at least one Linear team');
|
|
76
|
+
const before = await readFile(configPath, 'utf8');
|
|
77
|
+
const raw = JSON.parse(before);
|
|
78
|
+
const projects = (raw.projects ?? {});
|
|
79
|
+
raw.projects = projects;
|
|
80
|
+
const entry = (projects[id] ?? {});
|
|
81
|
+
projects[id] = entry;
|
|
82
|
+
const linear = (entry[SECTION] ?? {});
|
|
83
|
+
entry[SECTION] = linear;
|
|
84
|
+
linear.teams = options.teams;
|
|
85
|
+
if (options.lanes)
|
|
86
|
+
entry.lanes = options.lanes;
|
|
87
|
+
await writeWhole(configPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
88
|
+
try {
|
|
89
|
+
const loaded = await loadConfig(configPath);
|
|
90
|
+
linearProjectSchema.parse(linear);
|
|
91
|
+
// A checkout receives one issue stream: the teams just written may not
|
|
92
|
+
// belong to another project too. Collisions between other projects are
|
|
93
|
+
// not this write's doing; the module's start check says those.
|
|
94
|
+
const mine = new Set(linear.teams);
|
|
95
|
+
for (const other of Object.keys(loaded.config.projects)) {
|
|
96
|
+
if (other === id)
|
|
97
|
+
continue;
|
|
98
|
+
const shared = (writtenLinear(loaded, other)?.teams ?? []).filter(team => mine.has(team));
|
|
99
|
+
if (shared.length)
|
|
100
|
+
throw new Error(`Linear team ${shared.join(', ')} is already mapped to project ${other}; a team belongs to one project`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
await writeWhole(configPath, before);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
const lanes = entry.lanes;
|
|
108
|
+
return { id, teams: options.teams, ...(lanes ? { lanes } : {}) };
|
|
109
|
+
}
|
|
110
|
+
/** Bytes land whole or not at all: temp next door, rename over the live
|
|
111
|
+
* path. A kill mid-write used to leave truncated JSON where the config
|
|
112
|
+
* was — the restore-on-refusal pattern below protects against a bad
|
|
113
|
+
* write, not against a half one. */
|
|
114
|
+
async function writeWhole(path, contents) {
|
|
115
|
+
const temp = `${path}.aivi-tmp`;
|
|
116
|
+
rmSync(temp, { force: true });
|
|
117
|
+
await writeFile(temp, contents);
|
|
118
|
+
renameSync(temp, path);
|
|
119
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Logger } from '@aivi/core';
|
|
2
|
+
import type { PublicRoutes } from '@aivi/plugin';
|
|
3
|
+
import { type LinearWebhook } from './webhook.ts';
|
|
4
|
+
/** An app's own stream, at `app/<id>`: agent-session events — and, for the
|
|
5
|
+
* primary, the workspace's **Issues** data changes on the same route. */
|
|
6
|
+
export declare const appWebhookPath: (app: string) => string;
|
|
7
|
+
export interface WebhookApp {
|
|
8
|
+
id: string;
|
|
9
|
+
webhookSecret: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One public route per app. Failures of `dispatch` are logged, never
|
|
13
|
+
* returned: a retry would only replay what already failed. Bad signatures
|
|
14
|
+
* are logged with the delivery id, never the body.
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerWebhookRoutes(routes: PublicRoutes, apps: WebhookApp[], dispatch: (app: string, payload: LinearWebhook) => Promise<void>, log?: Logger): () => void;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getLogger } from '@aivi/core';
|
|
2
|
+
import { MODULE_ID } from './config.js';
|
|
3
|
+
import { verifyWebhook } from './webhook.js';
|
|
4
|
+
/** An app's own stream, at `app/<id>`: agent-session events — and, for the
|
|
5
|
+
* primary, the workspace's **Issues** data changes on the same route. */
|
|
6
|
+
export const appWebhookPath = (app) => `/linear/webhooks/app/${app}`;
|
|
7
|
+
/** Verify, acknowledge at once (Linear retries anything but a 200), then dispatch. */
|
|
8
|
+
const route = (label, webhookSecret, dispatch, log) => {
|
|
9
|
+
return async (request) => {
|
|
10
|
+
if (request.method !== 'POST')
|
|
11
|
+
return { status: 405, body: { error: 'Use POST' } };
|
|
12
|
+
const verdict = verifyWebhook({
|
|
13
|
+
body: request.body,
|
|
14
|
+
signature: request.headers['linear-signature'],
|
|
15
|
+
secret: webhookSecret,
|
|
16
|
+
});
|
|
17
|
+
const delivery = request.headers['linear-delivery'];
|
|
18
|
+
if (!verdict.ok) {
|
|
19
|
+
log.warn('linear.webhook.refused', { ...label, delivery, reason: verdict.reason });
|
|
20
|
+
return { status: verdict.status, body: { error: verdict.reason } };
|
|
21
|
+
}
|
|
22
|
+
log.debug('linear.webhook', { ...label, delivery, type: verdict.payload.type, action: verdict.payload.action });
|
|
23
|
+
dispatch(verdict.payload).catch(error => {
|
|
24
|
+
log.warn('linear.webhook.failed', { ...label, delivery, error });
|
|
25
|
+
});
|
|
26
|
+
return { status: 200, body: { ok: true } };
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* One public route per app. Failures of `dispatch` are logged, never
|
|
31
|
+
* returned: a retry would only replay what already failed. Bad signatures
|
|
32
|
+
* are logged with the delivery id, never the body.
|
|
33
|
+
*/
|
|
34
|
+
export function registerWebhookRoutes(routes, apps, dispatch, log = getLogger(['aivi', MODULE_ID])) {
|
|
35
|
+
const unregister = apps.map(app => routes.register(appWebhookPath(app.id), route({ app: app.id }, app.webhookSecret, p => dispatch(app.id, p), log)));
|
|
36
|
+
return () => {
|
|
37
|
+
for (const off of unregister)
|
|
38
|
+
off();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker run's **progress stream**: while a run works, its OpenCode
|
|
3
|
+
* session is mirrored into the agent session through the platform's
|
|
4
|
+
* transient progress surface (`Platform.progress`) — Linear shows it as
|
|
5
|
+
* ephemeral `thought` and `action` activities (ruled 2026-10-02: "thoughts
|
|
6
|
+
* and action types, marked as ephemeral to prevent spamming"). The reducer
|
|
7
|
+
* and the renderer are the host's own channel machinery, so the worker's
|
|
8
|
+
* line and a chat placeholder say the same thing in the same words.
|
|
9
|
+
*
|
|
10
|
+
* No polling and no interval: besides the throttle's trailing edge, the
|
|
11
|
+
* only timer waits for the next instant the rendered line changes on its
|
|
12
|
+
* own (the long-turn suffix, the idle notice, an elapsed refresh). The
|
|
13
|
+
* follower pauses while a question awaits a person — nothing happens then —
|
|
14
|
+
* and resumes when the answer lands.
|
|
15
|
+
*/
|
|
16
|
+
import type { Logger } from '@aivi/core';
|
|
17
|
+
import { type ProgressClock } from '@aivi/host';
|
|
18
|
+
import type { SessionEvents } from '@aivi/plugin/module';
|
|
19
|
+
import type { Platform } from '@aivi/plugin/tracker';
|
|
20
|
+
export interface RunProgressOptions {
|
|
21
|
+
/** `status` shows the one line; `tools` lets a running tool speak as an `action`. */
|
|
22
|
+
mode: 'status' | 'tools';
|
|
23
|
+
events: SessionEvents;
|
|
24
|
+
log: Logger;
|
|
25
|
+
/** At most one activity per window: the first at once, later ones coalesced. */
|
|
26
|
+
throttleMs?: number;
|
|
27
|
+
clock?: Partial<ProgressClock>;
|
|
28
|
+
}
|
|
29
|
+
export declare class RunProgress {
|
|
30
|
+
private readonly platform;
|
|
31
|
+
private readonly conversation;
|
|
32
|
+
private readonly sessionId;
|
|
33
|
+
private readonly mode;
|
|
34
|
+
private readonly events;
|
|
35
|
+
private readonly log;
|
|
36
|
+
private readonly throttleMs;
|
|
37
|
+
private readonly clock;
|
|
38
|
+
private state;
|
|
39
|
+
private shown;
|
|
40
|
+
private lastPostAt;
|
|
41
|
+
private unwatch;
|
|
42
|
+
private trailing;
|
|
43
|
+
private change;
|
|
44
|
+
private inflight;
|
|
45
|
+
private following;
|
|
46
|
+
constructor(platform: Platform, conversation: string, sessionId: string, options: RunProgressOptions);
|
|
47
|
+
/** Follow the session: watch and arm. Idempotent; a stopped follower
|
|
48
|
+
* resumes with its history — the elapsed line keeps counting, because
|
|
49
|
+
* the worker did. */
|
|
50
|
+
start(): void;
|
|
51
|
+
/** Say nothing more: unwatch, disarm, let the in-flight activity land. */
|
|
52
|
+
stop(): Promise<void>;
|
|
53
|
+
/** Wait for the next instant the line changes by itself. */
|
|
54
|
+
private arm;
|
|
55
|
+
private schedule;
|
|
56
|
+
/** One activity per visible change — and only when the text actually
|
|
57
|
+
* changed, so the stream says each moment once. A running tool is
|
|
58
|
+
* offered as a tool; a `status`-mode follower keeps it inside the line. */
|
|
59
|
+
private flush;
|
|
60
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker run's **progress stream**: while a run works, its OpenCode
|
|
3
|
+
* session is mirrored into the agent session through the platform's
|
|
4
|
+
* transient progress surface (`Platform.progress`) — Linear shows it as
|
|
5
|
+
* ephemeral `thought` and `action` activities (ruled 2026-10-02: "thoughts
|
|
6
|
+
* and action types, marked as ephemeral to prevent spamming"). The reducer
|
|
7
|
+
* and the renderer are the host's own channel machinery, so the worker's
|
|
8
|
+
* line and a chat placeholder say the same thing in the same words.
|
|
9
|
+
*
|
|
10
|
+
* No polling and no interval: besides the throttle's trailing edge, the
|
|
11
|
+
* only timer waits for the next instant the rendered line changes on its
|
|
12
|
+
* own (the long-turn suffix, the idle notice, an elapsed refresh). The
|
|
13
|
+
* follower pauses while a question awaits a person — nothing happens then —
|
|
14
|
+
* and resumes when the answer lands.
|
|
15
|
+
*/
|
|
16
|
+
import { DEFAULT_CLOCK, nextRenderChange, reduceProgress, renderProgress, startProgress, } from '@aivi/host';
|
|
17
|
+
export class RunProgress {
|
|
18
|
+
platform;
|
|
19
|
+
conversation;
|
|
20
|
+
sessionId;
|
|
21
|
+
mode;
|
|
22
|
+
events;
|
|
23
|
+
log;
|
|
24
|
+
throttleMs;
|
|
25
|
+
clock;
|
|
26
|
+
state;
|
|
27
|
+
shown;
|
|
28
|
+
lastPostAt;
|
|
29
|
+
unwatch;
|
|
30
|
+
trailing;
|
|
31
|
+
change;
|
|
32
|
+
inflight = Promise.resolve();
|
|
33
|
+
following = false;
|
|
34
|
+
constructor(platform, conversation, sessionId, options) {
|
|
35
|
+
this.platform = platform;
|
|
36
|
+
this.conversation = conversation;
|
|
37
|
+
this.sessionId = sessionId;
|
|
38
|
+
this.mode = options.mode;
|
|
39
|
+
this.events = options.events;
|
|
40
|
+
this.log = options.log;
|
|
41
|
+
this.throttleMs = options.throttleMs ?? 2_000;
|
|
42
|
+
this.clock = { ...DEFAULT_CLOCK, ...options.clock };
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
this.state = startProgress(now);
|
|
45
|
+
this.shown = renderProgress(this.state, this.mode, now, this.clock);
|
|
46
|
+
this.lastPostAt = 0;
|
|
47
|
+
}
|
|
48
|
+
/** Follow the session: watch and arm. Idempotent; a stopped follower
|
|
49
|
+
* resumes with its history — the elapsed line keeps counting, because
|
|
50
|
+
* the worker did. */
|
|
51
|
+
start() {
|
|
52
|
+
if (this.following || !this.platform.progress)
|
|
53
|
+
return;
|
|
54
|
+
this.following = true;
|
|
55
|
+
this.unwatch = this.events.watch(this.sessionId, event => {
|
|
56
|
+
this.state = reduceProgress(this.state, event, Date.now());
|
|
57
|
+
this.schedule();
|
|
58
|
+
});
|
|
59
|
+
this.arm();
|
|
60
|
+
}
|
|
61
|
+
/** Say nothing more: unwatch, disarm, let the in-flight activity land. */
|
|
62
|
+
async stop() {
|
|
63
|
+
this.following = false;
|
|
64
|
+
this.unwatch?.();
|
|
65
|
+
this.unwatch = undefined;
|
|
66
|
+
clearTimeout(this.trailing);
|
|
67
|
+
this.trailing = undefined;
|
|
68
|
+
clearTimeout(this.change);
|
|
69
|
+
this.change = undefined;
|
|
70
|
+
await this.inflight;
|
|
71
|
+
}
|
|
72
|
+
/** Wait for the next instant the line changes by itself. */
|
|
73
|
+
arm() {
|
|
74
|
+
clearTimeout(this.change);
|
|
75
|
+
if (!this.following)
|
|
76
|
+
return;
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const at = nextRenderChange(this.state, now, this.clock);
|
|
79
|
+
this.change = setTimeout(() => this.schedule(), Math.max(0, at - now)).unref();
|
|
80
|
+
}
|
|
81
|
+
schedule() {
|
|
82
|
+
if (!this.following || this.trailing)
|
|
83
|
+
return;
|
|
84
|
+
const wait = this.lastPostAt + this.throttleMs - Date.now();
|
|
85
|
+
if (wait <= 0) {
|
|
86
|
+
this.flush();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.trailing = setTimeout(() => {
|
|
90
|
+
this.trailing = undefined;
|
|
91
|
+
this.flush();
|
|
92
|
+
}, wait).unref();
|
|
93
|
+
}
|
|
94
|
+
/** One activity per visible change — and only when the text actually
|
|
95
|
+
* changed, so the stream says each moment once. A running tool is
|
|
96
|
+
* offered as a tool; a `status`-mode follower keeps it inside the line. */
|
|
97
|
+
flush() {
|
|
98
|
+
if (!this.following)
|
|
99
|
+
return;
|
|
100
|
+
this.arm();
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const text = renderProgress(this.state, this.mode, now, this.clock);
|
|
103
|
+
if (text === this.shown)
|
|
104
|
+
return;
|
|
105
|
+
this.shown = text;
|
|
106
|
+
this.lastPostAt = now;
|
|
107
|
+
const running = this.mode === 'tools' ? this.state.tools.findLast(t => t.state === 'running' && !t.pending) : undefined;
|
|
108
|
+
const head = text.split('\n')[0];
|
|
109
|
+
const line = {
|
|
110
|
+
text: head,
|
|
111
|
+
...(running ? { tool: { name: running.name, ...(running.detail ? { detail: running.detail } : {}) } } : {}),
|
|
112
|
+
};
|
|
113
|
+
this.inflight = this.inflight
|
|
114
|
+
.then(async () => {
|
|
115
|
+
if (this.following)
|
|
116
|
+
await this.platform.progress?.(this.conversation, line);
|
|
117
|
+
})
|
|
118
|
+
.catch(error => this.log.warn('progress.activity.failed', { error }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Linear's project-setup contributor, at its `./setupProject` subpath: it
|
|
2
|
+
* maps a new project to Linear teams and offers the project's lane array —
|
|
3
|
+
* the board's states in board order, each asked what kind of lane it is
|
|
4
|
+
* (agent, human, or none). It clones
|
|
5
|
+
* nothing (a tracker has no checkout to give): it hands back the `linear`
|
|
6
|
+
* section core writes under the project, and the **core** `lanes` array
|
|
7
|
+
* core writes on the project. Everything Linear-specific — apps, teams,
|
|
8
|
+
* workflow states — lives here; core only knows this plugin serves the
|
|
9
|
+
* `tracker` role. */
|
|
10
|
+
import { type Logger, type ProjectLaneInput } from '@aivi/core';
|
|
11
|
+
import { type LaneKind, type ProjectContributor } from '@aivi/plugin';
|
|
12
|
+
import { type LinearTeam } from './client.ts';
|
|
13
|
+
import type { LinearConfig } from './config.ts';
|
|
14
|
+
/** The states the wizard asks about, in board order: deduped by name across
|
|
15
|
+
* the picked teams (two mapped teams can share a state name, and one name
|
|
16
|
+
* gets one lane). Nothing is dropped — **every state is asked**: what a
|
|
17
|
+
* state is for is the person's answer to the lane question, not aivi's
|
|
18
|
+
* guess from the state's type. Linear's `type` is
|
|
19
|
+
* real API data, so it only sets which answer the question starts on. The
|
|
20
|
+
* client already returns each team's states in board order (type group,
|
|
21
|
+
* then position). Exported pure for the test that pins the dedupe. */
|
|
22
|
+
export declare function boardStates(states: LinearTeam['states']): LinearTeam['states'];
|
|
23
|
+
/** Which answer the lane question starts on, from Linear's state type (real
|
|
24
|
+
* API data): a `completed` state starts on Human — a person takes finished
|
|
25
|
+
* work; `canceled` and `duplicate` start on None — they end out of the
|
|
26
|
+
* workflow; everything else starts on Agent. A start is a highlight, not a
|
|
27
|
+
* ruling: the person's answer wins. */
|
|
28
|
+
export declare function laneKindDefault(type: string): LaneKind;
|
|
29
|
+
/** The lanes the queue question may offer (ruled 2026-10-02): **one**
|
|
30
|
+
* question after all lanes are configured, and its candidates are the
|
|
31
|
+
* lanes that could legally hold the queue — a queue lane must feed a
|
|
32
|
+
* worker lane, so a lane whose next works nobody is no candidate, and the
|
|
33
|
+
* last lane feeds nothing at all. And a lane that has an agent is **never**
|
|
34
|
+
* an option (ruled 2026-10-02): a queue is where people wait for a worker,
|
|
35
|
+
* not a worker lane itself — the queue runs between the human lanes and
|
|
36
|
+
* the working ones, like a real board's waiting column. A **human** lane is
|
|
37
|
+
* a legal queue: that is what "used as a queue" means in the lane answer's
|
|
38
|
+
* own words. Offering anything else would write a config the operator did
|
|
39
|
+
* not mean. Exported pure, so the wizard's rules are testable apart from
|
|
40
|
+
* prompts. */
|
|
41
|
+
export declare function queueCandidates(lanes: ProjectLaneInput[]): ProjectLaneInput[];
|
|
42
|
+
/** Mark the chosen queue lane. The queue answer is the wizard's later
|
|
43
|
+
* word, so a lane that just chose an agent gives it up — and says so out
|
|
44
|
+
* loud, because a lane silently losing its worker is not a wizard. The
|
|
45
|
+
* returned lines are what the caller logs; the marking itself follows
|
|
46
|
+
* core's load rules: a queue lane works none and writes nothing. */
|
|
47
|
+
export declare function markQueue(lanes: ProjectLaneInput[], chosen: string): string[];
|
|
48
|
+
/** The crossing to Linear the wizard needs: the teams it can list.
|
|
49
|
+
* Production is `clientFor`; a test answers with its own teams, because
|
|
50
|
+
* the wizard's questions, order and written config are the unit — the
|
|
51
|
+
* GraphQL round trip is client.test.ts's subject, and the live gate's. */
|
|
52
|
+
export type LinearTeamsClient = {
|
|
53
|
+
listTeams(): Promise<LinearTeam[]>;
|
|
54
|
+
};
|
|
55
|
+
export type MakeTeamsClient = (config: LinearConfig, app: string | undefined, log: Logger) => LinearTeamsClient;
|
|
56
|
+
/** The wizard, in its order: app, teams, the project's name, what kind of
|
|
57
|
+
* lane each board state is (agent, human, or none), then the queue over
|
|
58
|
+
* the no-agent lanes. Each step is its own question and its own honest
|
|
59
|
+
* failure; the whole is the ruling. */
|
|
60
|
+
declare function makeContributor(makeClient?: MakeTeamsClient): ProjectContributor;
|
|
61
|
+
export declare const contributor: ProjectContributor;
|
|
62
|
+
export { makeContributor };
|
|
63
|
+
export default contributor;
|