pr-shepherd 0.34.0 → 0.36.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 (231) hide show
  1. package/.claude-plugin/plugin.json +3 -2
  2. package/.grok-plugin/marketplace.json +17 -0
  3. package/README.md +80 -43
  4. package/bin/api.d.mts +80 -0
  5. package/bin/api.mjs +236 -0
  6. package/bin/checks/classify.d.mts +41 -0
  7. package/bin/checks/startup-failures.d.mts +2 -0
  8. package/bin/checks/superseded.d.mts +21 -0
  9. package/bin/checks/triage.d.mts +4 -0
  10. package/bin/checks/triage.mjs +11 -3
  11. package/bin/classify/apply.d.mts +18 -0
  12. package/bin/classify/apply.mjs +4 -0
  13. package/bin/classify/loader.d.mts +10 -0
  14. package/bin/classify/types.d.mts +35 -0
  15. package/bin/cli/args.d.mts +18 -0
  16. package/bin/cli/args.mjs +1 -0
  17. package/bin/cli/clean-formatter.d.mts +2 -0
  18. package/bin/cli/default-poll.d.mts +2 -0
  19. package/bin/cli/default-poll.mjs +1 -0
  20. package/bin/cli/duration-flag.d.mts +2 -0
  21. package/bin/cli/duration.d.mts +13 -0
  22. package/bin/cli/fence.d.mts +1 -0
  23. package/bin/cli/fix-formatter-extra.d.mts +3 -0
  24. package/bin/cli/fix-formatter.d.mts +2 -0
  25. package/bin/cli/fix-formatter.mjs +6 -6
  26. package/bin/cli/formatters.d.mts +7 -0
  27. package/bin/cli/handlers.d.mts +4 -0
  28. package/bin/cli/handlers.mjs +11 -10
  29. package/bin/cli/help-command-pages.d.mts +231 -0
  30. package/bin/cli/help-command-pages.mjs +106 -1
  31. package/bin/cli/help-iterate-poll-pages.d.mts +4 -0
  32. package/bin/cli/help-iterate-poll-pages.mjs +14 -7
  33. package/bin/cli/help-log-file-page.d.mts +1 -0
  34. package/bin/cli/help-top-page.d.mts +1 -0
  35. package/bin/cli/help-top-page.mjs +16 -16
  36. package/bin/cli/help.d.mts +236 -0
  37. package/bin/cli/help.mjs +17 -0
  38. package/bin/cli/iterate-emitter.d.mts +8 -0
  39. package/bin/cli/iterate-flags.d.mts +11 -0
  40. package/bin/cli/iterate-formatter.d.mts +19 -0
  41. package/bin/cli/iterate-formatter.mjs +11 -2
  42. package/bin/cli/iterate-instructions.d.mts +6 -0
  43. package/bin/cli/iterate-lean.d.mts +12 -0
  44. package/bin/cli/iterate-lean.mjs +1 -0
  45. package/bin/cli/journal-formatter.d.mts +2 -0
  46. package/bin/cli/journal-formatter.mjs +15 -0
  47. package/bin/cli/journal-handler.d.mts +1 -0
  48. package/bin/cli/journal-handler.mjs +7 -20
  49. package/bin/cli/list-formatters.d.mts +76 -0
  50. package/bin/cli/list-formatters.mjs +9 -9
  51. package/bin/cli/mark-files-as-viewed-flags.d.mts +11 -0
  52. package/bin/cli/mark-files-as-viewed-formatter.d.mts +2 -0
  53. package/bin/cli/mutate-formatter.d.mts +2 -0
  54. package/bin/cli/poll-handler.d.mts +1 -0
  55. package/bin/cli/poll-handler.mjs +9 -2
  56. package/bin/cli/resolve-validators.d.mts +3 -0
  57. package/bin/cli/resolve-validators.mjs +3 -3
  58. package/bin/cli/runner.d.mts +7 -0
  59. package/bin/cli/suggestion-renderer.d.mts +3 -0
  60. package/bin/cli/validate-default-args.d.mts +6 -0
  61. package/bin/cli-parser.d.mts +2 -0
  62. package/bin/cli-parser.mjs +72 -16
  63. package/bin/commands/check-annotations.d.mts +5 -0
  64. package/bin/commands/check-status.d.mts +3 -0
  65. package/bin/commands/check-terminal-report.d.mts +5 -0
  66. package/bin/commands/check.d.mts +7 -0
  67. package/bin/commands/check.mjs +28 -23
  68. package/bin/commands/clean.d.mts +21 -0
  69. package/bin/commands/commit-suggestion-instruction.d.mts +8 -0
  70. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  71. package/bin/commands/commit-suggestion.d.mts +8 -0
  72. package/bin/commands/commit-suggestion.mjs +20 -13
  73. package/bin/commands/iterate/check-instructions.d.mts +16 -0
  74. package/bin/commands/iterate/check-instructions.mjs +3 -3
  75. package/bin/commands/iterate/classify.d.mts +18 -0
  76. package/bin/commands/iterate/classify.mjs +4 -4
  77. package/bin/commands/iterate/escalate.d.mts +31 -0
  78. package/bin/commands/iterate/escalate.mjs +7 -4
  79. package/bin/commands/iterate/fix-code.d.mts +25 -0
  80. package/bin/commands/iterate/fix-code.mjs +1 -1
  81. package/bin/commands/iterate/helpers.d.mts +14 -0
  82. package/bin/commands/iterate/helpers.mjs +19 -7
  83. package/bin/commands/iterate/index.d.mts +2 -0
  84. package/bin/commands/iterate/index.mjs +8 -12
  85. package/bin/commands/iterate/render.d.mts +5 -0
  86. package/bin/commands/iterate/render.mjs +8 -6
  87. package/bin/commands/iterate/reruns.d.mts +20 -0
  88. package/bin/commands/iterate/stall.d.mts +6 -0
  89. package/bin/commands/journal/index.d.mts +14 -0
  90. package/bin/commands/journal/index.mjs +1 -0
  91. package/bin/commands/journal/transform.d.mts +22 -0
  92. package/bin/commands/log-file.d.mts +5 -0
  93. package/bin/commands/mark-files-as-viewed.d.mts +26 -0
  94. package/bin/commands/mark-files-as-viewed.mjs +1 -0
  95. package/bin/commands/poll.d.mts +12 -0
  96. package/bin/commands/poll.mjs +52 -17
  97. package/bin/commands/ready-delay.d.mts +29 -0
  98. package/bin/commands/ready-delay.mjs +3 -13
  99. package/bin/commands/ready-mergeability.d.mts +15 -0
  100. package/bin/commands/resolve-mutate.d.mts +4 -0
  101. package/bin/commands/resolve-mutate.mjs +1 -0
  102. package/bin/commands/resolve.d.mts +4 -0
  103. package/bin/commands/shepherd-journal.d.mts +7 -0
  104. package/bin/commands/shepherd-journal.mjs +2 -2
  105. package/bin/comments/authors.d.mts +14 -0
  106. package/bin/comments/marker.d.mts +2 -0
  107. package/bin/comments/minimize-policy.d.mts +4 -0
  108. package/bin/comments/pending-ops.d.mts +15 -0
  109. package/bin/comments/rate-limit.d.mts +18 -0
  110. package/bin/comments/resolve.d.mts +34 -0
  111. package/bin/comments/resolve.mjs +1 -0
  112. package/bin/comments/review-thread-markers.d.mts +8 -0
  113. package/bin/comments/review-visibility.d.mts +28 -0
  114. package/bin/comments/sha-poll.d.mts +2 -0
  115. package/bin/comments/thread-visibility.d.mts +11 -0
  116. package/bin/comments/visible-comments.d.mts +11 -0
  117. package/bin/config/load.d.mts +60 -0
  118. package/bin/config/load.mjs +129 -16
  119. package/bin/config.json +0 -2
  120. package/bin/execution-context.d.mts +9 -0
  121. package/bin/execution-context.mjs +19 -0
  122. package/bin/exit-codes.d.mts +51 -0
  123. package/bin/github/activity.d.mts +3 -0
  124. package/bin/github/activity.mjs +7 -0
  125. package/bin/github/batch-page-helpers.d.mts +45 -0
  126. package/bin/github/batch-page-helpers.mjs +63 -0
  127. package/bin/github/batch-page.d.mts +14 -0
  128. package/bin/github/batch-page.mjs +62 -0
  129. package/bin/github/batch-parse-suites.d.mts +4 -0
  130. package/bin/github/batch-parse-suites.mjs +25 -0
  131. package/bin/github/batch-parser-helpers.d.mts +22 -0
  132. package/bin/github/batch-parsers-rules.d.mts +6 -0
  133. package/bin/github/batch-parsers-rules.mjs +122 -0
  134. package/bin/github/batch-parsers.d.mts +3 -0
  135. package/bin/github/batch-parsers.mjs +12 -0
  136. package/bin/github/batch-raw-rules.d.mts +59 -0
  137. package/bin/github/batch-raw-rules.mjs +1 -0
  138. package/bin/github/batch-raw-types.d.mts +216 -0
  139. package/bin/github/batch-response.d.mts +4 -0
  140. package/bin/github/batch.d.mts +24 -0
  141. package/bin/github/batch.mjs +14 -120
  142. package/bin/github/branch-protection.d.mts +3 -0
  143. package/bin/github/check-annotations.d.mts +2 -0
  144. package/bin/github/client.d.mts +46 -0
  145. package/bin/github/client.mjs +7 -2
  146. package/bin/github/errors.d.mts +25 -0
  147. package/bin/github/gql/batch-pr-page.gql +189 -0
  148. package/bin/github/gql/batch-pr.gql +79 -21
  149. package/bin/github/gql/commit-suggestion-thread.gql +40 -0
  150. package/bin/github/gql/review-thread-comments.gql +1 -0
  151. package/bin/github/graphql-http.d.mts +19 -0
  152. package/bin/github/graphql-response.d.mts +7 -0
  153. package/bin/github/http-auth.d.mts +4 -0
  154. package/bin/github/http-request.d.mts +7 -0
  155. package/bin/github/http-utils.d.mts +11 -0
  156. package/bin/github/http.d.mts +6 -0
  157. package/bin/github/http.mjs +2 -1
  158. package/bin/github/pagination.d.mts +46 -0
  159. package/bin/github/pagination.mjs +3 -2
  160. package/bin/github/queries.d.mts +28 -0
  161. package/bin/github/queries.mjs +4 -0
  162. package/bin/github/rest-http.d.mts +7 -0
  163. package/bin/github/rest-http.mjs +25 -86
  164. package/bin/github/rest-text.d.mts +1 -0
  165. package/bin/github/rest-text.mjs +88 -0
  166. package/bin/github/suggestion-thread.d.mts +9 -0
  167. package/bin/github/suggestion-thread.mjs +45 -0
  168. package/bin/github/thread-comments.d.mts +2 -0
  169. package/bin/github/thread-comments.mjs +12 -8
  170. package/bin/index.d.mts +10 -0
  171. package/bin/index.mjs +1 -1
  172. package/bin/log/log-file.d.mts +28 -0
  173. package/bin/log/session.d.mts +31 -0
  174. package/bin/log/setup.d.mts +6 -0
  175. package/bin/mcp/index.d.mts +5 -0
  176. package/bin/mcp/index.mjs +8 -0
  177. package/bin/mcp/server.d.mts +8 -0
  178. package/bin/mcp/server.mjs +157 -0
  179. package/bin/mcp-stdio.d.mts +2 -0
  180. package/bin/mcp-stdio.mjs +7 -0
  181. package/bin/merge-status/derive.d.mts +19 -0
  182. package/bin/merge-status/derive.mjs +2 -0
  183. package/bin/merge-status/requirements-format.d.mts +3 -0
  184. package/bin/merge-status/requirements-format.mjs +88 -0
  185. package/bin/merge-status/requirements.d.mts +2 -0
  186. package/bin/merge-status/requirements.mjs +51 -0
  187. package/bin/reporters/agent.d.mts +23 -0
  188. package/bin/reporters/agent.mjs +3 -0
  189. package/bin/state/base.d.mts +10 -0
  190. package/bin/state/base.mjs +23 -0
  191. package/bin/state/bot-cr-seen.d.mts +51 -0
  192. package/bin/state/bot-cr-seen.mjs +4 -14
  193. package/bin/state/fix-attempts.d.mts +27 -0
  194. package/bin/state/fix-attempts.mjs +3 -13
  195. package/bin/state/iterate-stall.d.mts +27 -0
  196. package/bin/state/iterate-stall.mjs +3 -13
  197. package/bin/state/seen-comments.d.mts +62 -0
  198. package/bin/state/seen-comments.mjs +6 -13
  199. package/bin/suggestions/extract.d.mts +8 -0
  200. package/bin/suggestions/parse.d.mts +48 -0
  201. package/bin/suggestions/patch.d.mts +14 -0
  202. package/bin/threads/transcript.d.mts +14 -0
  203. package/bin/threads/transcript.mjs +4 -0
  204. package/bin/types/activity.d.mts +30 -0
  205. package/bin/types/agent-thread.d.mts +9 -0
  206. package/bin/types/check-annotations.d.mts +14 -0
  207. package/bin/types/check-classification.d.mts +19 -0
  208. package/bin/types/github.d.mts +136 -0
  209. package/bin/types/iterate.d.mts +156 -0
  210. package/bin/types/merge-requirements.d.mts +82 -0
  211. package/bin/types/merge-requirements.mjs +2 -0
  212. package/bin/types/protected-run.d.mts +6 -0
  213. package/bin/types/report.d.mts +176 -0
  214. package/bin/types/review-thread.d.mts +12 -0
  215. package/bin/types.d.mts +10 -0
  216. package/bin/types.mjs +1 -0
  217. package/bin/util/markdown.d.mts +1 -0
  218. package/bin/util/path-segment.d.mts +4 -0
  219. package/bin/util/path-segment.mjs +2 -0
  220. package/bin/util/pool.d.mts +2 -0
  221. package/bin/util/pool.mjs +18 -0
  222. package/bin/util/sleep.d.mts +1 -0
  223. package/bin/util/worktree.d.mts +9 -0
  224. package/bin/util/worktree.mjs +4 -1
  225. package/package.json +51 -37
  226. package/plugins/pr-shepherd/.codex-plugin/plugin.json +3 -2
  227. package/plugins/pr-shepherd/.codex.mcp.json +8 -0
  228. package/plugins/pr-shepherd/.mcp.json +6 -0
  229. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +5 -19
  230. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +6 -15
  231. package/src/classify/types.mts +12 -0
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runPrShepherdMcpStdio } from "./mcp/index.mjs";
3
+ runPrShepherdMcpStdio().catch((error) => {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ process.stderr.write(`pr-shepherd-mcp error: ${message}\n`);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Derives a shepherd `MergeStatusResult` from raw PR data.
3
+ *
4
+ * `pr.state` is passed through unchanged; `iterate` handles the cancel action
5
+ * for non-OPEN (merged/closed) PRs — this function does not branch on it.
6
+ *
7
+ * Interpretation order for `status` — first match wins:
8
+ * 1. mergeable == CONFLICTING → CONFLICTS (hard conflict even for drafts)
9
+ * 2. blockingBotReviewInProgress → BLOCKED
10
+ * 3. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts, even for drafts)
11
+ * 4. isDraft → DRAFT
12
+ * 5. mergeStateStatus BEHIND → BEHIND
13
+ * 6. mergeStateStatus BLOCKED / HAS_HOOKS → BLOCKED
14
+ * 7. mergeStateStatus UNSTABLE → UNSTABLE
15
+ * 8. mergeStateStatus UNKNOWN → UNKNOWN
16
+ * 9. mergeStateStatus CLEAN → CLEAN
17
+ */
18
+ import type { BatchPrData, MergeStatusResult } from "../types.mts";
19
+ export declare function deriveMergeStatus(pr: BatchPrData): MergeStatusResult;
@@ -16,6 +16,7 @@
16
16
  * 9. mergeStateStatus CLEAN → CLEAN
17
17
  */
18
18
  import { loadConfig } from "../config/load.mjs";
19
+ import { deriveMergeRequirements } from "./requirements.mjs";
19
20
  export function deriveMergeStatus(pr) {
20
21
  const blockingBotReviewInProgress = detectBlockingBotReview(pr);
21
22
  let status;
@@ -55,6 +56,7 @@ export function deriveMergeStatus(pr) {
55
56
  reviewDecision: pr.reviewDecision,
56
57
  blockingBotReviewInProgress,
57
58
  mergeStateStatus: pr.mergeStateStatus,
59
+ mergeRequirements: deriveMergeRequirements(pr),
58
60
  };
59
61
  }
60
62
  // ---------------------------------------------------------------------------
@@ -0,0 +1,3 @@
1
+ import type { MergeRequirements } from "../types.mts";
2
+ export declare function formatMergeRequirementLines(req: MergeRequirements): string[];
3
+ export declare function blockedReasonFromRequirements(req: MergeRequirements | undefined): string | null;
@@ -0,0 +1,88 @@
1
+ const REQUIRED = "[Required]";
2
+ const NOT_REQUIRED = "[Not Required]";
3
+ export function formatMergeRequirementLines(req) {
4
+ const lines = [
5
+ formatApprovals(req.approvals.current, req.approvals.requiredCount),
6
+ formatConversations(req.conversationsResolved),
7
+ ];
8
+ if (req.codeOwnerReview)
9
+ lines.push(`Code owner review: ${REQUIRED}`);
10
+ if (req.lastPushApproval)
11
+ lines.push(`Last-push approval: ${REQUIRED}`);
12
+ if (req.signedCommits)
13
+ lines.push(`Signed commits: ${REQUIRED}`);
14
+ if (req.linearHistory)
15
+ lines.push(`Linear history: ${REQUIRED}`);
16
+ if (req.branchUpToDate) {
17
+ const value = req.branchUpToDate.current ? "Yes" : "No";
18
+ lines.push(`Branch up to date: ${value} ${REQUIRED}`);
19
+ }
20
+ if (req.requiredStatusChecks) {
21
+ const n = req.requiredStatusChecks.contexts.length;
22
+ lines.push(`Required status checks: ${n} ${REQUIRED}`);
23
+ }
24
+ if (req.requiredDeployments) {
25
+ const envs = req.requiredDeployments.environments.join(", ");
26
+ lines.push(`Required deployments: ${envs} ${REQUIRED}`);
27
+ }
28
+ if (req.requiredWorkflows)
29
+ lines.push(`Required workflows: ${REQUIRED}`);
30
+ if (req.codeScanning)
31
+ lines.push(`Code scanning: ${REQUIRED}`);
32
+ if (req.mergeQueue)
33
+ lines.push(formatMergeQueue(req.mergeQueue));
34
+ if (req.stack) {
35
+ const { number, position, size, baseRefName } = req.stack;
36
+ lines.push(`Stack: #${number} ${position}/${size} (base ${baseRefName})`);
37
+ }
38
+ return lines;
39
+ }
40
+ export function blockedReasonFromRequirements(req) {
41
+ if (!req)
42
+ return null;
43
+ const unmet = [];
44
+ if (req.approvals.requiredCount > 0 && req.approvals.current < req.approvals.requiredCount) {
45
+ const need = req.approvals.requiredCount - req.approvals.current;
46
+ unmet.push(`awaiting ${need} approval${need === 1 ? "" : "s"}`);
47
+ }
48
+ if (req.conversationsResolved.required && !req.conversationsResolved.resolved) {
49
+ unmet.push("unresolved conversations are required");
50
+ }
51
+ if (req.branchUpToDate && !req.branchUpToDate.current) {
52
+ unmet.push("branch is behind base");
53
+ }
54
+ if (req.mergeQueue?.inQueue) {
55
+ const pos = req.mergeQueue.position != null ? ` position ${req.mergeQueue.position}` : "";
56
+ unmet.push(`in merge queue${pos}`);
57
+ }
58
+ else if (req.mergeQueue?.required) {
59
+ unmet.push("merge queue is required");
60
+ }
61
+ return unmet.length > 0 ? unmet.join("; ") : null;
62
+ }
63
+ function formatApprovals(current, requiredCount) {
64
+ const tag = requiredCount > 0 ? REQUIRED : NOT_REQUIRED;
65
+ if (current === 0 && requiredCount === 0)
66
+ return `Approvals: None ${NOT_REQUIRED}`;
67
+ if (current === 0)
68
+ return `Approvals: None ${REQUIRED}`;
69
+ if (requiredCount > 0)
70
+ return `Approvals: ${current}/${requiredCount} ${tag}`;
71
+ return `Approvals: ${current} ${NOT_REQUIRED}`;
72
+ }
73
+ function formatConversations(c) {
74
+ const value = c.resolved ? "Yes" : "No";
75
+ const tag = c.required ? REQUIRED : NOT_REQUIRED;
76
+ return `Conversations Resolved: ${value} ${tag}`;
77
+ }
78
+ function formatMergeQueue(q) {
79
+ const tag = q.required ? REQUIRED : NOT_REQUIRED;
80
+ if (q.inQueue) {
81
+ const pos = q.position != null ? `position ${q.position}` : "Yes";
82
+ const state = q.state ? ` ${q.state}` : "";
83
+ return `Merge queue: ${pos}${state} ${tag}`;
84
+ }
85
+ if (q.required || q.enabled)
86
+ return `Merge queue: No ${tag}`;
87
+ return `Merge queue: No ${NOT_REQUIRED}`;
88
+ }
@@ -0,0 +1,2 @@
1
+ import type { BatchPrData, MergeRequirements } from "../types.mts";
2
+ export declare function deriveMergeRequirements(pr: BatchPrData): MergeRequirements;
@@ -0,0 +1,51 @@
1
+ import { EMPTY_BRANCH_RULES } from "../github/batch-parsers-rules.mjs";
2
+ export function deriveMergeRequirements(pr) {
3
+ const rules = pr.branchRules ?? EMPTY_BRANCH_RULES;
4
+ const currentApprovals = pr.latestReviews.filter((r) => r.state === "APPROVED").length;
5
+ const unresolvedCount = pr.reviewThreads.filter((t) => !t.isResolved).length;
6
+ const req = {
7
+ approvals: {
8
+ current: currentApprovals,
9
+ requiredCount: rules.requiredApprovingReviewCount,
10
+ },
11
+ conversationsResolved: {
12
+ resolved: unresolvedCount === 0,
13
+ unresolvedCount,
14
+ required: rules.requiresConversationResolution,
15
+ },
16
+ };
17
+ if (rules.requiresCodeOwnerReviews)
18
+ req.codeOwnerReview = { required: true };
19
+ if (rules.requiresLastPushApproval)
20
+ req.lastPushApproval = { required: true };
21
+ if (rules.requiresCommitSignatures)
22
+ req.signedCommits = { required: true };
23
+ if (rules.requiresLinearHistory)
24
+ req.linearHistory = { required: true };
25
+ if (rules.requiresStrictStatusChecks) {
26
+ req.branchUpToDate = { current: pr.mergeStateStatus !== "BEHIND", required: true };
27
+ }
28
+ if (rules.requiredStatusCheckContexts.length > 0) {
29
+ req.requiredStatusChecks = { contexts: rules.requiredStatusCheckContexts };
30
+ }
31
+ if (rules.requiredDeploymentEnvironments.length > 0) {
32
+ req.requiredDeployments = { environments: rules.requiredDeploymentEnvironments };
33
+ }
34
+ if (rules.requiresWorkflows)
35
+ req.requiredWorkflows = { required: true };
36
+ if (rules.requiresCodeScanning)
37
+ req.codeScanning = { required: true };
38
+ const queueRequired = rules.requiresMergeQueue || Boolean(pr.isMergeQueueEnabled);
39
+ if (queueRequired || pr.isInMergeQueue) {
40
+ req.mergeQueue = {
41
+ required: queueRequired,
42
+ enabled: Boolean(pr.isMergeQueueEnabled),
43
+ inQueue: Boolean(pr.isInMergeQueue),
44
+ ...(pr.mergeQueueEntry?.position !== undefined && { position: pr.mergeQueueEntry.position }),
45
+ ...(pr.mergeQueueEntry?.state !== undefined && { state: pr.mergeQueueEntry.state }),
46
+ };
47
+ }
48
+ if (pr.stack)
49
+ req.stack = pr.stack;
50
+ return req;
51
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Projections for the agent-facing iterate output.
3
+ *
4
+ * These strip fields that are always-false by the time items reach iterate
5
+ * (isResolved, isOutdated, isMinimized, createdAtUnix) and check metadata the
6
+ * agent/iterate prompt never reads (event, status, category).
7
+ * conclusion is preserved on AgentCheck so the formatter can branch on run-level conclusions.
8
+ * detailsUrl is preserved in AgentCheck as a fallback for external status checks.
9
+ * The original domain types are preserved as internal snapshot types.
10
+ */
11
+ import type { ReviewThread, PrComment, TriagedCheck, ClassifiedCheck, AgentThread, AgentComment, AgentCheck, AgentStalledCheck } from "../types.mts";
12
+ export declare function toAgentThread(t: ReviewThread): AgentThread;
13
+ export declare function toAgentComment(c: PrComment & {
14
+ edited?: boolean;
15
+ }): AgentComment;
16
+ export declare function toAgentCheck(c: TriagedCheck): AgentCheck;
17
+ export declare function toAgentStalledCheck(c: ClassifiedCheck, nowSeconds: number): AgentStalledCheck;
18
+ /**
19
+ * Project failing checks for the agent. Deduplicates only null-runId external
20
+ * checks by name — when runId is present each check may have a distinct job and
21
+ * log tail, so they are all kept.
22
+ */
23
+ export declare function toAgentChecks(checks: TriagedCheck[]): AgentCheck[];
@@ -21,6 +21,7 @@ export function toAgentThread(t) {
21
21
  t.startLine !== t.line && { startLine: t.startLine }),
22
22
  author: t.author,
23
23
  ...(t.authorType !== undefined && { authorType: t.authorType }),
24
+ ...(t.authorAssociation !== undefined && { authorAssociation: t.authorAssociation }),
24
25
  body: t.body,
25
26
  url: t.url,
26
27
  ...(t.edited === true && { edited: true }),
@@ -29,6 +30,7 @@ export function toAgentThread(t) {
29
30
  id: c.id,
30
31
  author: c.author,
31
32
  ...(c.authorType !== undefined && { authorType: c.authorType }),
33
+ ...(c.authorAssociation !== undefined && { authorAssociation: c.authorAssociation }),
32
34
  body: c.body,
33
35
  url: c.url,
34
36
  })),
@@ -41,6 +43,7 @@ export function toAgentComment(c) {
41
43
  id: c.id,
42
44
  author: c.author,
43
45
  ...(c.authorType !== undefined && { authorType: c.authorType }),
46
+ ...(c.authorAssociation !== undefined && { authorAssociation: c.authorAssociation }),
44
47
  body: c.body,
45
48
  url: c.url,
46
49
  ...(c.edited === true && { edited: true }),
@@ -0,0 +1,10 @@
1
+ export declare function resolveStateBase(): string;
2
+ /**
3
+ * `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
4
+ * Owner, repo, PR number, and each extra part must be a safe path segment.
5
+ */
6
+ export declare function resolvePrStatePath(key: {
7
+ owner: string;
8
+ repo: string;
9
+ pr: number;
10
+ }, ...parts: string[]): string;
@@ -1,6 +1,29 @@
1
1
  import { join } from "node:path";
2
2
  import { tmpdir } from "node:os";
3
+ import { SAFE_PR_NUMBER, SAFE_SEGMENT } from "../util/path-segment.mjs";
3
4
  export function resolveStateBase() {
4
5
  const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
5
6
  return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
6
7
  }
8
+ /**
9
+ * `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
10
+ * Owner, repo, PR number, and each extra part must be a safe path segment.
11
+ */
12
+ 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}`);
18
+ }
19
+ const pr = String(key.pr);
20
+ if (!SAFE_PR_NUMBER.test(pr)) {
21
+ throw new Error(`Invalid state key segment "pr": ${key.pr}`);
22
+ }
23
+ for (const part of parts) {
24
+ if (!SAFE_SEGMENT.test(part)) {
25
+ throw new Error(`Invalid state key segment: ${part}`);
26
+ }
27
+ }
28
+ return join(resolveStateBase(), `${key.owner}-${key.repo}`, pr, ...parts);
29
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Persistent first-seen state for bot CHANGES_REQUESTED reviews.
3
+ *
4
+ * Bot CRs are auto-dismissed via `--dismiss-review-ids` in the post-push
5
+ * `apply review:` command. If the agent drops that flag, the bot CR keeps the PR in
6
+ * `CHANGES_REQUESTED` state. This file tracks when each bot CR was first
7
+ * observed so the iterate loop can escalate after `iterate.stallTimeoutMinutes`
8
+ * — independent of the broader fingerprint-based `stall-timeout` mechanism in
9
+ * `iterate-stall.mts` (which only fires when no field of the iterate result
10
+ * changed).
11
+ *
12
+ * State lives in
13
+ * `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/bot-cr-seen.json`.
14
+ *
15
+ * `bodyHash` lets us reset `firstSeenAt` when the bot re-issues the review
16
+ * with a different body, so a fresh review gets the full timeout window.
17
+ */
18
+ interface BotCrSeenEntry {
19
+ /** Unix timestamp (seconds) when this review was first observed undismissed. */
20
+ firstSeenAt: number;
21
+ /** SHA-256 body-hash prefix; reset firstSeenAt when this changes. */
22
+ bodyHash: string;
23
+ }
24
+ export interface BotCrSeenState {
25
+ reviews: Record<string, BotCrSeenEntry>;
26
+ }
27
+ interface StateKey {
28
+ owner: string;
29
+ repo: string;
30
+ pr: number;
31
+ }
32
+ export declare function readBotCrSeenState(key: StateKey): Promise<BotCrSeenState | null>;
33
+ export declare function writeBotCrSeenState(key: StateKey, state: BotCrSeenState): Promise<void>;
34
+ /**
35
+ * Update tracked entries against the current set of bot CR reviews:
36
+ * - Insert any new IDs with `firstSeenAt = now` and the current body hash.
37
+ * - Reset `firstSeenAt` for entries whose body hash changed (review re-issued).
38
+ * - Drop entries whose IDs are no longer in the input (review was dismissed
39
+ * or superseded by an approval).
40
+ *
41
+ * Pure function: returns the next state and the IDs whose age has reached the
42
+ * `stallTimeoutSeconds` threshold (escalate candidates).
43
+ */
44
+ export declare function updateBotCrSeenState(previous: BotCrSeenState | null, currentBotCrReviews: ReadonlyArray<{
45
+ id: string;
46
+ body: string;
47
+ }>, nowSeconds: number, stallTimeoutSeconds: number): {
48
+ next: BotCrSeenState;
49
+ staleIds: string[];
50
+ };
51
+ export {};
@@ -2,7 +2,7 @@
2
2
  * Persistent first-seen state for bot CHANGES_REQUESTED reviews.
3
3
  *
4
4
  * Bot CRs are auto-dismissed via `--dismiss-review-ids` in the post-push
5
- * `resolve:` command. If the agent drops that flag, the bot CR keeps the PR in
5
+ * `apply review:` command. If the agent drops that flag, the bot CR keeps the PR in
6
6
  * `CHANGES_REQUESTED` state. This file tracks when each bot CR was first
7
7
  * observed so the iterate loop can escalate after `iterate.stallTimeoutMinutes`
8
8
  * — independent of the broader fingerprint-based `stall-timeout` mechanism in
@@ -17,9 +17,8 @@
17
17
  */
18
18
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
19
19
  import { randomUUID } from "node:crypto";
20
- import { join, dirname } from "node:path";
21
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
22
- import { resolveStateBase } from "./base.mjs";
20
+ import { dirname } from "node:path";
21
+ import { resolvePrStatePath } from "./base.mjs";
23
22
  import { hashBody } from "./seen-comments.mjs";
24
23
  export async function readBotCrSeenState(key) {
25
24
  try {
@@ -97,14 +96,5 @@ export function updateBotCrSeenState(previous, currentBotCrReviews, nowSeconds,
97
96
  return { next: { reviews: next }, staleIds };
98
97
  }
99
98
  function resolvePath(key) {
100
- for (const [field, value] of [
101
- ["owner", key.owner],
102
- ["repo", key.repo],
103
- ]) {
104
- if (!SAFE_SEGMENT.test(value)) {
105
- throw new Error(`Invalid state key segment "${field}": ${value}`);
106
- }
107
- }
108
- const base = resolveStateBase();
109
- return join(base, `${key.owner}-${key.repo}`, String(key.pr), "bot-cr-seen.json");
99
+ return resolvePrStatePath(key, "bot-cr-seen.json");
110
100
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Persistent attempt counter for the iterate escalation guard.
3
+ *
4
+ * Tracks how many times each review thread has been dispatched to the fix_code
5
+ * handler without being resolved. Counts are reset automatically when the HEAD
6
+ * commit SHA changes (i.e. a new push landed).
7
+ *
8
+ * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
9
+ */
10
+ export interface FixAttemptsState {
11
+ /** HEAD SHA at the time the counts were last written. Reset key. */
12
+ headSha: string;
13
+ /** Map from thread ID → number of fix_code dispatches that included this thread. */
14
+ threadAttempts: Record<string, number>;
15
+ /** Map from thread ID → body hash used for the associated attempt count. */
16
+ threadBodyHashes?: Record<string, string>;
17
+ }
18
+ interface StateKey {
19
+ owner: string;
20
+ repo: string;
21
+ pr: number;
22
+ }
23
+ /** Read the current attempt state. Returns null on miss. */
24
+ export declare function readFixAttempts(key: StateKey): Promise<FixAttemptsState | null>;
25
+ /** Write attempt state (fire-and-forget — never throws). */
26
+ export declare function writeFixAttempts(key: StateKey, state: FixAttemptsState): Promise<void>;
27
+ export {};
@@ -9,9 +9,8 @@
9
9
  */
10
10
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
11
11
  import { randomUUID } from "node:crypto";
12
- import { join, dirname } from "node:path";
13
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
14
- import { resolveStateBase } from "./base.mjs";
12
+ import { dirname } from "node:path";
13
+ import { resolvePrStatePath } from "./base.mjs";
15
14
  // ---------------------------------------------------------------------------
16
15
  // Public API
17
16
  // ---------------------------------------------------------------------------
@@ -54,14 +53,5 @@ export async function writeFixAttempts(key, state) {
54
53
  // Helpers
55
54
  // ---------------------------------------------------------------------------
56
55
  function resolvePath(key) {
57
- for (const [field, value] of [
58
- ["owner", key.owner],
59
- ["repo", key.repo],
60
- ]) {
61
- if (!SAFE_SEGMENT.test(value)) {
62
- throw new Error(`Invalid state key segment "${field}": ${value}`);
63
- }
64
- }
65
- const base = resolveStateBase();
66
- return join(base, `${key.owner}-${key.repo}`, String(key.pr), "fix-attempts.json");
56
+ return resolvePrStatePath(key, "fix-attempts.json");
67
57
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Persistent stall-detection state for the iterate loop.
3
+ *
4
+ * Tracks the fingerprint of the last iterate result and when that fingerprint
5
+ * was first seen. If the fingerprint does not change for stallTimeoutSeconds
6
+ * the iterate command escalates instead of repeating the same action.
7
+ *
8
+ * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/iterate-stall.json`.
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 {
17
+ owner: string;
18
+ repo: string;
19
+ 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>;
27
+ export {};
@@ -9,9 +9,8 @@
9
9
  */
10
10
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
11
11
  import { randomUUID } from "node:crypto";
12
- import { join, dirname } from "node:path";
13
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
14
- import { resolveStateBase } from "./base.mjs";
12
+ import { dirname } from "node:path";
13
+ import { resolvePrStatePath } from "./base.mjs";
15
14
  // ---------------------------------------------------------------------------
16
15
  // Public API
17
16
  // ---------------------------------------------------------------------------
@@ -70,14 +69,5 @@ export async function writeStallState(key, state) {
70
69
  // Helpers
71
70
  // ---------------------------------------------------------------------------
72
71
  function resolvePath(key) {
73
- for (const [field, value] of [
74
- ["owner", key.owner],
75
- ["repo", key.repo],
76
- ]) {
77
- if (!SAFE_SEGMENT.test(value)) {
78
- throw new Error(`Invalid state key segment "${field}": ${value}`);
79
- }
80
- }
81
- const base = resolveStateBase();
82
- return join(base, `${key.owner}-${key.repo}`, String(key.pr), "iterate-stall.json");
72
+ return resolvePrStatePath(key, "iterate-stall.json");
83
73
  }
@@ -0,0 +1,62 @@
1
+ export interface SeenMarker {
2
+ seenAt: number;
3
+ id?: string;
4
+ [key: string]: unknown;
5
+ }
6
+ interface StateKey {
7
+ owner: string;
8
+ repo: string;
9
+ pr: number;
10
+ }
11
+ /** Compute a 16-hex-char SHA-256 prefix of a comment body. */
12
+ export declare function hashBody(body: string): string;
13
+ /**
14
+ * Classify a candidate item against the seen map.
15
+ *
16
+ * - "new" — no marker exists; surface the body and write the marker.
17
+ * - "edited" — marker exists but stored hash differs from the current body;
18
+ * surface the updated body and update the marker hash.
19
+ * - "unchanged" — marker exists and hash matches (or marker has no hash, which
20
+ * is treated conservatively as unchanged).
21
+ */
22
+ export declare function classifyItem(id: string, body: string, map: Map<string, SeenMarker>): "new" | "edited" | "unchanged";
23
+ /**
24
+ * Read the seen/ directory once and return a Set of already-seen IDs.
25
+ * Prefer this over repeated hasSeen() calls to avoid EMFILE on large PRs.
26
+ * Returns an empty Set if the directory does not yet exist.
27
+ */
28
+ export declare function loadSeenSet(key: StateKey): Promise<Set<string>>;
29
+ /**
30
+ * Read the seen/ directory and return a Map from ID to SeenMarker.
31
+ * Used when the caller needs the stored bodyHash to detect in-place edits.
32
+ * Returns an empty Map if the directory does not yet exist.
33
+ *
34
+ * Map keys are the stored `id` field when present (guarding against
35
+ * case-insensitive filesystem collisions), falling back to the filename for
36
+ * legacy markers that predate this field.
37
+ */
38
+ export declare function loadSeenMap(key: StateKey): Promise<Map<string, SeenMarker>>;
39
+ /** Return true if a "seen" marker exists for this id. */
40
+ export declare function hasSeen(key: StateKey, id: string): Promise<boolean>;
41
+ /**
42
+ * Write (or update) a "seen" marker for this id, storing the body hash so
43
+ * in-place edits can be detected on future fetches.
44
+ *
45
+ * - First call (no existing marker): creates `{ seenAt: now, bodyHash, id }`.
46
+ * - Subsequent call, hash unchanged: no-op (skips the write).
47
+ * - Subsequent call, hash changed: updates `bodyHash`, preserves original `seenAt`.
48
+ *
49
+ * All errors are silently swallowed — the marker is best-effort.
50
+ */
51
+ export declare function markSeen(key: StateKey, id: string, body: string): Promise<void>;
52
+ export declare function markReviewInlineThreads(key: StateKey, reviewId: string, inlineThreadIds: readonly string[]): Promise<void>;
53
+ /**
54
+ * Write a marker after Shepherd successfully replies to a review thread.
55
+ *
56
+ * `previousBody` suppresses stale GitHub fetches that have not yet included the new reply.
57
+ * `body` suppresses the expected final transcript once GitHub includes Shepherd's reply.
58
+ */
59
+ export declare function markReplySeen(key: StateKey, id: string, previousBody: string, body: string, replyBody: string): Promise<void>;
60
+ /** Read the full marker for inspection (returns null on miss or error). */
61
+ export declare function readSeenMarker(key: StateKey, id: string): Promise<SeenMarker | null>;
62
+ export {};
@@ -3,7 +3,7 @@ import { readFile, writeFile, rename, unlink, mkdir, access, readdir } from "nod
3
3
  import { join, dirname } from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { SAFE_SEGMENT } from "../util/path-segment.mjs";
6
- import { resolveStateBase } from "./base.mjs";
6
+ import { resolvePrStatePath } from "./base.mjs";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Public API
9
9
  // ---------------------------------------------------------------------------
@@ -57,7 +57,9 @@ export async function loadSeenMap(key) {
57
57
  try {
58
58
  const dir = resolveDir(key);
59
59
  const entries = await readdir(dir);
60
- for (const entry of entries.filter((e) => e.endsWith(".json"))) {
60
+ for (const entry of entries) {
61
+ if (!entry.endsWith(".json") || !SAFE_SEGMENT.test(entry))
62
+ continue;
61
63
  try {
62
64
  const raw = await readFile(join(dir, entry), "utf8");
63
65
  const marker = JSON.parse(raw);
@@ -182,16 +184,7 @@ export async function readSeenMarker(key, id) {
182
184
  // Helpers
183
185
  // ---------------------------------------------------------------------------
184
186
  function resolveDir(key) {
185
- for (const [field, value] of [
186
- ["owner", key.owner],
187
- ["repo", key.repo],
188
- ]) {
189
- if (!SAFE_SEGMENT.test(value)) {
190
- throw new Error(`Invalid state key segment "${field}": ${value}`);
191
- }
192
- }
193
- const base = resolveStateBase();
194
- return join(base, `${key.owner}-${key.repo}`, String(key.pr), "seen");
187
+ return resolvePrStatePath(key, "seen");
195
188
  }
196
189
  function resolvePath(key, id) {
197
190
  if (!SAFE_SEGMENT.test(id)) {
@@ -203,5 +196,5 @@ function resolvePath(key, id) {
203
196
  // causing seen-markers to overwrite each other and items to re-surface every
204
197
  // tick. SHA-256 is case-sensitive so distinct IDs get distinct files.
205
198
  const hash = createHash("sha256").update(id, "utf8").digest("hex");
206
- return join(resolveDir(key), `${hash}.json`);
199
+ return resolvePrStatePath(key, "seen", `${hash}.json`);
207
200
  }
@@ -0,0 +1,8 @@
1
+ import type { SuggestionBlock } from "../types.mts";
2
+ export declare function extractSuggestion(thread: {
3
+ path: string | null;
4
+ line: number | null;
5
+ startLine: number | null;
6
+ body: string;
7
+ author: string;
8
+ }): SuggestionBlock | null;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Parse GitHub review-comment "suggestion" blocks.
3
+ *
4
+ * GitHub's "Commit suggestion" UI button treats the first ```suggestion fenced
5
+ * block in a review comment as a replacement for the commented line range.
6
+ * This module extracts that block from the comment body.
7
+ *
8
+ * There is no GitHub API for applying suggestions — tools that reproduce the
9
+ * button (this one included) must parse + commit themselves.
10
+ */
11
+ interface ParsedSuggestion {
12
+ /**
13
+ * Replacement lines to splice in. Empty array means "delete these lines".
14
+ * `[""]` means "replace with a single blank line". Array length equals the
15
+ * number of replacement lines in the suggestion snippet (not the number of
16
+ * lines in the resulting file).
17
+ */
18
+ lines: readonly string[];
19
+ }
20
+ /**
21
+ * Return the first ```suggestion block from a review-comment body, or null if none.
22
+ *
23
+ * Handles three distinct cases:
24
+ * - Empty block (` ```suggestion\n``` `) → `lines: []` (deletion).
25
+ * - Blank-line-only body (` ```suggestion\n\n``` `) → `lines: [""]`.
26
+ * - Non-empty body → split into lines.
27
+ *
28
+ * The opening fence may be 3+ backticks; the closing fence must be at least
29
+ * as many backticks, at the start of a line (after the captured prefix). This
30
+ * means content lines that contain ` ``` ` in the middle (not at line-start)
31
+ * are treated as content rather than a closing fence — fixing the silent
32
+ * truncation in the original regex approach (issue #68).
33
+ *
34
+ * When the block is embedded in a quoted reply (e.g. `> ```suggestion …`),
35
+ * the leading prefix captured from the opening fence is stripped from each
36
+ * body line — but only when that exact prefix is present, so legitimate `>`
37
+ * characters inside the suggested code survive.
38
+ */
39
+ export declare function parseSuggestion(body: string): ParsedSuggestion | null;
40
+ /**
41
+ * True when a parsed suggestion's replacement is safe to commit: the joined
42
+ * replacement contains no nested ` ```suggestion ` marker and no unmatched
43
+ * ` ``` ` run (odd count). Both shapes previously masked silent truncation
44
+ * (issue #68). Conservative by design: reviewers whose suggestion content
45
+ * legitimately includes these markers must apply the change manually.
46
+ */
47
+ export declare function isCommittableSuggestion(parsed: ParsedSuggestion): boolean;
48
+ export {};