beflow 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/config.example.json +68 -0
  4. package/config.schema.json +413 -0
  5. package/package.json +72 -0
  6. package/src/agent/acpx.ts +197 -0
  7. package/src/agent/driver.ts +38 -0
  8. package/src/agent/events.ts +228 -0
  9. package/src/agent/issuefence.ts +42 -0
  10. package/src/agent/report.ts +44 -0
  11. package/src/cli.ts +910 -0
  12. package/src/config/load.ts +45 -0
  13. package/src/config/persist.ts +58 -0
  14. package/src/config/schema.ts +181 -0
  15. package/src/config/store.ts +119 -0
  16. package/src/core/accept.ts +25 -0
  17. package/src/core/continuation.ts +57 -0
  18. package/src/core/deadletter.ts +55 -0
  19. package/src/core/decision.ts +8 -0
  20. package/src/core/doctor.ts +223 -0
  21. package/src/core/drift.ts +59 -0
  22. package/src/core/gc.ts +223 -0
  23. package/src/core/inputquality.ts +30 -0
  24. package/src/core/issuetemplate.ts +175 -0
  25. package/src/core/mcp.ts +191 -0
  26. package/src/core/newissue.ts +343 -0
  27. package/src/core/notify.ts +151 -0
  28. package/src/core/prompts.ts +165 -0
  29. package/src/core/qualitygate.ts +70 -0
  30. package/src/core/queue.ts +40 -0
  31. package/src/core/review.ts +266 -0
  32. package/src/core/run.ts +1075 -0
  33. package/src/core/runstore.ts +144 -0
  34. package/src/core/runsview.ts +111 -0
  35. package/src/core/setup.ts +203 -0
  36. package/src/core/sla.ts +39 -0
  37. package/src/core/template.ts +65 -0
  38. package/src/core/watch.ts +825 -0
  39. package/src/core/worktree.ts +74 -0
  40. package/src/core/writeback.ts +88 -0
  41. package/src/index.ts +154 -0
  42. package/src/model/types.ts +35 -0
  43. package/src/prompts/defaults/continuation.md +9 -0
  44. package/src/prompts/defaults/implement.md +13 -0
  45. package/src/prompts/defaults/issue-enrich.md +30 -0
  46. package/src/prompts/defaults/issues/bug.md +35 -0
  47. package/src/prompts/defaults/issues/feature.md +24 -0
  48. package/src/prompts/defaults/issues/generic.md +16 -0
  49. package/src/prompts/defaults/issues/spike.md +24 -0
  50. package/src/prompts/defaults/report.md +20 -0
  51. package/src/prompts/defaults/review.md +34 -0
  52. package/src/prompts/defaults/spec.md +11 -0
  53. package/src/prompts/defaults/task.md +6 -0
  54. package/src/prompts/defaults/triage.md +11 -0
  55. package/src/prompts/text-modules.d.ts +4 -0
  56. package/src/resolve/jobkind.ts +11 -0
  57. package/src/resolve/metadata.ts +103 -0
  58. package/src/resolve/precedence.ts +104 -0
  59. package/src/trackers/factory.ts +17 -0
  60. package/src/trackers/linear/adapter.ts +416 -0
  61. package/src/trackers/linear/client.ts +264 -0
  62. package/src/trackers/linear/map.ts +113 -0
  63. package/src/trackers/linear/types.ts +44 -0
  64. package/src/trackers/marker.ts +20 -0
  65. package/src/trackers/plane/adapter.ts +754 -0
  66. package/src/trackers/plane/client.ts +302 -0
  67. package/src/trackers/plane/map.ts +168 -0
  68. package/src/trackers/plane/types.ts +134 -0
  69. package/src/trackers/tracker.ts +135 -0
@@ -0,0 +1,103 @@
1
+ import { jobKindSchema, runModeSchema } from "../config/schema.ts";
2
+ import type { IssueMeta, JobKind, RunMode } from "../model/types.ts";
3
+
4
+ const BODY_BLOCK = /<!--\s*beflow\s*([\s\S]*?)-->/i;
5
+
6
+ function asRunMode(value: string): RunMode | undefined {
7
+ const r = runModeSchema.safeParse(value);
8
+ return r.success ? r.data : undefined;
9
+ }
10
+
11
+ function asJobKind(value: string): JobKind | undefined {
12
+ const r = jobKindSchema.safeParse(value);
13
+ return r.success ? r.data : undefined;
14
+ }
15
+
16
+ function parseBodyBlock(body: string): IssueMeta {
17
+ const match = BODY_BLOCK.exec(body);
18
+ if (!match || match[1] === undefined) {
19
+ return {};
20
+ }
21
+
22
+ const meta: IssueMeta = {};
23
+ for (const line of match[1].split("\n")) {
24
+ const sep = line.indexOf(":");
25
+ if (sep === -1) {
26
+ continue;
27
+ }
28
+ const key = line.slice(0, sep).trim();
29
+ const value = line.slice(sep + 1).trim();
30
+ if (value === "") {
31
+ continue;
32
+ }
33
+
34
+ switch (key) {
35
+ case "agent":
36
+ meta.agent = value;
37
+ break;
38
+ case "repo":
39
+ meta.repo = value;
40
+ break;
41
+ case "runMode": {
42
+ const rm = asRunMode(value);
43
+ if (rm) {
44
+ meta.runMode = rm;
45
+ }
46
+ break;
47
+ }
48
+ case "jobKind": {
49
+ const jk = asJobKind(value);
50
+ if (jk) {
51
+ meta.jobKind = jk;
52
+ }
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ return meta;
58
+ }
59
+
60
+ function parseLabels(labels: string[]): IssueMeta {
61
+ const meta: IssueMeta = {};
62
+ for (const label of labels) {
63
+ const sep = label.indexOf(":");
64
+ if (sep === -1) {
65
+ continue;
66
+ }
67
+ const key = label.slice(0, sep).trim();
68
+ const value = label.slice(sep + 1).trim();
69
+ if (value === "") {
70
+ continue;
71
+ }
72
+
73
+ switch (key) {
74
+ case "agent":
75
+ meta.agent = value;
76
+ break;
77
+ case "repo":
78
+ meta.repo = value;
79
+ break;
80
+ case "run": {
81
+ const rm = asRunMode(value);
82
+ if (rm) {
83
+ meta.runMode = rm;
84
+ }
85
+ break;
86
+ }
87
+ case "jobkind": {
88
+ const jk = asJobKind(value);
89
+ if (jk) {
90
+ meta.jobKind = jk;
91
+ }
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ return meta;
97
+ }
98
+
99
+ export function parseIssueMeta(body: string, labels: string[]): IssueMeta {
100
+ const fromLabels = parseLabels(labels);
101
+ const fromBody = parseBodyBlock(body);
102
+ return { ...fromLabels, ...fromBody };
103
+ }
@@ -0,0 +1,104 @@
1
+ import type { Project } from "../config/schema.ts";
2
+ import type { IssueMeta, JobKind, Resolved, RunMode, StateGroup } from "../model/types.ts";
3
+ import { autoDetectJobKind } from "./jobkind.ts";
4
+
5
+ export interface ResolveInputs {
6
+ cli: Partial<Resolved>;
7
+ meta: IssueMeta;
8
+ project: Project;
9
+ // Optional because the resolver is defensive: if nothing global is set it
10
+ // falls through to the built-in. (In practice config.defaults always fills these.)
11
+ global: {
12
+ agent?: string;
13
+ routing?: { implement?: string; spec?: string; triage?: string };
14
+ runMode?: RunMode;
15
+ };
16
+ issue: {
17
+ type?: string;
18
+ state: { group: StateGroup };
19
+ areas: string[];
20
+ };
21
+ }
22
+
23
+ export function cascade<T>(...candidates: (T | undefined)[]): T | undefined {
24
+ for (const candidate of candidates) {
25
+ if (candidate !== undefined) {
26
+ return candidate;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ const AGENT_BUILTIN = "claude";
33
+ const RUN_MODE_BUILTIN: RunMode = "supervised";
34
+
35
+ export function resolveAgent(inputs: ResolveInputs, jobKind: JobKind): string {
36
+ return (
37
+ cascade(
38
+ inputs.cli.agent,
39
+ inputs.meta.agent,
40
+ inputs.project.routing?.[jobKind],
41
+ inputs.global.routing?.[jobKind],
42
+ inputs.project.defaults?.agent,
43
+ inputs.global.agent,
44
+ ) ?? AGENT_BUILTIN
45
+ );
46
+ }
47
+
48
+ export function resolveRunMode(inputs: ResolveInputs): RunMode {
49
+ return (
50
+ cascade(inputs.cli.runMode, inputs.meta.runMode, inputs.project.defaults?.runMode, inputs.global.runMode) ??
51
+ RUN_MODE_BUILTIN
52
+ );
53
+ }
54
+
55
+ function areaDerivedRepo(areas: string[], project: Project): string | undefined {
56
+ const primary = areas[0];
57
+ if (primary === undefined) {
58
+ return undefined;
59
+ }
60
+ return project.module_repo_map[primary];
61
+ }
62
+
63
+ export function resolveRepo(inputs: ResolveInputs): {
64
+ repo: string;
65
+ repoPath: string;
66
+ } {
67
+ const repo = cascade(
68
+ inputs.cli.repo,
69
+ inputs.meta.repo,
70
+ areaDerivedRepo(inputs.issue.areas, inputs.project),
71
+ inputs.project.default_repo,
72
+ );
73
+
74
+ if (repo === undefined) {
75
+ throw new Error("beflow: could not resolve a repo for this issue");
76
+ }
77
+
78
+ const repoPath = inputs.project.repos[repo];
79
+ if (repoPath === undefined) {
80
+ const known = Object.keys(inputs.project.repos).join(", ");
81
+ throw new Error(`beflow: resolved repo "${repo}" is not present in project.repos (known: ${known})`);
82
+ }
83
+
84
+ return { repo, repoPath };
85
+ }
86
+
87
+ export function resolveJobKind(inputs: ResolveInputs): JobKind {
88
+ return (
89
+ cascade(inputs.cli.jobKind, inputs.meta.jobKind) ??
90
+ autoDetectJobKind(inputs.issue.type, inputs.issue.state.group)
91
+ );
92
+ }
93
+
94
+ export function resolve(inputs: ResolveInputs): Resolved {
95
+ const { repo, repoPath } = resolveRepo(inputs);
96
+ const jobKind = resolveJobKind(inputs);
97
+ return {
98
+ agent: resolveAgent(inputs, jobKind),
99
+ jobKind,
100
+ repo,
101
+ repoPath,
102
+ runMode: resolveRunMode(inputs),
103
+ };
104
+ }
@@ -0,0 +1,17 @@
1
+ import type { Config, Registry } from "../config/schema.ts";
2
+ import { createLinearTracker } from "./linear/adapter.ts";
3
+ import { createPlaneTracker } from "./plane/adapter.ts";
4
+ import type { Tracker } from "./tracker.ts";
5
+
6
+ export function createTracker(config: Config, registry: Registry, env: NodeJS.ProcessEnv = process.env): Tracker {
7
+ switch (config.tracker) {
8
+ case "plane":
9
+ return createPlaneTracker(config, registry, env);
10
+ case "linear":
11
+ return createLinearTracker(config, registry, env);
12
+ default: {
13
+ const exhaustive: never = config.tracker;
14
+ throw new Error(`unknown tracker "${String(exhaustive)}"`);
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,416 @@
1
+ import { LinearClient } from "@linear/sdk";
2
+
3
+ import type { Config, Registry } from "../../config/schema.ts";
4
+ import type { Issue, IssueMeta } from "../../model/types.ts";
5
+ import { parseIssueMeta } from "../../resolve/metadata.ts";
6
+ import { hasMarker, stripMarker, withMarker } from "../marker.ts";
7
+ import type {
8
+ BlockerRef,
9
+ BoardState,
10
+ BoardTemplate,
11
+ Comment,
12
+ EnsureBoardOptions,
13
+ EnsureBoardResult,
14
+ IntakeItem,
15
+ IssueContext,
16
+ IssueDraft,
17
+ ProjectCreateResult,
18
+ ProjectCreateSpec,
19
+ QueueFilter,
20
+ Tracker,
21
+ } from "../tracker.ts";
22
+ import { IssueNotFoundError } from "../tracker.ts";
23
+ import { DEFAULT_STATE_COLOR, LinearSdkGateway } from "./client.ts";
24
+ import type { CreateIssueInput, LinearGateway } from "./client.ts";
25
+ import { mapIntakeItem, mapIssue, priorityRank, priorityToInt } from "./map.ts";
26
+ import type { RawLabel, RawWorkflowState } from "./types.ts";
27
+
28
+ interface TeamCaches {
29
+ statesById: Map<string, RawWorkflowState>;
30
+ statesByName: Map<string, RawWorkflowState>;
31
+ labelsById: Map<string, RawLabel>;
32
+ labelsByName: Map<string, RawLabel>;
33
+ }
34
+
35
+ export interface LinearTrackerOptions {
36
+ gateway: LinearGateway;
37
+ registry: Registry;
38
+ }
39
+
40
+ // Linear seeds a default "Canceled" state (American spelling); beflow's template
41
+ // uses "Cancelled". Canonicalize so ensureBoard reconciles against the existing
42
+ // state instead of creating a duplicate cancelled-group state. (Lower-casing also
43
+ // makes the existence check case-insensitive, which only helps avoid dup states.)
44
+ function canonicalStateName(name: string): string {
45
+ return name.toLowerCase().replace("canceled", "cancelled");
46
+ }
47
+
48
+ export class LinearTracker implements Tracker {
49
+ private readonly gateway: LinearGateway;
50
+ private readonly registry: Registry;
51
+ private readonly caches = new Map<string, TeamCaches>();
52
+
53
+ public constructor(options: LinearTrackerOptions) {
54
+ this.gateway = options.gateway;
55
+ this.registry = options.registry;
56
+ }
57
+
58
+ // For Linear the registry project key IS the Linear team key (e.g. "CG").
59
+ private resolveTeamKey(key: string): string {
60
+ const project = this.registry.projects[key];
61
+ if (project === undefined) {
62
+ const known = Object.keys(this.registry.projects).join(", ");
63
+ throw new Error(`linear: unknown project key "${key}" (known: ${known})`);
64
+ }
65
+ return key;
66
+ }
67
+
68
+ private teamKeyOfIssue(issueKey: string): string {
69
+ const dash = issueKey.lastIndexOf("-");
70
+ if (dash === -1) {
71
+ throw new Error(`linear: malformed issue key "${issueKey}"`);
72
+ }
73
+ return this.resolveTeamKey(issueKey.slice(0, dash));
74
+ }
75
+
76
+ private async loadCaches(teamKey: string): Promise<TeamCaches> {
77
+ const cached = this.caches.get(teamKey);
78
+ if (cached) {
79
+ return cached;
80
+ }
81
+
82
+ const [states, labels] = await Promise.all([
83
+ this.gateway.listStates(teamKey),
84
+ this.gateway.listLabels(teamKey),
85
+ ]);
86
+
87
+ const caches: TeamCaches = {
88
+ labelsById: new Map(labels.map((l) => [l.id, l])),
89
+ labelsByName: new Map(labels.map((l) => [l.name, l])),
90
+ statesById: new Map(states.map((s) => [s.id, s])),
91
+ statesByName: new Map(states.map((s) => [s.name, s])),
92
+ };
93
+ this.caches.set(teamKey, caches);
94
+ return caches;
95
+ }
96
+
97
+ public async getIssue(key: string): Promise<Issue> {
98
+ this.teamKeyOfIssue(key);
99
+ try {
100
+ const raw = await this.gateway.getIssueByIdentifier(key);
101
+ return mapIssue(raw);
102
+ } catch (err) {
103
+ if (isLinearNotFound(err)) {
104
+ throw new IssueNotFoundError(key);
105
+ }
106
+ throw err;
107
+ }
108
+ }
109
+
110
+ public async blockedBy(issue: Issue): Promise<BlockerRef[]> {
111
+ const blockers = await this.gateway.getBlockers(issue.id);
112
+ return blockers.map((b) => ({
113
+ done: b.stateType === "completed" || b.stateType === "cancelled",
114
+ key: b.identifier,
115
+ }));
116
+ }
117
+
118
+ public async issueContext(_issue: Issue): Promise<IssueContext> {
119
+ // REMAINING DOCUMENTED PARITY GAP (not a forgotten stub): linked-context fetch
120
+ // (parent epic + attachments) is Plane-only today and not yet wired for Linear.
121
+ // A safe degradation — Linear reports no context — tracked under Linear parity.
122
+ return Promise.resolve({ attachments: [] });
123
+ }
124
+
125
+ public async createIssue(project: string, draft: IssueDraft): Promise<Issue> {
126
+ const teamKey = this.resolveTeamKey(project);
127
+ const caches = await this.loadCaches(teamKey);
128
+
129
+ // Linear has no native work-item type, so draft.type is carried as a label
130
+ // (mirrors mapIssue, which leaves Issue.type undefined and treats labels as
131
+ // The type/area carrier). The names below are resolved through the team caches.
132
+ const labelNames = [...(draft.labels ?? [])];
133
+ if (draft.type !== undefined) {
134
+ labelNames.push(draft.type);
135
+ }
136
+
137
+ const labelIds: string[] = [];
138
+ for (const name of labelNames) {
139
+ const label = caches.labelsByName.get(name);
140
+ if (label === undefined) {
141
+ const known = [...caches.labelsByName.keys()].join(", ");
142
+ throw new Error(`linear: unknown label name "${name}" in team ${teamKey} (known: ${known})`);
143
+ }
144
+ labelIds.push(label.id);
145
+ }
146
+
147
+ const input: CreateIssueInput = {
148
+ description: draft.body,
149
+ priority: priorityToInt(draft.priority),
150
+ title: draft.title,
151
+ };
152
+
153
+ if (labelIds.length > 0) {
154
+ input.labelIds = labelIds;
155
+ }
156
+
157
+ if (draft.state !== undefined) {
158
+ const state = caches.statesByName.get(draft.state);
159
+ if (state === undefined) {
160
+ const known = [...caches.statesByName.keys()].join(", ");
161
+ throw new Error(`linear: unknown state name "${draft.state}" in team ${teamKey} (known: ${known})`);
162
+ }
163
+ input.stateId = state.id;
164
+ }
165
+
166
+ if (draft.assigneeId !== undefined) {
167
+ input.assigneeId = draft.assigneeId;
168
+ }
169
+
170
+ const raw = await this.gateway.createIssue(teamKey, input);
171
+ return mapIssue(raw);
172
+ }
173
+
174
+ public async listQueue(filter: QueueFilter): Promise<Issue[]> {
175
+ const teamKey = this.resolveTeamKey(filter.project);
176
+ const raws = await this.gateway.listIssues(teamKey, {
177
+ stateName: filter.state,
178
+ stateType: filter.state === undefined ? filter.stateGroup : undefined,
179
+ });
180
+ const issues = raws.map((raw) => mapIssue(raw));
181
+
182
+ return issues
183
+ .map((issue, index) => ({ index, issue }))
184
+ .sort((a, b) => {
185
+ const byRank = priorityRank(a.issue.priority) - priorityRank(b.issue.priority);
186
+ return byRank !== 0 ? byRank : a.index - b.index;
187
+ })
188
+ .map((entry) => entry.issue);
189
+ }
190
+
191
+ public async activeCycleIssueIds(_project: string): Promise<Set<string> | null> {
192
+ // REMAINING DOCUMENTED PARITY GAP (not a forgotten stub): cycle-aware scheduling
193
+ // is Plane-only today and not yet wired for Linear; null = no cycle filter.
194
+ return Promise.resolve(null);
195
+ }
196
+
197
+ public async updateState(issue: Issue, stateName: string): Promise<void> {
198
+ const teamKey = this.teamKeyOfIssue(issue.key);
199
+ const caches = await this.loadCaches(teamKey);
200
+ const state = caches.statesByName.get(stateName);
201
+ if (state === undefined) {
202
+ const known = [...caches.statesByName.keys()].join(", ");
203
+ throw new Error(`linear: unknown state name "${stateName}" in team ${teamKey} (known: ${known})`);
204
+ }
205
+ await this.gateway.updateIssueState(issue.id, state.id);
206
+ }
207
+
208
+ public async assign(issue: Issue, assigneeId: string): Promise<void> {
209
+ await this.gateway.updateIssueAssignee(issue.id, assigneeId);
210
+ }
211
+
212
+ public async addProperty(issue: Issue, name: string): Promise<void> {
213
+ const teamKey = this.teamKeyOfIssue(issue.key);
214
+ const caches = await this.loadCaches(teamKey);
215
+ const target = caches.labelsByName.get(name);
216
+ if (target === undefined) {
217
+ const known = [...caches.labelsByName.keys()].join(", ");
218
+ throw new Error(`linear: unknown label name "${name}" in team ${teamKey} (known: ${known})`);
219
+ }
220
+
221
+ const ids = new Set<string>();
222
+ for (const labelName of issue.labels) {
223
+ const existing = caches.labelsByName.get(labelName);
224
+ if (existing !== undefined) {
225
+ ids.add(existing.id);
226
+ }
227
+ }
228
+ ids.add(target.id);
229
+
230
+ await this.gateway.updateIssueLabels(issue.id, [...ids]);
231
+ }
232
+
233
+ public async removeProperty(issue: Issue, name: string): Promise<void> {
234
+ const teamKey = this.teamKeyOfIssue(issue.key);
235
+ const caches = await this.loadCaches(teamKey);
236
+ const target = caches.labelsByName.get(name);
237
+ if (target === undefined || !issue.labels.includes(name)) {
238
+ return;
239
+ }
240
+
241
+ const ids = new Set<string>();
242
+ for (const labelName of issue.labels) {
243
+ const existing = caches.labelsByName.get(labelName);
244
+ if (existing !== undefined) {
245
+ ids.add(existing.id);
246
+ }
247
+ }
248
+ ids.delete(target.id);
249
+
250
+ await this.gateway.updateIssueLabels(issue.id, [...ids]);
251
+ }
252
+
253
+ public async createProperty(teamKey: string, name: string, opts?: { color?: string }): Promise<void> {
254
+ const key = this.resolveTeamKey(teamKey);
255
+ const labels = await this.gateway.listLabels(key);
256
+ if (labels.some((l) => l.name === name)) {
257
+ return;
258
+ }
259
+ await this.gateway.createLabel(key, { color: opts?.color, name });
260
+ }
261
+
262
+ public async deleteProperty(teamKey: string, name: string): Promise<void> {
263
+ const key = this.resolveTeamKey(teamKey);
264
+ const labels = await this.gateway.listLabels(key);
265
+ const label = labels.find((l) => l.name === name);
266
+ if (label === undefined) {
267
+ return;
268
+ }
269
+ await this.gateway.deleteLabel(label.id);
270
+ }
271
+
272
+ public async comment(issue: Issue, body: string): Promise<void> {
273
+ // Linear comments take markdown directly — no HTML conversion.
274
+ await this.gateway.createComment(issue.id, withMarker(body));
275
+ }
276
+
277
+ public async listComments(issue: Issue): Promise<Comment[]> {
278
+ const raws = await this.gateway.listComments(issue.id);
279
+ return raws
280
+ .map((r) => ({
281
+ ...(r.authorId !== undefined ? { authorId: r.authorId } : {}),
282
+ body: stripMarker(r.body),
283
+ createdAt: r.createdAt,
284
+ id: r.id,
285
+ isBot: hasMarker(r.body),
286
+ }))
287
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
288
+ }
289
+
290
+ public async linkPR(issue: Issue, url: string, title?: string): Promise<void> {
291
+ await this.gateway.createAttachment(issue.id, url, title ?? "Pull Request");
292
+ }
293
+
294
+ public readMetadata(issue: Issue): IssueMeta {
295
+ return parseIssueMeta(issue.body, issue.labels);
296
+ }
297
+
298
+ public async listInbox(project: string): Promise<IntakeItem[]> {
299
+ const teamKey = this.resolveTeamKey(project);
300
+ const raws = await this.gateway.listTriage(teamKey);
301
+ return raws.map(mapIntakeItem);
302
+ }
303
+
304
+ public async acceptInbox(project: string, item: IntakeItem): Promise<void> {
305
+ const teamKey = this.resolveTeamKey(project);
306
+ const caches = await this.loadCaches(teamKey);
307
+ const backlog = [...caches.statesById.values()].find((s) => s.type === "backlog");
308
+ if (backlog === undefined) {
309
+ throw new Error(`linear: team ${teamKey} has no backlog-type state to accept triage into`);
310
+ }
311
+ await this.gateway.updateIssueState(item.issueId, backlog.id);
312
+ }
313
+
314
+ public async inspectBoard(project: string): Promise<BoardState> {
315
+ const teamKey = this.resolveTeamKey(project);
316
+ const [states, labels] = await Promise.all([
317
+ this.gateway.listStates(teamKey),
318
+ this.gateway.listLabels(teamKey),
319
+ ]);
320
+ // Linear has no modules/types — empty arrays (mirrors ensureBoard).
321
+ return { labels: labels.map((l) => l.name), modules: [], states: states.map((s) => s.name), types: [] };
322
+ }
323
+
324
+ public async ensureBoard(
325
+ project: string,
326
+ template: BoardTemplate,
327
+ _opts?: EnsureBoardOptions,
328
+ ): Promise<EnsureBoardResult> {
329
+ const teamKey = this.resolveTeamKey(project);
330
+ // Linear reconcile (update-drifted) and prune aren't implemented yet;
331
+ // Create-only, with no orphan detection.
332
+ const result: EnsureBoardResult = {
333
+ created: [],
334
+ orphans: [],
335
+ pruned: [],
336
+ skipped: [],
337
+ updated: [],
338
+ warnings: [],
339
+ };
340
+
341
+ const [states, labels] = await Promise.all([
342
+ this.gateway.listStates(teamKey),
343
+ this.gateway.listLabels(teamKey),
344
+ ]);
345
+
346
+ const stateNames = new Set(states.map((s) => canonicalStateName(s.name)));
347
+ for (const state of template.states) {
348
+ if (stateNames.has(canonicalStateName(state.name))) {
349
+ result.skipped.push(`state:${state.name}`);
350
+ continue;
351
+ }
352
+ await this.gateway.createState(teamKey, {
353
+ color: state.color || DEFAULT_STATE_COLOR,
354
+ name: state.name,
355
+ type: state.group,
356
+ });
357
+ result.created.push(`state:${state.name}`);
358
+ }
359
+
360
+ const labelNames = new Set(labels.map((l) => l.name));
361
+ for (const label of template.labels) {
362
+ if (labelNames.has(label.name)) {
363
+ result.skipped.push(`label:${label.name}`);
364
+ continue;
365
+ }
366
+ await this.gateway.createLabel(teamKey, {
367
+ color: label.color,
368
+ name: label.name,
369
+ });
370
+ result.created.push(`label:${label.name}`);
371
+ }
372
+
373
+ if (template.modules.length > 0) {
374
+ result.warnings.push(
375
+ `linear: has no modules; skipped ${String(template.modules.length)} module(s) from the template (use labels for areas instead).`,
376
+ );
377
+ }
378
+ if (template.types.length > 0) {
379
+ result.warnings.push(
380
+ `linear: has no work-item types; skipped ${String(template.types.length)} type(s) from the template.`,
381
+ );
382
+ }
383
+
384
+ return result;
385
+ }
386
+ public async createProject(spec: ProjectCreateSpec): Promise<ProjectCreateResult> {
387
+ const team = await this.gateway.createTeam({ key: spec.identifier, name: spec.name });
388
+ return { trackerProjectId: team.id };
389
+ }
390
+ }
391
+
392
+ export function createLinearTracker(
393
+ config: Config,
394
+ registry: Registry,
395
+ env: NodeJS.ProcessEnv = process.env,
396
+ ): LinearTracker {
397
+ const linearConfig = config.trackers.linear;
398
+ if (linearConfig === undefined) {
399
+ throw new Error("linear: config.trackers.linear is not configured");
400
+ }
401
+
402
+ const apiKey = env[linearConfig.apiKeyEnv];
403
+ if (apiKey === undefined || apiKey === "") {
404
+ throw new Error(`linear: API key env var "${linearConfig.apiKeyEnv}" is unset`);
405
+ }
406
+
407
+ const gateway = new LinearSdkGateway(new LinearClient({ apiKey }));
408
+ return new LinearTracker({ gateway, registry });
409
+ }
410
+
411
+ // Message-based heuristic: the Linear SDK surfaces an unknown identifier as a
412
+ // generic error with no stable not-found discriminator, so we match on its text.
413
+ function isLinearNotFound(err: unknown): boolean {
414
+ const message = err instanceof Error ? err.message : String(err);
415
+ return /not found|could not find|entity not found/i.test(message);
416
+ }