patchrelay 0.54.3 → 0.55.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 CHANGED
@@ -8,9 +8,9 @@ This repository ships **three independent services**. Install one, two, or all t
8
8
 
9
9
  | Service | Package | Role |
10
10
  |-|-|-|
11
- | [`patchrelay`](./) | `npm install -g patchrelay` | Linear-driven harness that runs Codex sessions inside your real repos. Fully autonomous on webhooks: implementation, review fix, CI repair, queue repair. |
12
- | [`review-quill`](./packages/review-quill) | `npm install -g review-quill` | GitHub PR review bot. Reviews every merge-ready head from a real local checkout and posts a normal `APPROVE` / `REQUEST_CHANGES` review. |
13
- | [`merge-steward`](./packages/merge-steward) | `npm install -g merge-steward` | Serial merge queue. Speculatively integrates approved PRs on top of the latest `main`, runs CI on the integrated SHA, and fast-forwards `main` only when that tested result is green. |
11
+ | [`patchrelay`](./) | `pnpm add -g patchrelay` | Linear-driven harness that runs Codex sessions inside your real repos. Fully autonomous on webhooks: implementation, review fix, CI repair, queue repair. |
12
+ | [`review-quill`](./packages/review-quill) | `pnpm add -g review-quill` | GitHub PR review bot. Reviews every merge-ready head from a real local checkout and posts a normal `APPROVE` / `REQUEST_CHANGES` review. |
13
+ | [`merge-steward`](./packages/merge-steward) | `pnpm add -g merge-steward` | Serial merge queue. Speculatively integrates approved PRs on top of the latest `main`, runs CI on the integrated SHA, and fast-forwards `main` only when that tested result is green. |
14
14
 
15
15
  Common setups:
16
16
 
@@ -22,7 +22,7 @@ Common setups:
22
22
 
23
23
  - **PRs ship tested against the latest `main`.** The queue re-validates on the integrated SHA at admission time, and retries if `main` moves during validation. No more "green yesterday, broken today."
24
24
  - **Many PR failures have mechanical fixes an agent can handle.** Requested changes like a rename, a missing null check, a new test, refreshing against `main`, resolving a conflict surfaced by speculation, or rerunning a flaky job. Both services publish structured failure reasons (inline review comments, failing check names, queue incidents) an agent can act on directly.
25
- - **No prerequisites beyond GitHub.** A GitHub App, a webhook, and `npm install -g` per service.
25
+ - **No prerequisites beyond GitHub.** A GitHub App, a webhook, and `pnpm add -g` per service.
26
26
 
27
27
  ## Use with your own agent
28
28
 
@@ -45,7 +45,7 @@ Prerequisites:
45
45
  - a public HTTPS entrypoint (Caddy, nginx, tunnel) so Linear and GitHub can reach your webhooks
46
46
 
47
47
  ```bash
48
- npm install -g patchrelay
48
+ pnpm add -g patchrelay
49
49
  patchrelay init https://patchrelay.example.com
50
50
  ```
51
51
 
@@ -61,6 +61,8 @@ patchrelay service status
61
61
  patchrelay dashboard
62
62
  ```
63
63
 
64
+ When one Linear team owns issues for multiple repositories, include `--project <Linear project>` on each `repo link`. PatchRelay routes Linear webhooks by project first, so separate projects inside the same `USE` team can map to separate GitHub repos.
65
+
64
66
  Each repo needs two workflow files for repo-specific run behavior:
65
67
 
66
68
  - `IMPLEMENTATION_WORKFLOW.md` — implementation, CI repair, queue repair runs
@@ -119,7 +121,7 @@ See the [merge-steward package README](./packages/merge-steward/README.md) for t
119
121
  - [Prompting](./docs/prompting.md) — how workflow files and the built-in scaffold compose
120
122
  - [Secrets](./docs/secrets.md) — systemd credentials, resolution order
121
123
  - [review-quill reference](./docs/review-quill.md) · [merge-steward reference](./docs/merge-steward.md)
122
- - [Product brief](./docs/product-specs/patchrelay.md) · [Dashboard guidance](./docs/dashboard-guidance.md) · [Design docs](./docs/design-docs/index.md)
124
+ - [Dashboard guidance](./docs/dashboard-guidance.md) · [Design docs](./docs/design-docs/index.md)
123
125
  - [Contributing](./CONTRIBUTING.md) · [Security policy](./SECURITY.md)
124
126
 
125
127
  ## Status
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.54.3",
4
- "commit": "83c6b1f9e9e3",
5
- "builtAt": "2026-04-30T08:20:16.706Z"
3
+ "version": "0.55.0",
4
+ "commit": "bd44ea445936",
5
+ "builtAt": "2026-05-01T12:06:57.419Z"
6
6
  }
@@ -4,25 +4,16 @@ import { Box, Text, useStdout } from "ink";
4
4
  import { IssueRow } from "./IssueRow.js";
5
5
  import { StatusBar } from "./StatusBar.js";
6
6
  import { HelpBar } from "./HelpBar.js";
7
- const CHROME_ROWS = 3;
7
+ import { computeIssueListLayout, computeVisibleIssueParts, computeVisibleWindowForTotal } from "./list-layout.js";
8
8
  export function computeVisibleWindow(issues, selectedIndex, maxRows) {
9
- if (issues.length === 0)
10
- return { start: 0, end: 0 };
11
- const clamped = Math.max(0, Math.min(selectedIndex, issues.length - 1));
12
- const half = Math.floor(maxRows / 2);
13
- let start = Math.max(0, clamped - half);
14
- let end = Math.min(issues.length, start + maxRows);
15
- if (end - start < maxRows) {
16
- start = Math.max(0, end - maxRows);
17
- }
18
- return { start, end };
9
+ return computeVisibleWindowForTotal(issues.length, selectedIndex, maxRows);
19
10
  }
20
11
  export function IssueListView({ issues, selectedIndex, connected, lastServerMessageAt, filter, frozen, compact = false, }) {
21
12
  const { stdout } = useStdout();
22
13
  const cols = stdout?.columns ?? 80;
23
- const rows = stdout?.rows ?? 24;
14
+ const rows = Math.max(1, stdout?.rows ?? 24);
24
15
  const titleWidth = Math.max(0, cols - 42);
25
- const maxVisibleRows = Math.max(1, rows - CHROME_ROWS);
16
+ const layout = computeIssueListLayout(rows);
26
17
  const [, tick] = useReducer((c) => c + 1, 0);
27
18
  useEffect(() => {
28
19
  if (frozen)
@@ -30,9 +21,9 @@ export function IssueListView({ issues, selectedIndex, connected, lastServerMess
30
21
  const id = setInterval(tick, 5000);
31
22
  return () => clearInterval(id);
32
23
  }, [frozen]);
33
- const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows);
24
+ const { start: startIndex, end: endIndex, showAbove, showBelow, } = computeVisibleIssueParts(issues.length, selectedIndex, layout.bodyRows);
34
25
  const visible = issues.slice(startIndex, endIndex);
35
26
  const hiddenAbove = startIndex;
36
27
  const hiddenBelow = Math.max(0, issues.length - endIndex);
37
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, frozen: frozen ?? false }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: " " })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 ? _jsx(Text, { dimColor: true, children: ` ↑${hiddenAbove}` }) : null, visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth, compact: compact }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 ? _jsx(Text, { dimColor: true, children: ` ↓${hiddenBelow}` }) : null] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
28
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, frozen: frozen ?? false }), _jsx(Box, { marginTop: layout.showBodyGap ? 1 : 0, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: " " })) : (_jsxs(_Fragment, { children: [showAbove ? _jsx(Text, { dimColor: true, children: ` ↑${hiddenAbove}` }) : null, visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth, compact: compact }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), showBelow ? _jsx(Text, { dimColor: true, children: ` ↓${hiddenBelow}` }) : null] })) }), layout.showHelp ? (_jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })) : null] }));
38
29
  }
@@ -0,0 +1,44 @@
1
+ export function computeIssueListLayout(totalRows) {
2
+ const rows = Math.max(1, totalRows);
3
+ const showBodyGap = rows >= 5;
4
+ const showHelp = rows >= 8;
5
+ const chromeRows = 1 + (showBodyGap ? 1 : 0) + (showHelp ? 2 : 0);
6
+ return {
7
+ bodyRows: Math.max(1, rows - chromeRows),
8
+ showBodyGap,
9
+ showHelp,
10
+ };
11
+ }
12
+ export function computeVisibleWindowForTotal(total, selectedIndex, maxRows) {
13
+ if (total === 0)
14
+ return { start: 0, end: 0 };
15
+ const clamped = Math.max(0, Math.min(selectedIndex, total - 1));
16
+ const half = Math.floor(maxRows / 2);
17
+ let start = Math.max(0, clamped - half);
18
+ let end = Math.min(total, start + maxRows);
19
+ if (end - start < maxRows) {
20
+ start = Math.max(0, end - maxRows);
21
+ }
22
+ return { start, end };
23
+ }
24
+ export function computeVisibleIssueParts(total, selectedIndex, rowBudget) {
25
+ if (total === 0 || rowBudget <= 0) {
26
+ return { start: 0, end: 0, showAbove: false, showBelow: false };
27
+ }
28
+ let { start, end } = computeVisibleWindowForTotal(total, selectedIndex, Math.max(1, rowBudget));
29
+ let hiddenAbove = start > 0;
30
+ let hiddenBelow = end < total;
31
+ if (rowBudget >= 3 && (hiddenAbove || hiddenBelow)) {
32
+ const indicatorRows = (hiddenAbove ? 1 : 0) + (hiddenBelow ? 1 : 0);
33
+ ({ start, end } = computeVisibleWindowForTotal(total, selectedIndex, Math.max(1, rowBudget - indicatorRows)));
34
+ hiddenAbove = start > 0;
35
+ hiddenBelow = end < total;
36
+ }
37
+ const usedRows = end - start;
38
+ let remaining = Math.max(0, rowBudget - usedRows);
39
+ const showAbove = hiddenAbove && remaining > 0;
40
+ if (showAbove)
41
+ remaining -= 1;
42
+ const showBelow = hiddenBelow && remaining > 0;
43
+ return { start, end, showAbove, showBelow };
44
+ }
package/dist/config.js CHANGED
@@ -52,6 +52,7 @@ const projectSchema = z.object({
52
52
  trusted_actors: trustedActorsSchema,
53
53
  issue_key_prefixes: z.array(z.string().min(1)).default([]),
54
54
  linear_team_ids: z.array(z.string().min(1)).default([]),
55
+ linear_project_ids: z.array(z.string().min(1)).default([]),
55
56
  allow_labels: z.array(z.string().min(1)).default([]),
56
57
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
57
58
  branch_prefix: z.string().min(1).optional(),
@@ -466,6 +467,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
466
467
  worktreeRoot: ensureAbsolutePath(defaultWorktreeRoot(repository.githubRepo)),
467
468
  issueKeyPrefixes: repository.issueKeyPrefixes,
468
469
  linearTeamIds: repository.linearTeamIds,
470
+ linearProjectIds: repository.linearProjectIds,
469
471
  allowLabels: [],
470
472
  reviewChecks: repository.reviewChecks,
471
473
  gateChecks: repository.gateChecks,
@@ -503,6 +505,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
503
505
  : {}),
504
506
  issueKeyPrefixes: project.issue_key_prefixes,
505
507
  linearTeamIds: project.linear_team_ids,
508
+ linearProjectIds: project.linear_project_ids,
506
509
  allowLabels: project.allow_labels,
507
510
  reviewChecks: project.review_checks,
508
511
  gateChecks: project.gate_checks,
@@ -656,6 +659,7 @@ function validateConfigSemantics(config, options) {
656
659
  const githubRepos = new Set();
657
660
  const issuePrefixes = new Map();
658
661
  const linearTeamIds = new Map();
662
+ const linearProjectIds = new Map();
659
663
  for (const repository of config.repositories) {
660
664
  if (githubRepos.has(repository.githubRepo)) {
661
665
  throw new Error(`Duplicate repository github_repo: ${repository.githubRepo}`);
@@ -667,19 +671,26 @@ function validateConfigSemantics(config, options) {
667
671
  throw new Error(`Duplicate project id: ${project.id}`);
668
672
  }
669
673
  projectIds.add(project.id);
674
+ for (const linearProjectId of project.linearProjectIds) {
675
+ const owner = linearProjectIds.get(linearProjectId);
676
+ if (owner && owner !== project.id) {
677
+ throw new Error(`Linear project id "${linearProjectId}" is configured for both ${owner} and ${project.id}`);
678
+ }
679
+ linearProjectIds.set(linearProjectId, project.id);
680
+ }
670
681
  for (const prefix of project.issueKeyPrefixes) {
671
682
  const owner = issuePrefixes.get(prefix);
672
- if (owner && owner !== project.id) {
673
- throw new Error(`Issue key prefix "${prefix}" is configured for both ${owner} and ${project.id}`);
683
+ if (owner && owner.id !== project.id && !hasDisjointProjectRouting(owner, project)) {
684
+ throw new Error(`Issue key prefix "${prefix}" is configured for both ${owner.id} and ${project.id}`);
674
685
  }
675
- issuePrefixes.set(prefix, project.id);
686
+ issuePrefixes.set(prefix, project);
676
687
  }
677
688
  for (const teamId of project.linearTeamIds) {
678
689
  const owner = linearTeamIds.get(teamId);
679
- if (owner && owner !== project.id) {
680
- throw new Error(`Linear team id "${teamId}" is configured for both ${owner} and ${project.id}`);
690
+ if (owner && owner.id !== project.id && !hasDisjointProjectRouting(owner, project)) {
691
+ throw new Error(`Linear team id "${teamId}" is configured for both ${owner.id} and ${project.id}`);
681
692
  }
682
- linearTeamIds.set(teamId, project.id);
693
+ linearTeamIds.set(teamId, project);
683
694
  }
684
695
  }
685
696
  if (config.operatorApi.enabled &&
@@ -689,3 +700,10 @@ function validateConfigSemantics(config, options) {
689
700
  throw new Error("operator_api.enabled requires operator_api.bearer_token_env when server.bind is not 127.0.0.1");
690
701
  }
691
702
  }
703
+ function hasDisjointProjectRouting(left, right) {
704
+ if (left.linearProjectIds.length === 0 || right.linearProjectIds.length === 0) {
705
+ return false;
706
+ }
707
+ const rightProjectIds = new Set(right.linearProjectIds);
708
+ return left.linearProjectIds.every((linearProjectId) => !rightProjectIds.has(linearProjectId));
709
+ }
@@ -88,6 +88,10 @@ const LINEAR_ISSUE_SELECTION = `
88
88
  }
89
89
  }
90
90
  }
91
+ project {
92
+ id
93
+ name
94
+ }
91
95
  `;
92
96
  export class LinearGraphqlClient {
93
97
  options;
@@ -388,6 +392,8 @@ export class LinearGraphqlClient {
388
392
  ...(issue.state?.type ? { stateType: issue.state.type } : {}),
389
393
  ...(issue.team?.id ? { teamId: issue.team.id } : {}),
390
394
  ...(issue.team?.key ? { teamKey: issue.team.key } : {}),
395
+ ...(issue.project?.id ? { projectId: issue.project.id } : {}),
396
+ ...(issue.project?.name ? { projectName: issue.project.name } : {}),
391
397
  ...(issue.delegate?.id ? { delegateId: issue.delegate.id } : {}),
392
398
  ...(issue.delegate?.name ? { delegateName: issue.delegate.name } : {}),
393
399
  workflowStates: (issue.team?.states?.nodes ?? []).map((state) => ({
@@ -42,6 +42,16 @@ export function buildAlreadyRunningThought(runType) {
42
42
  body: `PatchRelay is already working on the ${lowerRunTypeLabel(runType)} workflow.`,
43
43
  };
44
44
  }
45
+ export function buildBlockedDelegationActivity(blockedByKeys = []) {
46
+ const blockers = blockedByKeys.filter((key) => key.trim().length > 0);
47
+ const blockerText = blockers.length > 0
48
+ ? ` Waiting on ${blockers.join(", ")}.`
49
+ : " Waiting for blocker issues to complete.";
50
+ return {
51
+ type: "response",
52
+ body: `PatchRelay accepted this delegation and will not start implementation until the issue is unblocked.${blockerText}`,
53
+ };
54
+ }
45
55
  export function buildPromptDeliveredThought(runType) {
46
56
  return {
47
57
  type: "thought",
@@ -11,12 +11,30 @@ function matchesProject(issue, project) {
11
11
  return false;
12
12
  }
13
13
  export function resolveProject(config, issue) {
14
+ if (issue.projectId) {
15
+ const projectMatches = config.projects.filter((project) => matchesLinearProject(issue, project));
16
+ if (projectMatches.length === 1) {
17
+ return projectMatches[0];
18
+ }
19
+ if (projectMatches.length > 1) {
20
+ return undefined;
21
+ }
22
+ }
14
23
  const matches = config.projects.filter((project) => matchesProject(issue, project));
15
24
  if (matches.length === 1) {
16
25
  return matches[0];
17
26
  }
18
27
  return undefined;
19
28
  }
29
+ function matchesLinearProject(issue, project) {
30
+ if (!issue.projectId || !project.linearProjectIds.includes(issue.projectId)) {
31
+ return false;
32
+ }
33
+ if (project.linearTeamIds.length === 0 || !issue.teamId) {
34
+ return true;
35
+ }
36
+ return project.linearTeamIds.includes(issue.teamId);
37
+ }
20
38
  export function triggerEventAllowed(project, triggerEvent) {
21
39
  return project.triggerEvents.includes(triggerEvent);
22
40
  }
@@ -1,6 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
3
- import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
3
+ import { buildAlreadyRunningThought, buildBlockedDelegationActivity, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
4
4
  import { triggerEventAllowed } from "../project-resolution.js";
5
5
  export class AgentSessionHandler {
6
6
  config;
@@ -47,6 +47,12 @@ export class AgentSessionHandler {
47
47
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
48
48
  return;
49
49
  }
50
+ if ((trackedIssue?.blockedByCount ?? 0) > 0) {
51
+ const latestIssue = this.db.issues.getIssue(project.id, normalized.issue.id);
52
+ await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType);
53
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildBlockedDelegationActivity(trackedIssue?.blockedByKeys));
54
+ return;
55
+ }
50
56
  if (!trackedIssue?.blockedByCount) {
51
57
  await this.publishAgentActivity(linear, normalized.agentSession.id, {
52
58
  type: "elicitation",
@@ -113,6 +113,8 @@ export function mergeIssueMetadata(issue, liveIssue) {
113
113
  ...(issue.attachments && issue.attachments.length > 0 ? {} : liveIssue.attachments ? { attachments: liveIssue.attachments } : {}),
114
114
  ...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
115
115
  ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
116
+ ...(issue.projectId ? {} : liveIssue.projectId ? { projectId: liveIssue.projectId } : {}),
117
+ ...(issue.projectName ? {} : liveIssue.projectName ? { projectName: liveIssue.projectName } : {}),
116
118
  ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
117
119
  ...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
118
120
  ...(issue.stateType ? {} : liveIssue.stateType ? { stateType: liveIssue.stateType } : {}),
package/dist/webhooks.js CHANGED
@@ -200,8 +200,11 @@ function extractIssueMetadata(payload) {
200
200
  const title = getString(issueRecord, "title");
201
201
  const url = getString(issueRecord, "url") ?? payload.url;
202
202
  const delegateRecord = asRecord(issueRecord.delegate);
203
+ const projectRecord = asRecord(issueRecord.project);
203
204
  const teamId = getString(issueRecord, "teamId") ?? getString(teamRecord ?? {}, "id");
204
205
  const teamKey = getString(teamRecord ?? {}, "key");
206
+ const projectId = getString(issueRecord, "projectId") ?? getString(projectRecord ?? {}, "id");
207
+ const projectName = getString(projectRecord ?? {}, "name");
205
208
  const stateRecord = asRecord(issueRecord.state);
206
209
  const stateId = getString(issueRecord, "stateId") ?? getString(stateRecord ?? {}, "id");
207
210
  const stateName = getString(stateRecord ?? {}, "name");
@@ -227,6 +230,8 @@ function extractIssueMetadata(payload) {
227
230
  ...(url ? { url } : {}),
228
231
  ...(teamId ? { teamId } : {}),
229
232
  ...(teamKey ? { teamKey } : {}),
233
+ ...(projectId ? { projectId } : {}),
234
+ ...(projectName ? { projectName } : {}),
230
235
  ...(stateId ? { stateId } : {}),
231
236
  ...(stateName ? { stateName } : {}),
232
237
  ...(stateType ? { stateType } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.54.3",
3
+ "version": "0.55.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {