patchrelay 0.49.3 → 0.50.1

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.49.3",
4
- "commit": "39948af65ed2",
5
- "builtAt": "2026-04-20T00:34:55.802Z"
3
+ "version": "0.50.1",
4
+ "commit": "27eb3a4a1840",
5
+ "builtAt": "2026-04-20T12:44:08.573Z"
6
6
  }
@@ -490,7 +490,13 @@ function deriveCiOwner(params) {
490
490
  return "paused";
491
491
  if (params.factoryState === "changes_requested")
492
492
  return "patchrelay";
493
- if (params.reviewQuillAttempt)
493
+ if (params.reviewQuillAttempt?.backlog
494
+ && params.currentHeadSha
495
+ && params.reviewQuillAttempt.headSha
496
+ && params.currentHeadSha !== params.reviewQuillAttempt.headSha) {
497
+ return "review-quill";
498
+ }
499
+ if (params.reviewQuillAttempt && !params.reviewQuillAttempt.backlog)
494
500
  return "review-quill";
495
501
  if (headAdvancedPastBlockingReview)
496
502
  return "reviewer";
@@ -524,7 +530,10 @@ function describeCiOwnership(params) {
524
530
  : "PatchRelay owns the next requested-changes move";
525
531
  }
526
532
  if (params.owner === "review-quill") {
527
- return params.reviewQuillAttempt
533
+ if (params.reviewQuillAttempt?.backlog) {
534
+ return "review-quill is actively reconciling this repo; this PR is waiting in the current review backlog";
535
+ }
536
+ return params.reviewQuillAttempt?.id && params.reviewQuillAttempt.status
528
537
  ? `review-quill attempt #${params.reviewQuillAttempt.id} is ${params.reviewQuillAttempt.status} on the current head`
529
538
  : "review-quill owns the current review attempt";
530
539
  }
@@ -576,7 +585,14 @@ function describeCiOwnership(params) {
576
585
  return "No visible next owner for this PR state";
577
586
  }
578
587
  function isResolvedDependency(dep) {
579
- return dep.blockerCurrentLinearStateType === "completed" || dep.blockerCurrentLinearState?.trim().toLowerCase() === "done";
588
+ const stateType = dep.blockerCurrentLinearStateType?.trim().toLowerCase();
589
+ const state = dep.blockerCurrentLinearState?.trim().toLowerCase();
590
+ return stateType === "completed"
591
+ || stateType === "canceled"
592
+ || stateType === "cancelled"
593
+ || state === "done"
594
+ || state === "canceled"
595
+ || state === "cancelled";
580
596
  }
581
597
  function needsReviewAutomation(issue) {
582
598
  if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
@@ -586,6 +602,7 @@ function needsReviewAutomation(issue) {
586
602
  }
587
603
  async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
588
604
  const owners = new Map();
605
+ const repoBacklog = await probeReviewQuillRepoBacklog(runCommand);
589
606
  for (const snapshot of snapshots) {
590
607
  const issueKey = snapshot.issue.issueKey;
591
608
  const prNumber = snapshot.issue.prNumber;
@@ -601,8 +618,12 @@ async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
601
618
  const activeAttempt = probe.attempts.find((attempt) => (attempt.status === "queued" || attempt.status === "running")
602
619
  && !attempt.stale
603
620
  && attempt.headSha === probe.currentHeadSha);
604
- if (!activeAttempt)
621
+ if (!activeAttempt) {
622
+ if (repoBacklog.has(repoFullName)) {
623
+ owners.set(issueKey, { backlog: true, headSha: probe.latestAttemptHeadSha });
624
+ }
605
625
  continue;
626
+ }
606
627
  owners.set(issueKey, {
607
628
  id: activeAttempt.id,
608
629
  status: activeAttempt.status,
@@ -611,6 +632,42 @@ async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
611
632
  }
612
633
  return owners;
613
634
  }
635
+ async function probeReviewQuillRepoBacklog(runCommand) {
636
+ let result;
637
+ try {
638
+ result = await runCommand("review-quill", ["status", "--json"]);
639
+ }
640
+ catch {
641
+ return new Set();
642
+ }
643
+ if (result.exitCode !== 0) {
644
+ return new Set();
645
+ }
646
+ const parsed = safeJsonParse(result.stdout);
647
+ if (!parsed || parsed.runtime?.reconcileInProgress !== true || !Array.isArray(parsed.repos)) {
648
+ return new Set();
649
+ }
650
+ const activeRepos = new Set();
651
+ for (const repo of parsed.repos) {
652
+ if (!repo || typeof repo !== "object")
653
+ continue;
654
+ const repoFullName = typeof repo.repoFullName === "string"
655
+ ? String(repo.repoFullName).trim()
656
+ : undefined;
657
+ const runningAttempts = typeof repo.runningAttempts === "number"
658
+ ? Number(repo.runningAttempts)
659
+ : 0;
660
+ const queuedAttempts = typeof repo.queuedAttempts === "number"
661
+ ? Number(repo.queuedAttempts)
662
+ : 0;
663
+ if (!repoFullName)
664
+ continue;
665
+ if (runningAttempts > 0 || queuedAttempts > 0) {
666
+ activeRepos.add(repoFullName);
667
+ }
668
+ }
669
+ return activeRepos;
670
+ }
614
671
  async function collectActiveOverlapFindings(snapshots, runCommand) {
615
672
  const findings = [];
616
673
  const diffsByProject = new Map();
@@ -723,6 +780,7 @@ async function probeReviewQuillAttempts(runCommand, repoFullName, prNumber) {
723
780
  if (!prProbe.ok) {
724
781
  return { ok: false, error: prProbe.error };
725
782
  }
783
+ let latestAttemptHeadSha;
726
784
  const attempts = parsedAttempts.attempts.flatMap((entry) => {
727
785
  if (!entry || typeof entry !== "object")
728
786
  return [];
@@ -730,6 +788,9 @@ async function probeReviewQuillAttempts(runCommand, repoFullName, prNumber) {
730
788
  const headSha = entry.headSha;
731
789
  const status = entry.status;
732
790
  const stale = entry.stale;
791
+ if (!latestAttemptHeadSha && typeof headSha === "string" && headSha.trim().length > 0) {
792
+ latestAttemptHeadSha = headSha.trim();
793
+ }
733
794
  if (typeof id !== "number"
734
795
  || typeof headSha !== "string"
735
796
  || (status !== "queued" && status !== "running")) {
@@ -745,6 +806,7 @@ async function probeReviewQuillAttempts(runCommand, repoFullName, prNumber) {
745
806
  return {
746
807
  ok: true,
747
808
  currentHeadSha: prProbe.pr.headRefOid,
809
+ latestAttemptHeadSha,
748
810
  attempts,
749
811
  };
750
812
  }
@@ -1,10 +1,15 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { issueTokenFor, prTokenFor } from "./issue-token.js";
4
- import { truncate } from "./format-utils.js";
4
+ import { formatIssueAge, truncate } from "./format-utils.js";
5
5
  const KEY_WIDTH = 8;
6
6
  const GLYPH_WIDTH = 3;
7
7
  const PHRASE_WIDTH = 18;
8
+ const AGE_WIDTH = 4;
9
+ const WIDE_PR_PHRASE_THRESHOLD = 34;
10
+ function shouldShowVerbosePrToken(titleWidth, compact) {
11
+ return !compact && (titleWidth ?? 60) >= WIDE_PR_PHRASE_THRESHOLD;
12
+ }
8
13
  export function IssueRow({ issue, selected, titleWidth, compact = false, }) {
9
14
  const key = issue.issueKey ?? issue.projectId;
10
15
  const token = issueTokenFor(issue);
@@ -12,11 +17,18 @@ export function IssueRow({ issue, selected, titleWidth, compact = false, }) {
12
17
  const cursorChar = selected ? "\u25b8" : " ";
13
18
  const paddedKey = key.padEnd(KEY_WIDTH, " ");
14
19
  const paddedPhrase = token.phrase.padEnd(PHRASE_WIDTH, " ");
15
- const availableTitleWidth = Math.max(0, (titleWidth ?? 60) - (pr ? 10 : 0));
20
+ const age = formatIssueAge(issue.updatedAt);
21
+ const verbosePr = pr && shouldShowVerbosePrToken(titleWidth, compact);
22
+ const prWidth = pr
23
+ ? verbosePr
24
+ ? `#${pr.prNumber} ${pr.glyph} ${pr.phrase}`.length
25
+ : `#${pr.prNumber} ${pr.glyph}`.length
26
+ : 0;
27
+ const availableTitleWidth = Math.max(0, (titleWidth ?? 60) - prWidth - (pr ? 2 : 0) - (AGE_WIDTH + 2));
16
28
  const title = !compact && selected && issue.title
17
29
  ? ` ${truncate(issue.title, Math.max(0, availableTitleWidth))}`
18
30
  : "";
19
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: cursorChar }), _jsx(Text, { bold: selected, color: token.color, children: ` ${paddedKey}` }), _jsx(Text, { color: token.color, children: ` ${token.glyph.padEnd(GLYPH_WIDTH - 1, " ")}` }), _jsx(Text, { children: ` ${paddedPhrase}` }), pr ? (_jsx(_Fragment, { children: _jsx(Text, { color: pr.color, children: `#${pr.prNumber} ${pr.glyph}` }) })) : null, title ? _jsx(Text, { dimColor: true, children: title }) : null] }));
31
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: cursorChar }), _jsx(Text, { bold: selected, color: token.color, children: ` ${paddedKey}` }), _jsx(Text, { color: token.color, children: ` ${token.glyph.padEnd(GLYPH_WIDTH - 1, " ")}` }), _jsx(Text, { children: ` ${paddedPhrase}` }), pr ? (_jsx(_Fragment, { children: _jsx(Text, { color: pr.color, children: verbosePr ? `#${pr.prNumber} ${pr.glyph} ${pr.phrase}` : `#${pr.prNumber} ${pr.glyph}` }) })) : null, _jsx(Text, { dimColor: true, children: ` ${age}` }), title ? _jsx(Text, { dimColor: true, children: title }) : null] }));
20
32
  }
21
33
  export function estimateIssueRowHeight(_issue, _selected, _cols, _titleWidth, _compact = false) {
22
34
  return 1;
@@ -19,6 +19,9 @@ export function relativeTime(iso) {
19
19
  const days = Math.floor(hours / 24);
20
20
  return `${days}d`;
21
21
  }
22
+ export function formatIssueAge(updatedAt) {
23
+ return relativeTime(updatedAt).padStart(4, " ");
24
+ }
22
25
  /** Format millisecond duration as "2m 30s" or "45s". */
23
26
  export function formatDuration(ms) {
24
27
  const seconds = Math.floor(ms / 1000);
@@ -1,4 +1,5 @@
1
1
  import { isUndelegatedPausedIssue } from "../../paused-issue-state.js";
2
+ import { hasFailedPrChecks, hasPendingPrChecks, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, prChecksFact, } from "./pr-status.js";
2
3
  const GLYPH = {
3
4
  running: "\u25cf",
4
5
  queued: "\u25cb",
@@ -84,6 +85,7 @@ export function prTokenFor(issue) {
84
85
  glyph: GLYPH[kind],
85
86
  color: COLOR[kind],
86
87
  kind,
88
+ phrase: prPhraseFor(issue),
87
89
  };
88
90
  }
89
91
  function prKind(issue) {
@@ -101,3 +103,20 @@ function prKind(issue) {
101
103
  return "approved";
102
104
  return "running";
103
105
  }
106
+ function prPhraseFor(issue) {
107
+ if (issue.prState === "merged")
108
+ return "merged";
109
+ if (issue.prState === "closed")
110
+ return "closed";
111
+ if (isChangesRequestedReviewState(issue.prReviewState))
112
+ return "changes req";
113
+ if (isApprovedReviewState(issue.prReviewState))
114
+ return "approved";
115
+ if (isAwaitingReviewState(issue.prReviewState))
116
+ return "awaiting review";
117
+ if (hasFailedPrChecks(issue))
118
+ return prChecksFact(issue)?.text ?? "checks failed";
119
+ if (hasPendingPrChecks(issue))
120
+ return prChecksFact(issue)?.text ?? "checks running";
121
+ return prChecksFact(issue)?.text ?? "open";
122
+ }
package/dist/config.js CHANGED
@@ -22,9 +22,9 @@ const repoSettingsSchema = z.object({
22
22
  branch_prefix: z.string().min(1).optional(),
23
23
  });
24
24
  const repairBudgetsSchema = z.object({
25
- ci_repair: z.number().int().positive().default(3),
26
- queue_repair: z.number().int().positive().default(3),
27
- review_fix: z.number().int().positive().default(3),
25
+ ci_repair: z.number().int().positive().default(10),
26
+ queue_repair: z.number().int().positive().default(10),
27
+ review_fix: z.number().int().positive().default(10),
28
28
  });
29
29
  const projectSchema = z.object({
30
30
  id: z.string().min(1),
@@ -37,9 +37,9 @@ const projectSchema = z.object({
37
37
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
38
38
  branch_prefix: z.string().min(1).optional(),
39
39
  repair_budgets: repairBudgetsSchema.default({
40
- ci_repair: 3,
41
- queue_repair: 3,
42
- review_fix: 3,
40
+ ci_repair: 10,
41
+ queue_repair: 10,
42
+ review_fix: 10,
43
43
  }),
44
44
  /** Check names that are review gates (AI Review, quality analysis). Default: code class. */
45
45
  review_checks: z.array(z.string().min(1)).default([]),
@@ -63,9 +63,9 @@ const repositorySchema = z.object({
63
63
  trigger_events: z.array(z.string().min(1)).min(1).optional(),
64
64
  branch_prefix: z.string().min(1).optional(),
65
65
  repair_budgets: repairBudgetsSchema.default({
66
- ci_repair: 3,
67
- queue_repair: 3,
68
- review_fix: 3,
66
+ ci_repair: 10,
67
+ queue_repair: 10,
68
+ review_fix: 10,
69
69
  }),
70
70
  github: z.object({
71
71
  webhook_secret: z.string().min(1).optional(),
@@ -1,35 +1,15 @@
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
1
  export function classifyIssue(params) {
21
- if (params.issue.issueClassSource === "explicit"
22
- && (params.issue.issueClass === "implementation" || params.issue.issueClass === "orchestration")) {
23
- return { issueClass: params.issue.issueClass, issueClassSource: "explicit" };
24
- }
25
2
  if (params.issue.parentLinearIssueId) {
26
3
  return { issueClass: "implementation", issueClassSource: "hierarchy" };
27
4
  }
28
5
  if (params.childIssueCount > 0) {
6
+ if (params.issue.issueClassSource === "explicit" && params.issue.issueClass === "orchestration") {
7
+ return { issueClass: "orchestration", issueClassSource: "explicit" };
8
+ }
29
9
  return { issueClass: "orchestration", issueClassSource: "hierarchy" };
30
10
  }
31
- if (looksLikeUmbrellaText(params.issue)) {
32
- return { issueClass: "orchestration", issueClassSource: "heuristic" };
11
+ if (params.issue.issueClassSource === "explicit" && params.issue.issueClass === "implementation") {
12
+ return { issueClass: "implementation", issueClassSource: "explicit" };
33
13
  }
34
14
  return { issueClass: "implementation", issueClassSource: "heuristic" };
35
15
  }
@@ -182,10 +182,15 @@ function buildOrchestrationScopeDiscipline(context) {
182
182
  "Adopt already-existing canonical child issues when they cover the intended split.",
183
183
  "Do not recreate child issues that already exist under this parent unless a genuinely missing required slice remains.",
184
184
  "Do not create an overlapping umbrella PR unless this parent clearly owns unique direct cleanup work that child issues do not already cover.",
185
+ "When you split required implementation work out of the parent, make it a child issue of this umbrella rather than making the parent block the child.",
186
+ "If sequencing matters, let the parent wait on the child; do not model the child as blocked by the parent umbrella it is supposed to satisfy.",
185
187
  "If child work is still in motion, babysit the plan, record useful observations, and return to waiting.",
186
188
  "If child work looks delivered, audit whether the original parent goal is actually satisfied.",
187
189
  "Create blocking follow-up work only when it is necessary to satisfy the original parent goal.",
188
190
  "Prefer non-blocking follow-up issues over keeping the umbrella open for optional polish or adjacent expansion.",
191
+ "New child issues should stay in Backlog and undelegated by default.",
192
+ "Only delegate or move a new child to Start when it is immediately actionable, unblocked, and you intend for PatchRelay to begin it right away.",
193
+ "If you create multiple new child issues, keep later-wave or dependency-bound children queued rather than waking them all at once.",
189
194
  "",
190
195
  "### Child Issue Summaries",
191
196
  "",
@@ -476,6 +481,7 @@ function buildOrchestrationWorkflowGuidance() {
476
481
  "",
477
482
  "Use the wake reason and current child issue summaries to decide what kind of orchestration work is needed now.",
478
483
  "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.",
484
+ "When creating follow-up work, prefer one immediately runnable child at a time and keep the rest queued until their prerequisites are genuinely ready.",
479
485
  "Keep outputs concise and observable in Linear.",
480
486
  ].join("\n");
481
487
  }
@@ -488,6 +494,8 @@ function buildPublicationContract(runType, issueClass) {
488
494
  "By default, orchestration work should finish without opening an overlapping umbrella PR.",
489
495
  "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.",
490
496
  "If you create new blocking follow-up work, justify it against the original parent goal rather than optional polish.",
497
+ "Blocking follow-up children must block the parent goal they satisfy, not the other way around.",
498
+ "If you create new child issues, leave them in Backlog unless they are truly ready to start now.",
491
499
  ].join("\n");
492
500
  }
493
501
  if (runType === "implementation") {
@@ -1,6 +1,6 @@
1
- export const DEFAULT_CI_REPAIR_BUDGET = 3;
2
- export const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
3
- export const DEFAULT_REVIEW_FIX_BUDGET = 3;
1
+ export const DEFAULT_CI_REPAIR_BUDGET = 10;
2
+ export const DEFAULT_QUEUE_REPAIR_BUDGET = 10;
3
+ export const DEFAULT_REVIEW_FIX_BUDGET = 10;
4
4
  export function getCiRepairBudget(project) {
5
5
  return project?.repairBudgets?.ciRepair ?? DEFAULT_CI_REPAIR_BUDGET;
6
6
  }
@@ -304,13 +304,15 @@ export class DesiredStageRecorder {
304
304
  ? "child_delivered"
305
305
  : wasResolved && !isResolved
306
306
  ? "child_regressed"
307
- : "child_changed";
308
- wakeOrchestrationParentsForChildEvent({
309
- db: this.db,
310
- child: issue,
311
- eventType,
312
- changeKind,
313
- });
307
+ : undefined;
308
+ if (eventType) {
309
+ wakeOrchestrationParentsForChildEvent({
310
+ db: this.db,
311
+ child: issue,
312
+ eventType,
313
+ changeKind,
314
+ });
315
+ }
314
316
  }
315
317
  return {
316
318
  issue: this.db.issueToTrackedIssue(issue),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.49.3",
3
+ "version": "0.50.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {