patchrelay 0.36.0 → 0.36.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.36.0",
4
- "commit": "75e78336dd48",
5
- "builtAt": "2026-04-08T22:47:52.865Z"
3
+ "version": "0.36.2",
4
+ "commit": "45b497cfbf8e",
5
+ "builtAt": "2026-04-08T23:32:38.896Z"
6
6
  }
@@ -96,6 +96,7 @@ export async function collectClusterHealth(config, db, runCommand) {
96
96
  });
97
97
  }
98
98
  }
99
+ checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
99
100
  for (const snapshot of snapshots) {
100
101
  if (!snapshot.issue.prNumber) {
101
102
  continue;
@@ -523,6 +524,70 @@ async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
523
524
  }
524
525
  return owners;
525
526
  }
527
+ async function collectActiveOverlapFindings(snapshots, runCommand) {
528
+ const findings = [];
529
+ const diffsByProject = new Map();
530
+ for (const snapshot of snapshots) {
531
+ const { issue } = snapshot;
532
+ if (issue.activeRunId === undefined || !issue.worktreePath) {
533
+ continue;
534
+ }
535
+ const files = await listModifiedTrackedFiles(runCommand, issue.worktreePath);
536
+ if (files.size === 0) {
537
+ continue;
538
+ }
539
+ const projectDiffs = diffsByProject.get(issue.projectId) ?? [];
540
+ projectDiffs.push({ issue, files });
541
+ diffsByProject.set(issue.projectId, projectDiffs);
542
+ }
543
+ for (const [projectId, diffs] of diffsByProject) {
544
+ for (let leftIndex = 0; leftIndex < diffs.length; leftIndex += 1) {
545
+ const left = diffs[leftIndex];
546
+ for (let rightIndex = leftIndex + 1; rightIndex < diffs.length; rightIndex += 1) {
547
+ const right = diffs[rightIndex];
548
+ const overlap = [...left.files].filter((file) => right.files.has(file)).sort();
549
+ if (overlap.length === 0) {
550
+ continue;
551
+ }
552
+ findings.push({
553
+ status: "warn",
554
+ scope: "issue:overlap",
555
+ message: `Active work overlaps with ${right.issue.issueKey ?? right.issue.linearIssueId}: ${overlap.slice(0, 3).join(", ")}${overlap.length > 3 ? " ..." : ""}`,
556
+ ...(left.issue.issueKey ? { issueKey: left.issue.issueKey } : {}),
557
+ projectId,
558
+ });
559
+ }
560
+ }
561
+ }
562
+ return findings;
563
+ }
564
+ async function listModifiedTrackedFiles(runCommand, worktreePath) {
565
+ let result;
566
+ try {
567
+ result = await runCommand("git", ["-C", worktreePath, "status", "--porcelain", "--untracked-files=no"]);
568
+ }
569
+ catch {
570
+ return new Set();
571
+ }
572
+ if (result.exitCode !== 0) {
573
+ return new Set();
574
+ }
575
+ const files = new Set();
576
+ for (const line of result.stdout.split("\n")) {
577
+ if (line.trim().length === 0)
578
+ continue;
579
+ const rawPath = line.slice(3).trim();
580
+ if (!rawPath)
581
+ continue;
582
+ const normalized = rawPath.includes(" -> ")
583
+ ? rawPath.split(" -> ").at(-1)?.trim()
584
+ : rawPath;
585
+ if (normalized) {
586
+ files.add(normalized);
587
+ }
588
+ }
589
+ return files;
590
+ }
526
591
  function getGateCheckNames(project) {
527
592
  const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
528
593
  return configured.length > 0 ? configured : ["verify"];
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
4
  import { relativeTime, truncate } from "./format-utils.js";
5
- import { hasDisplayPrBlocker, isRereviewNeeded, prChecksFact } from "./pr-status.js";
5
+ import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
6
6
  // ─── State display ──────────────────────────────────────────────
7
7
  const TERMINAL_STATES = new Set(["done", "failed", "escalated"]);
8
8
  function needsOperatorIntervention(issue) {
@@ -17,6 +17,8 @@ function effectiveState(issue) {
17
17
  return "blocked";
18
18
  if (issue.sessionState === "waiting_input")
19
19
  return "awaiting_input";
20
+ if (issue.prNumber !== undefined)
21
+ return issue.factoryState;
20
22
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
21
23
  return "ready";
22
24
  return issue.factoryState;
@@ -80,18 +82,22 @@ function buildFacts(issue, selected) {
80
82
  facts.push({ text: "operator action needed", color: "red" });
81
83
  }
82
84
  // Review state — only show when it matters (not yet approved, or changes requested)
83
- if (issue.prReviewState === "approved") {
85
+ if (isApprovedReviewState(issue.prReviewState)) {
84
86
  facts.push({ text: "approved", color: "green" });
85
87
  }
86
88
  else if (rereviewNeeded) {
87
89
  facts.push({ text: "re-review needed", color: "yellow" });
88
90
  }
89
- else if (issue.prReviewState === "changes_requested") {
91
+ else if (isChangesRequestedReviewState(issue.prReviewState)) {
90
92
  facts.push({ text: "changes requested", color: "yellow" });
91
93
  }
92
- else if (issue.prNumber !== undefined && !issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))) {
94
+ else if (issue.prNumber !== undefined
95
+ && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
93
96
  facts.push({ text: "awaiting review", color: "yellow" });
94
97
  }
98
+ if (issue.factoryState === "awaiting_queue") {
99
+ facts.push({ text: "merge queue", color: "cyan" });
100
+ }
95
101
  // Check status — compact
96
102
  const checksFact = prChecksFact(issue);
97
103
  if (checksFact) {
@@ -110,6 +116,10 @@ function blockerText(issue) {
110
116
  return issue.waitingReason ?? "Waiting for input";
111
117
  if (needsOperatorIntervention(issue))
112
118
  return issue.statusNote ?? issue.waitingReason ?? "Needs operator intervention";
119
+ if (issue.waitingReason && issue.activeRunType && issue.factoryState === "pr_open")
120
+ return issue.waitingReason;
121
+ if (issue.waitingReason && issue.activeRunType && issue.factoryState === "awaiting_queue")
122
+ return issue.waitingReason;
113
123
  if (issue.waitingReason && !issue.activeRunType)
114
124
  return issue.waitingReason;
115
125
  if (issue.blockedByCount > 0)
@@ -129,8 +139,10 @@ function blockerText(issue) {
129
139
  }
130
140
  if (rereviewNeeded)
131
141
  return "Awaiting re-review after requested changes";
132
- if (issue.prReviewState === "changes_requested")
142
+ if (isChangesRequestedReviewState(issue.prReviewState))
133
143
  return "Review changes requested";
144
+ if (issue.prNumber !== undefined && isAwaitingReviewState(issue.prReviewState))
145
+ return "Awaiting review";
134
146
  return null;
135
147
  }
136
148
  // ─── Render ─────────────────────────────────────────────────────
@@ -3,7 +3,7 @@ import { buildTimelineRows } from "./timeline-presentation.js";
3
3
  import { planStepColor, planStepSymbol } from "./plan-helpers.js";
4
4
  import { progressBar } from "./format-utils.js";
5
5
  import { describePatchRelayFreshness } from "./freshness.js";
6
- import { hasDisplayPrBlocker, isRereviewNeeded, prChecksFact } from "./pr-status.js";
6
+ import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
7
7
  import { renderRichTextLines, renderTextLines } from "./render-rich-text.js";
8
8
  const SESSION_DISPLAY = {
9
9
  idle: { label: "idle", color: "blueBright" },
@@ -400,12 +400,17 @@ function buildFactSegments(issue, issueContext) {
400
400
  const rereviewNeeded = isRereviewNeeded(issue);
401
401
  if (issue.prNumber !== undefined)
402
402
  facts.push([{ text: `PR #${issue.prNumber}`, color: "cyan" }]);
403
- if (issue.prReviewState === "approved")
403
+ if (isApprovedReviewState(issue.prReviewState))
404
404
  facts.push([{ text: "approved", color: "green" }]);
405
405
  else if (rereviewNeeded)
406
406
  facts.push([{ text: "re-review needed", color: "yellow" }]);
407
- else if (issue.prReviewState === "changes_requested")
407
+ else if (isChangesRequestedReviewState(issue.prReviewState))
408
408
  facts.push([{ text: "changes requested", color: "yellow" }]);
409
+ else if (issue.prNumber !== undefined
410
+ && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
411
+ facts.push([{ text: "awaiting review", color: "yellow" }]);
412
+ if (issue.factoryState === "awaiting_queue")
413
+ facts.push([{ text: "merge queue", color: "cyan" }]);
409
414
  if (issue.waitingReason && issue.sessionState === "waiting_input")
410
415
  facts.push([{ text: issue.waitingReason, color: "yellow" }]);
411
416
  const checks = prChecksFact({
@@ -512,6 +517,8 @@ function effectiveState(issue) {
512
517
  return "blocked";
513
518
  if (issue.sessionState === "waiting_input")
514
519
  return "awaiting_input";
520
+ if (issue.prNumber !== undefined)
521
+ return issue.factoryState;
515
522
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
516
523
  return "ready";
517
524
  return issue.factoryState;
@@ -523,6 +530,10 @@ function blockerText(issue, issueContext) {
523
530
  if (issue.sessionState === "failed" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
524
531
  return issue.statusNote ?? issue.waitingReason ?? "Needs operator intervention";
525
532
  }
533
+ if (issue.waitingReason && issue.activeRunType && issue.factoryState === "pr_open")
534
+ return issue.waitingReason;
535
+ if (issue.waitingReason && issue.activeRunType && issue.factoryState === "awaiting_queue")
536
+ return issue.waitingReason;
526
537
  if (issue.waitingReason && !issue.activeRunType)
527
538
  return issue.waitingReason;
528
539
  if (issue.blockedByCount > 0)
@@ -545,10 +556,11 @@ function blockerText(issue, issueContext) {
545
556
  }
546
557
  if (rereviewNeeded)
547
558
  return "Awaiting re-review after requested changes";
548
- if (issue.prReviewState === "changes_requested")
559
+ if (isChangesRequestedReviewState(issue.prReviewState))
549
560
  return "Review changes requested";
550
- if (issue.prNumber !== undefined && !issue.prReviewState && effectiveState(issue) !== "done")
561
+ if (issue.prNumber !== undefined && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
551
562
  return "Awaiting review";
563
+ }
552
564
  return null;
553
565
  }
554
566
  function cleanCommand(raw) {
@@ -7,6 +7,20 @@ function isFailingCheckStatus(status) {
7
7
  function isPendingCheckStatus(status) {
8
8
  return status === "pending" || status === "in_progress";
9
9
  }
10
+ function normalizeReviewState(state) {
11
+ const normalized = state?.trim().toLowerCase();
12
+ return normalized && normalized.length > 0 ? normalized : undefined;
13
+ }
14
+ export function isApprovedReviewState(state) {
15
+ return normalizeReviewState(state) === "approved";
16
+ }
17
+ export function isChangesRequestedReviewState(state) {
18
+ return normalizeReviewState(state) === "changes_requested";
19
+ }
20
+ export function isAwaitingReviewState(state) {
21
+ const normalized = normalizeReviewState(state);
22
+ return normalized === "review_required" || normalized === "commented";
23
+ }
10
24
  export function hasPendingPrChecks(issue) {
11
25
  const summary = issue.prChecksSummary;
12
26
  if (summary?.total) {
@@ -29,7 +43,7 @@ export function arePrChecksCompleteAndGreen(issue) {
29
43
  return isPassingCheckStatus(issue.prCheckStatus);
30
44
  }
31
45
  export function isRereviewNeeded(issue) {
32
- return issue.prReviewState === "changes_requested"
46
+ return isChangesRequestedReviewState(issue.prReviewState)
33
47
  && arePrChecksCompleteAndGreen(issue)
34
48
  && !issue.activeRunType;
35
49
  }
@@ -61,10 +75,13 @@ export function hasDisplayPrBlocker(issue) {
61
75
  if (issue.prNumber === undefined || issue.activeRunType) {
62
76
  return false;
63
77
  }
78
+ if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
79
+ return true;
80
+ }
64
81
  if (hasPendingPrChecks(issue) || hasFailedPrChecks(issue)) {
65
82
  return true;
66
83
  }
67
- if (issue.prReviewState === "changes_requested" && !isRereviewNeeded(issue)) {
84
+ if (isChangesRequestedReviewState(issue.prReviewState) && !isRereviewNeeded(issue)) {
68
85
  return true;
69
86
  }
70
87
  if (!issue.prReviewState && issue.factoryState === "pr_open") {
@@ -67,7 +67,7 @@ export function computeAggregates(issues) {
67
67
  active++;
68
68
  if (!issue.activeRunType && issue.blockedByCount > 0)
69
69
  blocked++;
70
- if (!issue.activeRunType && issue.readyForExecution && !isDone && !isFailed)
70
+ if (!issue.activeRunType && issue.prNumber === undefined && issue.readyForExecution && !isDone && !isFailed)
71
71
  ready++;
72
72
  if (isDone)
73
73
  done++;
package/dist/db.js CHANGED
@@ -1189,6 +1189,9 @@ export class PatchRelayDatabase {
1189
1189
  return explicitSummaryText;
1190
1190
  }
1191
1191
  const latestSummary = extractLatestAssistantSummary(latestRun);
1192
+ if (latestRun && (latestRun.status === "queued" || latestRun.status === "running")) {
1193
+ return latestSummary;
1194
+ }
1192
1195
  if (this.shouldKeepPreviousIssueSummary(issue, latestRun)) {
1193
1196
  return this.findLatestCompletedRunSummary(issue.projectId, issue.linearIssueId)
1194
1197
  ?? existingSummaryText
@@ -1,3 +1,4 @@
1
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
1
2
  const TERMINAL_SESSION_EVENTS = new Set([
2
3
  "stop_requested",
3
4
  "undelegated",
@@ -116,7 +117,7 @@ export function extractLatestAssistantSummary(run) {
116
117
  try {
117
118
  const parsed = JSON.parse(run.summaryJson);
118
119
  if (typeof parsed.latestAssistantMessage === "string" && parsed.latestAssistantMessage.trim()) {
119
- return parsed.latestAssistantMessage;
120
+ return sanitizeOperatorFacingText(parsed.latestAssistantMessage);
120
121
  }
121
122
  }
122
123
  catch {
@@ -129,14 +130,14 @@ export function extractLatestAssistantSummary(run) {
129
130
  if (Array.isArray(parsed.assistantMessages)) {
130
131
  const latest = parsed.assistantMessages.findLast((value) => typeof value === "string" && value.trim());
131
132
  if (typeof latest === "string")
132
- return latest;
133
+ return sanitizeOperatorFacingText(latest);
133
134
  }
134
135
  }
135
136
  catch {
136
137
  // ignore malformed report json
137
138
  }
138
139
  }
139
- return run.failureReason;
140
+ return sanitizeOperatorFacingText(run.failureReason);
140
141
  }
141
142
  function parseEventJson(raw) {
142
143
  if (!raw)
@@ -1,9 +1,10 @@
1
1
  import { formatRunTypeLabel } from "./agent-session-plan.js";
2
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
3
  function lowerRunTypeLabel(runType) {
3
4
  return formatRunTypeLabel(runType).toLowerCase();
4
5
  }
5
6
  function trimSummary(summary, maxLength = 300) {
6
- const value = summary?.trim();
7
+ const value = sanitizeOperatorFacingText(summary);
7
8
  if (!value) {
8
9
  return undefined;
9
10
  }
@@ -153,7 +154,7 @@ export function summarizeIssueStateForLinear(issue) {
153
154
  case "waiting_input":
154
155
  return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
155
156
  case "running":
156
- return issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.";
157
+ return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.");
157
158
  case "idle":
158
159
  return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
159
160
  case "done":
@@ -0,0 +1,12 @@
1
+ function unwrapShellWrappedCommand(text) {
2
+ return text
3
+ .replace(/`(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+'([^`\n]+)'`/g, "`$1`")
4
+ .replace(/`(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+"([^`\n]+)"`/g, "`$1`");
5
+ }
6
+ export function sanitizeOperatorFacingText(text) {
7
+ const trimmed = text?.trim();
8
+ if (!trimmed) {
9
+ return undefined;
10
+ }
11
+ return unwrapShellWrappedCommand(trimmed);
12
+ }
package/dist/service.js CHANGED
@@ -31,6 +31,8 @@ function shouldSuppressStatusNote(params) {
31
31
  if (!note)
32
32
  return true;
33
33
  return note === "codex turn was interrupted"
34
+ || note.startsWith("zombie: never started")
35
+ || note === "stale thread after restart"
34
36
  || note === "patchrelay received your mention. delegate the issue to patchrelay to start work.";
35
37
  }
36
38
  export function parseCiSnapshotSummary(snapshotJson) {
@@ -1,7 +1,7 @@
1
1
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
2
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
3
  function clean(value) {
3
- const trimmed = value?.trim();
4
- return trimmed ? trimmed : undefined;
4
+ return sanitizeOperatorFacingText(value);
5
5
  }
6
6
  function eventStatusNote(event) {
7
7
  if (!event)
@@ -1,5 +1,7 @@
1
1
  export const PATCHRELAY_WAITING_REASONS = {
2
2
  activeWork: "PatchRelay is actively working",
3
+ finalizingPublishedPr: "PatchRelay is finalizing a published PR",
4
+ finalizingMergedChange: "PatchRelay is finalizing a merged change",
3
5
  waitingForOperatorInput: "Waiting on operator input",
4
6
  waitingForReviewFeedback: "Waiting to address review feedback",
5
7
  waitingForReviewOnNewHead: "Waiting on review of a newer pushed head",
@@ -12,6 +14,12 @@ export const PATCHRELAY_WAITING_REASONS = {
12
14
  };
13
15
  export function derivePatchRelayWaitingReason(params) {
14
16
  if (params.activeRunType) {
17
+ if (params.prNumber !== undefined && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
18
+ return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
19
+ }
20
+ if (params.factoryState === "done") {
21
+ return PATCHRELAY_WAITING_REASONS.finalizingMergedChange;
22
+ }
15
23
  return `PatchRelay is running ${humanize(params.activeRunType)}`;
16
24
  }
17
25
  if (params.activeRunId !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.0",
3
+ "version": "0.36.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {