pr-shepherd 0.58.0 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.58.0",
4
+ "version": "0.59.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -4,6 +4,43 @@ Drive a pull request to merge-ready with a coding agent. It works with Claude Co
4
4
 
5
5
  **Estimated per PR session: −42% cost and −19% turns vs. `gh`, −77% cost vs. the GitHub MCP server. While waiting on CI: ~60 GraphQL points an hour vs. ~360 for `gh pr checks --watch`.**
6
6
 
7
+ ## What it does
8
+
9
+ - Gathers the PR's review items, CI failures, and merge requirements in one call.
10
+ - Returns one action with numbered instructions and exact commands. `CANCEL` ends the loop and carries none.
11
+ - Never mutates git. The agent edits, commits, pushes, and decides what each comment deserves.
12
+
13
+ ```text
14
+ $ pr-shepherd 123
15
+
16
+ # PR #123 [FIX_CODE]
17
+
18
+ **status** `UNRESOLVED_COMMENTS` · **repo** `owner/repo`
19
+ **summary** 3 passing
20
+
21
+ ## Review threads
22
+
23
+ ### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice · MEMBER)
24
+
25
+ > The variable name is misleading.
26
+
27
+ ## Failing checks
28
+
29
+ - `24697658766` — `CI › lint / typecheck / test (22.x)` [conclusion: FAILURE]
30
+ > oxfmt
31
+
32
+ ## Instructions
33
+
34
+ 1. Fix each warranted item.
35
+ 2. Triage `## Failing checks`. Playbook: "CI failure triage".
36
+ 3. Commit and push any changes.
37
+ 4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
38
+ 5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
39
+ 6. Rerun Shepherd now.
40
+ ```
41
+
42
+ More features are in [docs/features.md](docs/features.md).
43
+
7
44
  ## Outcomes
8
45
 
9
46
  | Outcome | Headline | Evidence |
@@ -17,23 +54,6 @@ Drive a pull request to merge-ready with a coding agent. It works with Claude Co
17
54
 
18
55
  **[jongleberry.com/pr-shepherd](https://jongleberry.com/pr-shepherd/)** covers the why and the design principles.
19
56
 
20
- ## What it does
21
-
22
- - Gathers the PR's review items, CI failures, and merge requirements in one call.
23
- - Returns one action with numbered instructions and exact commands. `CANCEL` ends the loop and carries none.
24
- - Never mutates git. The agent edits, commits, pushes, and decides what each comment deserves.
25
-
26
- ```mermaid
27
- flowchart LR
28
- A[Agent] -->|iterate PR| S[pr-shepherd]
29
- S -->|batched, conditional reads| G[(GitHub)]
30
- G --> S
31
- S -->|one action + numbered instructions| A
32
- A -->|fix, push, run the printed commands| G
33
- ```
34
-
35
- Without Shepherd, an agent fans out across `gh`, the GitHub MCP server, and GraphQL to rebuild PR state, then guesses the next step. Details for each outcome follow; more features are in [docs/features.md](docs/features.md).
36
-
37
57
  ## Outcome details
38
58
 
39
59
  ### Context and correctness
@@ -179,34 +199,7 @@ Requirements:
179
199
  pr-shepherd 42 # shell: poll for work (270s cap; --until-terminal removes it)
180
200
  ```
181
201
 
182
- Leave out the PR number to use the current branch. Example `FIX_CODE` output:
183
-
184
- ```text
185
- # PR #123 [FIX_CODE]
186
-
187
- **status** `UNRESOLVED_COMMENTS` · **repo** `owner/repo`
188
- **summary** 3 passing
189
-
190
- ## Review threads
191
-
192
- ### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice · MEMBER)
193
-
194
- > The variable name is misleading.
195
-
196
- ## Failing checks
197
-
198
- - `24697658766` — `CI › lint / typecheck / test (22.x)` [conclusion: FAILURE]
199
- > oxfmt
200
-
201
- ## Instructions
202
-
203
- 1. Fix each warranted item.
204
- 2. Triage `## Failing checks`. Playbook: "CI failure triage".
205
- 3. Commit and push any changes.
206
- 4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
207
- 5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
208
- 6. Rerun Shepherd now.
209
- ```
202
+ Leave out the PR number to use the current branch.
210
203
 
211
204
  ## Documentation
212
205
 
@@ -1,8 +1,8 @@
1
1
  import type { ClassifyRule } from "../types.mts";
2
2
  /**
3
3
  * CodeRabbit progress, rate-limit, review-skipped and draft notices, plus its "Action not
4
- * completed" command replies. The summarize comment is later edited in place into the PR
5
- * walkthrough, so the rule only hides it from agent output and never minimizes it.
4
+ * completed" command replies. If the comment is later edited into a PR walkthrough, its new
5
+ * body no longer matches and Shepherd surfaces the edited minimized comment to the agent.
6
6
  */
7
7
  declare const rule: ClassifyRule;
8
8
  export default rule;
@@ -1,14 +1,54 @@
1
1
  import { isBotItem } from "./author.mjs";
2
2
  const NOTICE_ENVELOPE = /^<!-- This is an auto-generated comment: (rate limited|skip review|review in progress) by coderabbit.ai -->\n([\s\S]*?)\n<!-- end of auto-generated comment: \1 by coderabbit.ai -->$/;
3
3
  const COMMAND_REPLY = /^<!-- This is an auto-generated reply by CodeRabbit -->\n<!-- CodeRabbit review command invocation: v2:[a-f0-9]+ -->\n<details>\n<summary>⚠️ Action not completed<\/summary>\n\n(?:Review rate limited\.|Review skipped: \d+ files exceed the limit of \d+\.)\n\n(?:> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits\. This command is applicable only when automatic reviews are paused\.|---\n\nYour included review limit is currently reached under our \[Fair Usage Limits Policy\]\(https:\/\/docs.coderabbit.ai\/management\/plans#fair-usage-limits-policy\)\. This review may still proceed through usage-based billing if eligible\. Your next included review will be available in \d+ minutes?\.)\n\n<\/details>$/;
4
+ const FINISHING_TOUCH_BLOCK = `<!-- finishing_touch_checkbox_start -->
5
+
6
+ <details>
7
+ <summary><strong>✨ Finishing Touches</strong></summary>
8
+ <dl>
9
+ <dd>
10
+
11
+ <details open>
12
+ <summary><strong>📝 Generate docstrings</strong></summary>
13
+ <dl>
14
+ <dd>
15
+
16
+ - [ ] <!-- {"checkboxId":"3e1879ae-f29b-4d0d-8e06-d12b7ba33d98"} --> Commit to this branch
17
+ - [ ] <!-- {"checkboxId":"7962f53c-55bc-4827-bfbf-6a18da830691"} --> Create a new PR
18
+
19
+ </dd>
20
+ </dl>
21
+ </details>
22
+ <details open>
23
+ <summary><strong>🧪 Generate unit tests (beta)</strong></summary>
24
+ <dl>
25
+ <dd>
26
+
27
+ - [ ] <!-- {"checkboxId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} --> Commit to this branch
28
+ - [ ] <!-- {"checkboxId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} --> Create a new PR
29
+
30
+ </dd>
31
+ </dl>
32
+ </details>
33
+
34
+ </dd>
35
+ </dl>
36
+ </details>
37
+
38
+ <!-- finishing_touch_checkbox_end -->`;
39
+ const AUTOPILOT_BLOCK = /\n<!-- autopilot:start -->\n- \[ \] <!-- \{"checkboxId":"2708ad07-9f24-4260-9c11-7dc76a49f2e3"\} --> <strong title="Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts">Autofix<\/strong> · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts\n<!-- autopilot:end -->$/;
4
40
  /** Strips CodeRabbit's footer and header blocks so only the notice envelope remains. */
5
41
  function stripChrome(body) {
6
- return body
42
+ const withoutFooter = body
7
43
  .trim()
8
44
  .replace(/\n<!-- tips_start -->[\s\S]*?<!-- tips_end -->$/, "")
9
45
  .trim()
10
- .replace(/\n<!-- autopilot:start -->[\s\S]*?<!-- autopilot:end -->$/, "")
11
- .trim()
46
+ .replace(AUTOPILOT_BLOCK, "")
47
+ .trim();
48
+ const withoutFinishingTouch = withoutFooter.endsWith(`\n\n${FINISHING_TOUCH_BLOCK}`)
49
+ ? withoutFooter.slice(0, -FINISHING_TOUCH_BLOCK.length).trim()
50
+ : withoutFooter;
51
+ return withoutFinishingTouch
12
52
  .replace(/^<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\s*/, "")
13
53
  .replace(/^<!-- review_stack_entry_start -->[\s\S]*?<!-- review_stack_entry_end -->\s*/, "");
14
54
  }
@@ -23,8 +63,8 @@ function isQuotedNotice(quoted) {
23
63
  }
24
64
  /**
25
65
  * CodeRabbit progress, rate-limit, review-skipped and draft notices, plus its "Action not
26
- * completed" command replies. The summarize comment is later edited in place into the PR
27
- * walkthrough, so the rule only hides it from agent output and never minimizes it.
66
+ * completed" command replies. If the comment is later edited into a PR walkthrough, its new
67
+ * body no longer matches and Shepherd surfaces the edited minimized comment to the agent.
28
68
  */
29
69
  const rule = (item) => {
30
70
  if (!isBotItem(item, ["coderabbitai"], ["pr-comment"]))
@@ -33,6 +73,10 @@ const rule = (item) => {
33
73
  const quoted = body.match(NOTICE_ENVELOPE)?.[2]?.trim();
34
74
  if (!(quoted && isQuotedNotice(quoted)) && !COMMAND_REPLY.test(body))
35
75
  return null;
36
- return { suppress: true, reason: "CodeRabbit progress, review-limit, or skipped-review notice" };
76
+ return {
77
+ autoResolve: true,
78
+ suppress: true,
79
+ reason: "CodeRabbit progress, review-limit, or skipped-review notice",
80
+ };
37
81
  };
38
82
  export default rule;
@@ -6,7 +6,7 @@ const QUOTA_NOTICES = [
6
6
  /^Codex usage limits have been reached\. Please try again later\.$/,
7
7
  ];
8
8
  const ABOUT_BLOCK = /\n\n<details> <summary>ℹ️ About Codex (?:in GitHub|security reviews in GitHub)<\/summary>[\s\S]*?<\/details>$/;
9
- const ACTIVITY_SUMMARY = /^<!-- codex-pull-request-review-summary -->\n(?:<!-- codex-security-review:v1 \{[^\n]*\} -->\n)?\n?## Codex Review Summary\n\nThis comment shows the latest Codex review activity on this pull request\.\n\n\| Review \| Status \| Commit \| Review trigger \|\n\| --- \| --- \| --- \| --- \|\n(?:\|[^\n]+\|\n?)+$/;
9
+ const ACTIVITY_SUMMARY = /^<!-- codex-pull-request-review-summary -->\n(?:<!-- codex-review-state:v1 \{[^\n]*\} -->\n)?(?:<!-- codex-security-review:v1 \{[^\n]*\} -->\n)?\n?## Codex Review Summary\n\nThis comment shows the latest Codex review activity on this pull request\.\n\n\| Review \| Status \| Commit \| Review trigger \|\n\| --- \| --- \| --- \| --- \|\n(?:\|[^\n]+\|\n?)+$/;
10
10
  const CLEAN_REVIEW = /^Codex Review: Didn't find any major issues\. (?:Bravo\.|Breezy!|Hooray!|:\+1:)\n\n\*\*Reviewed commit:\*\* `[a-f0-9]+`$/;
11
11
  const CLEAN_SECURITY = /^### 🛡️ Codex Security Review(?: · _Automatically triggered_)?\n\nSecurity review completed\. No security issues were found in this pull request\.\n\n\*\*Reviewed commit:\*\* `[a-f0-9]+`\n\n\[View security finding report\]\(https:\/\/chatgpt.com\/codex\/cloud\/tasks\/[a-zA-Z0-9_]+\)\n\n_Only the user who started this review can view the report in Codex\._$/;
12
12
  /**
@@ -1,4 +1,4 @@
1
1
  import type { ClassifyRule } from "../types.mts";
2
- /** Gemini Code Assist daily-quota and review-limit notices on conversation comments. A heading means a real review. */
2
+ /** Complete Gemini Code Assist quota and review-limit notices. */
3
3
  declare const rule: ClassifyRule;
4
4
  export default rule;
@@ -1,13 +1,14 @@
1
1
  import { isBotItem } from "./author.mjs";
2
- /** Gemini Code Assist daily-quota and review-limit notices on conversation comments. A heading means a real review. */
2
+ const NOTICES = [
3
+ "You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!",
4
+ "Review limit reached. I'll be able to review this pull request again tomorrow.",
5
+ ];
6
+ /** Complete Gemini Code Assist quota and review-limit notices. */
3
7
  const rule = (item) => {
4
8
  if (!isBotItem(item, ["gemini-code-assist"], ["pr-comment"]))
5
9
  return null;
6
- if (/^#{1,6}\s/m.test(item.body))
10
+ if (!NOTICES.includes(item.body.trim()))
7
11
  return null;
8
- if (!/You have reached your daily quota limit|Review limit reached/i.test(item.body)) {
9
- return null;
10
- }
11
12
  return { autoResolve: true, suppress: true, reason: "Gemini quota or review-limit notice" };
12
13
  };
13
14
  export default rule;
@@ -1,4 +1,4 @@
1
1
  import type { ClassifyRule } from "../types.mts";
2
- /** Kilo Code conversation comment whose status line reads "No Issues Found" and that carries no findings. */
2
+ /** Complete Kilo Code no-issues status or report with a file list and no extra prose. */
3
3
  declare const rule: ClassifyRule;
4
4
  export default rule;
@@ -1,16 +1,20 @@
1
1
  import { isBotItem } from "./author.mjs";
2
- const STATUS_LINE = /^[^\S\n]*(?:\*\*)?Status:?(?:\*\*)?:?[^\S\n]*(?:\p{Extended_Pictographic}️?[^\S\n]*)?No Issues Found\b/imu;
3
- /** Headings a no-issues report carries; any other heading means real feedback. */
4
- const REPORT_HEADING = /^(?:Code Review Summary|Files Reviewed\b.*)$/i;
5
- /** Kilo Code conversation comment whose status line reads "No Issues Found" and that carries no findings. */
2
+ const PLAIN_STATUS = /^Status: ✅ No Issues Found \| Recommendation: Merge$/;
3
+ const REVIEW_REPORT = /^## Code Review Summary\n\n\*\*Status:\*\* No Issues Found \| \*\*Recommendation:\*\* Merge(?:\n\n### Files Reviewed \((\d+) files\)\n((?:- `[^`\n]+`(?:\n|$))+))?$/;
4
+ /** Complete Kilo Code no-issues status or report with a file list and no extra prose. */
6
5
  const rule = (item) => {
7
6
  if (!isBotItem(item, ["kilo-code-bot"], ["pr-comment"]))
8
7
  return null;
9
- if (!STATUS_LINE.test(item.body))
10
- return null;
11
- for (const [, heading] of item.body.matchAll(/^#{1,6}\s+(.*)$/gm)) {
12
- if (!REPORT_HEADING.test(heading.trim()))
8
+ const body = item.body.trim();
9
+ if (!PLAIN_STATUS.test(body)) {
10
+ const report = body.match(REVIEW_REPORT);
11
+ if (!report)
13
12
  return null;
13
+ if (report[1] !== undefined) {
14
+ const listed = report[2].trimEnd().split("\n").length;
15
+ if (listed !== Number(report[1]))
16
+ return null;
17
+ }
14
18
  }
15
19
  return { autoResolve: true, suppress: true, reason: "Kilo Code no-issues report" };
16
20
  };
@@ -1,8 +1,4 @@
1
1
  import type { ClassifyRule } from "../types.mts";
2
- /**
3
- * Sourcery's private-repo upsell and weekly rate-limit notices on conversation comments. A real review opens
4
- * with a Markdown heading ("Reviewer's Guide", "Summary by Sourcery"); the notices carry none,
5
- * so a heading means genuine feedback that merely quotes the phrase.
6
- */
2
+ /** Complete Sourcery private-repo upsell and weekly rate-limit notices. */
7
3
  declare const rule: ClassifyRule;
8
4
  export default rule;
@@ -1,18 +1,15 @@
1
1
  import { isBotItem } from "./author.mjs";
2
- /**
3
- * Sourcery's private-repo upsell and weekly rate-limit notices on conversation comments. A real review opens
4
- * with a Markdown heading ("Reviewer's Guide", "Summary by Sourcery"); the notices carry none,
5
- * so a heading means genuine feedback that merely quotes the phrase.
6
- */
2
+ const NO_ACCESS = /^Hi @[A-Za-z0-9-]+! 👋\n\nYour private repo does not have access to Sourcery\.\n\nPlease \[upgrade\]\(https:\/\/app\.sourcery\.ai\/login\?connection=github(?:&git_namespace_id=\d+)?&from_surface=github_bot&intent=private_repo_upsell\) to continue using Sourcery ✨$/;
3
+ const RATE_LIMIT = /^Sorry @[A-Za-z0-9-]+, you have reached your weekly rate limit for Sourcery\. Please try again later\.$/;
4
+ /** Complete Sourcery private-repo upsell and weekly rate-limit notices. */
7
5
  const rule = (item) => {
8
6
  if (!isBotItem(item, ["sourcery-ai"], ["pr-comment"]))
9
7
  return null;
10
- if (/^#{1,6}\s/m.test(item.body))
11
- return null;
12
- if (/does not have access to Sourcery/i.test(item.body)) {
8
+ const body = item.body.trim();
9
+ if (NO_ACCESS.test(body)) {
13
10
  return { autoResolve: true, suppress: true, reason: "Sourcery no-access upsell" };
14
11
  }
15
- if (/reached your weekly rate limit/i.test(item.body)) {
12
+ if (RATE_LIMIT.test(body)) {
16
13
  return { autoResolve: true, suppress: true, reason: "Sourcery rate-limit notice" };
17
14
  }
18
15
  return null;
@@ -9,7 +9,7 @@ import { classifyReviewSummaries } from "./classify.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
10
  import { clearStallState } from "../../state/iterate-stall.mjs";
11
11
  import { handleFixCode } from "./fix-code.mjs";
12
- import { normalizeBotUsernames } from "../../comments/authors.mjs";
12
+ import { isConfiguredBotAuthor, normalizeBotUsernames } from "../../comments/authors.mjs";
13
13
  import { autoMinimizeComments } from "../../comments/resolve.mjs";
14
14
  import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
15
15
  import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mjs";
@@ -95,14 +95,20 @@ async function runIterateCore(opts) {
95
95
  if (unminimized.length > 0)
96
96
  reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
97
97
  }
98
- // Hidden PR comments surface once so the agent can acknowledge them, but
99
- // they are not readiness evidence: bots keep editing hidden notices after a
100
- // PR settles. They never restart the ready-delay or void a READY receipt.
98
+ // A first look at an already-minimized comment is only an acknowledgment.
99
+ // A non-bot edit that no longer matches a noise rule may carry new feedback;
100
+ // it must restart the ready delay and void an existing READY receipt. Bots
101
+ // regenerate minimized summaries on their own, so their edits surface for
102
+ // acknowledgment without restarting the countdown.
103
+ const editedMinimizedFeedback = report.comments.firstLook.some((comment) => comment.isMinimized === true &&
104
+ comment.edited === true &&
105
+ !isConfiguredBotAuthor(comment, botUsernames));
101
106
  const hasReadinessWork = report.threads.actionable.length > 0 ||
102
107
  report.threads.resolutionOnly.length > 0 ||
103
108
  report.threads.firstLook.length > 0 ||
104
109
  (report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
105
110
  report.comments.actionable.length > 0 ||
111
+ editedMinimizedFeedback ||
106
112
  (report.comments.minimizeIds?.length ?? 0) > 0 ||
107
113
  report.changesRequestedReviews.length > 0 ||
108
114
  hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
@@ -123,7 +129,7 @@ async function runIterateCore(opts) {
123
129
  !hasReadinessWork &&
124
130
  !activeMerge &&
125
131
  staleAncestry === null;
126
- const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
132
+ const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, editedMinimizedFeedback, checkContext);
127
133
  const headSha = report.headSha ?? "unknown";
128
134
  // The elapsed marker survives a hidden-comment acknowledgement tick and is
129
135
  // consumed only when this tick cancels or merges. A receipt that is still
@@ -385,7 +391,7 @@ async function recordReadyReceipt(key, report, context) {
385
391
  }
386
392
  }
387
393
  /** Clear a stale READY receipt; return whether a current receipt remains. */
388
- async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
394
+ async function revalidateReadyReceipt(key, report, hasReadinessWork, editedMinimizedFeedback, context) {
389
395
  const receipt = await readReadyReceipt(key);
390
396
  if (!receipt)
391
397
  return false;
@@ -398,6 +404,7 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
398
404
  !report.headSha ||
399
405
  !report.baseRefOid ||
400
406
  transportReadinessGaps(report, report.mergeStatus.mergeStateStatus).length > 0 ||
407
+ editedMinimizedFeedback ||
401
408
  (!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
402
409
  await clearReadyReceipt(key);
403
410
  return false;
@@ -18,8 +18,7 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
18
18
  raw.reviews.pageInfo.hasPreviousPage ||
19
19
  raw.reviewThreads.pageInfo.hasPreviousPage;
20
20
  for (const comment of raw.comments.nodes) {
21
- if (!comment.isMinimized &&
22
- !isSuppressed(rules, "pr-comment", comment) &&
21
+ if (!isSuppressed(rules, "pr-comment", comment, comment.id, undefined, comment.body, comment.isMinimized) &&
23
22
  classifyItem(comment.id, comment.body, seen) !== "unchanged")
24
23
  actionable += 1;
25
24
  }
@@ -0,0 +1,6 @@
1
+ import type { scanMarkdownLines } from "./markdown-line.mts";
2
+ export declare const LEGACY: RegExp;
3
+ export declare const JOURNAL_SUMMARY: RegExp;
4
+ export declare const CLOSE = "</details>";
5
+ /** Return the line of the canonical `</details>` closing a journal container, or null. */
6
+ export declare function closeJournalContainer(lines: string[], syntax: ReturnType<typeof scanMarkdownLines>, start: number): number | null;
@@ -0,0 +1,33 @@
1
+ import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
2
+ export const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
3
+ export const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
4
+ export const CLOSE = "</details>";
5
+ /** Return the line of the canonical `</details>` closing a journal container, or null. */
6
+ export function closeJournalContainer(lines, syntax, start) {
7
+ let depth = 1;
8
+ let html = rawHtmlState();
9
+ for (let i = start; i < lines.length; i++) {
10
+ if (syntax[i].nested)
11
+ continue;
12
+ if (syntax[i].rawHtml) {
13
+ for (const tag of rawDetailsTags(lines[i], html))
14
+ if ((depth += tag) === 0)
15
+ return null;
16
+ continue;
17
+ }
18
+ html = rawHtmlState();
19
+ if (syntax[i].ignored)
20
+ continue;
21
+ const visible = syntax[i].visiblePrefix;
22
+ if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
23
+ return null;
24
+ depth += detailsTags(visible, false);
25
+ const closes = detailsTags(visible, true);
26
+ if (closes) {
27
+ depth -= closes;
28
+ if (depth <= 0)
29
+ return depth === 0 && lines[i] === CLOSE ? i : null;
30
+ }
31
+ }
32
+ return null;
33
+ }
@@ -0,0 +1,15 @@
1
+ export declare function detailsTags(line: string, closing: boolean): number;
2
+ /** Tokenizer state carried across the lines of one raw HTML block. */
3
+ export type RawHtmlState = {
4
+ mode: "comment" | "data" | "raw-text" | "tag";
5
+ quote: '"' | "'" | null;
6
+ rawTag: string | null;
7
+ terminator: RegExp;
8
+ };
9
+ export declare const rawHtmlState: () => RawHtmlState;
10
+ /**
11
+ * Return `1` per `<details>` opener and `-1` per closer that the browser
12
+ * creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
13
+ * elements, and (possibly multiline) attribute values do not count.
14
+ */
15
+ export declare function rawDetailsTags(line: string, state: RawHtmlState): Array<1 | -1>;
@@ -0,0 +1,92 @@
1
+ import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
2
+ import { structuralDetailsStart } from "./markdown-structure.mjs";
3
+ export function detailsTags(line, closing) {
4
+ if (structuralDetailsStart(line) === null)
5
+ return 0;
6
+ const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
7
+ return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
8
+ }
9
+ const RAW_TEXT = new Set([
10
+ "iframe",
11
+ "noembed",
12
+ "noframes",
13
+ "script",
14
+ "style",
15
+ "textarea",
16
+ "title",
17
+ "xmp",
18
+ ]);
19
+ export const rawHtmlState = () => ({
20
+ mode: "data",
21
+ quote: null,
22
+ rawTag: null,
23
+ terminator: /--!?>/,
24
+ });
25
+ // Comments, CDATA sections, and bogus comments such as `<!x>`, `<?x>`, and `</ x>`.
26
+ const COMMENTS = [
27
+ [/^<!--/, /--!?>/],
28
+ [/^<!\[CDATA\[/, /\]\]>/],
29
+ [/^<(?:[!?]|\/[^A-Za-z>])/, />/],
30
+ ];
31
+ /**
32
+ * Return `1` per `<details>` opener and `-1` per closer that the browser
33
+ * creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
34
+ * elements, and (possibly multiline) attribute values do not count.
35
+ */
36
+ export function rawDetailsTags(line, state) {
37
+ const tags = [];
38
+ let i = 0;
39
+ while (i < line.length) {
40
+ if (state.mode === "comment") {
41
+ const end = state.terminator.exec(line.slice(i));
42
+ if (!end)
43
+ break;
44
+ state.mode = "data";
45
+ i += end.index + end[0].length;
46
+ }
47
+ else if (state.mode === "raw-text") {
48
+ const end = new RegExp(`</${state.rawTag}(?=[\\s/>]|$)`, "i").exec(line.slice(i));
49
+ if (!end)
50
+ break;
51
+ state.mode = "tag";
52
+ state.rawTag = null;
53
+ i += end.index + end[0].length;
54
+ }
55
+ else if (state.mode === "tag") {
56
+ const char = line[i++];
57
+ if (state.quote) {
58
+ if (char === state.quote)
59
+ state.quote = null;
60
+ }
61
+ else if (char === '"' || char === "'")
62
+ state.quote = char;
63
+ else if (char === ">")
64
+ state.mode = state.rawTag ? "raw-text" : "data";
65
+ }
66
+ else {
67
+ const open = line.indexOf("<", i);
68
+ if (open === -1)
69
+ break;
70
+ const comment = COMMENTS.map(([start, end]) => [start.exec(line.slice(open)), end]).find(([start]) => start);
71
+ if (comment) {
72
+ state.mode = "comment";
73
+ state.terminator = comment[1];
74
+ i = open + comment[0][0].length;
75
+ continue;
76
+ }
77
+ // Tag names run until whitespace, `/`, or `>`, so `details:x` is not `details`.
78
+ const tag = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(line.slice(open));
79
+ if (!tag) {
80
+ i = open + 1;
81
+ continue;
82
+ }
83
+ const name = tag[2].toLowerCase();
84
+ if (name === "details")
85
+ tags.push(tag[1] ? -1 : 1);
86
+ state.mode = "tag";
87
+ state.rawTag = !tag[1] && RAW_TEXT.has(name) ? name : null;
88
+ i = open + tag[0].length;
89
+ }
90
+ }
91
+ return tags;
92
+ }
@@ -1,6 +1,8 @@
1
+ /** `rawHtml` marks lines inside a blank-line-terminated raw HTML block, whose tags still render. */
1
2
  type MarkdownLine = {
2
3
  ignored: boolean;
3
4
  nested: boolean;
5
+ rawHtml: boolean;
4
6
  visiblePrefix: string;
5
7
  };
6
8
  /** Return whether a Markdown line starts a block that interrupts inline content. */
@@ -35,7 +35,7 @@ function scanMarkdown(lines) {
35
35
  const container = resolveMarkdownContainer(line, activeContainer);
36
36
  activeContainer = container.tokens;
37
37
  const nested = container.tokens.length > 0;
38
- const push = (ignored, visiblePrefix) => result.push({ ignored, nested, visiblePrefix });
38
+ const push = (ignored, visiblePrefix, rawHtml = false) => result.push({ ignored, nested, rawHtml, visiblePrefix });
39
39
  if (indentedCodeContainer) {
40
40
  if (stripMarkdownContainer(line, indentedCodeContainer) !== null) {
41
41
  push(true, "");
@@ -53,7 +53,7 @@ function scanMarkdown(lines) {
53
53
  else {
54
54
  const end = rawHtmlEnd(html, content);
55
55
  if (end === null) {
56
- push(true, "");
56
+ push(true, "", html.kind === "blank-line");
57
57
  continue;
58
58
  }
59
59
  html = null;
@@ -100,7 +100,7 @@ function scanMarkdown(lines) {
100
100
  const end = rawHtmlEnd(openingHtml, content);
101
101
  if (end === null) {
102
102
  html = openingHtml;
103
- push(true, "");
103
+ push(true, "", openingHtml.kind === "blank-line");
104
104
  continue;
105
105
  }
106
106
  allowCodeOpeners = false;
@@ -1,42 +1,16 @@
1
- import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
1
+ import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
2
+ import { CLOSE, closeJournalContainer, JOURNAL_SUMMARY, LEGACY } from "./journal-container.mjs";
2
3
  import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
3
4
  import { setextParagraphStart } from "./markdown-setext.mjs";
4
- import { structuralDetailsStart } from "./markdown-structure.mjs";
5
- const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
6
- const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
7
5
  const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
8
- const CLOSE = "</details>";
9
6
  const OPEN = "<details>";
10
7
  const SUMMARY = "<summary>Shepherd Journal</summary>";
11
8
  const stripCr = (s) => s.replace(/\r$/, "");
12
- function detailsTags(line, closing) {
13
- if (structuralDetailsStart(line) === null)
14
- return 0;
15
- const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
16
- return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
17
- }
18
- function close(lines, syntax, start) {
19
- let depth = 1;
20
- for (let i = start; i < lines.length; i++) {
21
- if (syntax[i].ignored || syntax[i].nested)
22
- continue;
23
- const visible = syntax[i].visiblePrefix;
24
- if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
25
- return null;
26
- depth += detailsTags(visible, false);
27
- const closes = detailsTags(visible, true);
28
- if (closes) {
29
- depth -= closes;
30
- if (depth <= 0)
31
- return depth === 0 && lines[i] === CLOSE ? i : null;
32
- }
33
- }
34
- return null;
35
- }
36
9
  export function scanShepherdJournal(lines) {
37
10
  const syntax = scanMarkdownLines(lines);
38
11
  const found = [];
39
12
  let detailsDepth = 0;
13
+ let html = rawHtmlState();
40
14
  let legacy = null;
41
15
  for (let i = 0; i < lines.length; i++) {
42
16
  if (!syntax[i].ignored &&
@@ -48,7 +22,16 @@ export function scanShepherdJournal(lines) {
48
22
  legacy.contentEnd = legacy.end = i;
49
23
  continue;
50
24
  }
51
- if (syntax[i].ignored || syntax[i].nested)
25
+ if (syntax[i].nested)
26
+ continue;
27
+ if (syntax[i].rawHtml) {
28
+ // Raw-HTML details stay open past the block; stray closers are ignored, as in the browser.
29
+ for (const tag of rawDetailsTags(lines[i], html))
30
+ detailsDepth = Math.max(0, detailsDepth + tag);
31
+ continue;
32
+ }
33
+ html = rawHtmlState();
34
+ if (syntax[i].ignored)
52
35
  continue;
53
36
  const visible = syntax[i].visiblePrefix;
54
37
  detailsDepth += detailsTags(visible, false);
@@ -66,7 +49,7 @@ export function scanShepherdJournal(lines) {
66
49
  lines[i - 1] !== OPEN ||
67
50
  lines[i + 1] !== "")
68
51
  return "error";
69
- const end = close(lines, syntax, i + 2);
52
+ const end = closeJournalContainer(lines, syntax, i + 2);
70
53
  if (end === null)
71
54
  return "error";
72
55
  detailsDepth--;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.58.0",
4
+ "version": "0.59.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.58.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.59.1", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.58.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.59.1", "pr-shepherd-mcp"]
5
5
  }
6
6
  }