patchrelay 0.47.1 → 0.48.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
@@ -1,408 +1,125 @@
1
1
  # PatchRelay
2
2
 
3
- PatchRelay is a self-hosted harness for delegated Linear work and upkeep of linked pull requests on your own machine.
3
+ Self-hosted toolkit for shipping code with agents: a Linear-driven harness that runs Codex sessions inside your real repos, plus two GitHub-native services that review PRs and deliver them through a merge queue. Each component works on its own, and they communicate only through GitHub.
4
4
 
5
- It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge-steward incidents on linked delegated PRs. Separate downstream services own review automation and merge execution.
5
+ ## The stack
6
6
 
7
- PatchRelay is the system around the model:
7
+ This repository ships **three independent services**. Install one, two, or all three.
8
8
 
9
- - webhook intake and verification (Linear and GitHub)
10
- - Linear OAuth and workspace installations
11
- - issue-to-repo routing
12
- - issue worktree and branch lifecycle
13
- - context packaging, run orchestration, and thread continuity
14
- - reactive CI repair and review fix loops
15
- - queue repair runs triggered by Merge Steward evictions
16
- - native Linear agent input forwarding into active runs
17
- - read-only inspection and run reporting
9
+ | Service | Package | Role |
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. |
18
14
 
19
- If you want Codex to work inside your real repos with your real tools, secrets, SSH access, and deployment surface, PatchRelay is the harness that makes that loop reliable.
15
+ Common setups:
20
16
 
21
- ## Why PatchRelay
17
+ - **Full autonomy** — all three. PatchRelay implements from a Linear issue, review-quill reviews, merge-steward delivers. No human in the room.
18
+ - **Supervised delivery** — `review-quill` + `merge-steward` without PatchRelay, driven by your own agent (Claude Code, Cursor, Codex CLI, …). See [Use with your own agent](#use-with-your-own-agent).
19
+ - **Queue only** or **review only** — run either downstream service on its own if you already have the other half of the story.
22
20
 
23
- - Keep the agent in the real environment instead of rebuilding that environment in a hosted sandbox.
24
- - Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
25
- - Keep deterministic workflow logic outside the model: context packaging, routing, run orchestration, worktree ownership, verification, and reporting.
26
- - Choose the Codex approval and sandbox settings that match your risk tolerance.
27
- - Let Linear drive the loop through delegation and native agent sessions.
28
- - Let GitHub drive reactive loops through PR reviews and CI check events.
29
- - Drop into the exact issue worktree and resume control manually when needed.
21
+ ### What this buys you
30
22
 
31
- ## What PatchRelay Owns
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
+ - **Many PR failures have mechanical fixes an agent can handle.** Requested changes like a rename, a missing null check, a new test; rebase on `main`; resolving a conflict surfaced by speculation; 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.
32
26
 
33
- PatchRelay does the deterministic harness work that you do not want to re-implement around every model run:
27
+ ## Use with your own agent
34
28
 
35
- - verifies and deduplicates Linear and GitHub webhooks
36
- - maps issue events to the correct local project
37
- - packages the right issue, repo, review, and failure context for each loop
38
- - creates and reuses one durable worktree and branch per issue lifecycle
39
- - starts Codex threads for implementation runs
40
- - triggers reactive runs for CI failures, review feedback, and Merge Steward evictions
41
- - opens and updates PRs for delegated implementation work
42
- - marks its own PRs ready when implementation is complete
43
- - can later repair a linked PR that was opened externally once the issue is delegated
44
- - persists enough state to correlate the Linear issue, local workspace, run, and Codex thread
45
- - reports progress back to Linear and forwards follow-up agent input into active runs
46
- - exposes CLI and optional read-only inspection surfaces so operators can understand what happened
29
+ For supervised delivery an agent you drive from Claude Code / Cursor / Codex iterating on PRs in real time — install the [`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the companion marketplace:
47
30
 
48
- PatchRelay does not own review decisions or queue admission. GitHub is the source of truth for PR readiness, `reviewbot` owns review automation, and [Merge Steward](./packages/merge-steward) owns queueing and merge execution.
49
-
50
- ## System Layers
51
-
52
- PatchRelay works best when read as five layers with clear ownership:
53
-
54
- - policy layer: repo workflow files (`IMPLEMENTATION_WORKFLOW.md`, `REVIEW_WORKFLOW.md`)
55
- - coordination layer: issue claiming, run scheduling, retry budgets, and reconciliation
56
- - execution layer: durable worktrees, Codex threads, and queued turn input delivery
57
- - integration layer: Linear webhooks, GitHub webhooks, OAuth, project routing, and state sync
58
- - observability layer: CLI inspection, session status, and operator endpoints
59
-
60
- That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps context, action, verification, and repair coordinated in a real repository with real operational state.
61
-
62
- ## Runtime Model
63
-
64
- PatchRelay is designed for a local, operator-owned setup:
65
-
66
- - PatchRelay service runs on your machine or server (default `127.0.0.1:8787`)
67
- - Codex runs through `codex app-server`
68
- - Linear is the control surface
69
- - `patchrelay` CLI is the operator interface
70
- - a reverse proxy exposes the Linear-facing and GitHub-facing webhook routes
71
-
72
- Linux and Node.js `24+` are the intended runtime.
73
-
74
- You will also need:
75
-
76
- - `git`
77
- - `codex`
78
- - a Linear OAuth app for this PatchRelay deployment
79
- - a Linear webhook secret
80
- - a public HTTPS entrypoint such as Caddy, nginx, or a tunnel so Linear and GitHub can reach your PatchRelay webhooks
81
-
82
- ## How It Works
83
-
84
- 1. A human delegates PatchRelay on an issue to start automation.
85
- 2. PatchRelay verifies the webhook, routes the issue to the right local project, and packages the issue context for the first loop.
86
- 3. Delegated issues create or reuse the issue worktree and launch an implementation run through `codex app-server`.
87
- 4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
88
- 5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures and review fix on changes requested.
89
- 6. PatchRelay opens draft PRs while implementation is in progress and marks its own PR ready when implementation is complete.
90
- 7. Downstream automation reacts to GitHub truth: `reviewbot` reviews ready PRs with green CI, and Merge Steward admits ready PRs with green CI and approval into the merge queue.
91
- 8. If requested changes, red CI, or a merge-steward incident lands on a linked delegated PR, PatchRelay resumes work on that same PR branch.
92
- 9. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
93
-
94
- ### Undelegation And Re-delegation
95
-
96
- Undelegation pauses PatchRelay authority. It does not erase PR truth.
97
-
98
- - If there is no PR yet, the issue keeps its literal local-work state such as `delegated` or `implementing`, but PatchRelay becomes paused.
99
- - If a PR already exists, the issue keeps its PR-backed state and PatchRelay becomes observer-only.
100
- - Worktrees, branches, and PRs remain in place.
101
- - PatchRelay still reflects GitHub review, CI, queue, merge, and close events while undelegated.
102
- - PatchRelay does not enqueue implementation, review-fix, CI-repair, or queue-repair work again until the issue is delegated back.
103
- - If someone opens a new PR for the issue while it is undelegated, PatchRelay can link that PR when the title, body, or branch name contains one unambiguous tracked issue key for the project.
104
-
105
- Downstream services stay PR-centric:
106
-
107
- - `review-quill` may still review a qualifying PR
108
- - `merge-steward` may still queue or merge a qualifying PR
109
-
110
- When the issue is delegated back to PatchRelay, it should resume from current truth:
111
-
112
- - no PR: queue implementation
113
- - PR with requested changes: queue review fix or branch upkeep
114
- - PR with failing CI: queue CI repair
115
- - PR with queue eviction/conflict: queue queue repair
116
- - healthy open PR: keep waiting on review
117
- - approved PR: keep waiting downstream
118
-
119
- ## Ownership Model
120
-
121
- PatchRelay keeps ownership simple:
122
-
123
- - workflow truth: the current factory state plus GitHub PR/review/CI facts
124
- - runtime authority: whether PatchRelay may actively write or repair code right now
125
-
126
- PatchRelay persists one explicit authority bit:
127
-
128
- - `delegatedToPatchRelay`: whether PatchRelay may actively implement or repair code for the issue right now
129
-
130
- Once a PR is linked to an issue, delegation decides whether PatchRelay may repair it. The PR may have been opened by PatchRelay, a human, or another external system.
131
-
132
- That authority does not change just because:
133
-
134
- - the issue is undelegated
135
- - the PR becomes ready for review
136
- - the PR is approved
137
- - the PR enters or leaves the merge queue
138
-
139
- ## Factory State Machine
140
-
141
- Each issue progresses through a factory state machine:
142
-
143
- ```text
144
- delegated → preparing → implementing → pr_open → awaiting_review
145
- → changes_requested (review fix run) → back to implementing
146
- → repairing_ci (CI repair run) → back to pr_open
147
- → awaiting_queue → done (merged)
148
- → repairing_queue (queue repair run) → back to pr_open
149
- → escalated or failed (when retry budgets are exhausted)
31
+ ```
32
+ /plugin marketplace add krasnoperov/patchrelay-agents
33
+ /plugin install ship-pr@patchrelay
150
34
  ```
151
35
 
152
- Run types:
153
-
154
- - `implementation` — initial coding work
155
- - `review_fix` — address reviewer feedback
156
- - `ci_repair` — fix failing CI checks
157
- - `queue_repair` — fix merge queue failures
158
-
159
- PatchRelay treats these as distinct loop types with different context, entry conditions, and success criteria rather than as one generic "ask the agent again" workflow.
160
-
161
- The long-term runtime model is a small durable `IssueSession`:
162
-
163
- - `idle`
164
- - `running`
165
- - `waiting_input`
166
- - `done`
167
- - `failed`
168
-
169
- Waiting on review or queue should be represented as a waiting reason, not as a large internal control-plane state machine.
170
-
171
- `awaiting_input` is reserved for real human-needed situations:
172
-
173
- - a completion check asked a question
174
- - an operator explicitly stopped the run and wants a next decision
175
- - a reply is required before PatchRelay can continue
176
-
177
- Undelegated local work should stay in its literal workflow state and show a paused waiting reason instead.
178
-
179
- ## Restart And Reconciliation
180
-
181
- PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
182
-
183
- After a restart, the service can answer:
184
-
185
- - which issue owns each active worktree
186
- - which run was active or queued
187
- - which Codex thread and turn belong to that work
188
- - whether the issue is still eligible to continue
189
- - whether the run should resume, hand off, or fail back to a human state
190
-
191
- This is why PatchRelay keeps a durable `issues` and `runs` table alongside Codex thread history and Linear state. The goal is not to duplicate the model transcript. The goal is to make automation restartable, inspectable, and recoverable when the process or machine is interrupted.
192
-
193
- ## Workflow Files
194
-
195
- PatchRelay uses repo-local workflow files as prompts for Codex runs:
196
-
197
- - `IMPLEMENTATION_WORKFLOW.md` — used for implementation, CI repair, and queue repair runs
198
- - `REVIEW_WORKFLOW.md` — used for review fix runs
199
-
200
- These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
201
-
202
- The built-in PatchRelay prompt scaffold lives in `src/prompting/patchrelay.ts`. It is intentionally small: task objective, scope discipline, reactive repair context, workflow guidance, and publication contract. Installation-level and repo-level prompt config can add one extra instructions file or replace a small set of policy sections. See [`docs/prompting.md`](./docs/prompting.md).
203
-
204
- ## Knowledge And Validation Surfaces
205
-
206
- PatchRelay works best when repository guidance follows progressive disclosure:
207
-
208
- - keep the root entrypoints short and navigational
209
- - treat deeper `docs/` content as the durable system of record
210
- - capture architecture, workflow, and product decisions in versioned files instead of chat history or operator memory
211
-
212
- PatchRelay should also help agents validate their own work inside the issue loop:
213
-
214
- - package the smallest useful context for the current run instead of replaying ever-growing transcript history
215
- - preserve high-signal failure evidence such as review feedback, failing checks, and queue incidents
216
- - make repo-local validation surfaces legible per worktree so the next run can see what passed, what failed, and what needs repair
217
-
218
- Keeping those knowledge and validation surfaces clean is part of the harness, not optional documentation polish.
219
-
220
- ## Access Control
221
-
222
- PatchRelay reacts only for issues that route to a configured project.
223
-
224
- - use `linear_team_ids`, `issue_key_prefixes`, and optional labels to keep unrelated or public boards out of scope
225
- - in the normal setup, anyone with access to the routed Linear project can delegate work to the PatchRelay app
226
- - use `trusted_actors` only when a project needs a narrower allowlist inside Linear
36
+ `ship-pr` teaches the agent to block on `review-quill pr status --wait` and `merge-steward pr status --wait`, read structured failure reasons on exit `2`, fix the code, push, and re-enter the wait. No polling loop, no LLM-judged "is it done yet?". See [patchrelay-agents](https://github.com/krasnoperov/patchrelay-agents) for more.
227
37
 
228
- That keeps the default model simple without forcing an extra allowlist for every team.
38
+ ## Quick start (PatchRelay harness)
229
39
 
230
- ## Quick Start
40
+ Prerequisites:
231
41
 
232
- ### 1. Install
42
+ - Linux with shell access, Node.js `24+`
43
+ - `git` and `codex` (authenticated for the same user that will run PatchRelay)
44
+ - a Linear OAuth app and webhook secret
45
+ - a public HTTPS entrypoint (Caddy, nginx, tunnel) so Linear and GitHub can reach your webhooks
233
46
 
234
47
  ```bash
235
48
  npm install -g patchrelay
236
- ```
237
-
238
- ### 2. Bootstrap config
239
-
240
- ```bash
241
49
  patchrelay init https://patchrelay.example.com
242
50
  ```
243
51
 
244
- `patchrelay init` requires the public HTTPS origin up front because Linear needs a fixed webhook URL and OAuth callback URL for this PatchRelay instance.
245
-
246
- It creates the local config, env file, and system service units:
247
-
248
- - `~/.config/patchrelay/runtime.env`
249
- - `~/.config/patchrelay/service.env`
250
- - `~/.config/patchrelay/patchrelay.json`
251
- - `/etc/systemd/system/patchrelay.service`
252
-
253
- The generated `patchrelay.json` is intentionally minimal, and `patchrelay init` prints the webhook URL, OAuth callback URL, and the Linear app values you need next.
254
-
255
- ### 3. Configure access
256
-
257
- Edit `~/.config/patchrelay/service.env` and fill in only the Linear OAuth client values. Keep the generated webhook secret and token-encryption key:
258
-
259
- ```bash
260
- LINEAR_WEBHOOK_SECRET=generated-by-patchrelay-init
261
- PATCHRELAY_TOKEN_ENCRYPTION_KEY=generated-by-patchrelay-init
262
- LINEAR_OAUTH_CLIENT_ID=replace-with-linear-oauth-client-id
263
- LINEAR_OAUTH_CLIENT_SECRET=replace-with-linear-oauth-client-secret
264
- ```
265
-
266
- Keep service secrets in `service.env`. `runtime.env` is for non-secret overrides such as `PATCHRELAY_DB_PATH` or `PATCHRELAY_LOG_FILE`. Everyday local inspection commands do not require exporting these values in your shell.
267
-
268
- ### 4. Connect PatchRelay to Linear
269
-
270
- Connect PatchRelay to one Linear workspace:
52
+ `init` writes the local config, env files, and systemd unit. Edit `~/.config/patchrelay/service.env` to fill in the Linear OAuth client id and secret (the webhook secret and token-encryption key are generated for you). Then:
271
53
 
272
54
  ```bash
273
- patchrelay linear connect
274
- patchrelay linear sync
275
- ```
276
-
277
- This authorizes the workspace once, then caches its teams and projects locally. Workspace auth is separate from repo linking.
278
-
279
- ### 5. Link a GitHub repo
280
-
281
- Link repos by GitHub identity, not by local path:
282
-
283
- ```bash
284
- patchrelay repo link krasnoperov/usertold --workspace usertold --team USE
285
- ```
286
-
287
- PatchRelay treats the GitHub repo as the source of truth. It reuses an existing local clone under the managed repo root when `origin` already matches, or clones it automatically when missing. Use `--path <path>` only when you want a non-default local location.
288
-
289
- The generated `~/.config/patchrelay/patchrelay.json` stays machine-level service config. Repo links should be created with the CLI, not by hand-editing the file.
290
-
291
- `patchrelay repo link` is idempotent:
292
-
293
- - it creates or updates the linked repo entry
294
- - it refreshes the selected Linear workspace catalog before resolving teams/projects
295
- - it reloads the service when it can
296
- - if workflow files or secrets are still missing, it tells you exactly what to fix and can be rerun safely
297
-
298
- ### 6. Add workflow docs to the repo
299
-
300
- PatchRelay looks for:
301
-
302
- ```text
303
- IMPLEMENTATION_WORKFLOW.md
304
- REVIEW_WORKFLOW.md
305
- ```
306
-
307
- These files define how the agent should work in that repo.
308
-
309
- ### 7. Validate
310
-
311
- ```bash
312
- patchrelay doctor
55
+ patchrelay linear connect # one-time Linear OAuth
56
+ patchrelay linear sync # cache teams/projects
57
+ patchrelay repo link krasnoperov/usertold \
58
+ --workspace usertold --team USE # link a GitHub repo
59
+ patchrelay doctor # validate
313
60
  patchrelay service status
314
61
  patchrelay dashboard
315
62
  ```
316
63
 
317
- ### 8. Check linked workspaces and repos
318
-
319
- ```bash
320
- patchrelay linear list
321
- patchrelay repo list
322
- ```
323
-
324
- If you later add another local repo from the same workspace, run `patchrelay repo link <owner/repo> --workspace <workspace> --team <team>` again. PatchRelay reuses the existing workspace installation instead of opening a new OAuth flow.
325
-
326
- Important:
64
+ Each repo needs two workflow files that act as agent prompts:
327
65
 
328
- - Linear needs a public HTTPS URL to reach your webhook.
329
- - `patchrelay init <public-base-url>` writes `server.public_base_url`, which PatchRelay uses when it prints webhook URLs.
330
- - For ingress, OAuth app setup, and webhook details, use the self-hosting docs.
66
+ - `IMPLEMENTATION_WORKFLOW.md` implementation, CI repair, queue repair runs
67
+ - `REVIEW_WORKFLOW.md` review fix runs
331
68
 
332
- ## Daily Loop
69
+ Keep them short, action-oriented, human-authored. See [prompting.md](./docs/prompting.md) for how the built-in scaffold composes them with the rest of the prompt.
333
70
 
334
- 1. Delegate a Linear issue to the PatchRelay app.
335
- 2. Linear sends the delegation and agent-session webhooks to PatchRelay, which creates or reuses the issue worktree and launches an implementation run.
336
- 3. Follow up in the Linear agent session to steer the active run or wake it with fresh input while it remains delegated.
337
- 4. GitHub webhooks automatically trigger CI repair, review fix, or merge queue repair runs when needed.
338
- 5. Watch progress from the terminal or open the same worktree and take over manually.
71
+ Full install, ingress, and GitHub/Linear app setup: [self-hosting.md](./docs/self-hosting.md). Daily ops and CLI cheatsheet: [operator-guide.md](./docs/operator-guide.md).
339
72
 
340
- Useful commands:
73
+ ## How it works
341
74
 
342
- - `patchrelay dashboard`
343
- - `patchrelay issue list --active`
344
- - `patchrelay issue show APP-123`
345
- - `patchrelay issue watch APP-123`
346
- - `patchrelay issue path APP-123 --cd`
347
- - `patchrelay issue open APP-123`
348
- - `patchrelay issue retry APP-123`
349
- - `patchrelay service restart`
350
- - `patchrelay service logs --lines 100`
75
+ 1. A human delegates an issue to the PatchRelay Linear app.
76
+ 2. PatchRelay verifies the webhook, routes the issue to the right local repo, prepares a durable worktree, and launches an implementation run through `codex app-server`.
77
+ 3. PatchRelay persists thread ids, run state, and observations so work stays inspectable and restartable.
78
+ 4. GitHub webhooks drive reactive repair loops — CI repair on check failures, review fix on requested changes, queue repair on merge-steward evictions.
79
+ 5. `review-quill` reviews ready PRs; `merge-steward` admits approved, green PRs and delivers them by speculative integration.
80
+ 6. An operator can take over inside the same worktree at any time.
351
81
 
352
- PatchRelay's operator surface is being reduced to its own runtime responsibilities: issue status,
353
- active work, waiting reason, worktree handoff, and retry controls.
82
+ Architecture and failure taxonomy: [architecture.md](./docs/architecture.md). Downstream delivery: [merge-queue.md](./docs/merge-queue.md).
354
83
 
355
- `patchrelay issue open` is the handoff bridge: it opens a normal Codex CLI session in the issue worktree and resumes the existing thread when PatchRelay has one.
84
+ ## Downstream services
356
85
 
357
- If automation looks stuck, this is the usual operator path:
86
+ Two separate services handle review and delivery. Both are independent, GitHub-native, and usable without PatchRelay.
358
87
 
359
- 1. `patchrelay dashboard` to see active issues and waiting reasons across the service.
360
- 2. `patchrelay issue show APP-123` or `patchrelay issue watch APP-123` to inspect one issue in more detail.
361
- 3. `patchrelay issue open APP-123` to take over inside the exact worktree and continue from the same issue context.
362
- 4. `patchrelay service logs --lines 100` if the problem looks like webhook intake, Codex startup, or service runtime failure.
88
+ ### review-quill
363
89
 
364
- Today that takeover path is intentionally YOLO mode: it launches Codex with `--dangerously-bypass-approvals-and-sandbox`.
90
+ Watches PRs and posts ordinary GitHub reviews from a real local checkout of the PR head. By default reviews as soon as the head updates; can optionally wait for configured checks to go green first.
365
91
 
366
- ## Operator View
367
-
368
- PatchRelay keeps enough durable state to answer the questions that matter during and after a run:
369
-
370
- - which worktree and branch belong to an issue
371
- - which run is active or queued
372
- - which Codex thread owns the current work
373
- - what the agent said
374
- - which commands it ran
375
- - which files it changed
376
- - whether the run completed, failed, or needs handoff
377
-
378
- ## Merge Steward
92
+ ```bash
93
+ review-quill init https://review.example.com
94
+ review-quill repo attach owner/repo
95
+ review-quill doctor --repo repo
96
+ ```
379
97
 
380
- [Merge Steward](./packages/merge-steward) is a separate service that owns serial merge queue integration. PatchRelay develops code and produces pull requests. Merge Steward delivers those PRs into production — rebasing onto main, waiting for CI, and merging when green.
98
+ See the [review-quill package README](./packages/review-quill/README.md) for the pitch and quick start, or [docs/review-quill.md](./docs/review-quill.md) for the full operator reference.
381
99
 
382
- The two services communicate through GitHub. PatchRelay makes its own PR ready, and Merge Steward decides queue admission and merge execution from GitHub truth. On failure, the steward reports the incident through GitHub signals, and PatchRelay can trigger a queue repair run in response.
100
+ ### merge-steward
383
101
 
384
- The steward now has its own bootstrap flow:
102
+ Serial merge queue with speculative integration. Rebases each approved PR onto the current `main`, runs CI on the integrated SHA, and fast-forwards `main` only when that tested result is green. Evictions produce a durable incident and a GitHub check run — the signal an agent uses to trigger a repair.
385
103
 
386
104
  ```bash
387
105
  merge-steward init https://queue.example.com
388
- merge-steward attach app owner/repo --base-branch main --required-check test,lint
389
- merge-steward doctor --repo app
106
+ merge-steward attach owner/repo --base-branch main
107
+ merge-steward doctor --repo repo
390
108
  merge-steward service status
391
- merge-steward queue status --repo app
392
109
  ```
393
110
 
394
- See [Merge queue](./docs/merge-queue.md) for the full two-service overview and [Merge Steward README](./packages/merge-steward/README.md) for operational details.
111
+ See the [merge-steward package README](./packages/merge-steward/README.md) for the pitch and quick start, [docs/merge-steward.md](./docs/merge-steward.md) for the full operator reference, or [docs/merge-queue.md](./docs/merge-queue.md) for the two-service overview.
395
112
 
396
113
  ## Docs
397
114
 
398
- Use the README for the product overview and quick start. Use the docs for operating details:
399
-
400
- - [Merge queue and delivery](./docs/merge-queue.md)
401
- - [Self-hosting and deployment](./docs/self-hosting.md)
402
- - [Architecture](./docs/architecture.md)
403
- - [Design docs index](./docs/design-docs/index.md)
404
- - [Design principles](./docs/design-docs/core-beliefs.md)
405
- - [External reference patterns](./docs/references/external-patterns.md)
115
+ - [Self-hosting and deployment](./docs/self-hosting.md) install, ingress, OAuth and GitHub App setup
116
+ - [Architecture](./docs/architecture.md) — components, ownership, state machine, failure taxonomy
117
+ - [Operator guide](./docs/operator-guide.md) — daily loop, CLI cheatsheet, troubleshooting
118
+ - [Merge queue](./docs/merge-queue.md) — the two-service delivery story
119
+ - [Prompting](./docs/prompting.md) — how workflow files and the built-in scaffold compose
120
+ - [Secrets](./docs/secrets.md) — systemd credentials, resolution order
121
+ - [review-quill reference](./docs/review-quill.md) · [merge-steward reference](./docs/merge-steward.md)
122
+ - [Design docs](./docs/design-docs/index.md) · [Core beliefs](./docs/design-docs/core-beliefs.md)
406
123
  - [Security policy](./SECURITY.md)
407
124
 
408
125
  ## Status
@@ -19,6 +19,14 @@ function implementationPlan() {
19
19
  { content: "Merge", status: "pending" },
20
20
  ];
21
21
  }
22
+ function orchestrationPlan() {
23
+ return [
24
+ { content: "Review umbrella goal and child set", status: "pending" },
25
+ { content: "Wait for or inspect child progress", status: "pending" },
26
+ { content: "Audit delivered outcome", status: "pending" },
27
+ { content: "Close umbrella or create follow-up work", status: "pending" },
28
+ ];
29
+ }
22
30
  function reviewFixPlan() {
23
31
  return [
24
32
  { content: "Prepare workspace", status: "completed" },
@@ -95,6 +103,27 @@ function resolvePlanRunType(params) {
95
103
  }
96
104
  }
97
105
  export function buildAgentSessionPlan(params) {
106
+ if (params.issueClass === "orchestration") {
107
+ switch (params.factoryState) {
108
+ case "done":
109
+ return setStatuses(orchestrationPlan(), ["completed", "completed", "completed", "completed"]);
110
+ case "awaiting_input":
111
+ case "failed":
112
+ case "escalated":
113
+ return setStatuses(orchestrationPlan(), ["completed", "completed", "completed", "inProgress"]);
114
+ case "implementing":
115
+ case "changes_requested":
116
+ case "repairing_ci":
117
+ case "repairing_queue":
118
+ return setStatuses(orchestrationPlan(), ["completed", "inProgress", "pending", "pending"]);
119
+ case "pr_open":
120
+ case "awaiting_queue":
121
+ return setStatuses(orchestrationPlan(), ["completed", "completed", "inProgress", "pending"]);
122
+ case "delegated":
123
+ default:
124
+ return setStatuses(orchestrationPlan(), ["inProgress", "pending", "pending", "pending"]);
125
+ }
126
+ }
98
127
  const runType = resolvePlanRunType(params);
99
128
  switch (params.factoryState) {
100
129
  case "delegated":
@@ -151,6 +180,7 @@ export function buildAgentSessionPlanForIssue(issue, options) {
151
180
  factoryState: issue.factoryState,
152
181
  ciRepairAttempts: issue.ciRepairAttempts,
153
182
  queueRepairAttempts: issue.queueRepairAttempts,
183
+ ...(issue.issueClass ? { issueClass: issue.issueClass } : {}),
154
184
  ...(issue.pendingRunType ? { pendingRunType: issue.pendingRunType } : {}),
155
185
  ...(options?.activeRunType ? { activeRunType: options.activeRunType } : {}),
156
186
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.47.1",
4
- "commit": "919d86a4ee45",
5
- "builtAt": "2026-04-18T12:45:08.672Z"
3
+ "version": "0.48.0",
4
+ "commit": "a606bc8b729a",
5
+ "builtAt": "2026-04-18T14:29:29.224Z"
6
6
  }
@@ -20,6 +20,14 @@ export class IssueStore {
20
20
  sets.push("delegated_to_patchrelay = @delegatedToPatchRelay");
21
21
  values.delegatedToPatchRelay = params.delegatedToPatchRelay ? 1 : 0;
22
22
  }
23
+ if (params.issueClass !== undefined) {
24
+ sets.push("issue_class = @issueClass");
25
+ values.issueClass = params.issueClass;
26
+ }
27
+ if (params.issueClassSource !== undefined) {
28
+ sets.push("issue_class_source = @issueClassSource");
29
+ values.issueClassSource = params.issueClassSource;
30
+ }
23
31
  if (params.issueKey !== undefined) {
24
32
  sets.push("issue_key = COALESCE(@issueKey, issue_key)");
25
33
  values.issueKey = params.issueKey;
@@ -221,7 +229,7 @@ export class IssueStore {
221
229
  else {
222
230
  this.connection.prepare(`
223
231
  INSERT INTO issues (
224
- project_id, linear_issue_id, delegated_to_patchrelay, issue_key, title, description, url,
232
+ project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, issue_key, title, description, url,
225
233
  priority, estimate,
226
234
  current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
227
235
  branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
@@ -234,7 +242,7 @@ export class IssueStore {
234
242
  ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
235
243
  updated_at
236
244
  ) VALUES (
237
- @projectId, @linearIssueId, @delegatedToPatchRelay, @issueKey, @title, @description, @url,
245
+ @projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @issueKey, @title, @description, @url,
238
246
  @priority, @estimate,
239
247
  @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
240
248
  @branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
@@ -251,6 +259,8 @@ export class IssueStore {
251
259
  projectId: params.projectId,
252
260
  linearIssueId: params.linearIssueId,
253
261
  delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
262
+ issueClass: params.issueClass ?? null,
263
+ issueClassSource: params.issueClassSource ?? null,
254
264
  issueKey: params.issueKey ?? null,
255
265
  title: params.title ?? null,
256
266
  description: params.description ?? null,
@@ -479,6 +489,10 @@ export function mapIssueRow(row) {
479
489
  projectId: String(row.project_id),
480
490
  linearIssueId: String(row.linear_issue_id),
481
491
  delegatedToPatchRelay: Number(row.delegated_to_patchrelay ?? 1) !== 0,
492
+ ...(row.issue_class !== null && row.issue_class !== undefined ? { issueClass: String(row.issue_class) } : {}),
493
+ ...(row.issue_class_source !== null && row.issue_class_source !== undefined
494
+ ? { issueClassSource: String(row.issue_class_source) }
495
+ : {}),
482
496
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
483
497
  ...(row.title !== null ? { title: String(row.title) } : {}),
484
498
  ...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
@@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS issues (
4
4
  project_id TEXT NOT NULL,
5
5
  linear_issue_id TEXT NOT NULL,
6
6
  delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
7
+ issue_class TEXT,
8
+ issue_class_source TEXT,
7
9
  issue_key TEXT,
8
10
  title TEXT,
9
11
  url TEXT,
@@ -242,6 +244,8 @@ export function runPatchRelayMigrations(connection) {
242
244
  // Clean up stale dedupe-only webhook records (no payload, never processable)
243
245
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
244
246
  addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
247
+ addColumnIfMissing(connection, "issues", "issue_class", "TEXT");
248
+ addColumnIfMissing(connection, "issues", "issue_class_source", "TEXT");
245
249
  // Add pending_merge_prep column for merge queue stewardship
246
250
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
247
251
  // Add merge_prep_attempts for retry budget / escalation
@@ -324,6 +328,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
324
328
  project_id TEXT NOT NULL,
325
329
  linear_issue_id TEXT NOT NULL,
326
330
  delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
331
+ issue_class TEXT,
332
+ issue_class_source TEXT,
327
333
  issue_key TEXT,
328
334
  title TEXT,
329
335
  description TEXT,
@@ -382,6 +388,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
382
388
  project_id,
383
389
  linear_issue_id,
384
390
  delegated_to_patchrelay,
391
+ issue_class,
392
+ issue_class_source,
385
393
  issue_key,
386
394
  title,
387
395
  description,
@@ -438,6 +446,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
438
446
  project_id,
439
447
  linear_issue_id,
440
448
  COALESCE(delegated_to_patchrelay, 1),
449
+ issue_class,
450
+ issue_class_source,
441
451
  issue_key,
442
452
  title,
443
453
  description,
@@ -1,6 +1,7 @@
1
1
  import { resolveClosedPrDisposition, resolveClosedPrFactoryState } from "./pr-state.js";
2
2
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
3
  import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
4
+ import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
4
5
  export async function handleGitHubTerminalPrEvent(params) {
5
6
  const { db, linearProvider, enqueueIssue, logger, codex, issue, event, config } = params;
6
7
  const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
@@ -66,6 +67,12 @@ export async function handleGitHubTerminalPrEvent(params) {
66
67
  }
67
68
  }
68
69
  if (event.triggerEvent === "pr_merged") {
70
+ wakeOrchestrationParentsForChildEvent({
71
+ db,
72
+ child: updatedIssue,
73
+ eventType: "child_delivered",
74
+ enqueueIssue,
75
+ });
69
76
  await completeLinearIssueAfterMerge(params, updatedIssue);
70
77
  }
71
78
  void syncGitHubLinearSession({
@@ -0,0 +1,31 @@
1
+ function normalizeText(value) {
2
+ return value?.trim().toLowerCase() ?? "";
3
+ }
4
+ function looksLikeUmbrellaText(issue) {
5
+ const haystack = `${normalizeText(issue.title)}\n${normalizeText(issue.description)}`;
6
+ if (!haystack.trim())
7
+ return false;
8
+ return [
9
+ "umbrella",
10
+ "tracker",
11
+ "tracking",
12
+ "rollout",
13
+ "migration",
14
+ "convergence",
15
+ "audit",
16
+ "follow-up issues",
17
+ "planning/specification issue only",
18
+ ].some((token) => haystack.includes(token));
19
+ }
20
+ export function classifyIssue(params) {
21
+ if (params.issue.issueClass === "implementation" || params.issue.issueClass === "orchestration") {
22
+ return { issueClass: params.issue.issueClass, issueClassSource: "explicit" };
23
+ }
24
+ if (params.trackedDependentCount > 0) {
25
+ return { issueClass: "orchestration", issueClassSource: "hierarchy" };
26
+ }
27
+ if (looksLikeUmbrellaText(params.issue)) {
28
+ return { issueClass: "orchestration", issueClassSource: "heuristic" };
29
+ }
30
+ return { issueClass: "implementation", issueClassSource: "heuristic" };
31
+ }
@@ -52,7 +52,7 @@ export function deriveSessionWakePlan(issue, events) {
52
52
  case "delegated":
53
53
  if (!runType) {
54
54
  runType = "implementation";
55
- wakeReason = "delegated";
55
+ wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
56
56
  }
57
57
  if (payload?.promptContext !== undefined) {
58
58
  context.promptContext = payload.promptContext;
@@ -61,6 +61,16 @@ export function deriveSessionWakePlan(issue, events) {
61
61
  context.promptBody = payload.promptBody;
62
62
  }
63
63
  break;
64
+ case "child_changed":
65
+ case "child_delivered":
66
+ case "child_regressed":
67
+ if (!runType) {
68
+ runType = "implementation";
69
+ wakeReason = event.eventType;
70
+ }
71
+ Object.assign(context, payload ?? {});
72
+ resumeThread = true;
73
+ break;
64
74
  case "direct_reply": {
65
75
  if (!runType) {
66
76
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
@@ -98,7 +108,7 @@ export function deriveSessionWakePlan(issue, events) {
98
108
  case "operator_prompt": {
99
109
  if (!runType) {
100
110
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
101
- wakeReason = event.eventType;
111
+ wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : event.eventType;
102
112
  }
103
113
  const text = typeof payload?.text === "string"
104
114
  ? payload.text
@@ -1,4 +1,5 @@
1
1
  import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
2
+ import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
2
3
  function shouldContinueForUnpublishedLocalChanges(message) {
3
4
  const normalized = message.trim().toLowerCase();
4
5
  if (!normalized)
@@ -206,6 +207,12 @@ export async function handleNoPrCompletionCheck(params) {
206
207
  detail: completionCheck.summary,
207
208
  activity: buildCompletionCheckActivity("done", completionCheck),
208
209
  });
210
+ const doneIssue = params.db.issues.getIssue(params.run.projectId, params.run.linearIssueId) ?? params.issue;
211
+ wakeOrchestrationParentsForChildEvent({
212
+ db: params.db,
213
+ child: doneIssue,
214
+ eventType: "child_delivered",
215
+ });
209
216
  return;
210
217
  }
211
218
  const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
@@ -0,0 +1,37 @@
1
+ import { classifyIssue } from "./issue-class.js";
2
+ export function wakeOrchestrationParentsForChildEvent(params) {
3
+ const parentIds = [];
4
+ for (const blocker of params.db.issues.listIssueDependencies(params.child.projectId, params.child.linearIssueId)) {
5
+ const parent = params.db.issues.getIssue(params.child.projectId, blocker.blockerLinearIssueId);
6
+ if (!parent || !parent.delegatedToPatchRelay) {
7
+ continue;
8
+ }
9
+ const classification = classifyIssue({
10
+ issue: parent,
11
+ trackedDependentCount: params.db.issues.listDependents(parent.projectId, parent.linearIssueId).length,
12
+ });
13
+ if (classification.issueClass !== "orchestration") {
14
+ continue;
15
+ }
16
+ params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(parent.projectId, parent.linearIssueId, {
17
+ projectId: parent.projectId,
18
+ linearIssueId: parent.linearIssueId,
19
+ eventType: params.eventType,
20
+ eventJson: JSON.stringify({
21
+ childIssueId: params.child.linearIssueId,
22
+ ...(params.child.issueKey ? { childIssueKey: params.child.issueKey } : {}),
23
+ ...(params.child.title ? { childTitle: params.child.title } : {}),
24
+ factoryState: params.child.factoryState,
25
+ ...(params.child.currentLinearState ? { currentLinearState: params.child.currentLinearState } : {}),
26
+ ...(params.child.prNumber !== undefined ? { prNumber: params.child.prNumber } : {}),
27
+ ...(params.child.prState ? { prState: params.child.prState } : {}),
28
+ }),
29
+ dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.child.prState ?? "no-pr"}`,
30
+ });
31
+ if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
32
+ params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
33
+ }
34
+ parentIds.push(parent.linearIssueId);
35
+ }
36
+ return parentIds;
37
+ }
@@ -65,7 +65,76 @@ function buildTaskObjective(issue) {
65
65
  ...(intro ? ["", intro] : []),
66
66
  ].join("\n");
67
67
  }
68
- function buildScopeDiscipline(issue) {
68
+ function summarizeRelationEntries(entries, options) {
69
+ if (entries.length === 0) {
70
+ return options?.emptyText ? [options.emptyText] : [];
71
+ }
72
+ const maxItems = options?.maxItems ?? 5;
73
+ const lines = entries.slice(0, maxItems).map((entry) => {
74
+ const issueRef = typeof entry.issueKey === "string" && entry.issueKey.trim()
75
+ ? entry.issueKey.trim()
76
+ : typeof entry.linearIssueId === "string" && entry.linearIssueId.trim()
77
+ ? entry.linearIssueId.trim()
78
+ : "unknown issue";
79
+ const title = typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : undefined;
80
+ const stateName = typeof entry.stateName === "string" && entry.stateName.trim()
81
+ ? entry.stateName.trim()
82
+ : typeof entry.currentLinearState === "string" && entry.currentLinearState.trim()
83
+ ? entry.currentLinearState.trim()
84
+ : undefined;
85
+ const factoryState = typeof entry.factoryState === "string" && entry.factoryState.trim() ? entry.factoryState.trim() : undefined;
86
+ const delegated = typeof entry.delegatedToPatchRelay === "boolean"
87
+ ? (entry.delegatedToPatchRelay ? "delegated" : "not delegated")
88
+ : undefined;
89
+ const openPr = typeof entry.hasOpenPr === "boolean"
90
+ ? (entry.hasOpenPr ? "open PR" : "no open PR")
91
+ : undefined;
92
+ return [
93
+ `- ${issueRef}`,
94
+ title ? `: ${title}` : "",
95
+ [stateName, factoryState, delegated, openPr].filter(Boolean).length > 0
96
+ ? ` (${[stateName, factoryState, delegated, openPr].filter(Boolean).join("; ")})`
97
+ : "",
98
+ ].join("");
99
+ });
100
+ if (entries.length > maxItems) {
101
+ lines.push(`- ...and ${entries.length - maxItems} more`);
102
+ }
103
+ return lines;
104
+ }
105
+ function buildCoordinationGuidance(context) {
106
+ const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
107
+ ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
108
+ : [];
109
+ const trackedDependents = Array.isArray(context?.trackedDependents)
110
+ ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
111
+ : [];
112
+ const lines = [
113
+ "### Coordination / Issue Topology",
114
+ "",
115
+ "First decide whether this issue should publish code itself or mainly coordinate other issues.",
116
+ "If this issue is a parent tracker, umbrella, migration program, or convergence container and the concrete implementation belongs in child issues, do not create a duplicate umbrella PR.",
117
+ "When child issues already own the concrete code slices, use this issue to coordinate, create or refine follow-up issues, or verify convergence. Only ship code here if this issue still has unique implementation scope that is not already owned elsewhere.",
118
+ "Prefer one PR per concrete implementation issue over a broad parent branch that restates overlapping child work.",
119
+ ];
120
+ if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
121
+ return lines;
122
+ }
123
+ lines.push("", "Known relations from PatchRelay:");
124
+ if (unresolvedBlockers.length > 0) {
125
+ lines.push("Unresolved blockers:");
126
+ lines.push(...summarizeRelationEntries(unresolvedBlockers));
127
+ }
128
+ if (trackedDependents.length > 0) {
129
+ if (unresolvedBlockers.length > 0) {
130
+ lines.push("");
131
+ }
132
+ lines.push("Tracked dependent issues:");
133
+ lines.push(...summarizeRelationEntries(trackedDependents));
134
+ }
135
+ return lines;
136
+ }
137
+ function buildScopeDiscipline(issue, context) {
69
138
  const description = issue.description?.trim();
70
139
  const scope = extractIssueSection(description, "Scope");
71
140
  const acceptance = extractIssueSection(description, "Acceptance criteria")
@@ -84,6 +153,8 @@ function buildScopeDiscipline(issue) {
84
153
  ...(scope ? ["### In Scope", "", scope, ""] : []),
85
154
  ...(acceptance ? ["### Acceptance / Done", "", acceptance, ""] : []),
86
155
  ...(relevantCode ? ["### Relevant Code", "", relevantCode, ""] : []),
156
+ ...buildCoordinationGuidance(context),
157
+ "",
87
158
  "### Likely Review Invariants",
88
159
  "",
89
160
  "- Check the surfaces explicitly named in the task before stopping.",
@@ -91,6 +162,41 @@ function buildScopeDiscipline(issue) {
91
162
  "- A review repair should fix the concrete concern on the current head, not silently expand the Linear issue into a broader rewrite.",
92
163
  ].join("\n");
93
164
  }
165
+ function buildOrchestrationScopeDiscipline(context) {
166
+ const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
167
+ ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
168
+ : [];
169
+ const trackedDependents = Array.isArray(context?.trackedDependents)
170
+ ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
171
+ : [];
172
+ return [
173
+ "## Scope Discipline",
174
+ "",
175
+ "This issue is orchestration work.",
176
+ "Treat it as the owner of convergence across related issues rather than as a normal code-owning implementation branch.",
177
+ "Inspect why this wake happened before acting.",
178
+ "Do not create an overlapping umbrella PR unless this parent clearly owns unique direct cleanup work that child issues do not already cover.",
179
+ "If child work is still in motion, babysit the plan, record useful observations, and return to waiting.",
180
+ "If child work looks delivered, audit whether the original parent goal is actually satisfied.",
181
+ "Create blocking follow-up work only when it is necessary to satisfy the original parent goal.",
182
+ "Prefer non-blocking follow-up issues over keeping the umbrella open for optional polish or adjacent expansion.",
183
+ "",
184
+ "### Child Issue Summaries",
185
+ "",
186
+ ...(trackedDependents.length > 0
187
+ ? summarizeRelationEntries(trackedDependents, { emptyText: "No child issues are currently tracked." })
188
+ : ["No child issues are currently tracked."]),
189
+ "",
190
+ ...(unresolvedBlockers.length > 0
191
+ ? ["### Unresolved Blockers", "", ...summarizeRelationEntries(unresolvedBlockers), ""]
192
+ : []),
193
+ "### Convergence Rule",
194
+ "",
195
+ "- Close the umbrella when the original parent goal is satisfied.",
196
+ "- If you discover one missing required slice, you may create a justified blocking follow-up.",
197
+ "- Do not invent optional expansion without explicit human approval.",
198
+ ].join("\n");
199
+ }
94
200
  function buildHumanContext(context) {
95
201
  const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
96
202
  const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
@@ -286,18 +392,30 @@ function buildFollowUpPromptPrelude(issue, runType, context) {
286
392
  "",
287
393
  wakeReason === "direct_reply"
288
394
  ? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
289
- : wakeReason === "completion_check_continue"
290
- ? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
291
- : wakeReason === "branch_upkeep"
292
- ? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
293
- : wakeReason === "followup_comment"
294
- ? "Why this turn exists: A human follow-up comment arrived after the previous turn."
295
- : `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
395
+ : wakeReason === "initial_delegate"
396
+ ? "Why this turn exists: This orchestration issue was just delegated and needs an initial plan."
397
+ : wakeReason === "child_delivered"
398
+ ? "Why this turn exists: A child issue was delivered and the umbrella needs to review the outcome."
399
+ : wakeReason === "child_changed"
400
+ ? "Why this turn exists: A child issue changed state and the umbrella may need to adjust."
401
+ : wakeReason === "child_regressed"
402
+ ? "Why this turn exists: A previously progressing child issue regressed and the umbrella needs to reassess."
403
+ : wakeReason === "human_instruction"
404
+ ? "Why this turn exists: A human added new guidance for this orchestration issue."
405
+ : wakeReason === "completion_check_continue"
406
+ ? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
407
+ : wakeReason === "branch_upkeep"
408
+ ? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
409
+ : wakeReason === "followup_comment"
410
+ ? "Why this turn exists: A human follow-up comment arrived after the previous turn."
411
+ : `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
296
412
  wakeReason === "direct_reply"
297
413
  ? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
298
- : wakeReason === "completion_check_continue"
299
- ? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
300
- : "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
414
+ : wakeReason === "initial_delegate"
415
+ ? "Required action now: Inspect the umbrella goal, review the child set, and record the next orchestration step."
416
+ : wakeReason === "completion_check_continue"
417
+ ? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
418
+ : "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
301
419
  "",
302
420
  ];
303
421
  if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
@@ -346,7 +464,26 @@ function buildWorkflowGuidance(repoPath, runType) {
346
464
  }
347
465
  return "";
348
466
  }
349
- function buildPublicationContract(runType) {
467
+ function buildOrchestrationWorkflowGuidance() {
468
+ return [
469
+ "## Workflow Guidance",
470
+ "",
471
+ "Use the wake reason and current child issue summaries to decide what kind of orchestration work is needed now.",
472
+ "Typical orchestration phases are: initial setup, waiting on child progress, reviewing delivered child work, final audit, creating a justified follow-up, or closing the umbrella.",
473
+ "Keep outputs concise and observable in Linear.",
474
+ ].join("\n");
475
+ }
476
+ function buildPublicationContract(runType, issueClass) {
477
+ if (issueClass === "orchestration") {
478
+ return [
479
+ "## Publication Requirements",
480
+ "",
481
+ "Before finishing, publish the orchestration outcome rather than leaving it implicit.",
482
+ "By default, orchestration work should finish without opening an overlapping umbrella PR.",
483
+ "Valid orchestration outcomes include: recording an observation, updating the rollout plan, creating follow-up issues, opening a small cleanup PR that the parent clearly owns, or closing the umbrella.",
484
+ "If you create new blocking follow-up work, justify it against the original parent goal rather than optional polish.",
485
+ ].join("\n");
486
+ }
350
487
  if (runType === "implementation") {
351
488
  return [
352
489
  "## Publication Requirements",
@@ -384,6 +521,7 @@ function buildPublicationContract(runType) {
384
521
  ].join("\n");
385
522
  }
386
523
  function buildSections(issue, runType, repoPath, context, followUp = false) {
524
+ const issueClass = issue.issueClass;
387
525
  const sections = [
388
526
  { id: "header", content: buildPromptHeader(issue) },
389
527
  ];
@@ -391,7 +529,10 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
391
529
  if (followUp && reactiveContext) {
392
530
  sections.push({ id: "follow-up-turn", content: reactiveContext });
393
531
  }
394
- sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, { id: "scope-discipline", content: buildScopeDiscipline(issue) });
532
+ sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, {
533
+ id: "scope-discipline",
534
+ content: issueClass === "orchestration" ? buildOrchestrationScopeDiscipline(context) : buildScopeDiscipline(issue, context),
535
+ });
395
536
  const humanContext = buildHumanContext(context);
396
537
  if (humanContext) {
397
538
  sections.push({ id: "human-context", content: humanContext });
@@ -399,11 +540,13 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
399
540
  if (!followUp && reactiveContext) {
400
541
  sections.push({ id: "reactive-context", content: reactiveContext });
401
542
  }
402
- const workflow = buildWorkflowGuidance(repoPath, runType);
543
+ const workflow = issueClass === "orchestration"
544
+ ? buildOrchestrationWorkflowGuidance()
545
+ : buildWorkflowGuidance(repoPath, runType);
403
546
  if (workflow) {
404
547
  sections.push({ id: "workflow-guidance", content: workflow });
405
548
  }
406
- sections.push({ id: "publication-contract", content: buildPublicationContract(runType) });
549
+ sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issueClass) });
407
550
  return sections;
408
551
  }
409
552
  function filterAllowedReplacements(promptLayer) {
@@ -16,6 +16,7 @@ import { RunReconciler } from "./run-reconciler.js";
16
16
  import { RunRecoveryService } from "./run-recovery-service.js";
17
17
  import { RunWakePlanner } from "./run-wake-planner.js";
18
18
  import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
19
+ import { classifyIssue } from "./issue-class.js";
19
20
  import { loadConfig } from "./config.js";
20
21
  function lowerCaseFirst(value) {
21
22
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
@@ -30,6 +31,9 @@ function shouldDelayZombieRecoveryLaunch(issue, issueSession, runType) {
30
31
  return 0;
31
32
  return getRemainingZombieRecoveryDelayMs(issue.lastZombieRecoveryAt, issue.zombieRecoveryAttempts);
32
33
  }
34
+ function isResolvedDependencyState(stateType) {
35
+ return stateType === "completed" || stateType?.trim().toLowerCase() === "done";
36
+ }
33
37
  export class RunOrchestrator {
34
38
  config;
35
39
  db;
@@ -135,6 +139,51 @@ export class RunOrchestrator {
135
139
  materializeLegacyPendingWake(issue, lease) {
136
140
  return this.runWakePlanner.materializeLegacyPendingWake(issue, lease);
137
141
  }
142
+ buildRelatedIssueContext(issue) {
143
+ const unresolvedBlockers = this.db.issues
144
+ .listIssueDependencies(issue.projectId, issue.linearIssueId)
145
+ .filter((entry) => !isResolvedDependencyState(entry.blockerCurrentLinearStateType))
146
+ .map((entry) => ({
147
+ linearIssueId: entry.blockerLinearIssueId,
148
+ ...(entry.blockerIssueKey ? { issueKey: entry.blockerIssueKey } : {}),
149
+ ...(entry.blockerTitle ? { title: entry.blockerTitle } : {}),
150
+ ...(entry.blockerCurrentLinearState ? { stateName: entry.blockerCurrentLinearState } : {}),
151
+ ...(entry.blockerCurrentLinearStateType ? { stateType: entry.blockerCurrentLinearStateType } : {}),
152
+ }));
153
+ const trackedDependents = this.db.issues
154
+ .listDependents(issue.projectId, issue.linearIssueId)
155
+ .map((entry) => this.db.issues.getIssue(issue.projectId, entry.linearIssueId))
156
+ .filter((entry) => Boolean(entry))
157
+ .map((entry) => ({
158
+ linearIssueId: entry.linearIssueId,
159
+ ...(entry.issueKey ? { issueKey: entry.issueKey } : {}),
160
+ ...(entry.title ? { title: entry.title } : {}),
161
+ factoryState: entry.factoryState,
162
+ ...(entry.currentLinearState ? { currentLinearState: entry.currentLinearState } : {}),
163
+ delegatedToPatchRelay: entry.delegatedToPatchRelay,
164
+ hasOpenPr: entry.prNumber !== undefined && entry.prState !== "closed" && entry.prState !== "merged",
165
+ }));
166
+ if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
167
+ return {};
168
+ }
169
+ return {
170
+ ...(unresolvedBlockers.length > 0 ? { unresolvedBlockers } : {}),
171
+ ...(trackedDependents.length > 0 ? { trackedDependents } : {}),
172
+ };
173
+ }
174
+ classifyTrackedIssue(issue) {
175
+ const trackedDependentCount = this.db.issues.listDependents(issue.projectId, issue.linearIssueId).length;
176
+ const classification = classifyIssue({ issue, trackedDependentCount });
177
+ if (issue.issueClass === classification.issueClass && issue.issueClassSource === classification.issueClassSource) {
178
+ return issue;
179
+ }
180
+ return this.db.issues.upsertIssue({
181
+ projectId: issue.projectId,
182
+ linearIssueId: issue.linearIssueId,
183
+ issueClass: classification.issueClass,
184
+ issueClassSource: classification.issueClassSource,
185
+ });
186
+ }
138
187
  // ─── Run ────────────────────────────────────────────────────────
139
188
  async run(item) {
140
189
  await this.refreshCodexRuntimeConfig();
@@ -144,7 +193,8 @@ export class RunOrchestrator {
144
193
  if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
145
194
  return;
146
195
  }
147
- const issue = this.db.issues.getIssue(item.projectId, item.issueId);
196
+ const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
197
+ const issue = initialIssue ? this.classifyTrackedIssue(initialIssue) : undefined;
148
198
  if (!issue || issue.activeRunId !== undefined)
149
199
  return;
150
200
  const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
@@ -177,9 +227,15 @@ export class RunOrchestrator {
177
227
  this.releaseIssueSessionLease(item.projectId, item.issueId);
178
228
  return;
179
229
  }
180
- const effectiveContext = isRequestedChangesRunType(runType)
230
+ const baseContext = isRequestedChangesRunType(runType)
181
231
  ? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
182
232
  : context;
233
+ const coordinationContext = runType === "implementation"
234
+ ? this.buildRelatedIssueContext(issue)
235
+ : undefined;
236
+ const effectiveContext = coordinationContext
237
+ ? { ...coordinationContext, ...(baseContext ?? {}) }
238
+ : baseContext;
183
239
  const sourceHeadSha = typeof effectiveContext?.failureHeadSha === "string"
184
240
  ? effectiveContext.failureHeadSha
185
241
  : typeof effectiveContext?.headSha === "string"
@@ -42,6 +42,7 @@ export function buildTrackedIssueRecord(params) {
42
42
  projectId: params.issue.projectId,
43
43
  linearIssueId: params.issue.linearIssueId,
44
44
  delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
45
+ ...(params.issue.issueClass ? { issueClass: params.issue.issueClass } : {}),
45
46
  ...(params.issue.issueKey ? { issueKey: params.issue.issueKey } : {}),
46
47
  ...(params.issue.title ? { title: params.issue.title } : {}),
47
48
  ...(params.issue.url ? { issueUrl: params.issue.url } : {}),
@@ -1,5 +1,6 @@
1
1
  import { triggerEventAllowed } from "../project-resolution.js";
2
2
  import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
3
+ import { classifyIssue } from "../issue-class.js";
3
4
  const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
4
5
  export class CommentWakeHandler {
5
6
  db;
@@ -24,6 +25,10 @@ export class CommentWakeHandler {
24
25
  const issue = this.db.issues.getIssue(project.id, normalized.issue.id);
25
26
  if (!issue)
26
27
  return;
28
+ const issueClass = classifyIssue({
29
+ issue,
30
+ trackedDependentCount: this.db.issues.listDependents(project.id, normalized.issue.id).length,
31
+ }).issueClass;
27
32
  const trimmedBody = normalized.comment.body.trim();
28
33
  const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
29
34
  const selfAuthored = isPatchRelayManagedCommentAuthor(installation, normalized.actor, normalized.comment.userName);
@@ -55,7 +60,7 @@ export class CommentWakeHandler {
55
60
  if (!issue.activeRunId) {
56
61
  if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
57
62
  const directReply = params.isDirectReplyToOutstandingQuestion(issue);
58
- const wakeIntent = directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
63
+ const wakeIntent = issueClass === "orchestration" || directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
59
64
  if (!wakeIntent) {
60
65
  this.feed?.publish({
61
66
  level: "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.47.1",
3
+ "version": "0.48.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {