pr-shepherd 0.38.1 → 0.40.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 (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -3
  3. package/bin/cli/iterate-instructions.mjs +2 -2
  4. package/bin/commands/commit-suggestion-instruction.d.mts +6 -3
  5. package/bin/commands/commit-suggestion-instruction.mjs +7 -15
  6. package/bin/commands/iterate/check-instructions.d.mts +31 -1
  7. package/bin/commands/iterate/check-instructions.mjs +36 -18
  8. package/bin/commands/iterate/render.mjs +7 -12
  9. package/bin/commands/journal/journal-item.mjs +57 -7
  10. package/bin/commands/journal/journal-markdown.d.mts +0 -1
  11. package/bin/commands/journal/journal-markdown.mjs +1 -6
  12. package/bin/commands/shepherd-journal.d.mts +7 -3
  13. package/bin/commands/shepherd-journal.mjs +8 -7
  14. package/bin/journal/append.d.mts +8 -0
  15. package/bin/journal/append.mjs +96 -0
  16. package/bin/journal/index.d.mts +3 -0
  17. package/bin/journal/index.mjs +3 -0
  18. package/bin/journal/markdown-backticks.d.mts +9 -0
  19. package/bin/journal/markdown-backticks.mjs +34 -0
  20. package/bin/journal/markdown-container.d.mts +22 -0
  21. package/bin/journal/markdown-container.mjs +110 -0
  22. package/bin/journal/markdown-html.d.mts +20 -0
  23. package/bin/journal/markdown-html.mjs +119 -0
  24. package/bin/journal/markdown-line.d.mts +8 -0
  25. package/bin/journal/markdown-line.mjs +166 -0
  26. package/bin/journal/markdown-setext.d.mts +7 -0
  27. package/bin/journal/markdown-setext.mjs +15 -0
  28. package/bin/journal/markdown-structure.d.mts +2 -0
  29. package/bin/journal/markdown-structure.mjs +35 -0
  30. package/bin/journal/reconcile.d.mts +17 -0
  31. package/bin/journal/reconcile.mjs +184 -0
  32. package/package.json +5 -1
  33. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  34. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  35. package/plugins/pr-shepherd/.mcp.json +1 -1
  36. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +44 -0
@@ -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.38.1",
4
+ "version": "0.40.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -67,10 +67,13 @@ Conversations Resolved: No [Not Required]
67
67
 
68
68
  1. Review each item under `## Review threads` and `## Failing checks` and decide whether it needs a code change.
69
69
  2. Apply every warranted review fix in each file referenced above.
70
- 3. Read the included CI log excerpt; fetch the full log if needed, then rerun transient failures or fix real failures.
70
+ 3. Triage every failure under `## Failing checks`. See "CI failure triage" in the pr-shepherd skill for `gh run view` / `gh run rerun` rules.
71
71
  4. If you changed code, commit any remaining changes and push before review mutations. Otherwise, do not commit or push.
72
- 5. Replace `$HEAD_SHA` and `$DISMISS_MESSAGE`, then run the `apply review:` command shown above.
73
- 6. `[FIX_CODE]` is non-terminal. Continue with the next poll using the same CLI mode and flags, or call MCP `iterate` again.
72
+ 5. Before `apply review:`, remove any `--reply-thread-ids` entry whose latest visible comment is your own Shepherd reply. Do not reply to yourself.
73
+ 6. Replace `$HEAD_SHA` with the pushed commit SHA, or `$(git rev-parse HEAD)` if you did not push.
74
+ 7. Replace `$DISMISS_MESSAGE` with one sentence describing what changed.
75
+ 8. Run the `apply review:` command shown above. See "Review-mutation mechanics" in the pr-shepherd skill for dismiss-ID retention.
76
+ 9. `[FIX_CODE]` is non-terminal. After completing these steps, iterate again with the same options to continue.
74
77
  ```
75
78
 
76
79
  See [docs/actions.md](docs/actions.md) for the complete output contract. Iterate/poll PR outcomes use exit codes `0` and `10`–`14`; command and GitHub failures use `sysexits.h` codes — [docs/exit-codes.md](docs/exit-codes.md).
@@ -2,11 +2,11 @@ export function buildSimpleIterateInstructions(result) {
2
2
  switch (result.action) {
3
3
  case "wait":
4
4
  return [
5
- "No action is needed this tick. Continue with the next poll using the same interface and mode: rerun the current `pr-shepherd` CLI invocation with its flags, or call MCP `iterate` again.",
5
+ "Non-terminal — no action needed this tick. Iterate again with the same options to continue.",
6
6
  ];
7
7
  case "mark_ready":
8
8
  return [
9
- "The CLI marked the PR ready for review. Continue with the next poll using the same interface and mode: rerun the current `pr-shepherd` CLI invocation with its flags, or call MCP `iterate` again.",
9
+ "The CLI marked the PR ready for review. Iterate again with the same options to continue.",
10
10
  ];
11
11
  case "cancel":
12
12
  return ["Stop — the PR loop is complete. No further polling is needed."];
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Build the `build-suggestion-patch` instruction step for agent consumers.
3
- * Currently emitted by iterate `fix_code` for suggestion review threads.
3
+ * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
4
+ * only the trigger and the concrete command; refusal/drift handling is invariant across
5
+ * every invocation, so it lives in the pr-shepherd skill's "Suggestion patches" playbook
6
+ * instead of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts
7
+ * minimal").
4
8
  * @param sectionName - The markdown section heading where suggestion threads appear,
5
9
  * e.g. `"## Review threads"`.
6
- * @param includeDriftHint - Whether to add the trailing note about drift on failed apply.
7
10
  */
8
- export declare function buildCommitSuggestionInstruction(prNumber: number, sectionName: string, includeDriftHint: boolean): string[];
11
+ export declare function buildCommitSuggestionInstruction(prNumber: number, sectionName: string): string;
@@ -1,12 +1,15 @@
1
1
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
2
  /**
3
3
  * Build the `build-suggestion-patch` instruction step for agent consumers.
4
- * Currently emitted by iterate `fix_code` for suggestion review threads.
4
+ * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
5
+ * only the trigger and the concrete command; refusal/drift handling is invariant across
6
+ * every invocation, so it lives in the pr-shepherd skill's "Suggestion patches" playbook
7
+ * instead of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts
8
+ * minimal").
5
9
  * @param sectionName - The markdown section heading where suggestion threads appear,
6
10
  * e.g. `"## Review threads"`.
7
- * @param includeDriftHint - Whether to add the trailing note about drift on failed apply.
8
11
  */
9
- export function buildCommitSuggestionInstruction(prNumber, sectionName, includeDriftHint) {
12
+ export function buildCommitSuggestionInstruction(prNumber, sectionName) {
10
13
  const command = buildPrShepherdCommand([
11
14
  "build-suggestion-patch",
12
15
  String(prNumber),
@@ -16,16 +19,5 @@ export function buildCommitSuggestionInstruction(prNumber, sectionName, includeD
16
19
  "<one-sentence headline>",
17
20
  "--format=json",
18
21
  ]).text;
19
- const driftHint = includeDriftHint
20
- ? "If the patch does not apply because the suggestion drifted, use the manual-fix step below. Do not retry the command."
21
- : "If the patch does not apply, use the manual-edit step below. Do not retry the command.";
22
- const manualStep = includeDriftHint ? "manual-fix step" : "manual-edit step";
23
- return [
24
- `For each thread marked \`[suggestion]\` under \`${sectionName}\`, run \`${command}\` to retrieve its patch and suggested commit.`,
25
- "The CLI only builds the patch. Apply it, stage the listed file, and follow the returned commit instructions.",
26
- `If the command refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), skip patch application and use the ${manualStep} below. Do not retry the command.`,
27
- "For any other refusal, follow the CLI error's stated recovery action; do not manually edit the suggestion.",
28
- driftHint,
29
- "Keep human-authored thread IDs in `apply review:` so Shepherd replies instead of resolving them.",
30
- ];
22
+ return `For each thread marked \`[suggestion]\` under \`${sectionName}\`, run \`${command}\` and apply the returned patch. See "Suggestion patches" in the pr-shepherd skill for refusals and drift.`;
31
23
  }
@@ -11,7 +11,37 @@ export declare function buildCrStaleClause(reviews: Review[]): string;
11
11
  * discarding the rest of the user's config.
12
12
  */
13
13
  export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string, isBehind: boolean): string[];
14
- /** Build the `Run the apply review: command` instruction, including its optional substitution hint. */
14
+ /**
15
+ * Build the `Run the apply review: command` instruction. Steps stay here (not in the skill)
16
+ * whenever the *unmodified, as-printed* command is unsafe without them:
17
+ *
18
+ * - `$HEAD_SHA`/`$DISMISS_MESSAGE` substitution: without it, the printed command has an
19
+ * empty `--message`/invalid `--require-sha` and `apply review` rejects the mutation.
20
+ * - Self-reply exclusion: a human thread whose latest visible comment is already Shepherd's
21
+ * own prior reply still has its ID in `--reply-thread-ids` by default. Running the
22
+ * printed command as-is replies to Shepherd's own reply, which can re-surface the thread
23
+ * and produce a self-perpetuating reply loop — worse than a rejected mutation, and not
24
+ * something a caller can discover by inspecting the command alone.
25
+ *
26
+ * Contrast with what *does* stay in the skill's "Review-mutation mechanics" playbook —
27
+ * dismiss-ID retention and the first-look/annotation ID-exclusion rules. Those only matter
28
+ * if the caller *edits* the printed command (removes an ID, or adds one back); the printed
29
+ * command run unmodified is already correct for them. The pointer below is load-bearing:
30
+ * without it, nothing in CLI output tells the agent that playbook exists.
31
+ */
15
32
  export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
33
+ /**
34
+ * Build the CI-triage instruction. The per-conclusion rerun policy (GitHub Actions log
35
+ * excerpts, `gh run view`/`gh run rerun` rules for CANCELLED/STARTUP_FAILURE/external
36
+ * failures) is invariant text keyed on the `[conclusion: …]` tags already rendered in
37
+ * `## Failing checks` — it lives in the pr-shepherd skill's "CI failure triage" playbook
38
+ * instead of being re-emitted every tick. This supersedes the "CI budget rules" example in
39
+ * CLAUDE.md's "Keep skills and loop prompts minimal" section (see that section's amendment
40
+ * note). The `(no runId)` case stays here because it flips `buildFixCompletionInstruction`
41
+ * to a human-handoff terminal state — that trigger, unlike the others, is CLI-decided. The
42
+ * CLI sentence does not claim every failure has a log excerpt to read (only GitHub Actions
43
+ * checks with a runId do — CANCELLED, STARTUP_FAILURE, and external checks may not); that
44
+ * per-kind detail is exactly what the skill playbook table disambiguates.
45
+ */
16
46
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
17
47
  export declare function buildFixCompletionInstruction(checks: AgentCheck[]): string;
@@ -20,7 +20,24 @@ export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
20
20
  return [];
21
21
  return [`The branch is behind \`origin/${baseBranch}\`. ${trimmedHint} before pushing.`];
22
22
  }
23
- /** Build the `Run the apply review: command` instruction, including its optional substitution hint. */
23
+ /**
24
+ * Build the `Run the apply review: command` instruction. Steps stay here (not in the skill)
25
+ * whenever the *unmodified, as-printed* command is unsafe without them:
26
+ *
27
+ * - `$HEAD_SHA`/`$DISMISS_MESSAGE` substitution: without it, the printed command has an
28
+ * empty `--message`/invalid `--require-sha` and `apply review` rejects the mutation.
29
+ * - Self-reply exclusion: a human thread whose latest visible comment is already Shepherd's
30
+ * own prior reply still has its ID in `--reply-thread-ids` by default. Running the
31
+ * printed command as-is replies to Shepherd's own reply, which can re-surface the thread
32
+ * and produce a self-perpetuating reply loop — worse than a rejected mutation, and not
33
+ * something a caller can discover by inspecting the command alone.
34
+ *
35
+ * Contrast with what *does* stay in the skill's "Review-mutation mechanics" playbook —
36
+ * dismiss-ID retention and the first-look/annotation ID-exclusion rules. Those only matter
37
+ * if the caller *edits* the printed command (removes an ID, or adds one back); the printed
38
+ * command run unmodified is already correct for them. The pointer below is load-bearing:
39
+ * without it, nothing in CLI output tells the agent that playbook exists.
40
+ */
24
41
  export function buildResolveCommandInstruction(resolveCommand) {
25
42
  if (!resolveCommand.hasMutations)
26
43
  return [];
@@ -34,29 +51,30 @@ export function buildResolveCommandInstruction(resolveCommand) {
34
51
  if (resolveCommand.requiresDismissMessage) {
35
52
  instructions.push("Replace `$DISMISS_MESSAGE` with one sentence describing what changed.");
36
53
  }
37
- instructions.push("Run the `apply review:` command shown above.");
54
+ instructions.push('Run the `apply review:` command shown above. See "Review-mutation mechanics" in the pr-shepherd skill for dismiss-ID retention.');
38
55
  return instructions;
39
56
  }
57
+ /**
58
+ * Build the CI-triage instruction. The per-conclusion rerun policy (GitHub Actions log
59
+ * excerpts, `gh run view`/`gh run rerun` rules for CANCELLED/STARTUP_FAILURE/external
60
+ * failures) is invariant text keyed on the `[conclusion: …]` tags already rendered in
61
+ * `## Failing checks` — it lives in the pr-shepherd skill's "CI failure triage" playbook
62
+ * instead of being re-emitted every tick. This supersedes the "CI budget rules" example in
63
+ * CLAUDE.md's "Keep skills and loop prompts minimal" section (see that section's amendment
64
+ * note). The `(no runId)` case stays here because it flips `buildFixCompletionInstruction`
65
+ * to a human-handoff terminal state — that trigger, unlike the others, is CLI-decided. The
66
+ * CLI sentence does not claim every failure has a log excerpt to read (only GitHub Actions
67
+ * checks with a runId do — CANCELLED, STARTUP_FAILURE, and external checks may not); that
68
+ * per-kind detail is exactly what the skill playbook table disambiguates.
69
+ */
40
70
  export function buildFailingCheckInstructions(checks) {
41
71
  if (checks.length === 0)
42
72
  return [];
43
- const hasRunId = checks.some((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
44
- const hasCancelled = checks.some((c) => c.runId && c.conclusion === "CANCELLED");
45
- const hasStartupFailure = checks.some((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
46
- const hasExternal = checks.some((c) => !c.runId && c.detailsUrl);
47
73
  const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
74
+ const hasTriageable = checks.some((c) => c.runId || c.detailsUrl);
48
75
  const instructions = [];
49
- if (hasRunId) {
50
- instructions.push("For each GitHub Actions failure under `## Failing checks`, read the included log excerpt first.", "If the excerpt is insufficient, run `gh run view <runId> --log-failed`. Open the run URL only if the API still lacks detail.", "Rerun transient infrastructure failures with `gh run rerun <runId> --failed`. Apply a code fix for real test or build failures.");
51
- }
52
- if (hasCancelled) {
53
- instructions.push("For each `[conclusion: CANCELLED]` failure, run `gh run rerun <runId>` unless this tick will push new commits.", "Do not treat a cancelled failure as resolved. `## Cancelled runs` is a different section.");
54
- }
55
- if (hasStartupFailure) {
56
- instructions.push("For each `[conclusion: STARTUP_FAILURE]` failure, inspect it with `gh run view <runId>` and rerun it with `gh run rerun <runId>` if warranted.");
57
- }
58
- if (hasExternal) {
59
- instructions.push("For each `external` failure, open its URL and inspect it.");
76
+ if (hasTriageable) {
77
+ instructions.push('Triage every failure under `## Failing checks`. See "CI failure triage" in the pr-shepherd skill for `gh run view` / `gh run rerun` rules.');
60
78
  }
61
79
  if (hasBare) {
62
80
  instructions.push("For each `(no runId)` failure, escalate to a human because no log or URL is available.");
@@ -68,5 +86,5 @@ export function buildFixCompletionInstruction(checks) {
68
86
  if (requiresHumanHandoff) {
69
87
  return "`[FIX_CODE]` requires a human handoff for an uninspectable failing check. Stop polling after escalating, and resume only after human direction.";
70
88
  }
71
- return "`[FIX_CODE]` is non-terminal. After completing these steps, continue with the next poll using the same interface and mode: rerun the current `pr-shepherd` CLI invocation with its flags, or call MCP `iterate` again.";
89
+ return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate again with the same options to continue.";
72
90
  }
@@ -1,6 +1,6 @@
1
1
  import { renderShellCommand } from "../../cli/runner.mjs";
2
2
  import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, buildFixCompletionInstruction, } from "./check-instructions.mjs";
3
- import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
3
+ import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
4
  import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
5
5
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
6
6
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
@@ -42,7 +42,7 @@ isBehind = false) {
42
42
  }
43
43
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
44
44
  if (firstLookTotal > 0) {
45
- instructions.push("Review every item under `## First-look items` before acting.", "If a first-look thread also appears under `## Review threads to resolve`, its ID is already in `apply review:`. Do not add first-look-only IDs to mutation flags.");
45
+ instructions.push("Review every item under `## First-look items` before acting.");
46
46
  }
47
47
  if (firstLookSummaries.length > 0)
48
48
  instructions.push(SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE);
@@ -54,35 +54,30 @@ isBehind = false) {
54
54
  instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
55
55
  }
56
56
  if (inProgressRunIds.length > 0) {
57
- instructions.push("If you will push, first cancel every ID under `## In-progress runs` with `gh run cancel <id>`.", "Ignore cancellation errors for runs that already finished.", "If you will not push, leave the in-progress runs alone.");
57
+ instructions.push("If you will push, first cancel every ID under `## In-progress runs` with `gh run cancel <id>` (ignore errors for runs that already finished). If you will not push, leave them alone.");
58
58
  }
59
59
  if (cancelledCount > 0) {
60
60
  instructions.push("Do not cancel the IDs under `## Cancelled runs` again. The CLI already cancelled them.");
61
61
  }
62
62
  const hasSuggestions = threads.some((t) => t.suggestion);
63
63
  if (hasSuggestions)
64
- instructions.push(...buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
64
+ instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads"));
65
65
  if (threads.length > 0 || actionableComments.length > 0) {
66
66
  // Actionable comments carry no file/line location (unlike threads), so "referenced above"
67
67
  // is only accurate when threads are present.
68
68
  const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
69
69
  instructions.push(`Apply every warranted review fix in ${filesRef}.`);
70
- if (hasSuggestions) {
71
- instructions.push("After source drift prevents a generated suggestion patch from applying, replace the heading's exact `path:startLine-endLine` range with the `Replaces lines …` block verbatim. An empty replacement deletes the range. One blank line replaces it with one blank line.", "When `build-suggestion-patch` refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), do not apply the replacement block verbatim. Inspect the surrounding source and reviewer intent, then make the intended edit manually.");
72
- }
73
70
  }
74
71
  if (resolutionOnlyThreads.length > 0) {
75
- instructions.push("Review the threads under `## Review threads to resolve` before running mutations.", "Use the generated commands as shown. Human-authored IDs use `--reply-thread-ids`. Bot and non-human IDs use `--resolve-thread-ids`. Shepherd does not resolve human-authored threads.");
72
+ instructions.push('Review the threads under `## Review threads to resolve` before running mutations. Use the generated commands as shown see "Review-mutation routing" in the pr-shepherd skill for which flag applies to which ID.');
76
73
  }
77
74
  instructions.push(...buildFailingCheckInstructions(failingChecks));
78
75
  if (hasAnnotations) {
79
- instructions.push("Inspect every referenced range under `## Check annotations` and apply any warranted change.", "Do not add annotation IDs to resolve or minimize mutations.");
76
+ instructions.push("Inspect every referenced range under `## Check annotations` and apply any warranted change.");
80
77
  }
81
78
  if (changesRequestedReviews.length > 0) {
82
79
  const staleClause = buildCrStaleClause(changesRequestedReviews);
83
80
  instructions.push(`Read every body under \`## Changes-requested reviews\` and apply any warranted change.${staleClause}`);
84
- if ((resolveCommand.dismissReviewIds?.length ?? 0) > 0)
85
- instructions.push("Keep every existing `--dismiss-review-ids` ID in `apply review:`. Each is a bot or non-human review that must be dismissed. Omitting one leaves the PR in `CHANGES_REQUESTED`.");
86
81
  }
87
82
  instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
88
83
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
@@ -98,7 +93,7 @@ isBehind = false) {
98
93
  firstLookTotal > 0 ||
99
94
  firstLookSummaries.length > 0 ||
100
95
  editedTotal > 0) {
101
- instructions.push(...buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
96
+ instructions.push(buildShepherdJournalInstruction(prNumber));
102
97
  }
103
98
  if (resolveOnlyCommand?.hasMutations)
104
99
  instructions.push("Run the `resolve-only:` command shown above.");
@@ -1,4 +1,53 @@
1
- import { isJournalLikeSummary, isReservedJournalMarker } from "./journal-markdown.mjs";
1
+ import { inQuotedHtmlAttribute, rawHtmlEnd, rawHtmlStart } from "../../journal/markdown-html.mjs";
2
+ import { stripMarkdownContainer } from "../../journal/markdown-container.mjs";
3
+ import { scanMarkdownLines } from "../../journal/markdown-line.mjs";
4
+ const RESERVED_CONTAINER_TAG = /(?:<details(?:\s+[^>]*)?\/?>|<\/details>)|<summary(?:\s+[^>]*)?>\s*Shepherd\s+Journal\b/i;
5
+ function reservedContainerTag(lines) {
6
+ const syntax = scanMarkdownLines(lines);
7
+ for (const [index] of lines.entries()) {
8
+ const match = RESERVED_CONTAINER_TAG.exec(syntax[index].visiblePrefix);
9
+ if (match)
10
+ return match[0];
11
+ }
12
+ return reservedContainerTagInRawHtml(lines);
13
+ }
14
+ function reservedContainerTagInRawHtml(lines) {
15
+ let block = null;
16
+ for (const line of lines) {
17
+ if (block) {
18
+ const content = stripMarkdownContainer(line, block.container);
19
+ if (content === null) {
20
+ block = null;
21
+ }
22
+ else {
23
+ const marker = reservedContainerTagOutsideHtmlAttributes(content);
24
+ if (marker)
25
+ return marker;
26
+ if (rawHtmlEnd(block, content) !== null)
27
+ block = null;
28
+ continue;
29
+ }
30
+ }
31
+ const opening = rawHtmlStart(line);
32
+ if (!opening)
33
+ continue;
34
+ const content = stripMarkdownContainer(line, opening.container) ?? "";
35
+ const marker = reservedContainerTagOutsideHtmlAttributes(content);
36
+ if (marker)
37
+ return marker;
38
+ if (rawHtmlEnd(opening, content) === null)
39
+ block = opening;
40
+ }
41
+ return null;
42
+ }
43
+ function reservedContainerTagOutsideHtmlAttributes(line) {
44
+ const expression = new RegExp(RESERVED_CONTAINER_TAG.source, "gi");
45
+ for (const match of line.matchAll(expression)) {
46
+ if (!inQuotedHtmlAttribute(line, match.index))
47
+ return match[0];
48
+ }
49
+ return null;
50
+ }
2
51
  export function validateJournalItem(input) {
3
52
  const lines = input.split("\n").map((line) => line.trimEnd());
4
53
  const nonBlank = lines.filter((line) => line.trim() !== "");
@@ -11,6 +60,13 @@ export function validateJournalItem(input) {
11
60
  error: `journal item must start with "- <text>"; got: ${JSON.stringify(nonBlank[0].slice(0, 40))}`,
12
61
  };
13
62
  }
63
+ const marker = reservedContainerTag(lines);
64
+ if (marker) {
65
+ return {
66
+ ok: false,
67
+ error: `journal item must not contain journal container marker ${JSON.stringify(marker)}`,
68
+ };
69
+ }
14
70
  for (const line of nonBlank.slice(1)) {
15
71
  if (line.startsWith("#")) {
16
72
  return {
@@ -18,12 +74,6 @@ export function validateJournalItem(input) {
18
74
  error: "journal item lines must not start with # (would break section structure)",
19
75
  };
20
76
  }
21
- if (isReservedJournalMarker(line.trim()) || isJournalLikeSummary(line.trim())) {
22
- return {
23
- ok: false,
24
- error: `journal item must not contain standalone journal container marker ${JSON.stringify(line.trim())}`,
25
- };
26
- }
27
77
  }
28
78
  return { ok: true, item: lines.join("\n").trim() };
29
79
  }
@@ -8,6 +8,5 @@ export type MarkdownScanState = {
8
8
  };
9
9
  export declare function findDetailsClose(lines: string[], startIdx: number): number;
10
10
  export declare function isJournalLikeSummary(line: string): boolean;
11
- export declare function isReservedJournalMarker(line: string): boolean;
12
11
  export declare function skipMarkdownLine(state: MarkdownScanState, line: string): boolean;
13
12
  export {};
@@ -1,4 +1,4 @@
1
- import { SHEPHERD_JOURNAL_DETAILS_CLOSE, SHEPHERD_JOURNAL_DETAILS_SUMMARY, } from "../shepherd-journal.mjs";
1
+ import { SHEPHERD_JOURNAL_DETAILS_CLOSE } from "../shepherd-journal.mjs";
2
2
  export function findDetailsClose(lines, startIdx) {
3
3
  let depth = 1;
4
4
  const state = { fence: null, comment: false };
@@ -23,11 +23,6 @@ export function findDetailsClose(lines, startIdx) {
23
23
  export function isJournalLikeSummary(line) {
24
24
  return /^<summary>\s*Shepherd\s+Journal\b/i.test(line);
25
25
  }
26
- export function isReservedJournalMarker(line) {
27
- return (isDetailsOpening(line) ||
28
- line === SHEPHERD_JOURNAL_DETAILS_SUMMARY ||
29
- line === SHEPHERD_JOURNAL_DETAILS_CLOSE);
30
- }
31
26
  function isDetailsOpening(line) {
32
27
  return /^<details(?:\s+[^>]*)?>$/.test(line);
33
28
  }
@@ -5,6 +5,10 @@ export declare const SHEPHERD_JOURNAL_DETAILS_SUMMARY = "<summary>Shepherd Journ
5
5
  export declare const SHEPHERD_JOURNAL_DETAILS_CLOSE = "</details>";
6
6
  export declare const SHEPHERD_JOURNAL_APPEND_HINT = "If Shepherd Journal details already exist, append entries inside them instead of creating another container.";
7
7
  export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
8
- export declare function buildShepherdJournalInstruction(prNumber: number, itemReferenceGuidance: string): string[];
9
- export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "Link threads and comments from their headings. Cite reviews by ID.";
10
- export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS = "Link threads and comments from their item bullets. Cite reviews by ID.";
8
+ /**
9
+ * Build the Shepherd Journal instruction step. The reference-citation convention (link
10
+ * threads/comments from their headings, cite reviews by ID) is invariant across every
11
+ * invocation, so it lives in the pr-shepherd skill's "Shepherd Journal" playbook instead
12
+ * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
+ */
14
+ export declare function buildShepherdJournalInstruction(prNumber: number): string;
@@ -5,11 +5,12 @@ export const SHEPHERD_JOURNAL_DETAILS_SUMMARY = "<summary>Shepherd Journal</summ
5
5
  export const SHEPHERD_JOURNAL_DETAILS_CLOSE = "</details>";
6
6
  export const SHEPHERD_JOURNAL_APPEND_HINT = "If Shepherd Journal details already exist, append entries inside them instead of creating another container.";
7
7
  export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
8
- export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
9
- return [
10
- `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`.`,
11
- itemReferenceGuidance,
12
- ];
8
+ /**
9
+ * Build the Shepherd Journal instruction step. The reference-citation convention (link
10
+ * threads/comments from their headings, cite reviews by ID) is invariant across every
11
+ * invocation, so it lives in the pr-shepherd skill's "Shepherd Journal" playbook instead
12
+ * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
+ */
14
+ export function buildShepherdJournalInstruction(prNumber) {
15
+ return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
13
16
  }
14
- export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "Link threads and comments from their headings. Cite reviews by ID.";
15
- export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS = "Link threads and comments from their item bullets. Cite reviews by ID.";
@@ -0,0 +1,8 @@
1
+ import { validateJournalItem } from "../commands/journal/journal-item.mts";
2
+ export { validateJournalItem };
3
+ export interface AppendResult {
4
+ body: string;
5
+ mutated: boolean;
6
+ sectionExisted: boolean;
7
+ }
8
+ export declare function appendJournalItem(body: string, item: string): AppendResult;
@@ -0,0 +1,96 @@
1
+ import { validateJournalItem } from "../commands/journal/journal-item.mjs";
2
+ import { fenceStart } from "./markdown-container.mjs";
3
+ import { rawHtmlStart } from "./markdown-html.mjs";
4
+ import { containsJournalEntry, scanShepherdJournal } from "./reconcile.mjs";
5
+ import { isSafeMarkdownInsertionPoint } from "./markdown-line.mjs";
6
+ const OPEN = "<details>";
7
+ const SUMMARY = "<summary>Shepherd Journal</summary>";
8
+ const CLOSE = "</details>";
9
+ export { validateJournalItem };
10
+ export function appendJournalItem(body, item) {
11
+ const validated = validateJournalItem(item);
12
+ if (!validated.ok)
13
+ throw new Error(validated.error);
14
+ if (validated.item
15
+ .split("\n")
16
+ .slice(1)
17
+ .some((line) => /^(?:[-+*]|\d{1,9}[.)])[ \t]+/.test(line)))
18
+ throw new Error("journal item must contain exactly one top-level list item");
19
+ item = validated.item;
20
+ if (item
21
+ .split("\n")
22
+ .some((line, index) => fenceStart(index === 0 ? line.slice(2) : line) ||
23
+ rawHtmlStart(index === 0 ? line.slice(2) : line)))
24
+ throw new Error("journal item must not start with a fenced or raw HTML block");
25
+ const newline = body.includes("\r\n") ? "\r\n" : "\n";
26
+ const lines = body.replaceAll("\r\n", "\n").split("\n");
27
+ const bounds = scanShepherdJournal(lines);
28
+ if (bounds === "error")
29
+ throw new Error("malformed, duplicate, or ambiguous Shepherd Journal container");
30
+ if (!bounds) {
31
+ if (!isSafeMarkdownInsertionPoint(lines))
32
+ throw new Error("cannot append Shepherd Journal inside an unterminated Markdown construct");
33
+ return create(lines, item, newline);
34
+ }
35
+ const content = lines.slice(bounds.contentStart, bounds.contentEnd);
36
+ if (bounds.format === "details" && containsJournalEntry(content, item))
37
+ return { body, mutated: false, sectionExisted: true };
38
+ const next = [...trimEnd(content), ...item.split("\n")];
39
+ if (bounds.format === "details") {
40
+ return {
41
+ body: [
42
+ ...lines.slice(0, bounds.contentStart),
43
+ ...next,
44
+ ...lines.slice(bounds.contentEnd),
45
+ ].join(newline),
46
+ mutated: true,
47
+ sectionExisted: true,
48
+ };
49
+ }
50
+ const legacyContent = trim(content);
51
+ const canonical = [
52
+ OPEN,
53
+ SUMMARY,
54
+ "",
55
+ ...legacyContent,
56
+ ...(containsJournalEntry(content, item) ? [] : item.split("\n")),
57
+ CLOSE,
58
+ ];
59
+ const suffix = lines.slice(bounds.end);
60
+ return {
61
+ body: [
62
+ ...lines.slice(0, bounds.start),
63
+ ...canonical,
64
+ ...(suffix[0]?.trim() ? [""] : []),
65
+ ...suffix,
66
+ ].join(newline),
67
+ mutated: true,
68
+ sectionExisted: true,
69
+ };
70
+ }
71
+ function create(lines, item, newline) {
72
+ const existing = trimEnd(lines);
73
+ return {
74
+ body: [
75
+ ...existing,
76
+ ...(existing.length ? [""] : []),
77
+ OPEN,
78
+ SUMMARY,
79
+ "",
80
+ ...item.split("\n"),
81
+ CLOSE,
82
+ ].join(newline),
83
+ mutated: true,
84
+ sectionExisted: false,
85
+ };
86
+ }
87
+ function trimEnd(lines) {
88
+ let end = lines.length;
89
+ while (end > 0 && lines[end - 1].trim() === "")
90
+ end--;
91
+ return lines.slice(0, end);
92
+ }
93
+ function trim(lines) {
94
+ const start = lines.findIndex((line) => line.trim() !== "");
95
+ return start === -1 ? [] : trimEnd(lines.slice(start));
96
+ }
@@ -0,0 +1,3 @@
1
+ /** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
2
+ export { appendJournalItem, validateJournalItem, type AppendResult } from "./append.mts";
3
+ export { reconcileShepherdJournal, type ShepherdJournalReconcileResult } from "./reconcile.mts";
@@ -0,0 +1,3 @@
1
+ /** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
2
+ export { appendJournalItem, validateJournalItem } from "./append.mjs";
3
+ export { reconcileShepherdJournal } from "./reconcile.mjs";
@@ -0,0 +1,9 @@
1
+ export type BacktickRun = {
2
+ escaped: boolean;
3
+ index: number;
4
+ length: number;
5
+ quoted: boolean;
6
+ };
7
+ export declare function backtickRuns(line: string): BacktickRun[];
8
+ export declare function nextBacktickRun(runs: BacktickRun[], offset: number, length?: number): BacktickRun | undefined;
9
+ export declare function nextCodeOpener(runs: BacktickRun[], offset: number): BacktickRun | undefined;
@@ -0,0 +1,34 @@
1
+ export function backtickRuns(line) {
2
+ const runs = [];
3
+ let inTag = false;
4
+ let quote = null;
5
+ for (let i = 0; i < line.length; i++) {
6
+ if (!inTag && line[i] === "<" && /^\/?[A-Za-z]/.test(line.slice(i + 1)))
7
+ inTag = true;
8
+ else if (inTag && quote) {
9
+ if (line[i] === quote)
10
+ quote = null;
11
+ }
12
+ else if (inTag && (line[i] === '"' || line[i] === "'"))
13
+ quote = line[i] === '"' ? '"' : "'";
14
+ else if (inTag && line[i] === ">")
15
+ inTag = false;
16
+ if (line[i] !== "`")
17
+ continue;
18
+ let slashes = 0;
19
+ while (line[i - slashes - 1] === "\\")
20
+ slashes++;
21
+ let end = i;
22
+ while (line[end] === "`")
23
+ end++;
24
+ runs.push({ escaped: slashes % 2 === 1, index: i, length: end - i, quoted: quote !== null });
25
+ i = end - 1;
26
+ }
27
+ return runs;
28
+ }
29
+ export function nextBacktickRun(runs, offset, length) {
30
+ return runs.find((run) => run.index >= offset && (length === undefined || run.length === length));
31
+ }
32
+ export function nextCodeOpener(runs, offset) {
33
+ return runs.find((run) => run.index >= offset && !run.quoted);
34
+ }
@@ -0,0 +1,22 @@
1
+ export type MarkdownContainer = Array<{
2
+ kind: "list";
3
+ width: number;
4
+ } | {
5
+ kind: "quote";
6
+ }>;
7
+ export declare function markdownContainer(line: string): {
8
+ content: string;
9
+ indent: number;
10
+ tokens: MarkdownContainer;
11
+ };
12
+ export declare function resolveMarkdownContainer(line: string, active: MarkdownContainer): ReturnType<typeof markdownContainer>;
13
+ export declare function fenceStart(line: string, parsed?: {
14
+ content: string;
15
+ indent: number;
16
+ tokens: MarkdownContainer;
17
+ }): {
18
+ container: MarkdownContainer;
19
+ length: number;
20
+ marker: string;
21
+ } | null;
22
+ export declare function stripMarkdownContainer(line: string, tokens: MarkdownContainer): string | null;