@clipboard-health/groundcrew 3.1.3 → 3.1.4

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.
@@ -4,11 +4,19 @@
4
4
  * typed `BoardState` instead of raw nodes.
5
5
  */
6
6
  import type { LinearClient } from "@linear/sdk";
7
- import { type ResolvedConfig } from "./config.ts";
7
+ import { type ResolvedConfig, type ResolvedProjectConfig } from "./config.ts";
8
8
  export interface Blocker {
9
9
  id: string;
10
10
  title: string;
11
11
  status: string | undefined;
12
+ /**
13
+ * SlugId of the project the blocker lives in. `undefined` when Linear
14
+ * returned no project for the blocker (rare — issues can technically
15
+ * exist without a project). Drives `isTerminalStatusForBlocker`'s
16
+ * pick between the blocker's own project terminals and the global
17
+ * union fallback.
18
+ */
19
+ projectSlugId: string | undefined;
12
20
  }
13
21
  export interface Issue {
14
22
  id: string;
@@ -23,6 +31,8 @@ export interface Issue {
23
31
  /** `undefined` when the ticket has no `agent-*` label — i.e. not groundcrew's concern. */
24
32
  model: string | undefined;
25
33
  teamId: string;
34
+ /** SlugId of the Linear project the issue belongs to — always one of `linear.projects[*].slugId`. */
35
+ projectSlugId: string;
26
36
  blockers: Blocker[];
27
37
  hasMoreBlockers: boolean;
28
38
  }
@@ -46,10 +56,22 @@ export declare class RepositoryResolutionError extends Error {
46
56
  repositories: readonly string[];
47
57
  });
48
58
  }
59
+ export declare class UnknownProjectError extends Error {
60
+ readonly ticket: string;
61
+ readonly projectSlugId: string | undefined;
62
+ readonly configuredSlugIds: readonly string[];
63
+ constructor(arguments_: {
64
+ ticket: string;
65
+ projectSlugId: string | undefined;
66
+ configuredSlugIds: readonly string[];
67
+ });
68
+ }
49
69
  export interface BoardSource {
50
70
  /**
51
- * Look up the configured project and fail loudly if it isn't there. Run
52
- * once at startup so a misconfigured slug surfaces before the first tick.
71
+ * Look up the configured projects and warn loudly on any that aren't
72
+ * there. Throws only when zero projects resolve, so a typo in one of
73
+ * several entries doesn't abort the watch loop. Run once at startup
74
+ * so misconfigurations surface before the first tick.
53
75
  */
54
76
  verify(): Promise<void>;
55
77
  /** Fetch the current board snapshot. Paginates internally. */
@@ -60,7 +82,16 @@ interface BoardSourceDeps {
60
82
  client: LinearClient;
61
83
  }
62
84
  export declare function createBoardSource(deps: BoardSourceDeps): BoardSource;
63
- export declare function isTerminalStatus(status: string, config: ResolvedConfig): boolean;
85
+ export declare function projectFor(issue: Issue, config: ResolvedConfig): ResolvedProjectConfig;
86
+ export declare function isTerminalStatusForIssue(issue: Issue, config: ResolvedConfig): boolean;
87
+ /**
88
+ * Terminal check for a blocker. When the blocker lives in a configured
89
+ * project, we use that project's terminal list directly. Otherwise we
90
+ * fall back to the union of terminals across all configured projects —
91
+ * matches today's single-project "is this name in our terminal list?"
92
+ * behavior so off-config blockers don't regress.
93
+ */
94
+ export declare function isTerminalStatusForBlocker(blocker: Blocker, config: ResolvedConfig): boolean;
64
95
  interface ResolvedIssue {
65
96
  uuid: string;
66
97
  title: string;
@@ -68,12 +99,14 @@ interface ResolvedIssue {
68
99
  repository: string;
69
100
  model: string;
70
101
  teamId: string;
102
+ projectSlugId: string;
71
103
  }
72
104
  export interface RawLinearIssue {
73
105
  uuid: string;
74
106
  title: string;
75
107
  description: string;
76
108
  teamId: string;
109
+ projectSlugId: string | undefined;
77
110
  labels: {
78
111
  name: string;
79
112
  }[];
@@ -127,7 +160,10 @@ export declare function resolveModelFor(arguments_: {
127
160
  /**
128
161
  * `agent-any` collapses to `models.default` here — manual setup doesn't run
129
162
  * the usage-gated `any` resolver, so the caller gets a concrete model name
130
- * instead of a sentinel that downstream code can't interpret.
163
+ * instead of a sentinel that downstream code can't interpret. Throws
164
+ * `UnknownProjectError` when the ticket lives in a Linear project that
165
+ * isn't listed in `linear.projects`, so callers can surface the misconfiguration
166
+ * instead of silently using the wrong status names.
131
167
  */
132
168
  export declare function fetchResolvedIssue(arguments_: {
133
169
  client: LinearClient;
@@ -1 +1 @@
1
- {"version":3,"file":"boardSource.d.ts","sourceRoot":"","sources":["../../src/lib/boardSource.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"boardSource.d.ts","sourceRoot":"","sources":["../../src/lib/boardSource.ts"],"names":[],"mappings":"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"}
@@ -3,7 +3,7 @@
3
3
  * snapshot. Owns the GraphQL queries and shape parsing so callers consume a
4
4
  * typed `BoardState` instead of raw nodes.
5
5
  */
6
- import { AGENT_ANY_MODEL, isShippedDefaultDisabled } from "./config.js";
6
+ import { AGENT_ANY_MODEL, findProjectBySlugId, isShippedDefaultDisabled, unionTerminalStatuses, } from "./config.js";
7
7
  import { log } from "./util.js";
8
8
  const AGENT_LABEL_PREFIX = "agent-";
9
9
  const ISSUES_PAGE_SIZE = 250;
@@ -17,61 +17,115 @@ export class RepositoryResolutionError extends Error {
17
17
  this.name = "RepositoryResolutionError";
18
18
  }
19
19
  }
20
+ export class UnknownProjectError extends Error {
21
+ ticket;
22
+ projectSlugId;
23
+ configuredSlugIds;
24
+ constructor(arguments_) {
25
+ const { ticket, projectSlugId, configuredSlugIds } = arguments_;
26
+ const ticketProjectClause = projectSlugId === undefined
27
+ ? "has no associated Linear project"
28
+ : `belongs to Linear project slugId "${projectSlugId}"`;
29
+ super(`Ticket ${ticket} ${ticketProjectClause}, which is not in linear.projects (configured: ${configuredSlugIds.join(", ")}). Add the project to your crew config or pick a ticket from a configured project.`);
30
+ this.name = "UnknownProjectError";
31
+ this.ticket = ticket;
32
+ this.projectSlugId = projectSlugId;
33
+ this.configuredSlugIds = configuredSlugIds;
34
+ }
35
+ }
20
36
  export function createBoardSource(deps) {
21
37
  const { config, client } = deps;
22
38
  return {
23
39
  async verify() {
24
- await verifyProject(client, config);
40
+ await verifyProjects(client, config);
25
41
  },
26
42
  async fetch() {
27
43
  return await fetchBoard(client, config);
28
44
  },
29
45
  };
30
46
  }
31
- export function isTerminalStatus(status, config) {
32
- return config.linear.statuses.terminal.includes(status);
47
+ export function projectFor(issue, config) {
48
+ const resolved = findProjectBySlugId(config, issue.projectSlugId);
49
+ /* v8 ignore next 5 @preserve -- fetchBoard's slugId filter and issueStatusBelongsToOwnProject keep production issues from reaching here with an unknown slugId */
50
+ if (resolved === undefined) {
51
+ throw new Error(`Issue ${issue.id} carries projectSlugId "${issue.projectSlugId}" which is not in linear.projects`);
52
+ }
53
+ return resolved;
54
+ }
55
+ export function isTerminalStatusForIssue(issue, config) {
56
+ return projectFor(issue, config).statuses.terminal.includes(issue.status);
57
+ }
58
+ /**
59
+ * Terminal check for a blocker. When the blocker lives in a configured
60
+ * project, we use that project's terminal list directly. Otherwise we
61
+ * fall back to the union of terminals across all configured projects —
62
+ * matches today's single-project "is this name in our terminal list?"
63
+ * behavior so off-config blockers don't regress.
64
+ */
65
+ export function isTerminalStatusForBlocker(blocker, config) {
66
+ if (blocker.status === undefined) {
67
+ return false;
68
+ }
69
+ if (blocker.projectSlugId !== undefined) {
70
+ const project = findProjectBySlugId(config, blocker.projectSlugId);
71
+ if (project !== undefined) {
72
+ return project.statuses.terminal.includes(blocker.status);
73
+ }
74
+ }
75
+ return unionTerminalStatuses(config).has(blocker.status);
33
76
  }
34
- async function verifyProject(client, config) {
35
- const response = await client.client.rawRequest(`query VerifyProject($slugId: String!) {
36
- projects(filter: { slugId: { eq: $slugId } }, first: 1) {
77
+ async function verifyProjects(client, config) {
78
+ const slugIds = config.linear.projects.map((project) => project.slugId);
79
+ const response = await client.client.rawRequest(`query VerifyProjects($slugIds: [String!]!) {
80
+ projects(filter: { slugId: { in: $slugIds } }, first: ${slugIds.length}) {
37
81
  nodes { id name slugId }
38
82
  }
39
- }`, { slugId: config.linear.slugId });
83
+ }`, { slugIds });
40
84
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
41
85
  const { projects } = response.data;
42
- const [project] = projects.nodes;
43
- if (!project) {
44
- throw new Error(`No Linear project found with slugId "${config.linear.slugId}" (linear.projectSlug = "${config.linear.projectSlug}"). Confirm the slug matches the trailing segment of your project's URL and that your Linear API key can access this workspace.`);
86
+ const resolved = new Map(projects.nodes.map((project) => [project.slugId.toLowerCase(), project]));
87
+ for (const project of config.linear.projects) {
88
+ const found = resolved.get(project.slugId);
89
+ if (found === undefined) {
90
+ log(`WARNING: no Linear project found with slugId "${project.slugId}" (linear.projects entry "${project.projectSlug}"). Check for typos, archived projects, or missing API-key access. Continuing without this project.`);
91
+ continue;
92
+ }
93
+ log(`Resolved Linear project: ${found.name} (slugId ${found.slugId})`);
94
+ }
95
+ if (resolved.size === 0) {
96
+ throw new Error(`No Linear projects resolved from linear.projects (${config.linear.projects.map((project) => `"${project.projectSlug}"`).join(", ")}). Confirm slugs match the trailing segment of each project's URL and that your Linear API key can access this workspace.`);
45
97
  }
46
- log(`Resolved Linear project: ${project.name} (slugId ${project.slugId})`);
47
98
  }
48
99
  async function fetchBoard(client, config) {
49
100
  const nodes = [];
50
101
  let after = null;
51
102
  // Two server-side filters narrow the response to tickets the orchestrator
52
103
  // can actually act on:
53
- // 1. State: only Todo (to dispatch), In-Progress (to count active
54
- // capacity), Done + extra terminal states (to drive cleanup). Backlog,
55
- // Triage, and custom columns are dropped server-side.
104
+ // 1. State: union of every configured project's
105
+ // {todo, inProgress, done, terminal} state names. Backlog, Triage,
106
+ // and custom columns are dropped server-side. Each issue is
107
+ // post-filtered against ITS OWN project's statuses below so a
108
+ // state name from project A doesn't leak into project B.
56
109
  // 2. Labels: at least one `agent-*` label — i.e. someone opted the ticket
57
110
  // in to groundcrew. Without this, every human-owned ticket on a shared
58
111
  // project would round-trip back just to be filtered out client-side.
59
112
  // The client-side `isGroundcrewIssue` guard in dispatcher.ts is now
60
113
  // belt-and-suspenders against query drift, not the load-bearing filter.
114
+ const slugIds = config.linear.projects.map((project) => project.slugId);
61
115
  const stateNames = [
62
- ...new Set([
63
- config.linear.statuses.todo,
64
- config.linear.statuses.inProgress,
65
- config.linear.statuses.done,
66
- ...config.linear.statuses.terminal,
67
- ]),
116
+ ...new Set(config.linear.projects.flatMap((project) => [
117
+ project.statuses.todo,
118
+ project.statuses.inProgress,
119
+ project.statuses.done,
120
+ ...project.statuses.terminal,
121
+ ])),
68
122
  ];
69
123
  for (;;) {
70
124
  // oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
71
- const response = await client.client.rawRequest(`query BoardIssues($slugId: String!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
125
+ const response = await client.client.rawRequest(`query BoardIssues($slugIds: [String!]!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
72
126
  issues(
73
127
  filter: {
74
- project: { slugId: { eq: $slugId } }
128
+ project: { slugId: { in: $slugIds } }
75
129
  state: { name: { in: $stateNames } }
76
130
  labels: { some: { name: { startsWith: $agentLabelPrefix } } }
77
131
  }
@@ -88,6 +142,7 @@ async function fetchBoard(client, config) {
88
142
  state { id name }
89
143
  team { id key }
90
144
  assignee { name }
145
+ project { slugId }
91
146
  children { nodes { id } }
92
147
  labels {
93
148
  nodes {
@@ -101,6 +156,7 @@ async function fetchBoard(client, config) {
101
156
  identifier
102
157
  title
103
158
  state { name }
159
+ project { slugId }
104
160
  }
105
161
  }
106
162
  pageInfo { hasNextPage }
@@ -109,7 +165,7 @@ async function fetchBoard(client, config) {
109
165
  pageInfo { hasNextPage endCursor }
110
166
  }
111
167
  }`, {
112
- slugId: config.linear.slugId,
168
+ slugIds,
113
169
  stateNames,
114
170
  agentLabelPrefix: AGENT_LABEL_PREFIX,
115
171
  after,
@@ -128,44 +184,86 @@ async function fetchBoard(client, config) {
128
184
  // would abort the whole `crew run` before the Todo filter ever runs.
129
185
  const issues = nodes
130
186
  .filter((node) => node.children.nodes.length === 0)
131
- .map((node) => {
132
- const modelResolution = resolveModelFor({ labels: node.labels.nodes, config });
133
- warnIfDisabledFallback(node.identifier, modelResolution, config);
134
- const repository = modelResolution.kind === "no-label"
135
- ? undefined
136
- : parseRepository({
137
- description: node.description ?? undefined,
138
- config,
139
- repositoryRegex,
140
- ticket: node.identifier,
141
- });
142
- let model;
143
- if (modelResolution.kind === "matched") {
144
- ({ model } = modelResolution);
145
- }
146
- else if (modelResolution.kind === "disabled-fallback") {
147
- model = modelResolution.fallbackModel;
148
- }
149
- else if (modelResolution.kind === "agent-any") {
150
- model = AGENT_ANY_MODEL;
151
- }
152
- return {
153
- id: node.identifier.toLowerCase(),
154
- uuid: node.id,
155
- title: node.title,
156
- status: node.state?.name ?? "Unknown",
157
- statusId: node.state?.id ?? "",
158
- assignee: node.assignee?.name ?? "Unassigned",
159
- updatedAt: node.updatedAt,
160
- repository,
161
- model,
162
- teamId: node.team?.id ?? "",
163
- blockers: blockersFromRelations(node.inverseRelations?.nodes ?? []),
164
- hasMoreBlockers: node.inverseRelations?.pageInfo.hasNextPage ?? false,
165
- };
166
- });
187
+ .filter((node) => issueStatusBelongsToOwnProject(node, config))
188
+ .map((node) => issueFromNode(node, config, repositoryRegex));
167
189
  return { timestamp: new Date().toISOString(), issues };
168
190
  }
191
+ function modelForResolution(resolution) {
192
+ if (resolution.kind === "matched") {
193
+ return resolution.model;
194
+ }
195
+ if (resolution.kind === "disabled-fallback") {
196
+ return resolution.fallbackModel;
197
+ }
198
+ if (resolution.kind === "agent-any") {
199
+ return AGENT_ANY_MODEL;
200
+ }
201
+ return undefined;
202
+ }
203
+ function issueFromNode(node, config, repositoryRegex) {
204
+ const modelResolution = resolveModelFor({ labels: node.labels.nodes, config });
205
+ warnIfDisabledFallback(node.identifier, modelResolution, config);
206
+ const repository = modelResolution.kind === "no-label"
207
+ ? undefined
208
+ : parseRepository({
209
+ description: node.description ?? undefined,
210
+ config,
211
+ repositoryRegex,
212
+ ticket: node.identifier,
213
+ });
214
+ // `issueStatusBelongsToOwnProject` drops nodes whose `state` or `project`
215
+ // is missing, so by the time we land here both are defined. The nullish
216
+ // coalescing on those fields is belt-and-suspenders for type narrowing.
217
+ return {
218
+ id: node.identifier.toLowerCase(),
219
+ uuid: node.id,
220
+ title: node.title,
221
+ /* v8 ignore next @preserve -- post-filter guarantees `state` is defined */
222
+ status: node.state?.name ?? "Unknown",
223
+ /* v8 ignore next @preserve -- post-filter guarantees `state` is defined */
224
+ statusId: node.state?.id ?? "",
225
+ assignee: node.assignee?.name ?? "Unassigned",
226
+ updatedAt: node.updatedAt,
227
+ repository,
228
+ model: modelForResolution(modelResolution),
229
+ teamId: node.team?.id ?? "",
230
+ /* v8 ignore next @preserve -- post-filter guarantees `project` is defined */
231
+ projectSlugId: node.project?.slugId?.toLowerCase() ?? "",
232
+ blockers: blockersFromRelations(node.inverseRelations?.nodes ?? []),
233
+ hasMoreBlockers: node.inverseRelations?.pageInfo.hasNextPage ?? false,
234
+ };
235
+ }
236
+ /**
237
+ * Drops issues whose status name isn't recognized by their own project's
238
+ * configured statuses. The union `stateNames` filter sent to Linear can
239
+ * pull in an issue from project A whose status name appears in project
240
+ * B's status list but not A's; this guard removes that cross-project
241
+ * leakage so each issue is judged only against its own project's rules.
242
+ */
243
+ function issueStatusBelongsToOwnProject(node, config) {
244
+ const slugId = node.project?.slugId?.toLowerCase();
245
+ if (slugId === undefined) {
246
+ return false;
247
+ }
248
+ const project = findProjectBySlugId(config, slugId);
249
+ if (project === undefined) {
250
+ return false;
251
+ }
252
+ const status = node.state?.name;
253
+ /* v8 ignore next 3 @preserve -- GraphQL state filter only returns issues whose state name is in the configured union; an undefined status implies a degenerate Linear response */
254
+ if (status === undefined) {
255
+ return false;
256
+ }
257
+ return projectStateNames(project).has(status);
258
+ }
259
+ function projectStateNames(project) {
260
+ return new Set([
261
+ project.statuses.todo,
262
+ project.statuses.inProgress,
263
+ project.statuses.done,
264
+ ...project.statuses.terminal,
265
+ ]);
266
+ }
169
267
  function escapeRegex(value) {
170
268
  return value.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw `\$&`);
171
269
  }
@@ -200,6 +298,7 @@ export async function fetchBlockersForTicket(arguments_) {
200
298
  identifier
201
299
  title
202
300
  state { name }
301
+ project { slugId }
203
302
  }
204
303
  }
205
304
  pageInfo { hasNextPage endCursor }
@@ -227,6 +326,7 @@ export async function fetchRawLinearIssue(arguments_) {
227
326
  title
228
327
  description
229
328
  team { id }
329
+ project { slugId }
230
330
  state { name }
231
331
  labels(first: ${ISSUE_LABEL_PAGE_SIZE}) {
232
332
  nodes { name }
@@ -238,6 +338,7 @@ export async function fetchRawLinearIssue(arguments_) {
238
338
  identifier
239
339
  title
240
340
  state { name }
341
+ project { slugId }
241
342
  }
242
343
  }
243
344
  pageInfo { hasNextPage }
@@ -254,6 +355,7 @@ export async function fetchRawLinearIssue(arguments_) {
254
355
  title: issue.title,
255
356
  description: issue.description ?? "",
256
357
  teamId: issue.team?.id ?? "",
358
+ projectSlugId: issue.project?.slugId?.toLowerCase(),
257
359
  labels: issue.labels.nodes,
258
360
  stateName: issue.state?.name ?? "",
259
361
  blockers: blockersFromRelations(issue.inverseRelations?.nodes ?? []),
@@ -262,33 +364,58 @@ export async function fetchRawLinearIssue(arguments_) {
262
364
  }
263
365
  export async function fetchInProgressIssueCount(arguments_) {
264
366
  const { client, config } = arguments_;
367
+ const slugIds = config.linear.projects.map((project) => project.slugId);
368
+ // The union state filter is permissive: it can pull in an issue whose state
369
+ // name happens to match a different project's `inProgress`. Post-filter
370
+ // against each issue's OWN project to count only true in-progress tickets.
371
+ const stateNames = [
372
+ ...new Set(config.linear.projects.map((project) => project.statuses.inProgress)),
373
+ ];
265
374
  let after = null;
266
375
  let count = 0;
267
376
  for (;;) {
268
377
  // oxlint-disable-next-line no-await-in-loop -- pagination cursor depends on the previous response
269
- const response = await client.client.rawRequest(`query InProgressIssues($slugId: String!, $stateName: String!, $agentLabelPrefix: String!, $after: String) {
378
+ const response = await client.client.rawRequest(`query InProgressIssues($slugIds: [String!]!, $stateNames: [String!]!, $agentLabelPrefix: String!, $after: String) {
270
379
  issues(
271
380
  filter: {
272
- project: { slugId: { eq: $slugId } }
273
- state: { name: { eq: $stateName } }
381
+ project: { slugId: { in: $slugIds } }
382
+ state: { name: { in: $stateNames } }
274
383
  labels: { some: { name: { startsWith: $agentLabelPrefix } } }
275
384
  }
276
385
  first: ${ISSUES_PAGE_SIZE}
277
386
  after: $after
278
387
  includeArchived: false
279
388
  ) {
280
- nodes { id }
389
+ nodes {
390
+ id
391
+ project { slugId }
392
+ state { name }
393
+ }
281
394
  pageInfo { hasNextPage endCursor }
282
395
  }
283
396
  }`, {
284
- slugId: config.linear.slugId,
285
- stateName: config.linear.statuses.inProgress,
397
+ slugIds,
398
+ stateNames,
286
399
  agentLabelPrefix: AGENT_LABEL_PREFIX,
287
400
  after,
288
401
  });
289
402
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- shape is fixed by our GraphQL query above
290
403
  const { issues: page } = response.data;
291
- count += page.nodes.length;
404
+ for (const node of page.nodes) {
405
+ const slugId = node.project?.slugId?.toLowerCase();
406
+ /* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
407
+ if (slugId === undefined) {
408
+ continue;
409
+ }
410
+ const project = findProjectBySlugId(config, slugId);
411
+ /* v8 ignore next 3 @preserve -- GraphQL slugId filter scopes results to configured projects */
412
+ if (project === undefined) {
413
+ continue;
414
+ }
415
+ if (node.state?.name === project.statuses.inProgress) {
416
+ count += 1;
417
+ }
418
+ }
292
419
  if (!page.pageInfo.hasNextPage) {
293
420
  return count;
294
421
  }
@@ -327,19 +454,31 @@ export function resolveModelFor(arguments_) {
327
454
  /**
328
455
  * `agent-any` collapses to `models.default` here — manual setup doesn't run
329
456
  * the usage-gated `any` resolver, so the caller gets a concrete model name
330
- * instead of a sentinel that downstream code can't interpret.
457
+ * instead of a sentinel that downstream code can't interpret. Throws
458
+ * `UnknownProjectError` when the ticket lives in a Linear project that
459
+ * isn't listed in `linear.projects`, so callers can surface the misconfiguration
460
+ * instead of silently using the wrong status names.
331
461
  */
332
462
  export async function fetchResolvedIssue(arguments_) {
333
463
  const { client, config, ticket } = arguments_;
464
+ const upper = ticket.toUpperCase();
334
465
  const raw = await fetchRawLinearIssue({ client, ticket });
466
+ const project = raw.projectSlugId === undefined ? undefined : findProjectBySlugId(config, raw.projectSlugId);
467
+ if (project === undefined) {
468
+ throw new UnknownProjectError({
469
+ ticket: upper,
470
+ projectSlugId: raw.projectSlugId,
471
+ configuredSlugIds: config.linear.projects.map((entry) => entry.slugId),
472
+ });
473
+ }
335
474
  const repositoryResolution = resolveRepositoryFor({
336
475
  description: raw.description,
337
476
  config,
338
- ticket: ticket.toUpperCase(),
477
+ ticket: upper,
339
478
  });
340
479
  if (repositoryResolution.kind === "missing") {
341
480
  throw new RepositoryResolutionError({
342
- ticket: ticket.toUpperCase(),
481
+ ticket: upper,
343
482
  repositories: config.workspace.knownRepositories,
344
483
  });
345
484
  }
@@ -362,6 +501,7 @@ export async function fetchResolvedIssue(arguments_) {
362
501
  repository: repositoryResolution.repository,
363
502
  model,
364
503
  teamId: raw.teamId,
504
+ projectSlugId: project.slugId,
365
505
  };
366
506
  }
367
507
  function parseRepository(arguments_) {
@@ -436,5 +576,6 @@ function blockersFromRelations(relations) {
436
576
  id: relation.issue?.identifier?.toLowerCase() ?? "unknown",
437
577
  title: relation.issue?.title ?? "",
438
578
  status: relation.issue?.state?.name,
579
+ projectSlugId: relation.issue?.project?.slugId?.toLowerCase(),
439
580
  }));
440
581
  }
@@ -94,29 +94,43 @@ interface DisabledUserModelDefinition {
94
94
  disabled: true;
95
95
  }
96
96
  type UserModelDefinition = EnabledUserModelDefinition | DisabledUserModelDefinition;
97
+ /**
98
+ * One Linear project the orchestrator should watch. Each project has its
99
+ * own status name overrides so multi-team setups with divergent workflow
100
+ * state names (e.g. "Todo" vs "To Do", "Shipped" vs "Done") can coexist
101
+ * under one `crew` process.
102
+ */
103
+ export interface ProjectConfig {
104
+ /**
105
+ * Project URL slug as it appears in Linear's URL bar — e.g.
106
+ * `ai-strategy-5152195762f3` from
107
+ * `https://linear.app/<workspace>/project/ai-strategy-5152195762f3`.
108
+ * The trailing 12-character hex `slugId` is what's used for the
109
+ * GraphQL filter; the leading name segment is kept intact in the
110
+ * config so `config.ts` is self-documenting at a glance, and so it
111
+ * survives Linear project renames.
112
+ */
113
+ projectSlug: string;
114
+ statuses?: {
115
+ todo?: string;
116
+ inProgress?: string;
117
+ done?: string;
118
+ terminal?: string[];
119
+ };
120
+ }
97
121
  /**
98
122
  * Loose user-facing shape — what a `config.ts` file declares.
99
- * Fields with defaults are optional; only `linear.projectSlug` and the
123
+ * Fields with defaults are optional; only `linear.projects` and the
100
124
  * `workspace.*` fields are required.
101
125
  */
102
126
  export interface Config {
103
127
  linear: {
104
128
  /**
105
- * Project URL slug as it appears in Linear's URL bar — e.g.
106
- * `ai-strategy-5152195762f3` from
107
- * `https://linear.app/<workspace>/project/ai-strategy-5152195762f3`.
108
- * The trailing 12-character hex `slugId` is what's used for the
109
- * GraphQL filter; the leading name segment is kept intact in the
110
- * config so `config.ts` is self-documenting at a glance, and so it
111
- * survives Linear project renames.
129
+ * One or more Linear projects to watch. A single `crew` process
130
+ * dispatches across all configured projects under a shared
131
+ * `orchestrator.maximumInProgress` budget.
112
132
  */
113
- projectSlug: string;
114
- statuses?: {
115
- todo?: string;
116
- inProgress?: string;
117
- done?: string;
118
- terminal?: string[];
119
- };
133
+ projects: ProjectConfig[];
120
134
  };
121
135
  git?: {
122
136
  remote?: string;
@@ -168,21 +182,24 @@ export interface Config {
168
182
  file?: string;
169
183
  };
170
184
  }
185
+ export interface ResolvedProjectConfig {
186
+ /** Original full slug from `ProjectConfig.projectSlug` — for log lines. */
187
+ projectSlug: string;
188
+ /** 12-char hex tail of `projectSlug` — the value Linear filters on. */
189
+ slugId: string;
190
+ statuses: {
191
+ todo: string;
192
+ inProgress: string;
193
+ done: string;
194
+ terminal: string[];
195
+ };
196
+ }
171
197
  /**
172
198
  * Strict shape after defaults are applied — what scripts work with.
173
199
  */
174
200
  export interface ResolvedConfig {
175
201
  linear: {
176
- /** Original full slug from `Config.linear.projectSlug` — for log lines. */
177
- projectSlug: string;
178
- /** 12-char hex tail of `projectSlug` — the value Linear filters on. */
179
- slugId: string;
180
- statuses: {
181
- todo: string;
182
- inProgress: string;
183
- done: string;
184
- terminal: string[];
185
- };
202
+ projects: ResolvedProjectConfig[];
186
203
  };
187
204
  git: {
188
205
  remote: string;
@@ -228,5 +245,22 @@ export interface ResolvedConfig {
228
245
  * Consumers needing to distinguish disabled-by-user from unknown-label use this.
229
246
  */
230
247
  export declare function isShippedDefaultDisabled(config: Pick<ResolvedConfig, "models">, name: string): boolean;
248
+ /**
249
+ * Returns the resolved project the issue belongs to, or `undefined` when
250
+ * its slugId isn't in `linear.projects[]`. Callers in the dispatcher
251
+ * path expect a project to always exist (the board fetch only surfaces
252
+ * issues from configured projects); callers in the manual-ticket path
253
+ * (`setupWorkspace`, `ticketDoctor`) use this to detect off-config
254
+ * tickets and surface a clear error.
255
+ */
256
+ export declare function findProjectBySlugId(config: Pick<ResolvedConfig, "linear">, slugId: string): ResolvedProjectConfig | undefined;
257
+ /**
258
+ * Union of every terminal status name configured across all watched
259
+ * projects. Used for blocker terminal checks when the blocker belongs
260
+ * to a project we don't watch — matches today's single-project "is the
261
+ * status terminal under any configured project?" behavior so off-config
262
+ * blockers don't regress.
263
+ */
264
+ export declare function unionTerminalStatuses(config: Pick<ResolvedConfig, "linear">): ReadonlySet<string>;
231
265
  export declare function loadConfig(): Promise<Readonly<ResolvedConfig>>;
232
266
  //# sourceMappingURL=config.d.ts.map