pr-shepherd 0.52.0 → 0.53.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.
Files changed (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -12
  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 +22 -0
  10. package/bin/cli/iterate-lean.mjs +1 -0
  11. package/bin/cli/poll-summary-formatter.mjs +4 -3
  12. package/bin/cli/runner.d.mts +1 -0
  13. package/bin/cli/runner.mjs +3 -1
  14. package/bin/commands/check.mjs +13 -4
  15. package/bin/commands/clean.mjs +7 -13
  16. package/bin/commands/iterate/api-usage.mjs +2 -2
  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 -0
  20. package/bin/commands/iterate/fix-code.mjs +6 -1
  21. package/bin/commands/iterate/helpers.d.mts +0 -1
  22. package/bin/commands/iterate/helpers.mjs +0 -15
  23. package/bin/commands/iterate/index.mjs +48 -27
  24. package/bin/commands/iterate/merge-state.mjs +6 -4
  25. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  26. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  27. package/bin/commands/iterate/parent-first.d.mts +6 -7
  28. package/bin/commands/iterate/parent-first.mjs +10 -54
  29. package/bin/commands/iterate/render.d.mts +1 -1
  30. package/bin/commands/iterate/render.mjs +7 -12
  31. package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
  32. package/bin/commands/iterate/stale-ancestry.mjs +10 -7
  33. package/bin/commands/iterate/stall.mjs +40 -3
  34. package/bin/commands/poll-progress.d.mts +5 -1
  35. package/bin/commands/poll-progress.mjs +7 -1
  36. package/bin/commands/poll-quota.mjs +2 -2
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +71 -122
  39. package/bin/commands/poll-summary.mjs +4 -2
  40. package/bin/commands/ready-delay.d.mts +9 -4
  41. package/bin/commands/ready-delay.mjs +34 -20
  42. package/bin/commands/shepherd-journal.mjs +4 -1
  43. package/bin/commands/stack-drain.d.mts +35 -0
  44. package/bin/commands/stack-drain.mjs +138 -0
  45. package/bin/commands/stack-layer-readiness.d.mts +6 -0
  46. package/bin/commands/stack-layer-readiness.mjs +34 -0
  47. package/bin/commands/stack-stall.d.mts +14 -0
  48. package/bin/commands/stack-stall.mjs +68 -0
  49. package/bin/commands/stack-work.d.mts +32 -0
  50. package/bin/commands/stack-work.mjs +38 -0
  51. package/bin/config/load.d.mts +2 -0
  52. package/bin/config/load.mjs +10 -0
  53. package/bin/config.json +1 -0
  54. package/bin/github/api-telemetry-aggregate.d.mts +1 -0
  55. package/bin/github/api-telemetry-aggregate.mjs +8 -2
  56. package/bin/github/api-telemetry.d.mts +4 -0
  57. package/bin/github/api-telemetry.mjs +12 -0
  58. package/bin/github/batch-parsers.mjs +1 -1
  59. package/bin/github/batch-raw-rules.d.mts +0 -3
  60. package/bin/github/batch-raw-types.d.mts +2 -0
  61. package/bin/github/errors.d.mts +5 -0
  62. package/bin/github/errors.mjs +4 -0
  63. package/bin/github/gql/batch-pr.gql +1 -0
  64. package/bin/github/gql/poll-stack-summary.gql +8 -2
  65. package/bin/github/gql/poll-stack-topology.gql +38 -0
  66. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  67. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  68. package/bin/github/gql/poll-summary-fragment.gql +5 -62
  69. package/bin/github/gql/pr-merge-policy.gql +0 -3
  70. package/bin/github/graphql-http.mjs +6 -0
  71. package/bin/github/http-auth.d.mts +3 -0
  72. package/bin/github/http-auth.mjs +6 -0
  73. package/bin/github/http-intermediate.d.mts +1 -0
  74. package/bin/github/http-intermediate.mjs +3 -0
  75. package/bin/github/merge-queue-checks.mjs +10 -1
  76. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  77. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  78. package/bin/github/poll-summary-fingerprint.mjs +24 -4
  79. package/bin/github/poll-summary-projector.mjs +8 -7
  80. package/bin/github/poll-summary-queue-removal.mjs +10 -1
  81. package/bin/github/poll-summary-raw.d.mts +19 -31
  82. package/bin/github/poll-summary-route.mjs +8 -5
  83. package/bin/github/poll-summary.mjs +16 -73
  84. package/bin/github/queries.d.mts +7 -0
  85. package/bin/github/queries.mjs +9 -1
  86. package/bin/github/queue-removal-freshness.d.mts +16 -0
  87. package/bin/github/queue-removal-freshness.mjs +26 -0
  88. package/bin/github/stack-read.d.mts +34 -0
  89. package/bin/github/stack-read.mjs +92 -0
  90. package/bin/log/log-file.d.mts +1 -1
  91. package/bin/log/log-file.mjs +4 -17
  92. package/bin/state/base.d.mts +18 -1
  93. package/bin/state/base.mjs +65 -13
  94. package/bin/state/fix-attempts.d.mts +1 -1
  95. package/bin/state/fix-attempts.mjs +1 -1
  96. package/bin/state/graphql-quota-policy.d.mts +7 -1
  97. package/bin/state/graphql-quota-policy.mjs +41 -14
  98. package/bin/state/graphql-quota-warnings.mjs +4 -6
  99. package/bin/state/iterate-stall.d.mts +7 -15
  100. package/bin/state/iterate-stall.mjs +6 -64
  101. package/bin/state/rest-cache.d.mts +1 -1
  102. package/bin/state/rest-cache.mjs +1 -1
  103. package/bin/state/stack-stall.d.mts +16 -0
  104. package/bin/state/stack-stall.mjs +12 -0
  105. package/bin/state/stall-state-store.d.mts +37 -0
  106. package/bin/state/stall-state-store.mjs +74 -0
  107. package/bin/types/api-usage.d.mts +2 -0
  108. package/bin/types/escalate.d.mts +1 -1
  109. package/bin/types/github.d.mts +1 -1
  110. package/bin/types/iterate.d.mts +2 -1
  111. package/bin/types/merge-requirements.d.mts +9 -0
  112. package/bin/types/poll-summary.d.mts +5 -2
  113. package/bin/types/report.d.mts +1 -1
  114. package/package.json +1 -1
  115. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  116. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  117. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Append-only per-worktree markdown log.
3
3
  *
4
- * Log path: $PR_SHEPHERD_STATE_DIR/<owner>-<repo>/worktrees/<basename>-<sha8>.md
4
+ * Log path: $PR_SHEPHERD_STATE_DIR/<owner>/<repo>/worktrees/<basename>-<sha8>.md
5
5
  *
6
6
  * Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
7
7
  * Write failures flip an internal disabled flag so the CLI never crashes because
@@ -9,8 +9,7 @@
9
9
  */
10
10
  import { appendFileSync, mkdirSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
- import { resolveStateBase } from "../state/base.mjs";
13
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
12
+ import { resolveRepoStateDir } from "../state/base.mjs";
14
13
  import { getWorktreeKey } from "../util/worktree.mjs";
15
14
  function computeDisabled() {
16
15
  if (process.env["PR_SHEPHERD_LOG_DISABLED"] === "1")
@@ -26,14 +25,9 @@ export function nextEntry() {
26
25
  return ++_entryCounter;
27
26
  }
28
27
  export function getLogFilePath(key) {
29
- const { owner, repo } = key;
30
- if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo)) {
31
- throw new Error(`Invalid repo key segments: ${owner}/${repo}`);
32
- }
33
- const base = resolveStateBase();
34
28
  // Worktree key injected at init time; fall back to "unknown" if not yet set.
35
29
  const wkey = _worktreeKey ?? "unknown";
36
- return join(base, `${owner}-${repo}`, "worktrees", `${wkey}.md`);
30
+ return join(resolveRepoStateDir(key), "worktrees", `${wkey}.md`);
37
31
  }
38
32
  let _worktreeKey = null;
39
33
  /**
@@ -44,9 +38,6 @@ export async function initLog(repoKey) {
44
38
  if (_disabled)
45
39
  return null;
46
40
  try {
47
- const { owner, repo } = repoKey;
48
- if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo))
49
- return null;
50
41
  _worktreeKey = await getWorktreeKey();
51
42
  const path = getLogFilePath(repoKey);
52
43
  mkdirSync(dirname(path), { recursive: true });
@@ -72,12 +63,8 @@ export function appendEntry(markdown) {
72
63
  }
73
64
  /** Resolve the log path without initializing (for the log-file subcommand). */
74
65
  export async function resolveLogPath(repoKey) {
75
- if (!SAFE_SEGMENT.test(repoKey.owner) || !SAFE_SEGMENT.test(repoKey.repo)) {
76
- throw new Error(`Invalid repo key segments: ${repoKey.owner}/${repoKey.repo}`);
77
- }
78
66
  const wkey = await getWorktreeKey();
79
- const base = resolveStateBase();
80
- return join(base, `${repoKey.owner}-${repoKey.repo}`, "worktrees", `${wkey}.md`);
67
+ return join(resolveRepoStateDir(repoKey), "worktrees", `${wkey}.md`);
81
68
  }
82
69
  /** Exposed for tests to reset module state. */
83
70
  export function _resetLogState() {
@@ -1,6 +1,6 @@
1
1
  export declare function resolveStateBase(): string;
2
2
  /**
3
- * `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
3
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/...parts`.
4
4
  * Owner, repo, PR number, and each extra part must be a safe path segment.
5
5
  */
6
6
  export declare function resolvePrStatePath(key: {
@@ -8,3 +8,20 @@ export declare function resolvePrStatePath(key: {
8
8
  repo: string;
9
9
  pr: number;
10
10
  }, ...parts: string[]): string;
11
+ /**
12
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/...parts`, beside the per-PR directories.
13
+ * Owner, repo, stack number, and each extra part must be a safe path segment.
14
+ */
15
+ export declare function resolveStackStatePath(key: {
16
+ owner: string;
17
+ repo: string;
18
+ stack: number;
19
+ }, ...parts: string[]): string;
20
+ /**
21
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>`.
22
+ * Owner and repo are separate segments so names that contain hyphens cannot collide.
23
+ */
24
+ export declare function resolveRepoStateDir(key: {
25
+ owner: string;
26
+ repo: string;
27
+ }): string;
@@ -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";
@@ -7,11 +7,17 @@ export interface GraphqlQuotaWarningState {
7
7
  lastRemaining: number;
8
8
  resetAt: number;
9
9
  warnedThresholds: number[];
10
+ /** Truncated SHA-256 of the credential. Absent in state written before fingerprints. */
11
+ credentialFingerprint?: string;
10
12
  rearmEpoch?: number;
11
13
  }
12
- export declare function evaluateGraphqlQuotaWarning(bands: GraphqlQuotaWarningBand[], sample: Pick<GraphqlApiUsage, "resource" | "limit" | "used" | "remaining" | "resetAt">, previous: GraphqlQuotaWarningState | null, observedAt?: number): {
14
+ type GraphqlQuotaSample = Pick<GraphqlApiUsage, "resource" | "limit" | "used" | "remaining" | "resetAt"> & {
15
+ credentialFingerprint?: string;
16
+ };
17
+ export declare function evaluateGraphqlQuotaWarning(bands: GraphqlQuotaWarningBand[], sample: GraphqlQuotaSample, previous: GraphqlQuotaWarningState | null, observedAt?: number): {
13
18
  warning?: GraphqlQuotaWarning;
14
19
  state: GraphqlQuotaWarningState & {
15
20
  rearmEpoch: number;
16
21
  };
17
22
  };
23
+ export {};
@@ -4,25 +4,28 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
4
4
  previous.resource !== sample.resource ||
5
5
  previous.limit !== sample.limit ||
6
6
  windowRolled ||
7
- (sample.used !== undefined && previous.lastUsed !== undefined
8
- ? sample.used < previous.lastUsed
9
- : sample.remaining > previous.lastRemaining);
10
- const warned = new Set(rearm ? [] : previous.warnedThresholds);
7
+ fingerprintChanged(previous, sample);
8
+ const effective = !rearm && previous !== null && usageRegressed(previous, sample)
9
+ ? newerSavedSample(previous)
10
+ : sample;
11
+ const warned = new Set(rearm || previous === null ? [] : previous.warnedThresholds);
11
12
  const rearmEpoch = rearm ? (previous?.rearmEpoch ?? 0) + 1 : (previous?.rearmEpoch ?? 0);
12
- const crossed = bands.filter((band) => sample.remaining * 100 <= sample.limit * band.remainingPercent);
13
+ const crossed = bands.filter((band) => effective.remaining * 100 <= effective.limit * band.remainingPercent);
13
14
  const newCrossed = crossed.filter((band) => !warned.has(band.remainingPercent));
14
15
  for (const band of crossed)
15
16
  warned.add(band.remainingPercent);
16
17
  const active = newCrossed.at(-1);
18
+ const credentialFingerprint = effective.credentialFingerprint ?? previous?.credentialFingerprint;
17
19
  const state = {
18
- resource: sample.resource,
19
- limit: sample.limit,
20
- ...(sample.used !== undefined && { lastUsed: sample.used }),
21
- lastRemaining: sample.remaining,
22
- resetAt: sample.resetAt,
20
+ resource: effective.resource,
21
+ limit: effective.limit,
22
+ ...(effective.used !== undefined && { lastUsed: effective.used }),
23
+ lastRemaining: effective.remaining,
24
+ resetAt: effective.resetAt,
23
25
  warnedThresholds: bands
24
26
  .map((band) => band.remainingPercent)
25
27
  .filter((threshold) => warned.has(threshold)),
28
+ ...(credentialFingerprint !== undefined && { credentialFingerprint }),
26
29
  rearmEpoch,
27
30
  };
28
31
  if (active === undefined)
@@ -31,13 +34,37 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
31
34
  warning: {
32
35
  resource: "graphql",
33
36
  thresholdPercent: active.remainingPercent,
34
- remaining: sample.remaining,
35
- limit: sample.limit,
36
- ...(sample.used !== undefined && { used: sample.used }),
37
- resetAt: sample.resetAt,
37
+ remaining: effective.remaining,
38
+ limit: effective.limit,
39
+ ...(effective.used !== undefined && { used: effective.used }),
40
+ resetAt: effective.resetAt,
38
41
  pollIntervalMinutes: active.pollIntervalMinutes,
39
42
  pollTimeoutMinutes: active.pollIntervalMinutes * 2,
40
43
  },
41
44
  state,
42
45
  };
43
46
  }
47
+ function fingerprintChanged(previous, sample) {
48
+ return (previous.credentialFingerprint !== undefined &&
49
+ sample.credentialFingerprint !== undefined &&
50
+ previous.credentialFingerprint !== sample.credentialFingerprint);
51
+ }
52
+ /** An older observation of the same window: lower used, or higher remaining when used is absent. */
53
+ function usageRegressed(previous, sample) {
54
+ if (sample.used !== undefined && previous.lastUsed !== undefined) {
55
+ return sample.used < previous.lastUsed;
56
+ }
57
+ return sample.remaining > previous.lastRemaining;
58
+ }
59
+ function newerSavedSample(previous) {
60
+ return {
61
+ resource: previous.resource,
62
+ limit: previous.limit,
63
+ ...(previous.lastUsed !== undefined && { used: previous.lastUsed }),
64
+ remaining: previous.lastRemaining,
65
+ resetAt: previous.resetAt,
66
+ ...(previous.credentialFingerprint !== undefined && {
67
+ credentialFingerprint: previous.credentialFingerprint,
68
+ }),
69
+ };
70
+ }
@@ -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 {
@@ -74,6 +70,8 @@ async function readState(path) {
74
70
  !Array.isArray(parsed.warnedThresholds)) {
75
71
  return null;
76
72
  }
73
+ if (typeof parsed.credentialFingerprint !== "string")
74
+ delete parsed.credentialFingerprint;
77
75
  return parsed;
78
76
  }
79
77
  catch {
@@ -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;
@@ -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
+ }
@@ -13,6 +13,8 @@ export interface GraphqlApiUsage extends ApiResourceUsage {
13
13
  unmeasuredRequestCount: number;
14
14
  /** Exact sum reported by rateLimit.nodeCount for measured GraphQL queries. */
15
15
  nodeCount: number;
16
+ /** Truncated SHA-256 of the credential. Quota state only; summarized usage omits it. */
17
+ credentialFingerprint?: string;
16
18
  }
17
19
  export interface ApiUsage {
18
20
  credentialSources: string[];
@@ -2,7 +2,7 @@ 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
4
  import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
5
- export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed";
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;
@@ -144,7 +144,7 @@ export interface BatchPrData extends BatchPrMergeFields {
144
144
  headRepoWithOwner: string | null;
145
145
  viewerAuthorization?: ViewerAuthorization;
146
146
  baseRefName: string;
147
- /** Git OID of the base branch tip observed with this PR, when available. */
147
+ /** Base commit GitHub recorded for this PR (`PullRequest.baseRefOid`), not the branch's live tip. */
148
148
  baseRefOid?: string;
149
149
  reviewRequests: Array<{
150
150
  login: string;