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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -3
- package/bin/cli/iterate-instructions.mjs +2 -2
- package/bin/commands/commit-suggestion-instruction.d.mts +6 -3
- package/bin/commands/commit-suggestion-instruction.mjs +7 -15
- package/bin/commands/iterate/check-instructions.d.mts +31 -1
- package/bin/commands/iterate/check-instructions.mjs +36 -18
- package/bin/commands/iterate/render.mjs +7 -12
- package/bin/commands/journal/journal-item.mjs +57 -7
- package/bin/commands/journal/journal-markdown.d.mts +0 -1
- package/bin/commands/journal/journal-markdown.mjs +1 -6
- package/bin/commands/shepherd-journal.d.mts +7 -3
- package/bin/commands/shepherd-journal.mjs +8 -7
- package/bin/journal/append.d.mts +8 -0
- package/bin/journal/append.mjs +96 -0
- package/bin/journal/index.d.mts +3 -0
- package/bin/journal/index.mjs +3 -0
- package/bin/journal/markdown-backticks.d.mts +9 -0
- package/bin/journal/markdown-backticks.mjs +34 -0
- package/bin/journal/markdown-container.d.mts +22 -0
- package/bin/journal/markdown-container.mjs +110 -0
- package/bin/journal/markdown-html.d.mts +20 -0
- package/bin/journal/markdown-html.mjs +119 -0
- package/bin/journal/markdown-line.d.mts +8 -0
- package/bin/journal/markdown-line.mjs +166 -0
- package/bin/journal/markdown-setext.d.mts +7 -0
- package/bin/journal/markdown-setext.mjs +15 -0
- package/bin/journal/markdown-structure.d.mts +2 -0
- package/bin/journal/markdown-structure.mjs +35 -0
- package/bin/journal/reconcile.d.mts +17 -0
- package/bin/journal/reconcile.mjs +184 -0
- package/package.json +5 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +44 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
|
|
2
|
+
import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
|
|
3
|
+
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
|
+
const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
8
|
+
const CLOSE = "</details>";
|
|
9
|
+
const OPEN = "<details>";
|
|
10
|
+
const SUMMARY = "<summary>Shepherd Journal</summary>";
|
|
11
|
+
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
|
+
export function scanShepherdJournal(lines) {
|
|
37
|
+
const syntax = scanMarkdownLines(lines);
|
|
38
|
+
const found = [];
|
|
39
|
+
let detailsDepth = 0;
|
|
40
|
+
let legacy = null;
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
if (!syntax[i].ignored &&
|
|
43
|
+
!syntax[i].nested &&
|
|
44
|
+
legacy?.end === lines.length &&
|
|
45
|
+
/^ {0,3}#{1,2}(?:[ \t]+|$)/.test(lines[i])) {
|
|
46
|
+
if (LEGACY.test(syntax[i].visiblePrefix))
|
|
47
|
+
return "error";
|
|
48
|
+
legacy.contentEnd = legacy.end = i;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (syntax[i].ignored || syntax[i].nested)
|
|
52
|
+
continue;
|
|
53
|
+
const visible = syntax[i].visiblePrefix;
|
|
54
|
+
detailsDepth += detailsTags(visible, false);
|
|
55
|
+
const closes = detailsTags(visible, true);
|
|
56
|
+
if (closes) {
|
|
57
|
+
if (detailsDepth >= closes) {
|
|
58
|
+
detailsDepth -= closes;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
return "error";
|
|
62
|
+
}
|
|
63
|
+
if (JOURNAL_SUMMARY.test(visible.trimStart())) {
|
|
64
|
+
if (lines[i] !== SUMMARY || lines[i - 1] !== OPEN || lines[i + 1] !== "")
|
|
65
|
+
return "error";
|
|
66
|
+
const end = close(lines, syntax, i + 2);
|
|
67
|
+
if (end === null)
|
|
68
|
+
return "error";
|
|
69
|
+
detailsDepth--;
|
|
70
|
+
found.push({
|
|
71
|
+
contentEnd: end,
|
|
72
|
+
contentStart: i + 2,
|
|
73
|
+
end: end + 1,
|
|
74
|
+
format: "details",
|
|
75
|
+
start: i - 1,
|
|
76
|
+
});
|
|
77
|
+
i = end;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (LEGACY.test(visible)) {
|
|
81
|
+
if (legacy)
|
|
82
|
+
return "error";
|
|
83
|
+
legacy = {
|
|
84
|
+
contentEnd: lines.length,
|
|
85
|
+
contentStart: i + 1,
|
|
86
|
+
end: lines.length,
|
|
87
|
+
format: "legacy",
|
|
88
|
+
start: i,
|
|
89
|
+
};
|
|
90
|
+
found.push(legacy);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const start = SETEXT.test(visible) ? setextParagraphStart(lines, syntax, i) : null;
|
|
94
|
+
if (legacy && legacy.end === lines.length && start !== null && start >= legacy.contentStart) {
|
|
95
|
+
legacy.contentEnd = legacy.end = start;
|
|
96
|
+
}
|
|
97
|
+
if (legacy && legacy.end === lines.length && lines[i].trim() === CLOSE)
|
|
98
|
+
return "error";
|
|
99
|
+
}
|
|
100
|
+
return detailsDepth !== 0 || found.length > 1 ? "error" : (found[0] ?? null);
|
|
101
|
+
}
|
|
102
|
+
function trim(lines) {
|
|
103
|
+
let a = 0;
|
|
104
|
+
let b = lines.length;
|
|
105
|
+
while (a < b && lines[a].trim() === "")
|
|
106
|
+
a++;
|
|
107
|
+
while (b > a && lines[b - 1].trim() === "")
|
|
108
|
+
b--;
|
|
109
|
+
return lines.slice(a, b);
|
|
110
|
+
}
|
|
111
|
+
function entries(lines) {
|
|
112
|
+
const result = [];
|
|
113
|
+
const syntax = scanMarkdownLines(lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)));
|
|
114
|
+
let current = null;
|
|
115
|
+
for (const [index, line] of lines.entries()) {
|
|
116
|
+
if (syntax[index].visiblePrefix.startsWith("- ")) {
|
|
117
|
+
if (current)
|
|
118
|
+
result.push(trim(current));
|
|
119
|
+
current = [line];
|
|
120
|
+
}
|
|
121
|
+
else if (current)
|
|
122
|
+
current.push(line);
|
|
123
|
+
}
|
|
124
|
+
if (current)
|
|
125
|
+
result.push(trim(current));
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
function hasUnrecognizedLeadingContent(lines) {
|
|
129
|
+
const syntax = scanMarkdownLines(lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)));
|
|
130
|
+
const firstEntry = syntax.findIndex((line) => line.visiblePrefix.startsWith("- "));
|
|
131
|
+
if (firstEntry === -1)
|
|
132
|
+
return lines.some((line) => line.trim() !== "");
|
|
133
|
+
return lines.slice(0, firstEntry).some((line) => line.trim() !== "");
|
|
134
|
+
}
|
|
135
|
+
function contains(a, b) {
|
|
136
|
+
return a.length === b.length && a.every((s, i) => stripCr(s) === stripCr(b[i]));
|
|
137
|
+
}
|
|
138
|
+
export function containsJournalEntry(lines, item) {
|
|
139
|
+
const target = item.split("\n");
|
|
140
|
+
return entries(lines).some((entry) => contains(target, entry));
|
|
141
|
+
}
|
|
142
|
+
function fail(reason) {
|
|
143
|
+
return {
|
|
144
|
+
error: `${reason}. Supply every live Shepherd Journal entry verbatim, or omit the journal from the supplied body to preserve it automatically.`,
|
|
145
|
+
ok: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function reconcileShepherdJournal(suppliedBody, liveBody) {
|
|
149
|
+
const liveLines = liveBody.split("\n");
|
|
150
|
+
const live = scanShepherdJournal(liveBody.replaceAll("\r\n", "\n").split("\n"));
|
|
151
|
+
const suppliedLines = suppliedBody.split("\n");
|
|
152
|
+
const supplied = scanShepherdJournal(suppliedBody.replaceAll("\r\n", "\n").split("\n"));
|
|
153
|
+
if (supplied === "error" || live === "error")
|
|
154
|
+
return fail("malformed, duplicate, or ambiguous Shepherd Journal container");
|
|
155
|
+
if (!live)
|
|
156
|
+
return { body: suppliedBody, ok: true };
|
|
157
|
+
if (live.format === "details" && supplied?.format === "legacy")
|
|
158
|
+
return fail("canonical Shepherd Journal details container cannot be downgraded to legacy H2");
|
|
159
|
+
const liveContent = trim(liveLines.slice(live.contentStart, live.contentEnd));
|
|
160
|
+
if (!liveContent.length)
|
|
161
|
+
return { body: suppliedBody, ok: true };
|
|
162
|
+
if (!supplied) {
|
|
163
|
+
if (!isSafeMarkdownInsertionPoint(suppliedBody.replaceAll("\r\n", "\n").split("\n")))
|
|
164
|
+
return fail("supplied body ends inside a Markdown construct that would hide the preserved journal");
|
|
165
|
+
const journal = liveLines.slice(live.start, live.end).join("\n");
|
|
166
|
+
const preservedJournal = journal.endsWith("\r") ? `${journal}\n` : journal;
|
|
167
|
+
return {
|
|
168
|
+
body: `${suppliedBody}${suppliedBody === "" ? "" : suppliedBody.endsWith("\n") ? "\n" : "\n\n"}${preservedJournal}`,
|
|
169
|
+
ok: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const liveEntries = entries(liveLines.slice(live.contentStart, live.contentEnd));
|
|
173
|
+
const liveJournalLines = liveLines.slice(live.contentStart, live.contentEnd);
|
|
174
|
+
if (!liveEntries.length || hasUnrecognizedLeadingContent(liveJournalLines))
|
|
175
|
+
return fail("live Shepherd Journal content uses an unrecognized entry format");
|
|
176
|
+
const target = entries(suppliedLines.slice(supplied.contentStart, supplied.contentEnd));
|
|
177
|
+
for (const entry of liveEntries) {
|
|
178
|
+
const match = target.findIndex((candidate) => contains(entry, candidate));
|
|
179
|
+
if (match === -1)
|
|
180
|
+
return fail(`supplied Shepherd Journal would drop live entry ${JSON.stringify(entry[0])}`);
|
|
181
|
+
target.splice(match, 1);
|
|
182
|
+
}
|
|
183
|
+
return { body: suppliedBody, ok: true };
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
"./classify": {
|
|
54
54
|
"types": "./src/classify/types.mts",
|
|
55
55
|
"default": "./bin/classify/types.mjs"
|
|
56
|
+
},
|
|
57
|
+
"./journal": {
|
|
58
|
+
"types": "./bin/journal/index.d.mts",
|
|
59
|
+
"default": "./bin/journal/index.mjs"
|
|
56
60
|
}
|
|
57
61
|
},
|
|
58
62
|
"scripts": {
|
|
@@ -19,3 +19,47 @@ Thin dispatcher for iterating a PR. Poll with the CLI; use MCP `iterate` only wh
|
|
|
19
19
|
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patch`; do not run a shell `pr-shepherd apply` command.
|
|
20
20
|
|
|
21
21
|
4. After completing the returned instructions, repeat step 2 unless the action is `[CANCEL]` or `[ESCALATE]`, the instructions require a human handoff, or the human directs you to stop.
|
|
22
|
+
|
|
23
|
+
## Playbooks
|
|
24
|
+
|
|
25
|
+
`## Instructions` steps reference these playbooks by name instead of repeating their
|
|
26
|
+
mechanics every tick. Apply the referenced playbook in full whenever a step points here.
|
|
27
|
+
|
|
28
|
+
### Suggestion patches
|
|
29
|
+
|
|
30
|
+
- The CLI only builds the patch. Apply it, stage the listed file, and follow the returned commit instructions.
|
|
31
|
+
- If the command refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), skip patch application and edit the file manually. Do not retry the command.
|
|
32
|
+
- For any other refusal, follow the CLI error's stated recovery action; do not manually edit the suggestion.
|
|
33
|
+
- If the patch does not apply for any other reason, edit the file manually instead. Do not retry the command.
|
|
34
|
+
- 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.
|
|
35
|
+
- Keep human-authored thread IDs in `apply review:` so Shepherd replies instead of resolving them.
|
|
36
|
+
|
|
37
|
+
### CI failure triage
|
|
38
|
+
|
|
39
|
+
Match each failure's `[conclusion: …]` tag under `## Failing checks` to a rule:
|
|
40
|
+
|
|
41
|
+
More specific rows win over the general "GitHub Actions failure" row — check conclusion first.
|
|
42
|
+
|
|
43
|
+
| Tag / kind | Do |
|
|
44
|
+
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| GitHub Actions failure (has a run ID, not `CANCELLED`/`STARTUP_FAILURE`) | Read the included log excerpt if one is rendered. If missing or insufficient, run a bounded command such as `gh run view <runId> --log-failed \| tail -n 200` — the unbounded form can dump excessive log output into context. Open the run URL only if that still lacks detail. |
|
|
46
|
+
| Transient infrastructure failure | Rerun with `gh run rerun <runId> --failed`. |
|
|
47
|
+
| Real test or build failure | Apply a code fix — do not rerun. |
|
|
48
|
+
| `[conclusion: CANCELLED]` | No log excerpt is rendered for this conclusion. Run `gh run rerun <runId>` unless this tick will push new commits. Not resolved by a rerun classification — `## Cancelled runs` is a different section. |
|
|
49
|
+
| `[conclusion: STARTUP_FAILURE]` | No log excerpt is rendered for this conclusion. Inspect with `gh run view <runId>`, rerun with `gh run rerun <runId>` if warranted. |
|
|
50
|
+
| `external` (no run ID, has a URL) | Open its URL and inspect it. |
|
|
51
|
+
|
|
52
|
+
### Review-mutation mechanics
|
|
53
|
+
|
|
54
|
+
Applies to every `apply review:` / `resolve-only:` command the CLI prints. Covers only what stays safe if you run the printed command **unmodified** — `$HEAD_SHA`/`$DISMISS_MESSAGE` substitution and the self-reply exclusion rule are separate CLI-printed steps, not covered here, because the printed command is unsafe by default without them.
|
|
55
|
+
|
|
56
|
+
- Never add first-look-only or check-annotation IDs to `--reply-thread-ids`, `--resolve-thread-ids`, `--dismiss-review-ids`, or `--minimize-comment-ids` — those flags are pre-populated by the CLI.
|
|
57
|
+
- Keep every existing `--dismiss-review-ids` ID the CLI already included. Each is a bot or non-human review that must be dismissed; omitting one leaves the PR in `CHANGES_REQUESTED`.
|
|
58
|
+
|
|
59
|
+
### Review-mutation routing
|
|
60
|
+
|
|
61
|
+
For threads under `## Review threads to resolve`: human-authored IDs use `--reply-thread-ids` (Shepherd replies instead of resolving them); bot and non-human IDs use `--resolve-thread-ids`. Use the commands as generated — do not move an ID between flags.
|
|
62
|
+
|
|
63
|
+
### Shepherd Journal
|
|
64
|
+
|
|
65
|
+
Link threads and comments in a journal entry from their headings in the CLI output. Cite reviews by ID.
|