pr-shepherd 0.11.0 → 0.12.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 +2 -2
- package/README.md +51 -9
- package/bin/agent-runtime.mjs +7 -0
- package/bin/cli/args.mjs +10 -2
- package/bin/cli/default-iterate.mjs +54 -0
- package/bin/cli/duration-flag.mjs +22 -0
- package/bin/cli/fix-formatter.mjs +14 -4
- package/bin/cli/formatters.mjs +10 -3
- package/bin/cli/handlers.mjs +24 -13
- package/bin/cli/iterate-formatter.mjs +14 -10
- package/bin/cli/iterate-instructions.mjs +75 -0
- package/bin/cli/iterate-lean.mjs +56 -6
- package/bin/cli/list-formatters.mjs +11 -0
- package/bin/cli-parser.iterate-fixtures.mjs +1 -0
- package/bin/cli-parser.mjs +15 -1
- package/bin/commands/check.mjs +5 -3
- package/bin/commands/iterate/classify.mjs +5 -4
- package/bin/commands/iterate/escalate.mjs +2 -1
- package/bin/commands/iterate/fix-code.mjs +7 -7
- package/bin/commands/iterate/index.mjs +1 -0
- package/bin/commands/iterate/render.mjs +12 -6
- package/bin/commands/iterate/stall.mjs +3 -1
- package/bin/commands/iterate.mjs +1 -1
- package/bin/commands/monitor.mjs +68 -17
- package/bin/commands/ready-delay.mjs +2 -1
- package/bin/commands/resolve-instructions.mjs +7 -3
- package/bin/commands/resolve.mjs +4 -4
- package/bin/index.mjs +1 -0
- package/bin/reporters/check-instructions.mjs +9 -3
- package/bin/reporters/json.mjs +2 -2
- package/bin/reporters/text.mjs +24 -16
- package/package.json +3 -2
|
@@ -11,6 +11,12 @@ export function renderFirstLookStatusTag(t) {
|
|
|
11
11
|
? `[status: outdated, auto-resolved${editedSuffix}]`
|
|
12
12
|
: `[status: ${t.firstLookStatus}${editedSuffix}]`;
|
|
13
13
|
}
|
|
14
|
+
export function renderThreadResolutionStatusTag(t) {
|
|
15
|
+
const status = [t.isOutdated ? "outdated" : null, t.isMinimized ? "minimized" : null]
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join(", ");
|
|
18
|
+
return status ? `[status: ${status}]` : "[status: unresolved]";
|
|
19
|
+
}
|
|
14
20
|
export function renderThreadBullet(t, opts = {}) {
|
|
15
21
|
const link = t.url ? ` [↗](${t.url})` : "";
|
|
16
22
|
const loc = t.path
|
|
@@ -33,3 +39,8 @@ export function renderReviewBullet(r, opts = {}) {
|
|
|
33
39
|
const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
|
|
34
40
|
return `- \`reviewId=${r.id}\` (@${r.author})${bodySuffix}`;
|
|
35
41
|
}
|
|
42
|
+
export function renderReviewListSection(heading, items) {
|
|
43
|
+
if (items.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
return `## ${heading}\n\n${items.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n")}`;
|
|
46
|
+
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* pr-shepherd --version
|
|
6
|
+
* pr-shepherd [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm]
|
|
7
|
+
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
8
|
+
* [--no-auto-cancel-actionable]
|
|
6
9
|
* pr-shepherd check [PR] [--format text|json]
|
|
7
10
|
* pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
|
|
8
11
|
* [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
|
|
@@ -22,10 +25,12 @@ import { runLogFile } from "./commands/log-file.mjs";
|
|
|
22
25
|
import { formatJson } from "./reporters/json.mjs";
|
|
23
26
|
import { formatText } from "./reporters/text.mjs";
|
|
24
27
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
28
|
+
import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
|
|
25
29
|
import { statusToExitCode } from "./cli/exit-codes.mjs";
|
|
26
30
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
27
31
|
import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
|
|
28
32
|
import { setupLog } from "./log/setup.mjs";
|
|
33
|
+
import { detectAgentRuntime } from "./agent-runtime.mjs";
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
30
35
|
// Entry
|
|
31
36
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +48,12 @@ export async function main(argv) {
|
|
|
43
48
|
}
|
|
44
49
|
// Initialize the per-worktree log and install a stdout tee.
|
|
45
50
|
await setupLog(argv);
|
|
51
|
+
if (isDefaultIterateInvocation(subcommand)) {
|
|
52
|
+
if (!validateDefaultIterateArgs(args))
|
|
53
|
+
return;
|
|
54
|
+
await handleIterate(args);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
46
57
|
switch (subcommand) {
|
|
47
58
|
case "check":
|
|
48
59
|
await handleCheck(args.slice(1));
|
|
@@ -80,8 +91,11 @@ function readVersion() {
|
|
|
80
91
|
// ---------------------------------------------------------------------------
|
|
81
92
|
async function handleCheck(args) {
|
|
82
93
|
const { prNumber, global: globalOpts } = parseCommonArgs(args);
|
|
94
|
+
const runtime = detectAgentRuntime();
|
|
83
95
|
const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
|
|
84
|
-
const output = globalOpts.format === "json"
|
|
96
|
+
const output = globalOpts.format === "json"
|
|
97
|
+
? formatJson(report, { runtime })
|
|
98
|
+
: formatText(report, { runtime });
|
|
85
99
|
process.stdout.write(`${output}\n`);
|
|
86
100
|
process.exitCode = statusToExitCode(report.status);
|
|
87
101
|
}
|
package/bin/commands/check.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export async function runCheck(opts) {
|
|
|
37
37
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
38
38
|
const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
|
|
39
39
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
40
|
-
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved
|
|
40
|
+
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
|
|
41
41
|
const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
|
|
42
42
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
43
43
|
let autoResolved = [];
|
|
@@ -47,7 +47,7 @@ export async function runCheck(opts) {
|
|
|
47
47
|
autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
|
|
48
48
|
autoResolveErrors = errors;
|
|
49
49
|
}
|
|
50
|
-
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
50
|
+
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
|
|
51
51
|
const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
|
|
52
52
|
const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
53
53
|
const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
@@ -106,13 +106,14 @@ export async function runCheck(opts) {
|
|
|
106
106
|
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
107
107
|
]);
|
|
108
108
|
const actionableThreads = activeThreads;
|
|
109
|
+
const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
|
|
109
110
|
const actionableComments = visibleComments;
|
|
110
111
|
const mergeStatus = deriveMergeStatus(batchData);
|
|
111
112
|
const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
|
|
112
113
|
!verdict.anyFailing &&
|
|
113
114
|
!verdict.anyInProgress &&
|
|
114
115
|
verdict.filteredNames.length > 0;
|
|
115
|
-
const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
|
|
116
|
+
const status = computeStatus(verdict, actionableThreads.length + resolutionOnlyThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
|
|
116
117
|
return {
|
|
117
118
|
pr: prNumber,
|
|
118
119
|
nodeId: batchData.nodeId,
|
|
@@ -131,6 +132,7 @@ export async function runCheck(opts) {
|
|
|
131
132
|
},
|
|
132
133
|
threads: {
|
|
133
134
|
actionable: actionableThreads,
|
|
135
|
+
resolutionOnly: resolutionOnlyThreads,
|
|
134
136
|
autoResolved,
|
|
135
137
|
autoResolveErrors,
|
|
136
138
|
firstLook: firstLookThreads,
|
|
@@ -20,10 +20,11 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals)
|
|
|
20
20
|
surfacedApprovals: approvals,
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
|
|
23
|
+
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber) {
|
|
24
24
|
const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const threadIds = [...threads.map((t) => t.id), ...resolutionOnlyThreads.map((t) => t.id)];
|
|
26
|
+
if (threadIds.length > 0) {
|
|
27
|
+
argv.push("--resolve-thread-ids", threadIds.join(","));
|
|
27
28
|
}
|
|
28
29
|
if (allCommentIds.length > 0) {
|
|
29
30
|
argv.push("--minimize-comment-ids", allCommentIds.join(","));
|
|
@@ -41,6 +42,6 @@ export function buildResolveCommand(threads, allCommentIds, reviews, checks, prN
|
|
|
41
42
|
// --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
|
|
42
43
|
// (rather than derived from argv.length) so callers don't couple to the
|
|
43
44
|
// base-argv shape.
|
|
44
|
-
const hasMutations =
|
|
45
|
+
const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
|
|
45
46
|
return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
|
|
46
47
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
export function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
|
|
2
|
+
export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
|
|
3
3
|
const triggers = [];
|
|
4
4
|
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
5
5
|
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
@@ -11,6 +11,7 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
|
|
|
11
11
|
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
12
12
|
if (changesRequestedReviews.length > 0 &&
|
|
13
13
|
actionableThreads.length === 0 &&
|
|
14
|
+
resolutionOnlyThreads.length === 0 &&
|
|
14
15
|
actionableComments.length === 0 &&
|
|
15
16
|
failingChecks.length === 0 &&
|
|
16
17
|
!hasConflicts) {
|
|
@@ -10,18 +10,17 @@ export async function handleFixCode(ctx) {
|
|
|
10
10
|
const failingChecks = report.checks.failing;
|
|
11
11
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
12
12
|
const isNewSha = stored?.headSha !== headSha;
|
|
13
|
-
// Accumulate across shas — only increment when a push is detected (sha changed)
|
|
14
13
|
const currentAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
15
14
|
if (isNewSha) {
|
|
16
15
|
for (const t of report.threads.actionable) {
|
|
17
16
|
currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
|
|
18
17
|
}
|
|
19
18
|
}
|
|
20
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, failingChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
19
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.threads.resolutionOnly, report.comments.actionable, report.changesRequestedReviews, failingChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
21
20
|
if (escalateTriggers.triggers.length > 0) {
|
|
22
21
|
const escalateBase = {
|
|
23
22
|
triggers: escalateTriggers.triggers,
|
|
24
|
-
unresolvedThreads: report.threads.actionable.map(toAgentThread),
|
|
23
|
+
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
25
24
|
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
26
25
|
changesRequestedReviews: report.changesRequestedReviews,
|
|
27
26
|
attemptHistory: escalateTriggers.thrashHistory,
|
|
@@ -36,7 +35,6 @@ export async function handleFixCode(ctx) {
|
|
|
36
35
|
},
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
|
-
// Save updated state (only incremented on sha change)
|
|
40
38
|
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts: currentAttempts });
|
|
41
39
|
let cancelled = [];
|
|
42
40
|
if (!opts.noAutoCancelActionable) {
|
|
@@ -49,6 +47,7 @@ export async function handleFixCode(ctx) {
|
|
|
49
47
|
const cancelledSet = new Set(cancelled);
|
|
50
48
|
const baseLookup = validateBaseBranch(report.baseBranch);
|
|
51
49
|
const threads = report.threads.actionable.map(toAgentThread);
|
|
50
|
+
const resolutionOnlyThreads = report.threads.resolutionOnly;
|
|
52
51
|
const actionableComments = report.comments.actionable.map(toAgentComment);
|
|
53
52
|
const checks = toAgentChecks(failingChecks);
|
|
54
53
|
const { changesRequestedReviews } = report;
|
|
@@ -60,11 +59,11 @@ export async function handleFixCode(ctx) {
|
|
|
60
59
|
hasConflicts;
|
|
61
60
|
const inProgressRunIds = needsPush ? buildInProgressRunIds(report, cancelledSet) : [];
|
|
62
61
|
const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
|
|
63
|
-
const resolveCommand = buildResolveCommand(threads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
62
|
+
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
64
63
|
if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
|
|
65
64
|
const fallbackEscalateBase = {
|
|
66
65
|
triggers: ["base-branch-unknown"],
|
|
67
|
-
unresolvedThreads: threads,
|
|
66
|
+
unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
|
|
68
67
|
ambiguousComments: actionableComments,
|
|
69
68
|
changesRequestedReviews,
|
|
70
69
|
suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
|
|
@@ -80,7 +79,7 @@ export async function handleFixCode(ctx) {
|
|
|
80
79
|
}
|
|
81
80
|
const firstLookThreads = report.threads.firstLook;
|
|
82
81
|
const firstLookComments = report.comments.firstLook;
|
|
83
|
-
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds);
|
|
82
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads);
|
|
84
83
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
|
|
85
84
|
...base,
|
|
86
85
|
baseBranch: baseLookup.branch,
|
|
@@ -88,6 +87,7 @@ export async function handleFixCode(ctx) {
|
|
|
88
87
|
fix: {
|
|
89
88
|
mode: "rebase-and-push",
|
|
90
89
|
threads,
|
|
90
|
+
resolutionOnlyThreads,
|
|
91
91
|
actionableComments,
|
|
92
92
|
reviewSummaryIds,
|
|
93
93
|
firstLookSummaries,
|
|
@@ -96,6 +96,7 @@ export async function runIterate(opts) {
|
|
|
96
96
|
edited: report.editedSummaries,
|
|
97
97
|
}, report.approvedReviews, config.iterate.minimizeApprovals);
|
|
98
98
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
99
|
+
report.threads.resolutionOnly.length > 0 ||
|
|
99
100
|
report.comments.actionable.length > 0 ||
|
|
100
101
|
report.changesRequestedReviews.length > 0 ||
|
|
101
102
|
report.checks.failing.length > 0 ||
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
|
|
2
|
+
export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
|
|
3
|
+
export const FIX_INSTRUCTION_END_ITERATION = "End this iteration.";
|
|
1
4
|
/**
|
|
2
5
|
* Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE` and whitespace-bearing
|
|
3
6
|
* argv entries in double quotes for placeholder substitution. Throws if argv contains `"`, `$`,
|
|
@@ -18,7 +21,7 @@ export function renderResolveCommand(rc) {
|
|
|
18
21
|
}
|
|
19
22
|
return parts.join(" ");
|
|
20
23
|
}
|
|
21
|
-
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = []) {
|
|
24
|
+
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = []) {
|
|
22
25
|
const instructions = [];
|
|
23
26
|
if (inProgressRunIds.length > 0) {
|
|
24
27
|
instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\`. Do this before applying any code fixes — the push at the end of this iteration will supersede those runs anyway, so letting them continue burns CI minutes for results no one will read. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
|
|
@@ -33,6 +36,9 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
33
36
|
: "";
|
|
34
37
|
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
|
|
35
38
|
}
|
|
39
|
+
if (resolutionOnlyThreads.length > 0) {
|
|
40
|
+
instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
|
|
41
|
+
}
|
|
36
42
|
const cancelledRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "CANCELLED");
|
|
37
43
|
const failedRunIdChecks = checks.filter((c) => c.runId && c.conclusion !== "CANCELLED");
|
|
38
44
|
const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
|
|
@@ -73,7 +79,7 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
73
79
|
}
|
|
74
80
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
75
81
|
if (firstLookTotal > 0) {
|
|
76
|
-
instructions.push(`Items in \`## First-look items\` are
|
|
82
|
+
instructions.push(`Items in \`## First-look items\` are shown so you can acknowledge their current status before acting. If a first-look thread also appears under \`## Review threads to resolve\`, its ID is already included in the \`resolve:\` command; otherwise do not pass first-look-only IDs to mutation flags.`);
|
|
77
83
|
}
|
|
78
84
|
if (firstLookSummaries.length > 0) {
|
|
79
85
|
instructions.push(`Review the bodies shown under \`## Review summaries (first look — to be minimized)\` — you are seeing these for the first time. Their IDs are already included in the \`resolve:\` command's \`--minimize-comment-ids\`; if any warrants a \`## Shepherd Journal\` entry, record it before running resolve.`);
|
|
@@ -82,7 +88,7 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
82
88
|
firstLookThreads.filter((t) => t.edited).length +
|
|
83
89
|
firstLookComments.filter((c) => c.edited).length;
|
|
84
90
|
if (editedTotal > 0) {
|
|
85
|
-
instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body
|
|
91
|
+
instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
|
|
86
92
|
}
|
|
87
93
|
if (resolveCommand.hasMutations) {
|
|
88
94
|
const substituteParts = [];
|
|
@@ -102,13 +108,13 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
102
108
|
instructions.push(`For any large decisions or rejections you made this iteration, add or update a \`## Shepherd Journal\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision. For threads and comments, use the markdown link shown in its heading above; for reviews, reference the review ID.`);
|
|
103
109
|
}
|
|
104
110
|
if (needsPush) {
|
|
105
|
-
instructions.push(
|
|
111
|
+
instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
|
|
106
112
|
}
|
|
107
113
|
else if (resolveCommand.hasMutations) {
|
|
108
|
-
instructions.push(
|
|
114
|
+
instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
|
|
109
115
|
}
|
|
110
116
|
else {
|
|
111
|
-
instructions.push(
|
|
117
|
+
instructions.push(FIX_INSTRUCTION_END_ITERATION);
|
|
112
118
|
}
|
|
113
119
|
return instructions;
|
|
114
120
|
}
|
|
@@ -7,6 +7,7 @@ export function computeStallFingerprint(action, headSha, base, report, reviewSum
|
|
|
7
7
|
...report.checks.inProgress.map((p) => `inProgress:${p.name}`),
|
|
8
8
|
].sort();
|
|
9
9
|
const threads = report.threads.actionable.map((t) => t.id).sort();
|
|
10
|
+
const resolutionOnlyThreads = report.threads.resolutionOnly.map((t) => t.id).sort();
|
|
10
11
|
const comments = report.comments.actionable.map((c) => c.id).sort();
|
|
11
12
|
const reviews = report.changesRequestedReviews.map((r) => r.id).sort();
|
|
12
13
|
const summaries = [...reviewSummaryIds].sort();
|
|
@@ -19,6 +20,7 @@ export function computeStallFingerprint(action, headSha, base, report, reviewSum
|
|
|
19
20
|
isDraft: base.isDraft,
|
|
20
21
|
checks,
|
|
21
22
|
threads,
|
|
23
|
+
resolutionOnlyThreads,
|
|
22
24
|
comments,
|
|
23
25
|
reviews,
|
|
24
26
|
summaries,
|
|
@@ -42,7 +44,7 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
|
|
|
42
44
|
const stalledMinutes = Math.floor(ageSeconds / 60);
|
|
43
45
|
const escalateBase = {
|
|
44
46
|
triggers: ["stall-timeout"],
|
|
45
|
-
unresolvedThreads: report.threads.actionable.map(toAgentThread),
|
|
47
|
+
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
46
48
|
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
47
49
|
changesRequestedReviews: report.changesRequestedReviews,
|
|
48
50
|
suggestion: buildEscalateSuggestion(["stall-timeout"], String(stalledMinutes)),
|
package/bin/commands/iterate.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { runIterate } from "./iterate/index.mjs";
|
|
2
|
-
export { renderResolveCommand } from "./iterate/render.mjs";
|
|
2
|
+
export { FIX_INSTRUCTION_END_ITERATION, FIX_INSTRUCTION_STOP_AFTER_PUSH, FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK, renderResolveCommand, } from "./iterate/render.mjs";
|
package/bin/commands/monitor.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCurrentPrNumber } from "../github/client.mjs";
|
|
2
2
|
import { loadConfig } from "../config/load.mjs";
|
|
3
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
3
4
|
export async function runMonitor(opts) {
|
|
4
5
|
const config = loadConfig();
|
|
5
6
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -16,30 +17,48 @@ export async function runMonitor(opts) {
|
|
|
16
17
|
// step 1 of formatMonitorResult's ## Instructions and the in-prompt Self-dedup
|
|
17
18
|
// block depend on this exact string — don't change the format.
|
|
18
19
|
const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
|
|
19
|
-
const loopPrompt = buildLoopPrompt(prNumber, loopTag, opts.readyDelaySuffix);
|
|
20
20
|
const loopArgs = interval;
|
|
21
|
-
|
|
21
|
+
const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix);
|
|
22
|
+
const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, loopArgs, opts.runtime ?? "claude");
|
|
23
|
+
return {
|
|
24
|
+
prNumber,
|
|
25
|
+
loopTag,
|
|
26
|
+
loopArgs,
|
|
27
|
+
loopPrompt,
|
|
28
|
+
reusableCommand,
|
|
29
|
+
};
|
|
22
30
|
}
|
|
23
31
|
// ---------------------------------------------------------------------------
|
|
24
32
|
// Formatters
|
|
25
33
|
// ---------------------------------------------------------------------------
|
|
26
|
-
export function formatMonitorResult(result) {
|
|
34
|
+
export function formatMonitorResult(result, opts) {
|
|
27
35
|
const { prNumber, loopTag, loopArgs, loopPrompt } = result;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
const runtime = opts?.runtime ?? "claude";
|
|
37
|
+
const sections = [
|
|
38
|
+
[
|
|
39
|
+
`# PR #${prNumber} [MONITOR]`,
|
|
40
|
+
"",
|
|
41
|
+
`Loop tag: \`${loopTag}\``,
|
|
42
|
+
`Loop args: \`${loopArgs}\``,
|
|
43
|
+
].join("\n"),
|
|
44
|
+
runtime === "codex" ? `Reusable command: \`${result.reusableCommand}\`` : null,
|
|
34
45
|
"## Loop prompt",
|
|
35
|
-
"",
|
|
36
46
|
loopPrompt,
|
|
37
|
-
"",
|
|
38
47
|
"## Instructions",
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
]
|
|
48
|
+
buildMonitorInstructions(result, runtime)
|
|
49
|
+
.map((inst, i) => `${i + 1}. ${inst}`)
|
|
50
|
+
.join("\n"),
|
|
51
|
+
];
|
|
52
|
+
return joinSections(sections);
|
|
53
|
+
}
|
|
54
|
+
export function formatMonitorJson(result, opts) {
|
|
55
|
+
const runtime = opts?.runtime ?? "claude";
|
|
56
|
+
const { reusableCommand, ...base } = result;
|
|
57
|
+
return {
|
|
58
|
+
...base,
|
|
59
|
+
...(runtime === "codex" && { reusableCommand }),
|
|
60
|
+
instructions: buildMonitorInstructions(result, runtime),
|
|
61
|
+
};
|
|
43
62
|
}
|
|
44
63
|
// ---------------------------------------------------------------------------
|
|
45
64
|
// Internal
|
|
@@ -53,9 +72,29 @@ function validateReadyDelaySuffix(readyDelaySuffix) {
|
|
|
53
72
|
}
|
|
54
73
|
return trimmed;
|
|
55
74
|
}
|
|
56
|
-
function
|
|
75
|
+
function buildIterateCommand(prNumber, readyDelaySuffix) {
|
|
57
76
|
const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
|
|
58
|
-
|
|
77
|
+
return `npx pr-shepherd ${prNumber}${validatedDelay ? ` --ready-delay ${validatedDelay}` : ""}`;
|
|
78
|
+
}
|
|
79
|
+
function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "claude") {
|
|
80
|
+
if (runtime === "codex") {
|
|
81
|
+
return [
|
|
82
|
+
loopTag,
|
|
83
|
+
"",
|
|
84
|
+
"**IMPORTANT — Codex recurrence rules:**",
|
|
85
|
+
"- Run the command below once and follow its `## Instructions` exactly.",
|
|
86
|
+
`- If the output tells you to continue the active Codex goal, wait about the configured interval (${loopArgs}) and rerun the reusable command from the monitor output.`,
|
|
87
|
+
"- Stop only when Shepherd emits `[CANCEL]` because the ready-delay completed or the PR was merged/closed, or when Shepherd emits `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).",
|
|
88
|
+
"- Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `npx pr-shepherd monitor`; Codex recurrence is explicit `iterate` command cycles.",
|
|
89
|
+
"",
|
|
90
|
+
"Run in a single Bash call:",
|
|
91
|
+
` ${iterateCmd}`,
|
|
92
|
+
"",
|
|
93
|
+
`Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with \`# PR #${prNumber} [\`), report the first line of stderr and stop so the user can retry.`,
|
|
94
|
+
"",
|
|
95
|
+
"The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
59
98
|
return [
|
|
60
99
|
loopTag,
|
|
61
100
|
"",
|
|
@@ -73,3 +112,15 @@ function buildLoopPrompt(prNumber, loopTag, readyDelaySuffix) {
|
|
|
73
112
|
"The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
|
|
74
113
|
].join("\n");
|
|
75
114
|
}
|
|
115
|
+
function buildMonitorInstructions(result, runtime) {
|
|
116
|
+
if (runtime === "codex") {
|
|
117
|
+
return [
|
|
118
|
+
"Run the `## Loop prompt` body once inline now.",
|
|
119
|
+
`For an active Codex goal, keep cycling with \`${result.reusableCommand}\` about every configured interval (${result.loopArgs}) until a terminal condition is reached. Codex does not create a \`/loop\` monitor.`,
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
return [
|
|
123
|
+
`Run \`CronList\`. If any job's prompt contains \`${result.loopTag}\`, run the \`## Loop prompt\` body once inline (as if it were a cron tick) then stop — do not create a duplicate loop.`,
|
|
124
|
+
"Otherwise, invoke the `/loop` skill via the Skill tool. Build the `args` parameter as: only the value inside the backticks on the `Loop args` line above (the interval — not the `Loop args:` label), then a blank line, then the full `## Loop prompt` body.",
|
|
125
|
+
];
|
|
126
|
+
}
|
|
@@ -16,7 +16,8 @@ import { resolveStateBase } from "../state/base.mjs";
|
|
|
16
16
|
* - If `isReady == true`: start or continue the ready timer.
|
|
17
17
|
* - If `isReady == false`: reset the timer.
|
|
18
18
|
*
|
|
19
|
-
* When `shouldCancel == true`, the
|
|
19
|
+
* When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
|
|
20
|
+
* the loop and tells one-shot agents to stop.
|
|
20
21
|
*/
|
|
21
22
|
export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo) {
|
|
22
23
|
const markerPath = readySincePath(prNumber, owner, repo);
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* `buildFixInstructions` in `commands/iterate/render.mts`).
|
|
5
5
|
*/
|
|
6
6
|
export function buildFetchInstructions(prNumber, result) {
|
|
7
|
-
const { actionableThreads, firstLookThreads, actionableComments, firstLookComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
|
|
7
|
+
const { actionableThreads, resolutionOnlyThreads, firstLookThreads, actionableComments, firstLookComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
|
|
8
8
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
9
9
|
const total = actionableThreads.length +
|
|
10
|
+
resolutionOnlyThreads.length +
|
|
10
11
|
actionableComments.length +
|
|
11
12
|
changesRequestedReviews.length +
|
|
12
13
|
reviewSummaries.length +
|
|
@@ -21,12 +22,12 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
21
22
|
const instructions = [];
|
|
22
23
|
instructions.push(`Classify every item listed above into exactly one of: Fixed / Actionable / Not relevant / Outdated / Acknowledge. Do not silently skip any item. Bot-authored review summaries (authors whose name contains \`[bot]\` or matches \`copilot-pull-request-reviewer\`, \`gemini-code-assist\`) default to Acknowledge with reason "bot summary — no actionable content" unless the body calls out an unaddressed issue.`);
|
|
23
24
|
if (firstLookTotal > 0) {
|
|
24
|
-
instructions.push(`Items in \`## First-look items\` are
|
|
25
|
+
instructions.push(`Items in \`## First-look items\` are shown so you can acknowledge their current status before acting. If a first-look thread also appears under \`## Review threads to resolve\`, include its ID in \`--resolve-thread-ids\`; otherwise do not pass first-look-only IDs to mutation flags.`);
|
|
25
26
|
}
|
|
26
27
|
const editedTotal = firstLookThreads.filter((t) => t.edited).length +
|
|
27
28
|
firstLookComments.filter((c) => c.edited).length;
|
|
28
29
|
if (editedTotal > 0) {
|
|
29
|
-
instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body
|
|
30
|
+
instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
|
|
30
31
|
}
|
|
31
32
|
if (hasSuggestions) {
|
|
32
33
|
instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
|
|
@@ -38,6 +39,9 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
38
39
|
instructions.push(`Rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`.`);
|
|
39
40
|
instructions.push(`Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && CURRENT_SHA=$(git rev-parse HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId,headSha --jq ".[] | select(.headSha != \\"$CURRENT_SHA\\") | .databaseId" | xargs -I{} gh run cancel {}\`.`);
|
|
40
41
|
}
|
|
42
|
+
if (resolutionOnlyThreads.length > 0) {
|
|
43
|
+
instructions.push(`Resolve each thread under \`## Review threads to resolve\` with \`--resolve-thread-ids\`. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
|
|
44
|
+
}
|
|
41
45
|
const requireShaHint = hasCodeItems
|
|
42
46
|
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the rebase-and-push step above ran.`
|
|
43
47
|
: "";
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import { loadConfig } from "../config/load.mjs";
|
|
|
6
6
|
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
7
7
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
8
8
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
9
|
-
/** Fetch mode: auto-resolve outdated threads and return all active items for LLM triage. */
|
|
10
9
|
export async function runResolveFetch(opts) {
|
|
11
10
|
const repo = await getRepoInfo();
|
|
12
11
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -15,7 +14,7 @@ export async function runResolveFetch(opts) {
|
|
|
15
14
|
}
|
|
16
15
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
17
16
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
18
|
-
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved
|
|
17
|
+
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
|
|
19
18
|
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
20
19
|
const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
|
|
21
20
|
const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
@@ -68,7 +67,8 @@ export async function runResolveFetch(opts) {
|
|
|
68
67
|
process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
|
-
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
70
|
+
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
|
|
71
|
+
const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
|
|
72
72
|
const cfg = loadConfig();
|
|
73
73
|
const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
|
|
74
74
|
const thread = rest;
|
|
@@ -118,6 +118,7 @@ export async function runResolveFetch(opts) {
|
|
|
118
118
|
const result = {
|
|
119
119
|
prNumber,
|
|
120
120
|
actionableThreads,
|
|
121
|
+
resolutionOnlyThreads,
|
|
121
122
|
firstLookThreads,
|
|
122
123
|
actionableComments: visibleComments,
|
|
123
124
|
firstLookComments,
|
|
@@ -127,7 +128,6 @@ export async function runResolveFetch(opts) {
|
|
|
127
128
|
};
|
|
128
129
|
return { ...result, instructions: buildFetchInstructions(prNumber, result) };
|
|
129
130
|
}
|
|
130
|
-
/** Mutation mode: resolve/minimize/dismiss by ID. */
|
|
131
131
|
export async function runResolveMutate(opts) {
|
|
132
132
|
const repo = await getRepoInfo();
|
|
133
133
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
package/bin/index.mjs
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
* All rebase policy, CI budget policy, and ready-to-merge gating live here so the
|
|
4
4
|
* skill stays a thin dispatcher and these rules co-evolve with the CLI data model.
|
|
5
5
|
*/
|
|
6
|
-
export function buildCheckInstructions(report) {
|
|
6
|
+
export function buildCheckInstructions(report, opts) {
|
|
7
|
+
const runtime = opts?.runtime ?? "claude";
|
|
7
8
|
const { mergeStatus, checks, threads, comments, changesRequestedReviews, status } = report;
|
|
8
9
|
const instructions = [];
|
|
9
10
|
// 1. Summary
|
|
10
|
-
const totalActionable = threads.actionable.length +
|
|
11
|
+
const totalActionable = threads.actionable.length +
|
|
12
|
+
threads.resolutionOnly.length +
|
|
13
|
+
comments.actionable.length +
|
|
14
|
+
changesRequestedReviews.length;
|
|
11
15
|
const total = checks.passing.length +
|
|
12
16
|
checks.failing.length +
|
|
13
17
|
checks.inProgress.length +
|
|
@@ -54,7 +58,9 @@ export function buildCheckInstructions(report) {
|
|
|
54
58
|
}
|
|
55
59
|
// 5. Continuous monitoring pointer (suppressed only when truly ready to merge)
|
|
56
60
|
if (!isReady) {
|
|
57
|
-
instructions.push(
|
|
61
|
+
instructions.push(runtime === "codex"
|
|
62
|
+
? `This is a one-shot check. For follow-up monitoring, run \`npx pr-shepherd ${report.pr}\`.`
|
|
63
|
+
: "This is a one-shot check. For continuous monitoring that acts on these signals automatically, use `/pr-shepherd:monitor`.");
|
|
58
64
|
}
|
|
59
65
|
return instructions;
|
|
60
66
|
}
|
package/bin/reporters/json.mjs
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* without string-scraping the human-readable text reporter.
|
|
6
6
|
*/
|
|
7
7
|
import { buildCheckInstructions } from "./check-instructions.mjs";
|
|
8
|
-
export function formatJson(report) {
|
|
9
|
-
return JSON.stringify({ ...report, instructions: buildCheckInstructions(report) }, null, 2);
|
|
8
|
+
export function formatJson(report, opts) {
|
|
9
|
+
return JSON.stringify({ ...report, instructions: buildCheckInstructions(report, opts) }, null, 2);
|
|
10
10
|
}
|