pr-shepherd 0.8.1 → 0.9.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.8.1",
4
+ "version": "0.9.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -10,28 +10,130 @@ Example Workflow:
10
10
  3. Accept the plan
11
11
  4. Switch to Auto Mode
12
12
  5. Prompt: `make a PR, then run /pr-shepherd:monitor`
13
- 6. ...
14
- 7. Human reviews PR with passing CI, no open threads, and all comments minimized
13
+ 6. Agent makes a draft PR
14
+ 7. PR has passing CI -> draft is marked Ready for Review
15
+ 8. Review bots begin providing reviews
16
+ 9. Agent automatically classifies and fixes review comments based on the Plan
17
+ 10. Human reviews PR with a well-documented PR title and description, passing CI, and no open threads/comments/reviews
15
18
 
16
- ## Why pr-shepherd
19
+ ## How it works
17
20
 
18
- Concrete improvements to an agentic PR-review workflow:
21
+ `pr-shepherd` optimizes token management, rate limits, and agentic orchestration by moving **ALL** deterministic logic and prompts to code via a CLI tool, enshrining what would be a large skill or command prompt (of which the agent would inevitably make mistakes) into the code and returning a clear, actionable prompt.
19
22
 
20
- - **Faster monitor loops** one batched GraphQL query per tick (see [docs/graphql.md](docs/graphql.md)) instead of N REST round-trips
21
- - **Lower context usage per iteration** — classification lives in the CLI; the agent receives one decision per tick and never sees raw GraphQL payloads or resolved threads
22
- - **Prompt-cache friendly** — the 4-minute default tick is tuned to Claude's 5-minute prompt-cache TTL (tunable via `watch.interval`)
23
- - **Reduced GitHub rate-limit exposure** — one batched GraphQL read per tick; loop-state files (fix-attempts, stall detection, ready-delay timer) are kept in `$TMPDIR/pr-shepherd-state/`
24
- - **No MCP surface** — skills call the CLI via `npx`; no long-lived MCP server, no extra auth boundary, smaller reasoning surface
25
- - **Skills over subagents** — skill prompts inject into the main conversation rather than spawning a subagent that reloads CLAUDE.md every turn
26
- - **Safe to interrupt** — all state lives in the PR on GitHub; the cron loop self-terminates when the PR is merged, closed, or settles after ready-delay
23
+ At a high level, to start the monitor, the skill/command invokes a CLI that returns a prompt to be ingested by the agent _(schematic paraphrased for brevity; actual output is more detailed)_:
27
24
 
28
- ## Design principles
25
+ ```
26
+ /pr-shepherd:monitor
27
+
28
+ > npx pr-shepherd monitor 123
29
+
30
+ # PR #123 [MONITOR]
31
+
32
+ Loop tag: `# pr-shepherd-loop:pr=123`
33
+ Loop args: `4m --max-turns 50 --expires 8h`
34
+
35
+ ## Loop prompt
36
+
37
+ # pr-shepherd-loop:pr=123
38
+
39
+ **IMPORTANT — recurrence rules:** Do not call ScheduleWakeup or /loop. End the turn
40
+ after completing the actions below. The cron job handles the next fire.
41
+
42
+ Run in a single Bash call:
43
+ npx pr-shepherd iterate 123 --no-cache
44
+
45
+ …(self-dedup guidance, error-handling instructions)…
46
+
47
+ ## Instructions
48
+
49
+ 1. Run `CronList`. If any job's prompt contains the loop tag, run the ## Loop prompt inline then stop.
50
+ 2. Otherwise, invoke the /loop skill with Loop args and the full ## Loop prompt body.
51
+ ```
52
+
53
+ Each iteration calls `npx pr-shepherd iterate <PR>`, which provides actionable feedback directly to the agent:
54
+
55
+ ```
56
+ > npx pr-shepherd iterate 123
57
+
58
+ # PR #123 [FIX_CODE]
59
+
60
+ **status** `UNRESOLVED_COMMENTS` · **merge** `BLOCKED` · **state** `OPEN` · **repo** `owner/repo`
61
+ **summary** 3 passing
62
+
63
+ ## Review threads
64
+
65
+ ### `PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate.mts:42` (@alice)
66
+
67
+ > The variable name is misleading.
68
+ >
69
+ > Consider renaming `x` to `remainingSeconds` so readers don't have to
70
+ > trace back to the declaration to understand its meaning.
71
+
72
+ ## Failing checks
29
73
 
30
- - **Reduced agent context** logic lives in the CLI, not the prompt
31
- - **Reduced GitHub rate-limit exhaustion** — primary PR state is fetched via a batched GraphQL query
32
- - **Fewer tool calls** — comment resolutions are batched; resolved threads never reach the agent
33
- - **Skills over subagents** — subagents reload all CLAUDE.md context on every turn; skills inject into the main conversation instead, keeping cost low
34
- - **JSON/text parity** — `--format=json` and `--format=text` carry equivalent information; every field in one has a representation in the other
74
+ - `24697658766``CI lint / typecheck / test (22.x)`
75
+ > npx oxfmt
76
+
77
+ ## Post-fix push
78
+
79
+ - base: `main`
80
+ - resolve: `npx pr-shepherd resolve 123 --resolve-thread-ids PRRT_kwDOSGizTs58XB1L --minimize-comment-ids IC_kwDOSGizTs7_ajT8,IC_kwDOSGizTs7_ajT9 --dismiss-review-ids PRR_kwDOSGizTs58XB1R --message "$DISMISS_MESSAGE" --require-sha "$HEAD_SHA"`
81
+
82
+ ## Instructions
83
+
84
+ _(schematic — actual steps depend on PR state)_
85
+
86
+ 1. Apply code fixes for each file referenced under `## Review threads`.
87
+ 2. For each failing check: examine the log tail to decide — rerun if transient, fix code if real.
88
+ 3. Commit changed files.
89
+ 4. Rebase and push: `git fetch origin && git rebase origin/main && git push --force-with-lease` — capture `HEAD_SHA=$(git rev-parse HEAD)`.
90
+ 5. Run the `resolve:` command above, substituting `"$HEAD_SHA"`.
91
+ 6. Add a `## Shepherd Journal` entry to the PR description for any large decisions made.
92
+ 7. Stop this iteration.
93
+ ```
94
+
95
+ On every iteration, a command is returned to instruct the agent exactly what to do. No guessing, no thinking, as few agentic turns as possible:
96
+
97
+ ```
98
+ npx pr-shepherd resolve 123 --resolve-thread-ids PRRT_kwDOSGizTs58XB1L --minimize-comment-ids IC_kwDOSGizTs7_ajT8,IC_kwDOSGizTs7_ajT9 --dismiss-review-ids PRR_kwDOSGizTs58XB1R --message "$DISMISS_MESSAGE" --require-sha "$HEAD_SHA"
99
+ ```
100
+
101
+ ## Workflow
102
+
103
+ This system makes opinionated decisions, which may or may not work for your team's workflow.
104
+
105
+ - The following PR branch protection rules are expected:
106
+ - There are required status checks
107
+ - All inline comments are resolved
108
+ - **ALL** comments/threads/reviews will be hidden by default except for PR approvals. The only option here is to hide PR approvals as well.
109
+ - The primary reason is to optimize tokens by avoiding re-fetching comments and re-adding them to the agent's context.
110
+ - This also ties hand-in-hand with requiring all inline comments to be resolved.
111
+ - We also want to avoid storing state as comments can be unresolved/minimized/hidden.
112
+ - `pr-shepherd` keeps the PR title and description up to date, including a journal of decisions with links to comments/threads/reviews (that would be hidden at this point).
113
+ - This may break your workflow if your PR titles and descriptions are restricted to a specific format.
114
+ - `pr-shepherd` does **NOT** reply to inline comments when resolving them. Doing so would require agentic loops and more tokens. Instead, it updates the PR title & description once per loop with only the relevant information.
115
+ - Branches are currently kept up-to-date with `git push --force-with-lease`. Please make a PR for making `merge <default branch>` an option.
116
+ - Branches are currently only rebased when 1) pushing a commit on a branch that is out of date or 2) there are merge conflicts. It does not continuously rebase the branch (use a merge queue for that).
117
+ - To optimize AI code reviewer tokens, create your pull requests initially as drafts and instruct your AI code reviewers to only code review PRs that are ready for review. `pr-shepherd` will automatically mark PRs as ready for review when all CI passes (can be disabled). If you have no intention of marking your PR as ready for review, then don't run `pr-shepherd`.
118
+
119
+ Some other workflow improvements:
120
+
121
+ - `pr-shepherd` knows whether a GitHub Copilot code review is in progress
122
+ - `pr-shepherd` waits 10 minutes (configurable) until after all comments are hidden and CI passes before exiting. The primary reason is to wait for any lingering automated code reviews that do not provide status updates via the GitHub GraphQL API.
123
+ - The agent is instructed to cancel failed CI runs and, when a failure looks transient (e.g. network timeout, runner setup crash), re-run them via `gh run rerun <id> --failed`. The primary reason is to minimize CI costs.
124
+ - `pr-shepherd` supports "commit suggestions" by converting them into a diff, applying them, and then committing them with attribution. This avoids a file read & write. One commit is always made per suggestion to avoid any merge conflicts — in these cases, the agent will resolve the comment manually.
125
+
126
+ Recommendations:
127
+
128
+ - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. As it uses `/loop`, it will continue working when your rate limit window is reset. There is a default timeout of 8 hours and 50 loops before exiting automatically.
129
+
130
+ ## Design Principles
131
+
132
+ - **Reduced agent context and turns** — logic lives in the CLI, not the prompt. The relevant context is provided automatically to the agent, reducing tool calls.
133
+ - **Reduced GitHub rate-limit exposure** — GraphQL requests are batched when possible
134
+ - **Minimal state** — `pr-shepherd` stores minimal state in `$PR_SHEPHERD_STATE_DIR` (default `$TMPDIR/pr-shepherd-state/`), not in the repository
135
+ - **Classifications and decisions still happen at the agent level** — `pr-shepherd`'s goal is to provide sufficient context to make informed decisions and provide clear actionable steps without writing unreliable code-level heuristics
136
+ - **Configurable** — `pr-shepherd` is configurable via `.pr-shepherdrc.yml`, which is only possible with a light prompt that simply invokes the CLI which returns the prompt.
35
137
 
36
138
  ## Usage
37
139
 
@@ -72,9 +174,9 @@ GitHub has seen the push before resolving).
72
174
 
73
175
  See [docs/skills.md](docs/skills.md) for full argument reference.
74
176
 
75
- ## Workflow
177
+ ## Iterate decision loop
76
178
 
77
- On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (fix code, rebase, rerun CI, mark ready, or wait). See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
179
+ On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, `wait`, or `cooldown`). See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
78
180
 
79
181
  ## Install
80
182
 
@@ -126,7 +228,7 @@ checks:
126
228
  - pull_request_target
127
229
  - merge_group # add for merge-queue repos
128
230
  actions:
129
- autoRebase: false # disable for repos that enforce merge commits
231
+ autoMarkReady: false # disable to stay draft until you manually promote
130
232
  ```
131
233
 
132
234
  Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state base dir).
@@ -49,6 +49,7 @@ export function getCiVerdict(classified) {
49
49
  // When there are no relevant checks (e.g. docs-only PR where all checks are filtered/skipped),
50
50
  // treat as allPassed rather than blocking — there's nothing to fail.
51
51
  const allPassed = !anyInProgress && !anyFailing;
52
+ const hasChecks = relevant.length > 0;
52
53
  const filteredNames = classified.filter((c) => c.category === "filtered").map((c) => c.name);
53
- return { allPassed, anyInProgress, anyFailing, filteredNames };
54
+ return { allPassed, hasChecks, anyInProgress, anyFailing, filteredNames };
54
55
  }
@@ -1,64 +1,53 @@
1
1
  /**
2
- * Triage failing check runs into three categories based solely on GitHub's
3
- * own conclusion field no log-content classification:
4
- * - timeout: conclusion is TIMED_OUT.
5
- * - cancelled: conclusion is CANCELLED, STARTUP_FAILURE, or STALE.
6
- * - actionable: everything else (FAILURE, ACTION_REQUIRED, …).
2
+ * Triage failing check runs: call the jobs API once per runId to fetch
3
+ * workflow name, job name, and the first failed step name. Then fetch the
4
+ * last N lines of the failing job's log so the agent can diagnose failures
5
+ * without a separate tool call.
7
6
  *
8
- * Exception: checks with runId === null are always classified as "actionable"
9
- * regardless of conclusion, so they surface in fix_code where the monitor
10
- * escalates to the user (no run to rerun/inspect).
7
+ * No heuristic classification is applied the raw GitHub `conclusion` field
8
+ * is preserved as-is. The agent decides whether a failure is transient or
9
+ * real based on the log tail and other context.
11
10
  *
12
- * For all checks with a non-null runId, the jobs API is called once per runId
13
- * (results are cached across checks that share a run) to fetch workflow name
14
- * and, for actionable checks, the first failed step name. No log fetching is done.
11
+ * For checks with runId === null (external StatusContexts) no API calls are
12
+ * made; workflowName, jobName, failedStep, and logTail are all absent.
15
13
  *
16
- * Note on infrastructure-killed FAILURE runs: GitHub reports these as
17
- * conclusion === "FAILURE", so they classify as "actionable". The jobs API
18
- * surfaces their failedStep (e.g. "Set up job") which gives the agent a
19
- * GitHub-native signal to distinguish runner setup deaths from real test
20
- * failures — without any log-pattern analysis at the CLI level.
14
+ * Jobs responses are cached by runId so checks that share a run (e.g. matrix
15
+ * builds or multiple required steps in one workflow) make only one API call.
16
+ * Log tails are fetched per job ID, not per runId.
21
17
  */
22
- import { rest } from "../github/http.mjs";
18
+ import { rest, restText } from "../github/http.mjs";
23
19
  // ---------------------------------------------------------------------------
24
20
  // Public API
25
21
  // ---------------------------------------------------------------------------
26
22
  /**
27
- * Triage each failing check: classify by GitHub conclusion and call the jobs
28
- * API for each check with a non-null runId to fetch workflow name and (for
29
- * actionable failures) the name of the first failed step.
30
- *
31
- * Jobs responses are cached by runId so checks that share a run (e.g. matrix
32
- * builds or multiple required steps in one workflow) make only one API call.
23
+ * Triage each failing check: call the jobs API for each check with a non-null
24
+ * runId to fetch workflow name, job name, and the first failed step name, then
25
+ * fetch the last `logTailLines` lines of the failing job's log.
33
26
  */
34
- export function triageFailingChecks(failingChecks, repo) {
27
+ export function triageFailingChecks(failingChecks, repo, logTailLines) {
35
28
  const jobsCache = new Map();
36
- return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
29
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines)));
37
30
  }
38
31
  // ---------------------------------------------------------------------------
39
32
  // Internal
40
33
  // ---------------------------------------------------------------------------
41
- async function triageCheck(check, repo, jobsCache) {
42
- const failureKind = check.runId === null ? "actionable" : classifyConclusion(check.conclusion);
34
+ async function triageCheck(check, repo, jobsCache, logTailLines) {
43
35
  if (check.runId === null) {
44
- return { ...check, failureKind };
36
+ return { ...check };
45
37
  }
46
38
  const jobs = await fetchJobs(check.runId, repo, jobsCache);
47
- const jobInfo = jobs ? pickJobInfo(jobs, check.name, failureKind) : undefined;
39
+ const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
40
+ const logTail = jobInfo?.jobId !== undefined && logTailLines > 0
41
+ ? await fetchLogTail(jobInfo.jobId, repo, logTailLines)
42
+ : undefined;
48
43
  return {
49
44
  ...check,
50
- failureKind,
51
- workflowName: jobInfo?.workflowName,
52
- ...(failureKind === "actionable" && { failedStep: jobInfo?.failedStep }),
45
+ ...(jobInfo?.workflowName !== undefined && { workflowName: jobInfo.workflowName }),
46
+ ...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
47
+ ...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
48
+ ...(logTail !== undefined && { logTail }),
53
49
  };
54
50
  }
55
- function classifyConclusion(c) {
56
- if (c === "TIMED_OUT")
57
- return "timeout";
58
- if (c === "CANCELLED" || c === "STARTUP_FAILURE" || c === "STALE")
59
- return "cancelled";
60
- return "actionable";
61
- }
62
51
  function fetchJobs(runId, repo, cache) {
63
52
  const cached = cache.get(runId);
64
53
  if (cached)
@@ -90,17 +79,38 @@ async function fetchJobsUncached(runId, repo) {
90
79
  }
91
80
  return allJobs;
92
81
  }
93
- function pickJobInfo(jobs, checkName, failureKind) {
82
+ function pickJobInfo(jobs, checkName) {
94
83
  // Match by name. For matrix jobs sharing a check name, prefer a failing one.
95
84
  // Fall back to prefix matching for matrix jobs whose workflow-API name includes
96
85
  // a suffix like "(ubuntu)" while checkName is just the base name.
97
86
  const exactMatches = jobs.filter((j) => j.name === checkName);
98
87
  const matchedJobs = exactMatches.length > 0 ? exactMatches : jobs.filter((j) => j.name.startsWith(checkName));
99
- const job = matchedJobs.find((j) => j.conclusion === "failure") ?? matchedJobs[0];
88
+ const job = matchedJobs.find((j) => j.conclusion === "failure") ??
89
+ matchedJobs.find((j) => j.conclusion !== null && j.conclusion !== "success") ??
90
+ matchedJobs[0];
100
91
  if (!job)
101
92
  return undefined;
102
- const failedStep = failureKind === "actionable"
103
- ? job.steps?.find((s) => s.conclusion === "failure")?.name
104
- : undefined;
105
- return { workflowName: job.workflow_name, failedStep };
93
+ const failedStep = job.steps?.find((s) => s.conclusion !== null &&
94
+ s.conclusion !== "success" &&
95
+ s.conclusion !== "skipped" &&
96
+ s.conclusion !== "neutral")?.name;
97
+ return {
98
+ workflowName: job.workflow_name,
99
+ jobName: job.name,
100
+ failedStep,
101
+ jobId: job.id,
102
+ };
103
+ }
104
+ async function fetchLogTail(jobId, repo, logTailLines) {
105
+ const { owner, name } = repo;
106
+ try {
107
+ const text = await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
108
+ const lines = text.split("\n");
109
+ if (lines.length <= logTailLines)
110
+ return text;
111
+ return lines.slice(-logTailLines).join("\n");
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
106
116
  }
package/bin/cli/args.mjs CHANGED
@@ -26,6 +26,7 @@ const BOOLEAN_FLAGS = new Set([
26
26
  "--no-auto-mark-ready",
27
27
  "--no-auto-cancel-actionable",
28
28
  "--dry-run",
29
+ "--verbose",
29
30
  ]);
30
31
  // ---------------------------------------------------------------------------
31
32
  // Strict integer parsing
@@ -44,18 +45,23 @@ export function parseCommonArgs(args) {
44
45
  tokens: true,
45
46
  options: {
46
47
  format: { type: "string" },
48
+ verbose: { type: "boolean" },
47
49
  },
48
50
  });
49
51
  const format = (values.format ?? "text");
52
+ const verbose = values.verbose === true;
50
53
  // Build the set of arg indices consumed by global flags so we can strip
51
54
  // them from `extra`. Subcommand-specific flags are left untouched.
52
55
  const consumedIndices = new Set();
53
56
  for (const tok of tokens ?? []) {
54
- if (tok.kind === "option" && tok.name === "format") {
57
+ if (tok.kind === "option" && (tok.name === "format" || tok.name === "verbose")) {
55
58
  consumedIndices.add(tok.index);
56
59
  // When the value is a separate arg (--flag value, not --flag=value),
57
60
  // inlineValue is false and the value occupies tok.index + 1.
58
- if ("inlineValue" in tok && tok.inlineValue === false && tok.value != null) {
61
+ if (tok.name === "format" &&
62
+ "inlineValue" in tok &&
63
+ tok.inlineValue === false &&
64
+ tok.value != null) {
59
65
  consumedIndices.add(tok.index + 1);
60
66
  }
61
67
  }
@@ -97,7 +103,7 @@ export function parseCommonArgs(args) {
97
103
  const extra = args.filter((_, i) => !consumedIndices.has(i));
98
104
  return {
99
105
  prNumber,
100
- global: { format },
106
+ global: { format, verbose },
101
107
  extra,
102
108
  };
103
109
  }
@@ -1,35 +1,48 @@
1
1
  import { renderResolveCommand } from "../commands/iterate.mjs";
2
+ function safeFence(content) {
3
+ const maxRun = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
4
+ return "`".repeat(Math.max(3, maxRun + 1));
5
+ }
2
6
  export function formatFixCodeResult(header, result) {
3
7
  const sections = [header];
4
8
  if (result.fix.threads.length > 0) {
5
9
  sections.push("## Review threads");
6
10
  for (const t of result.fix.threads) {
7
11
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
8
- sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
12
+ const heading = t.url ? `[${t.id}](${t.url})` : `\`${t.id}\``;
13
+ sections.push(`### ${heading} — ${loc} (@${t.author})`);
9
14
  sections.push(blockquote(t.body));
10
15
  }
11
16
  }
12
17
  if (result.fix.actionableComments.length > 0) {
13
18
  sections.push("## Actionable comments");
14
19
  for (const c of result.fix.actionableComments) {
15
- sections.push(`### \`${c.id}\` (@${c.author})`);
20
+ const heading = c.url ? `[${c.id}](${c.url})` : `\`${c.id}\``;
21
+ sections.push(`### ${heading} (@${c.author})`);
16
22
  sections.push(blockquote(c.body));
17
23
  }
18
24
  }
19
25
  if (result.fix.checks.length > 0) {
20
26
  sections.push("## Failing checks");
21
27
  const bullets = result.fix.checks.map((ch) => {
22
- const prefix = ch.workflowName ? `${ch.workflowName} › ` : "";
28
+ const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
29
+ const jobLabel = ch.jobName ? ch.jobName : ch.name;
23
30
  const locator = ch.runId
24
31
  ? `\`${ch.runId}\``
25
32
  : ch.detailsUrl
26
33
  ? `external \`${ch.detailsUrl}\``
27
34
  : "(no runId)";
28
- const lines = [`- ${locator} — \`${prefix}${ch.name}\``];
35
+ const lines = [`- ${locator} — \`${workflowPrefix}${jobLabel}\``];
29
36
  if (ch.failedStep)
30
37
  lines.push(` > ${ch.failedStep}`);
31
38
  if (ch.summary)
32
39
  lines.push(` > ${ch.summary}`);
40
+ if (ch.logTail) {
41
+ const fence = safeFence(ch.logTail);
42
+ lines.push(` ${fence}`);
43
+ lines.push(ch.logTail.replace(/^/gm, " ").trimEnd());
44
+ lines.push(` ${fence}`);
45
+ }
33
46
  return lines.join("\n");
34
47
  });
35
48
  sections.push(bullets.join("\n\n"));
@@ -46,12 +59,28 @@ export function formatFixCodeResult(header, result) {
46
59
  sections.push("## Review summaries (minimize only)");
47
60
  sections.push(result.fix.reviewSummaryIds.map((id) => `\`${id}\``).join(", "));
48
61
  }
49
- if (result.fix.surfacedSummaries.length > 0) {
50
- sections.push("## Review summaries (surfaced — not minimized)");
51
- for (const r of result.fix.surfacedSummaries) {
62
+ if (result.fix.surfacedApprovals.length > 0) {
63
+ sections.push("## Approvals (surfaced — not minimized)");
64
+ for (const r of result.fix.surfacedApprovals) {
52
65
  sections.push(`### \`${r.id}\` (@${r.author})`);
53
- sections.push(blockquote(r.body));
66
+ sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
67
+ }
68
+ }
69
+ const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
70
+ if (firstLookTotal > 0) {
71
+ sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
72
+ const bullets = [];
73
+ for (const t of result.fix.firstLookThreads) {
74
+ const statusTag = t.autoResolved
75
+ ? `[status: outdated, auto-resolved]`
76
+ : `[status: ${t.firstLookStatus}]`;
77
+ const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
78
+ bullets.push(`- \`${t.id}\` ${loc} (@${t.author}) ${statusTag}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
79
+ }
80
+ for (const c of result.fix.firstLookComments) {
81
+ bullets.push(`- \`${c.id}\` (@${c.author}) [status: minimized]: ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
54
82
  }
83
+ sections.push(bullets.join("\n"));
55
84
  }
56
85
  if (result.cancelled.length > 0) {
57
86
  sections.push("## Cancelled runs");
@@ -1,25 +1,38 @@
1
1
  export { formatIterateResult } from "./iterate-formatter.mjs";
2
+ export { projectIterateLean } from "./iterate-lean.mjs";
2
3
  export function formatFetchResult(result) {
3
- const total = result.actionableThreads.length +
4
+ const activeTotal = result.actionableThreads.length +
4
5
  result.actionableComments.length +
5
6
  result.changesRequestedReviews.length +
6
7
  result.reviewSummaries.length;
8
+ const firstLookTotal = result.firstLookThreads.length + result.firstLookComments.length;
9
+ const total = activeTotal + firstLookTotal;
10
+ const headingParts = [];
11
+ if (activeTotal > 0)
12
+ headingParts.push(`${activeTotal} actionable`);
13
+ if (firstLookTotal > 0)
14
+ headingParts.push(`${firstLookTotal} first-look`);
15
+ const headingSuffix = headingParts.length > 0 ? headingParts.join(", ") : "0 actionable";
7
16
  const sections = [];
8
- sections.push(`# PR #${result.prNumber} — Resolve fetch (${total === 0 ? "0 actionable" : `${total} actionable`})`);
17
+ sections.push(`# PR #${result.prNumber} — Resolve fetch (${headingSuffix})`);
9
18
  if (result.actionableThreads.length > 0) {
10
19
  sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
11
20
  (result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
12
21
  sections.push(result.actionableThreads
13
22
  .map((t) => {
14
23
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
15
- return `- \`threadId=${t.id}\` \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
24
+ const link = t.url ? ` [↗](${t.url})` : "";
25
+ return `- \`threadId=${t.id}\`${link} \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
16
26
  })
17
27
  .join("\n"));
18
28
  }
19
29
  if (result.actionableComments.length > 0) {
20
30
  sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
21
31
  sections.push(result.actionableComments
22
- .map((c) => `- \`commentId=${c.id}\` (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`)
32
+ .map((c) => {
33
+ const link = c.url ? ` [↗](${c.url})` : "";
34
+ return `- \`commentId=${c.id}\`${link} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
35
+ })
23
36
  .join("\n"));
24
37
  }
25
38
  if (result.changesRequestedReviews.length > 0) {
@@ -32,8 +45,30 @@ export function formatFetchResult(result) {
32
45
  .map((r) => `- \`reviewId=${r.id}\` (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`)
33
46
  .join("\n"));
34
47
  }
48
+ if (firstLookTotal > 0) {
49
+ sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
50
+ const bullets = [];
51
+ for (const t of result.firstLookThreads) {
52
+ const statusTag = t.autoResolved
53
+ ? `[status: outdated, auto-resolved]`
54
+ : `[status: ${t.firstLookStatus}]`;
55
+ const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
56
+ bullets.push(`- \`threadId=${t.id}\` ${loc} (@${t.author}) ${statusTag}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
57
+ }
58
+ for (const c of result.firstLookComments) {
59
+ bullets.push(`- \`commentId=${c.id}\` (@${c.author}) [status: minimized]: ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
60
+ }
61
+ sections.push(bullets.join("\n"));
62
+ }
35
63
  sections.push("## Summary");
36
- sections.push(total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`);
64
+ sections.push(total === 0
65
+ ? "0 actionable, 0 first-look — all items seen"
66
+ : [
67
+ activeTotal > 0 ? `${activeTotal} actionable` : null,
68
+ firstLookTotal > 0 ? `${firstLookTotal} first-look` : null,
69
+ ]
70
+ .filter(Boolean)
71
+ .join(", "));
37
72
  sections.push("## Instructions");
38
73
  sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
39
74
  return `${sections.join("\n\n")}\n`;
@@ -6,7 +6,7 @@ import { getRepoInfo } from "../github/client.mjs";
6
6
  import { loadConfig } from "../config/load.mjs";
7
7
  import { parseCommonArgs, getFlag, hasFlag, parseStatusPrNumbers, parseIntStrict, } from "./args.mjs";
8
8
  import { parseDurationToMinutes, iterateActionToExitCode, deriveSimpleReady, } from "./exit-codes.mjs";
9
- import { formatCommitSuggestionResult, formatIterateResult } from "./formatters.mjs";
9
+ import { formatCommitSuggestionResult, formatIterateResult, projectIterateLean, } from "./formatters.mjs";
10
10
  export async function handleCommitSuggestion(args) {
11
11
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
12
12
  const threadId = getFlag(extra, "--thread-id");
@@ -67,10 +67,11 @@ export async function handleIterate(args) {
67
67
  noAutoCancelActionable,
68
68
  });
69
69
  if (globalOpts.format === "json") {
70
- process.stdout.write(`${JSON.stringify(result)}\n`);
70
+ const output = globalOpts.verbose ? result : projectIterateLean(result);
71
+ process.stdout.write(`${JSON.stringify(output)}\n`);
71
72
  }
72
73
  else {
73
- process.stdout.write(`${formatIterateResult(result)}\n`);
74
+ process.stdout.write(`${formatIterateResult(result, { verbose: globalOpts.verbose })}\n`);
74
75
  }
75
76
  process.exitCode = iterateActionToExitCode(result.action);
76
77
  }
@@ -14,15 +14,42 @@ import { formatFixCodeResult } from "./fix-formatter.mjs";
14
14
  * unconditional: every action, every variant, always emits at least one step.
15
15
  * The SKILL simply follows those steps; it does not need its own dispatch table.
16
16
  */
17
- export function formatIterateResult(result) {
17
+ export function formatIterateResult(result, opts) {
18
+ const verbose = opts?.verbose ?? false;
18
19
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
19
- const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
20
- const summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
20
+ const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
21
+ ? ` · **reviewDecision** \`${result.reviewDecision}\``
22
+ : "";
23
+ const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
24
+ let summaryLine;
25
+ if (verbose) {
26
+ summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
27
+ }
28
+ else {
29
+ const counts = [`${result.summary.passing} passing`];
30
+ if (result.summary.skipped > 0)
31
+ counts.push(`${result.summary.skipped} skipped`);
32
+ if (result.summary.filtered > 0)
33
+ counts.push(`${result.summary.filtered} filtered`);
34
+ if (result.summary.inProgress > 0)
35
+ counts.push(`${result.summary.inProgress} inProgress`);
36
+ const segs = [`**summary** ${counts.join(", ")}`];
37
+ if (result.status === "READY" && result.remainingSeconds > 0) {
38
+ segs.push(`**remainingSeconds** ${result.remainingSeconds}`);
39
+ }
40
+ if (result.copilotReviewInProgress)
41
+ segs.push(`**copilotReviewInProgress**`);
42
+ if (result.isDraft)
43
+ segs.push(`**isDraft**`);
44
+ summaryLine = segs.join(" · ");
45
+ }
21
46
  const header = [heading, "", baseLine, summaryLine].join("\n");
22
47
  switch (result.action) {
23
48
  case "cooldown":
49
+ // In default mode: suppress base/summary lines — cooldown carries UNKNOWN/empty
50
+ // placeholders that add no value. Emit only heading + log + Instructions.
24
51
  return [
25
- header,
52
+ verbose ? header : heading,
26
53
  "",
27
54
  result.log,
28
55
  "",
@@ -39,12 +66,6 @@ export function formatIterateResult(result) {
39
66
  ];
40
67
  return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
41
68
  }
42
- case "rerun_ci": {
43
- const rerunInstructions = result.reran.map((r, i) => `${i + 1}. Run: \`gh run rerun ${r.runId} --failed\``);
44
- rerunInstructions.push(`${rerunInstructions.length + 1}. End this iteration — wait for CI to report results after the re-run.`);
45
- const parts = [header, result.log, "## Instructions", rerunInstructions.join("\n")];
46
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
47
- }
48
69
  case "mark_ready": {
49
70
  const parts = [
50
71
  header,