pr-shepherd 0.51.1 → 0.52.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.
Files changed (116) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -18
  3. package/bin/cli/help-command-pages.d.mts +1 -1
  4. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  5. package/bin/cli/help-iterate-poll-pages.mjs +4 -3
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +3 -2
  8. package/bin/cli/help.d.mts +2 -2
  9. package/bin/cli/iterate-instructions.mjs +41 -0
  10. package/bin/cli/iterate-lean.mjs +1 -3
  11. package/bin/cli/poll-summary-emitter.mjs +2 -3
  12. package/bin/cli/poll-summary-formatter.mjs +12 -3
  13. package/bin/cli/runner.d.mts +1 -0
  14. package/bin/cli/runner.mjs +3 -1
  15. package/bin/commands/check.mjs +14 -4
  16. package/bin/commands/clean.mjs +7 -13
  17. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  18. package/bin/commands/iterate/check-instructions.mjs +11 -7
  19. package/bin/commands/iterate/escalate.mjs +4 -13
  20. package/bin/commands/iterate/fix-code.d.mts +2 -0
  21. package/bin/commands/iterate/fix-code.mjs +20 -43
  22. package/bin/commands/iterate/helpers.d.mts +0 -1
  23. package/bin/commands/iterate/helpers.mjs +0 -15
  24. package/bin/commands/iterate/index.mjs +178 -11
  25. package/bin/commands/iterate/merge-state.mjs +35 -18
  26. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  27. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  28. package/bin/commands/iterate/parent-first.d.mts +25 -0
  29. package/bin/commands/iterate/parent-first.mjs +70 -0
  30. package/bin/commands/iterate/render.d.mts +1 -1
  31. package/bin/commands/iterate/render.mjs +7 -12
  32. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  33. package/bin/commands/iterate/stale-ancestry.mjs +40 -0
  34. package/bin/commands/iterate/stall.mjs +40 -3
  35. package/bin/commands/poll-progress.d.mts +5 -1
  36. package/bin/commands/poll-progress.mjs +7 -1
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +151 -107
  39. package/bin/commands/poll-summary.mjs +32 -6
  40. package/bin/commands/poll.mjs +3 -1
  41. package/bin/commands/ready-delay.d.mts +9 -4
  42. package/bin/commands/ready-delay.mjs +34 -20
  43. package/bin/commands/shepherd-journal.mjs +4 -1
  44. package/bin/commands/stack-drain.d.mts +35 -0
  45. package/bin/commands/stack-drain.mjs +129 -0
  46. package/bin/commands/stack-layer-readiness.d.mts +7 -0
  47. package/bin/commands/stack-layer-readiness.mjs +35 -0
  48. package/bin/commands/stack-stall.d.mts +14 -0
  49. package/bin/commands/stack-stall.mjs +69 -0
  50. package/bin/commands/stack-work.d.mts +32 -0
  51. package/bin/commands/stack-work.mjs +39 -0
  52. package/bin/config/load.d.mts +2 -0
  53. package/bin/config/load.mjs +10 -0
  54. package/bin/config.json +1 -0
  55. package/bin/exit-codes.d.mts +2 -0
  56. package/bin/exit-codes.mjs +2 -0
  57. package/bin/github/batch-parsers.mjs +1 -0
  58. package/bin/github/batch-raw-types.d.mts +2 -0
  59. package/bin/github/errors.d.mts +5 -0
  60. package/bin/github/errors.mjs +4 -0
  61. package/bin/github/gql/batch-pr.gql +1 -0
  62. package/bin/github/gql/poll-stack-summary.gql +8 -2
  63. package/bin/github/gql/poll-stack-topology.gql +38 -0
  64. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  65. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  66. package/bin/github/gql/poll-summary-fragment.gql +53 -56
  67. package/bin/github/merge-queue-checks.mjs +10 -1
  68. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  69. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  70. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  71. package/bin/github/poll-summary-fingerprint.mjs +48 -0
  72. package/bin/github/poll-summary-projector.mjs +70 -16
  73. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  74. package/bin/github/poll-summary-queue-removal.mjs +25 -0
  75. package/bin/github/poll-summary-raw.d.mts +52 -31
  76. package/bin/github/poll-summary-readiness.d.mts +6 -0
  77. package/bin/github/poll-summary-readiness.mjs +25 -0
  78. package/bin/github/poll-summary-route.mjs +8 -5
  79. package/bin/github/poll-summary.d.mts +3 -0
  80. package/bin/github/poll-summary.mjs +21 -73
  81. package/bin/github/queries.d.mts +7 -0
  82. package/bin/github/queries.mjs +9 -1
  83. package/bin/github/queue-removal-freshness.d.mts +16 -0
  84. package/bin/github/queue-removal-freshness.mjs +26 -0
  85. package/bin/github/stack-read.d.mts +34 -0
  86. package/bin/github/stack-read.mjs +92 -0
  87. package/bin/log/log-file.d.mts +1 -1
  88. package/bin/log/log-file.mjs +4 -17
  89. package/bin/state/base.d.mts +18 -1
  90. package/bin/state/base.mjs +65 -13
  91. package/bin/state/fix-attempts.d.mts +1 -1
  92. package/bin/state/fix-attempts.mjs +1 -1
  93. package/bin/state/graphql-quota-warnings.mjs +2 -6
  94. package/bin/state/iterate-stall.d.mts +7 -15
  95. package/bin/state/iterate-stall.mjs +6 -64
  96. package/bin/state/ready-receipts.d.mts +45 -0
  97. package/bin/state/ready-receipts.mjs +86 -0
  98. package/bin/state/rest-cache.d.mts +1 -1
  99. package/bin/state/rest-cache.mjs +1 -1
  100. package/bin/state/stack-stall.d.mts +16 -0
  101. package/bin/state/stack-stall.mjs +12 -0
  102. package/bin/state/stall-state-store.d.mts +37 -0
  103. package/bin/state/stall-state-store.mjs +74 -0
  104. package/bin/types/escalate.d.mts +2 -3
  105. package/bin/types/github.d.mts +2 -0
  106. package/bin/types/iterate.d.mts +2 -1
  107. package/bin/types/merge-requirements.d.mts +19 -0
  108. package/bin/types/poll-summary.d.mts +17 -2
  109. package/bin/types/report.d.mts +2 -0
  110. package/package.json +2 -2
  111. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  112. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  113. package/plugins/pr-shepherd/.mcp.json +1 -1
  114. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  115. package/bin/state/bot-cr-seen.d.mts +0 -51
  116. package/bin/state/bot-cr-seen.mjs +0 -100
@@ -1,29 +1,81 @@
1
- import { join } from "node:path";
1
+ import { execFileSync } from "node:child_process";
2
+ import { isAbsolute, join } from "node:path";
2
3
  import { tmpdir } from "node:os";
3
4
  import { SAFE_PR_NUMBER, SAFE_SEGMENT } from "../util/path-segment.mjs";
5
+ /** Read once per process; null when the platform or `getconf` offers no per-user temp dir. */
6
+ let darwinUserTempDir;
7
+ /**
8
+ * macOS's per-user temp dir, read from `confstr(_CS_DARWIN_USER_TEMP_DIR)` rather than
9
+ * `TMPDIR`. Sandboxed agent shells point `TMPDIR` at their own directory, so the same user's
10
+ * sandboxed CLI, unsandboxed CLI, and MCP server would otherwise keep separate state.
11
+ */
12
+ function readDarwinUserTempDir() {
13
+ if (process.platform !== "darwin")
14
+ return null;
15
+ try {
16
+ const dir = execFileSync("/usr/bin/getconf", ["DARWIN_USER_TEMP_DIR"], {
17
+ encoding: "utf8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ return isAbsolute(dir) ? dir : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
4
26
  export function resolveStateBase() {
5
27
  const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
6
- return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
28
+ if (envDir)
29
+ return envDir;
30
+ if (darwinUserTempDir === undefined)
31
+ darwinUserTempDir = readDarwinUserTempDir();
32
+ return join(darwinUserTempDir ?? tmpdir(), "pr-shepherd-state");
7
33
  }
8
34
  /**
9
- * `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
35
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/...parts`.
10
36
  * Owner, repo, PR number, and each extra part must be a safe path segment.
11
37
  */
12
38
  export function resolvePrStatePath(key, ...parts) {
13
- if (!SAFE_SEGMENT.test(key.owner)) {
14
- throw new Error(`Invalid state key segment "owner": ${key.owner}`);
15
- }
16
- if (!SAFE_SEGMENT.test(key.repo)) {
17
- throw new Error(`Invalid state key segment "repo": ${key.repo}`);
39
+ return resolveRepoStatePath(key, numberSegment("pr", key.pr), parts);
40
+ }
41
+ /**
42
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/...parts`, beside the per-PR directories.
43
+ * Owner, repo, stack number, and each extra part must be a safe path segment.
44
+ */
45
+ export function resolveStackStatePath(key, ...parts) {
46
+ return resolveRepoStatePath(key, `stack-${numberSegment("stack", key.stack)}`, parts);
47
+ }
48
+ /**
49
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>`.
50
+ * Owner and repo are separate segments so names that contain hyphens cannot collide.
51
+ */
52
+ export function resolveRepoStateDir(key) {
53
+ assertOwnerRepo(key);
54
+ return join(resolveStateBase(), key.owner, key.repo);
55
+ }
56
+ function numberSegment(name, value) {
57
+ const segment = String(value);
58
+ if (!SAFE_PR_NUMBER.test(segment)) {
59
+ throw new Error(`Invalid state key segment "${name}": ${value}`);
18
60
  }
19
- const pr = String(key.pr);
20
- if (!SAFE_PR_NUMBER.test(pr)) {
21
- throw new Error(`Invalid state key segment "pr": ${key.pr}`);
61
+ return segment;
62
+ }
63
+ function assertRepoSegment(name, value) {
64
+ // `.` and `..` match SAFE_SEGMENT, but they are real path segments here and would escape the base.
65
+ if (!SAFE_SEGMENT.test(value) || value === "." || value === "..") {
66
+ throw new Error(`Invalid state key segment "${name}": ${value}`);
22
67
  }
68
+ }
69
+ function assertOwnerRepo(key) {
70
+ assertRepoSegment("owner", key.owner);
71
+ assertRepoSegment("repo", key.repo);
72
+ }
73
+ function resolveRepoStatePath(key, entry, parts) {
74
+ assertOwnerRepo(key);
23
75
  for (const part of parts) {
24
- if (!SAFE_SEGMENT.test(part)) {
76
+ if (!SAFE_SEGMENT.test(part) || part === "." || part === "..") {
25
77
  throw new Error(`Invalid state key segment: ${part}`);
26
78
  }
27
79
  }
28
- return join(resolveStateBase(), `${key.owner}-${key.repo}`, pr, ...parts);
80
+ return join(resolveRepoStateDir(key), entry, ...parts);
29
81
  }
@@ -4,7 +4,7 @@
4
4
  * Tracks how many caller-visible times each review thread has been dispatched to
5
5
  * the fix_code handler without being resolved. Body edits reset the count.
6
6
  *
7
- * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
7
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
8
8
  */
9
9
  export interface FixAttemptsState {
10
10
  /** HEAD SHA at the time the counts were last written, retained for observability/compatibility. */
@@ -4,7 +4,7 @@
4
4
  * Tracks how many caller-visible times each review thread has been dispatched to
5
5
  * the fix_code handler without being resolved. Body edits reset the count.
6
6
  *
7
- * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
7
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
8
8
  */
9
9
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
10
10
  import { randomUUID } from "node:crypto";
@@ -1,8 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
- import { resolveStateBase } from "./base.mjs";
5
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
4
+ import { resolveRepoStateDir } from "./base.mjs";
6
5
  import { getWorktreeKey } from "../util/worktree.mjs";
7
6
  import { claimWarning } from "./graphql-quota-claims.mjs";
8
7
  import { evaluateGraphqlQuotaWarning, } from "./graphql-quota-policy.mjs";
@@ -52,9 +51,6 @@ async function serializeStateUpdate(key, update) {
52
51
  return result;
53
52
  }
54
53
  async function warningStatePath(key) {
55
- if (!SAFE_SEGMENT.test(key.owner) || !SAFE_SEGMENT.test(key.repo)) {
56
- throw new Error(`Invalid repo key segments: ${key.owner}/${key.repo}`);
57
- }
58
54
  let worktreeKey;
59
55
  try {
60
56
  worktreeKey = await getWorktreeKey();
@@ -62,7 +58,7 @@ async function warningStatePath(key) {
62
58
  catch {
63
59
  return undefined;
64
60
  }
65
- return join(resolveStateBase(), `${key.owner}-${key.repo}`, "worktrees", `${worktreeKey}-graphql-quota-warnings.json`);
61
+ return join(resolveRepoStateDir(key), "worktrees", `${worktreeKey}-graphql-quota-warnings.json`);
66
62
  }
67
63
  async function readState(path) {
68
64
  try {
@@ -5,23 +5,15 @@
5
5
  * was first seen. If the fingerprint does not change for stallTimeoutSeconds
6
6
  * the iterate command escalates instead of repeating the same action.
7
7
  *
8
- * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/iterate-stall.json`.
8
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/iterate-stall.json`.
9
9
  */
10
- interface StallState {
11
- /** Canonicalized JSON fingerprint of the material iterate inputs. */
12
- fingerprint: string;
13
- /** Unix timestamp (seconds) when this fingerprint was first seen. */
14
- firstSeenAt: number;
15
- }
16
- interface StateKey {
10
+ import { type StallStateStore } from "./stall-state-store.mts";
11
+ type Store = StallStateStore<{
17
12
  owner: string;
18
13
  repo: string;
19
14
  pr: number;
20
- }
21
- /** Read the current stall state. Returns null on miss, corrupt data, or invalid shape. */
22
- export declare function readStallState(key: StateKey): Promise<StallState | null>;
23
- /** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
24
- export declare function clearStallState(key: StateKey): Promise<void>;
25
- /** Write stall state (fire-and-forget — never throws). */
26
- export declare function writeStallState(key: StateKey, state: StallState): Promise<void>;
15
+ }>;
16
+ export declare const readStallState: Store["read"];
17
+ export declare const writeStallState: Store["write"];
18
+ export declare const clearStallState: Store["clear"];
27
19
  export {};
@@ -5,69 +5,11 @@
5
5
  * was first seen. If the fingerprint does not change for stallTimeoutSeconds
6
6
  * the iterate command escalates instead of repeating the same action.
7
7
  *
8
- * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/iterate-stall.json`.
8
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/iterate-stall.json`.
9
9
  */
10
- import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
11
- import { randomUUID } from "node:crypto";
12
- import { dirname } from "node:path";
13
10
  import { resolvePrStatePath } from "./base.mjs";
14
- // ---------------------------------------------------------------------------
15
- // Public API
16
- // ---------------------------------------------------------------------------
17
- /** Read the current stall state. Returns null on miss, corrupt data, or invalid shape. */
18
- export async function readStallState(key) {
19
- try {
20
- const raw = await readFile(resolvePath(key), "utf8");
21
- const parsed = JSON.parse(raw);
22
- if (parsed === null ||
23
- typeof parsed !== "object" ||
24
- typeof parsed["fingerprint"] !== "string" ||
25
- !Number.isFinite(parsed["firstSeenAt"])) {
26
- return null;
27
- }
28
- return parsed;
29
- }
30
- catch {
31
- return null;
32
- }
33
- }
34
- /** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
35
- export async function clearStallState(key) {
36
- try {
37
- await unlink(resolvePath(key));
38
- }
39
- catch {
40
- // Best-effort — file may not exist.
41
- }
42
- }
43
- /** Write stall state (fire-and-forget — never throws). */
44
- export async function writeStallState(key, state) {
45
- let tmp;
46
- try {
47
- const path = resolvePath(key);
48
- tmp = `${path}.${randomUUID()}.tmp`;
49
- await mkdir(dirname(path), { recursive: true });
50
- await writeFile(tmp, JSON.stringify(state), "utf8");
51
- await rename(tmp, path);
52
- tmp = undefined;
53
- }
54
- catch {
55
- // Best-effort.
56
- }
57
- finally {
58
- if (tmp !== undefined) {
59
- try {
60
- await unlink(tmp);
61
- }
62
- catch {
63
- // Best-effort cleanup.
64
- }
65
- }
66
- }
67
- }
68
- // ---------------------------------------------------------------------------
69
- // Helpers
70
- // ---------------------------------------------------------------------------
71
- function resolvePath(key) {
72
- return resolvePrStatePath(key, "iterate-stall.json");
73
- }
11
+ import { stallStateStore } from "./stall-state-store.mjs";
12
+ const store = stallStateStore((key) => resolvePrStatePath(key, "iterate-stall.json"));
13
+ export const readStallState = store.read;
14
+ export const writeStallState = store.write;
15
+ export const clearStallState = store.clear;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Durable evidence that a single-PR Shepherd poll observed a ready PR after
3
+ * its ready-delay elapsed. The ref OIDs and readiness fingerprint are part of
4
+ * the evidence: a receipt is never a general-purpose "this PR is ready"
5
+ * cache.
6
+ */
7
+ export interface ReadyReceipt {
8
+ version: 1;
9
+ owner: string;
10
+ repo: string;
11
+ pr: number;
12
+ headRefOid: string;
13
+ baseRefOid: string;
14
+ status: "READY";
15
+ isDraft: false;
16
+ /** Canonical representation of the readiness inputs observed by Shepherd. */
17
+ readinessFingerprint: string;
18
+ /** Exact merge-queue removal observed by the one-PR session, if any. */
19
+ acknowledgedQueueRemovalId?: string;
20
+ recordedAtUnix: number;
21
+ }
22
+ export interface ReadyReceiptKey {
23
+ owner: string;
24
+ repo: string;
25
+ pr: number;
26
+ }
27
+ export interface ReadyReceiptCurrentState {
28
+ headRefOid: string;
29
+ baseRefOid: string;
30
+ readinessFingerprint: string;
31
+ status: string;
32
+ isDraft: boolean;
33
+ }
34
+ /** Read a persisted one-PR readiness receipt. Invalid/stale-shaped files are ignored. */
35
+ export declare function readReadyReceipt(key: ReadyReceiptKey): Promise<ReadyReceipt | null>;
36
+ /** Persist evidence for a completed one-PR ready-delay observation. */
37
+ export declare function writeReadyReceipt(receipt: ReadyReceipt): Promise<void>;
38
+ /** Remove a receipt after the observed PR state no longer matches it. */
39
+ export declare function clearReadyReceipt(key: ReadyReceiptKey): Promise<void>;
40
+ /**
41
+ * Check the complete binding before using a receipt for aggregate routing.
42
+ * Callers must provide a fresh fingerprint from the same readiness inputs used
43
+ * to create the receipt; ref OIDs alone are not sufficient.
44
+ */
45
+ export declare function isReadyReceiptCurrent(receipt: ReadyReceipt | null, current: ReadyReceiptCurrentState): receipt is ReadyReceipt;
@@ -0,0 +1,86 @@
1
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { resolvePrStatePath } from "./base.mjs";
4
+ /** Read a persisted one-PR readiness receipt. Invalid/stale-shaped files are ignored. */
5
+ export async function readReadyReceipt(key) {
6
+ const path = receiptPath(key);
7
+ try {
8
+ const parsed = JSON.parse(await readFile(path, "utf8"));
9
+ return isReadyReceipt(parsed) && sameKey(parsed, key) ? parsed : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /** Persist evidence for a completed one-PR ready-delay observation. */
16
+ export async function writeReadyReceipt(receipt) {
17
+ if (!isReadyReceipt(receipt))
18
+ throw new Error("Invalid ready receipt");
19
+ const path = receiptPath(receipt);
20
+ await mkdir(dirname(path), { recursive: true });
21
+ await writeFile(path, `${JSON.stringify(receipt)}\n`, "utf8");
22
+ }
23
+ /** Remove a receipt after the observed PR state no longer matches it. */
24
+ export async function clearReadyReceipt(key) {
25
+ try {
26
+ await unlink(receiptPath(key));
27
+ }
28
+ catch (error) {
29
+ // Missing receipts are already clear. Any other failure must reach the
30
+ // caller so stale evidence cannot be silently retained as if it cleared.
31
+ if (isNodeErrorCode(error, "ENOENT"))
32
+ return;
33
+ throw error;
34
+ }
35
+ }
36
+ function isNodeErrorCode(error, code) {
37
+ return (error !== null &&
38
+ typeof error === "object" &&
39
+ "code" in error &&
40
+ error.code === code);
41
+ }
42
+ /**
43
+ * Check the complete binding before using a receipt for aggregate routing.
44
+ * Callers must provide a fresh fingerprint from the same readiness inputs used
45
+ * to create the receipt; ref OIDs alone are not sufficient.
46
+ */
47
+ export function isReadyReceiptCurrent(receipt, current) {
48
+ return (receipt !== null &&
49
+ receipt.status === "READY" &&
50
+ receipt.isDraft === false &&
51
+ current.status === "READY" &&
52
+ current.isDraft === false &&
53
+ receipt.headRefOid === current.headRefOid &&
54
+ receipt.baseRefOid === current.baseRefOid &&
55
+ receipt.readinessFingerprint === current.readinessFingerprint);
56
+ }
57
+ function receiptPath(key) {
58
+ return resolvePrStatePath(key, "ready-receipt.json");
59
+ }
60
+ function sameKey(receipt, key) {
61
+ return receipt.owner === key.owner && receipt.repo === key.repo && receipt.pr === key.pr;
62
+ }
63
+ function isReadyReceipt(value) {
64
+ if (value === null || typeof value !== "object")
65
+ return false;
66
+ const candidate = value;
67
+ return (candidate.version === 1 &&
68
+ typeof candidate.owner === "string" &&
69
+ typeof candidate.repo === "string" &&
70
+ typeof candidate.pr === "number" &&
71
+ Number.isInteger(candidate.pr) &&
72
+ candidate.pr > 0 &&
73
+ typeof candidate.headRefOid === "string" &&
74
+ candidate.headRefOid.length > 0 &&
75
+ typeof candidate.baseRefOid === "string" &&
76
+ candidate.baseRefOid.length > 0 &&
77
+ candidate.status === "READY" &&
78
+ candidate.isDraft === false &&
79
+ typeof candidate.readinessFingerprint === "string" &&
80
+ candidate.readinessFingerprint.length > 0 &&
81
+ (candidate.acknowledgedQueueRemovalId === undefined ||
82
+ (typeof candidate.acknowledgedQueueRemovalId === "string" &&
83
+ candidate.acknowledgedQueueRemovalId.length > 0)) &&
84
+ typeof candidate.recordedAtUnix === "number" &&
85
+ Number.isFinite(candidate.recordedAtUnix));
86
+ }
@@ -14,7 +14,7 @@
14
14
  * terminal, check-run annotations once the check is COMPLETED. No ETag
15
15
  * applies; the cache is keyed on an immutable identity instead.
16
16
  *
17
- * Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/rest-cache/`
17
+ * Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/rest-cache/`
18
18
  * and are removed for free when `pr-shepherd clean` deletes the PR's state
19
19
  * directory — there is no separate pruning routine.
20
20
  */
@@ -14,7 +14,7 @@
14
14
  * terminal, check-run annotations once the check is COMPLETED. No ETag
15
15
  * applies; the cache is keyed on an immutable identity instead.
16
16
  *
17
- * Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/rest-cache/`
17
+ * Entries live under `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/rest-cache/`
18
18
  * and are removed for free when `pr-shepherd clean` deletes the PR's state
19
19
  * directory — there is no separate pruning routine.
20
20
  */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Persistent stall-detection state for an aggregate `--stack` selection whose open layers can
3
+ * only wait. Keyed by native stack number because the anchor PR can merge or change.
4
+ *
5
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/stack-stall.json`.
6
+ */
7
+ import { type StallStateStore } from "./stall-state-store.mts";
8
+ type Store = StallStateStore<{
9
+ owner: string;
10
+ repo: string;
11
+ stack: number;
12
+ }>;
13
+ export declare const readStackStallState: Store["read"];
14
+ export declare const writeStackStallState: Store["write"];
15
+ export declare const clearStackStallState: Store["clear"];
16
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Persistent stall-detection state for an aggregate `--stack` selection whose open layers can
3
+ * only wait. Keyed by native stack number because the anchor PR can merge or change.
4
+ *
5
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/stack-stall.json`.
6
+ */
7
+ import { resolveStackStatePath } from "./base.mjs";
8
+ import { stallStateStore } from "./stall-state-store.mjs";
9
+ const store = stallStateStore((key) => resolveStackStatePath(key, "stack-stall.json"));
10
+ export const readStackStallState = store.read;
11
+ export const writeStackStallState = store.write;
12
+ export const clearStackStallState = store.clear;
@@ -0,0 +1,37 @@
1
+ interface StallState {
2
+ /** Canonicalized JSON fingerprint of the material inputs. */
3
+ fingerprint: string;
4
+ /** Unix timestamp (seconds) when this fingerprint was first seen. */
5
+ firstSeenAt: number;
6
+ }
7
+ /** A missing or unreadable-as-JSON file is `state: null`. Any other I/O or key error is `ok: false`. */
8
+ type StallReadResult = {
9
+ ok: true;
10
+ state: StallState | null;
11
+ } | {
12
+ ok: false;
13
+ reason: string;
14
+ };
15
+ type StallWriteResult = {
16
+ ok: true;
17
+ } | {
18
+ ok: false;
19
+ reason: string;
20
+ };
21
+ export interface StallStateStore<Key> {
22
+ /** Read the current stall state. Does not throw. */
23
+ read(key: Key): Promise<StallReadResult>;
24
+ /** Write stall state. Does not throw; `ok: false` means the timer was not saved. */
25
+ write(key: Key, state: StallState): Promise<StallWriteResult>;
26
+ /** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
27
+ clear(key: Key): Promise<void>;
28
+ }
29
+ /**
30
+ * `{fingerprint, firstSeenAt}` files behind the one-PR and `--stack` stall guards.
31
+ * A missing file is a miss. An unwritable directory or an unsafe key is a persistence failure
32
+ * so the caller can hand off instead of treating every tick as the first sighting.
33
+ * `ENOENT` is the only read error treated as a miss. Corrupt JSON is also a miss, so one
34
+ * successful rewrite can start the timer again.
35
+ */
36
+ export declare function stallStateStore<Key>(resolvePath: (key: Key) => string): StallStateStore<Key>;
37
+ export {};
@@ -0,0 +1,74 @@
1
+ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname } from "node:path";
4
+ /**
5
+ * `{fingerprint, firstSeenAt}` files behind the one-PR and `--stack` stall guards.
6
+ * A missing file is a miss. An unwritable directory or an unsafe key is a persistence failure
7
+ * so the caller can hand off instead of treating every tick as the first sighting.
8
+ * `ENOENT` is the only read error treated as a miss. Corrupt JSON is also a miss, so one
9
+ * successful rewrite can start the timer again.
10
+ */
11
+ export function stallStateStore(resolvePath) {
12
+ return {
13
+ async read(key) {
14
+ try {
15
+ const parsed = JSON.parse(await readFile(resolvePath(key), "utf8"));
16
+ if (parsed === null ||
17
+ typeof parsed !== "object" ||
18
+ typeof parsed["fingerprint"] !== "string" ||
19
+ !Number.isFinite(parsed["firstSeenAt"])) {
20
+ return { ok: true, state: null };
21
+ }
22
+ return { ok: true, state: parsed };
23
+ }
24
+ catch (error) {
25
+ // A missing file and corrupt JSON are both misses. One successful rewrite can start the timer.
26
+ if (isEnoent(error) || error instanceof SyntaxError)
27
+ return { ok: true, state: null };
28
+ return { ok: false, reason: errorReason(error) };
29
+ }
30
+ },
31
+ async write(key, state) {
32
+ let tmp;
33
+ try {
34
+ const path = resolvePath(key);
35
+ tmp = `${path}.${randomUUID()}.tmp`;
36
+ await mkdir(dirname(path), { recursive: true });
37
+ await writeFile(tmp, JSON.stringify(state), "utf8");
38
+ await rename(tmp, path);
39
+ tmp = undefined;
40
+ return { ok: true };
41
+ }
42
+ catch (error) {
43
+ return { ok: false, reason: errorReason(error) };
44
+ }
45
+ finally {
46
+ if (tmp !== undefined) {
47
+ try {
48
+ await unlink(tmp);
49
+ }
50
+ catch {
51
+ // Best-effort cleanup.
52
+ }
53
+ }
54
+ }
55
+ },
56
+ async clear(key) {
57
+ try {
58
+ await unlink(resolvePath(key));
59
+ }
60
+ catch {
61
+ // Best-effort — file may not exist. A leftover file can only make a later timer escalate sooner.
62
+ }
63
+ },
64
+ };
65
+ }
66
+ function isEnoent(error) {
67
+ return (typeof error === "object" &&
68
+ error !== null &&
69
+ "code" in error &&
70
+ error.code === "ENOENT");
71
+ }
72
+ function errorReason(error) {
73
+ return error instanceof Error ? error.message : String(error);
74
+ }
@@ -1,8 +1,8 @@
1
1
  import type { AgentCheck, AgentComment, AgentThread } from "./report.mts";
2
2
  import type { ResolveCommand } from "./iterate.mts";
3
3
  import type { CheckStatus, Review } from "./github.mts";
4
- import type { MergeQueueRemovalStatus, StackStatus } from "./merge-requirements.mts";
5
- export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "bot-cr-not-dismissed" | "merge-queue-removed" | "stacked-pr";
4
+ import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
5
+ export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "stall-state-unavailable" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed";
6
6
  export interface AgentStalledCheck {
7
7
  name: string;
8
8
  status: CheckStatus;
@@ -39,7 +39,6 @@ export interface EscalateDetails {
39
39
  suggestion: string;
40
40
  humanMessage: string;
41
41
  mergeQueueRemoval?: MergeQueueRemovalStatus;
42
- stack?: StackStatus;
43
42
  authorization?: Array<{
44
43
  action: "mark-ready" | "merge-or-enqueue";
45
44
  targetIds: string[];
@@ -144,6 +144,8 @@ export interface BatchPrData extends BatchPrMergeFields {
144
144
  headRepoWithOwner: string | null;
145
145
  viewerAuthorization?: ViewerAuthorization;
146
146
  baseRefName: string;
147
+ /** Base commit GitHub recorded for this PR (`PullRequest.baseRefOid`), not the branch's live tip. */
148
+ baseRefOid?: string;
147
149
  reviewRequests: Array<{
148
150
  login: string;
149
151
  }>;
@@ -1,7 +1,7 @@
1
1
  import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment } from "./report.mts";
2
2
  import type { ActiveCheck, PrActivitySummary } from "./activity.mts";
3
3
  import type { BranchProtection, MergeStateStatus, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
4
- import type { MergeRequirements } from "./merge-requirements.mts";
4
+ import type { MergeRequirements, StackDraftHold } from "./merge-requirements.mts";
5
5
  import type { EscalateDetails } from "./escalate.mts";
6
6
  import type { MergeCommandPlan } from "./merge-action.mts";
7
7
  import type { ProtectedRun } from "./protected-run.mts";
@@ -50,6 +50,7 @@ interface IterateResultWait extends IterateResultBase {
50
50
  action: "wait";
51
51
  log: string;
52
52
  deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
53
+ stackDraftHold?: StackDraftHold;
53
54
  }
54
55
  export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
55
56
  interface IterateResultCancel extends IterateResultBase {
@@ -39,6 +39,25 @@ export interface StackStatus {
39
39
  position: number;
40
40
  baseRefName: string;
41
41
  }
42
+ /** Why an open native stack layer does not yet let the layers above it advance. */
43
+ export type StackLayerBlockReason = "closed" | "draft" | "conflicting" | "queue-removal" | "failing-checks" | "review-work" | "checks-in-progress" | "merge-state" | "no-ready-receipt" | "stale-ancestry";
44
+ /** The lowest native stack layer that keeps an upper draft from being marked ready. */
45
+ export interface StackLowerLayerBlock {
46
+ pr: number;
47
+ reason: StackLayerBlockReason;
48
+ }
49
+ /**
50
+ * Why a native stack layer stays in draft on a WAIT tick. Repeating the same one-PR
51
+ * session cannot advance it, so the caller returns to the stack selector. A named
52
+ * `lowerLayer` is the layer to advance first; without one, the lower layers could not
53
+ * be verified.
54
+ */
55
+ export type StackDraftHold = {
56
+ kind: "lower-layer-not-ready";
57
+ lowerLayer?: StackLowerLayerBlock;
58
+ } | {
59
+ kind: "auto-mark-ready-disabled";
60
+ };
42
61
  /** Extra batch-PR fields for merge-queue, stacks, and folded branch rules. */
43
62
  export interface BatchPrMergeFields {
44
63
  branchRules?: BranchRules;