@henryqw/pi-pr 0.3.7 → 1.0.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/extensions/pr.ts CHANGED
@@ -3,266 +3,112 @@ import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
6
+ import { createPrCommandHandler } from "./pr-command.ts";
7
+ import { loadCurrentPullRequest } from "./pr-github.ts";
8
+ import {
9
+ formatPrFooter,
10
+ formatPrWidget,
11
+ projectPrDisplay,
12
+ } from "./pr-ui.ts";
7
13
 
8
14
  const POLL_INTERVAL_MS = 30_000;
9
- const REVIEW_POLL_WINDOW_MS = 20 * 60_000;
10
- const PR_FIELDS = "id,number,url,headRefOid,updatedAt,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
11
- const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
15
+ const UI_KEY = "pi-pr";
12
16
  const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
13
17
  const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
14
- const CREATE_PR_SKILL_COMMAND = "skill:pi-pr-create";
15
- const FAILED_CHECK_STATES = new Set(["ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", "STALE", "STARTUP_FAILURE", "TIMED_OUT"]);
16
- const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
17
-
18
- type CiStatus = "success" | "running" | "failure" | "none";
19
- type PullRequest = {
20
- id: string;
21
- number: number;
22
- url: URL;
23
- headRefOid: string;
24
- updatedAt: string;
25
- state: "OPEN" | "MERGED" | "CLOSED";
26
- isDraft: boolean;
27
- mergeable: string;
28
- reviewDecision: string | null;
29
- statusCheckRollup: unknown[];
30
- };
31
-
32
- function isRecord(value: unknown): value is Record<string, unknown> {
33
- return typeof value === "object" && value !== null && !Array.isArray(value);
34
- }
35
-
36
- function pullRequestUrl(value: unknown): URL | undefined {
37
- if (typeof value !== "string" || !URL.canParse(value)) return undefined;
38
- const url = new URL(value);
39
- return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
40
- }
41
-
42
- export function parsePullRequest(value: unknown): PullRequest | undefined {
43
- if (!isRecord(value)) return undefined;
44
-
45
- const id = value.id;
46
- const number = value.number;
47
- const url = pullRequestUrl(value.url);
48
- const headRefOid = value.headRefOid;
49
- const updatedAt = value.updatedAt;
50
- const state = value.state;
51
- const isDraft = value.isDraft;
52
- const mergeable = value.mergeable;
53
- const reviewDecision = value.reviewDecision;
54
- const statusCheckRollup = value.statusCheckRollup;
55
- if (
56
- typeof id !== "string" || !id ||
57
- typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 || !url ||
58
- typeof headRefOid !== "string" || !headRefOid ||
59
- typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt)) ||
60
- (state !== "MERGED" && state !== "CLOSED" && state !== "OPEN") ||
61
- typeof isDraft !== "boolean" || typeof mergeable !== "string" ||
62
- (reviewDecision !== null && typeof reviewDecision !== "string") ||
63
- (statusCheckRollup !== null && !Array.isArray(statusCheckRollup))
64
- ) return undefined;
65
-
66
- return { id, number, url, headRefOid, updatedAt, state, isDraft, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
67
- }
68
-
69
- function parseUnresolvedReviewCount(value: string): number {
70
- if (!value.trim()) throw new Error("Read review comments failed: invalid GitHub CLI output");
71
- const pages = value.trim().split(/\s+/).map(Number);
72
- const count = pages.reduce((total, page) => total + page, 0);
73
- if (pages.some((page) => !Number.isSafeInteger(page) || page < 0) || !Number.isSafeInteger(count)) {
74
- throw new Error("Read review comments failed: invalid GitHub CLI output");
75
- }
76
- return count;
77
- }
78
18
 
79
- function parseRepositoryName(json: string): string {
80
- let value: unknown;
81
- try {
82
- value = JSON.parse(json);
83
- } catch {
84
- throw new Error("Read push repository failed: invalid GitHub CLI output");
85
- }
86
- const nameWithOwner = isRecord(value) ? value.nameWithOwner : undefined;
87
- if (typeof nameWithOwner !== "string" || !nameWithOwner) {
88
- throw new Error("Read push repository failed: invalid GitHub CLI output");
89
- }
90
- return nameWithOwner.toLowerCase();
91
- }
92
-
93
- function parseOpenPullRequestNumbers(json: string, headRepository: string): number[] {
94
- let values: unknown;
95
- try {
96
- values = JSON.parse(json);
97
- } catch {
98
- throw new Error("Find pull requests failed: invalid GitHub CLI output");
99
- }
100
- if (!Array.isArray(values)) throw new Error("Find pull requests failed: invalid GitHub CLI output");
101
- return values.flatMap((value) => {
102
- const number = isRecord(value) ? value.number : undefined;
103
- const repository = isRecord(value) ? value.headRepository : undefined;
104
- if (repository === null) return [];
105
- const nameWithOwner = isRecord(repository) ? repository.nameWithOwner : undefined;
106
- if (
107
- typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 ||
108
- typeof nameWithOwner !== "string" || !nameWithOwner
109
- ) throw new Error("Find pull requests failed: invalid GitHub CLI output");
110
- return nameWithOwner.toLowerCase() === headRepository ? [number] : [];
111
- });
112
- }
113
-
114
- function ciStatus(rollup: unknown[]): CiStatus {
115
- if (!rollup.length) return "none";
116
- let running = false;
117
- for (const check of rollup) {
118
- const record = isRecord(check) ? check : undefined;
119
- const value = record?.conclusion ?? record?.state ?? record?.status;
120
- const state = typeof value === "string" ? value.toUpperCase() : undefined;
121
- if (state && FAILED_CHECK_STATES.has(state)) return "failure";
122
- if (!state || !SUCCESSFUL_CHECK_STATES.has(state)) running = true;
123
- }
124
- return running ? "running" : "success";
125
- }
126
-
127
- type Status = {
128
- text: string;
129
- color: "accent" | "warning" | "success" | "error" | "dim";
19
+ type PullRequestExtensionDependencies = {
20
+ loadCurrentPullRequest?: typeof loadCurrentPullRequest;
21
+ createPrCommandHandler?: typeof createPrCommandHandler;
130
22
  };
131
23
 
132
- function statusFor(pullRequest: PullRequest, ci: CiStatus): Status {
133
- if (pullRequest.state === "MERGED") return { text: "merged", color: "success" };
134
- if (pullRequest.state === "CLOSED") return { text: "closed", color: "dim" };
135
- if (pullRequest.mergeable === "CONFLICTING") return { text: "merge conflict", color: "error" };
136
- if (pullRequest.reviewDecision === "CHANGES_REQUESTED") return { text: "changes requested", color: "error" };
137
- if (ci === "failure") return { text: "CI failed", color: "error" };
138
- if (ci === "running") return { text: "CI running", color: "warning" };
139
- if (pullRequest.isDraft) return { text: "draft", color: "warning" };
140
- if (pullRequest.reviewDecision === "APPROVED") return { text: "approved", color: "success" };
141
- return { text: "open", color: "accent" };
142
- }
143
-
144
- export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"], unresolved = 0): string {
145
- const prText = theme.fg("text", `PR #${pullRequest.number}`);
146
- const link = getCapabilities().hyperlinks ? hyperlink(prText, pullRequest.url.href) : prText;
147
- const status = unresolved > 0
148
- ? { text: `${unresolved} unresolved`, color: "warning" as const }
149
- : statusFor(pullRequest, ciStatus(pullRequest.statusCheckRollup));
150
- return `${link} · ${theme.fg(status.color, status.text)}`;
151
- }
152
-
153
- export default function pullRequestExtension(pi: ExtensionAPI): void {
24
+ export default function pullRequestExtension(
25
+ pi: ExtensionAPI,
26
+ dependencies: PullRequestExtensionDependencies = {},
27
+ ): void {
28
+ const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
29
+ const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
154
30
  let context: ExtensionContext | undefined;
155
31
  let timer: ReturnType<typeof setInterval> | undefined;
156
32
  let active: AbortController | undefined;
157
33
  let queued = false;
158
- let reviewState: { id: string; unresolved: number } | undefined;
159
- let reviewWindow: { id: string; headRefOid: string; updatedAt: string; until: number } | undefined;
34
+ let refreshFailureReported = false;
35
+
36
+ const render = (ctx: ExtensionContext, pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>): void => {
37
+ const display = projectPrDisplay(pullRequest);
38
+ const footer = pullRequest === null ? undefined : formatPrFooter(display, ctx.ui.theme);
39
+ if (pullRequest !== null && footer === undefined) {
40
+ throw new Error("Current pull request display is missing a footer");
41
+ }
42
+ const widget = formatPrWidget(display);
43
+ ctx.ui.setStatus(UI_KEY, footer);
44
+ ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
45
+ };
160
46
 
161
- const stop = () => {
47
+ const stop = (): void => {
162
48
  context = undefined;
163
49
  queued = false;
164
- reviewState = undefined;
165
- reviewWindow = undefined;
50
+ refreshFailureReported = false;
166
51
  if (timer !== undefined) clearInterval(timer);
167
52
  timer = undefined;
168
53
  active?.abort();
169
54
  active = undefined;
170
55
  };
171
56
 
172
- const refresh = async (afterActive = false): Promise<void> => {
57
+ const reportRefreshFailure = (error: unknown): void => {
58
+ const ctx = context;
59
+ if (!ctx || refreshFailureReported) return;
60
+ refreshFailureReported = true;
61
+ try {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ ctx.ui.notify(`PR status refresh failed: ${message.slice(0, 500)}`, "error");
64
+ } catch (reportError) {
65
+ console.error("PR status refresh failed and could not be reported", error, reportError);
66
+ }
67
+ };
68
+
69
+ const refresh = async (): Promise<void> => {
173
70
  const ctx = context;
174
71
  if (!ctx) return;
175
72
  if (active) {
176
- queued ||= afterActive;
73
+ queued = true;
177
74
  return;
178
75
  }
179
76
 
180
77
  const controller = new AbortController();
78
+ const loadContext = { cwd: ctx.cwd, signal: controller.signal };
181
79
  active = controller;
182
80
  try {
183
- const result = await pi.exec(
184
- "gh",
185
- ["pr", "view", "--json", PR_FIELDS],
186
- { cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
187
- );
188
- if (controller.signal.aborted || context !== ctx) return;
189
- if (result.code !== 0) {
190
- ctx.ui.setStatus("pi-pr", undefined);
191
- return;
192
- }
193
-
194
- const pullRequest = parsePullRequest(JSON.parse(result.stdout));
195
- if (!pullRequest) {
196
- reviewState = undefined;
197
- reviewWindow = undefined;
198
- ctx.ui.setStatus("pi-pr", undefined);
199
- return;
200
- }
201
- if (pullRequest.state === "MERGED" || pullRequest.state === "CLOSED") {
202
- reviewState = undefined;
203
- reviewWindow = undefined;
204
- ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme));
205
- return;
206
- }
207
- const changedPullRequest = reviewWindow?.id !== pullRequest.id;
208
- if (
209
- changedPullRequest ||
210
- reviewWindow?.headRefOid !== pullRequest.headRefOid ||
211
- reviewWindow?.updatedAt !== pullRequest.updatedAt
212
- ) {
213
- if (changedPullRequest) reviewState = undefined;
214
- reviewWindow = {
215
- id: pullRequest.id,
216
- headRefOid: pullRequest.headRefOid,
217
- updatedAt: pullRequest.updatedAt,
218
- until: Date.now() + REVIEW_POLL_WINDOW_MS,
219
- };
220
- }
221
- ctx.ui.setStatus("pi-pr", formatPullRequest(
222
- pullRequest,
223
- ctx.ui.theme,
224
- reviewState?.id === pullRequest.id ? reviewState.unresolved : 0,
225
- ));
226
- if (Date.now() >= reviewWindow.until) return;
227
-
81
+ let pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
228
82
  try {
229
- const reviews = await pi.exec(
230
- "gh",
231
- [
232
- "api", "graphql", "--hostname", pullRequest.url.hostname, "--paginate",
233
- "-f", `query=${REVIEW_THREADS_QUERY}`, "-F", `id=${pullRequest.id}`,
234
- "--jq", "[.data.node.reviewThreads.nodes[] | select(.isResolved == false)] | length",
235
- ],
236
- { cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
237
- );
238
- if (controller.signal.aborted || context !== ctx || reviews.code !== 0) return;
239
- const unresolved = parseUnresolvedReviewCount(reviews.stdout);
240
- if (unresolved > 0 && (reviewState?.id !== pullRequest.id || unresolved > reviewState.unresolved)) {
241
- ctx.ui.notify(`PR #${pullRequest.number} has ${unresolved} unresolved review thread${unresolved === 1 ? "" : "s"}`, "warning");
242
- }
243
- reviewState = { id: pullRequest.id, unresolved };
244
- ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme, unresolved));
245
- } catch {
246
- // Keep known PR status when review lookup fails.
83
+ pullRequest = await load(pi, loadContext);
84
+ } catch (error) {
85
+ // Keep the last known display when lookup is unavailable.
86
+ if (!controller.signal.aborted && context === ctx) reportRefreshFailure(error);
87
+ return;
247
88
  }
248
- } catch {
249
- if (!controller.signal.aborted && context === ctx) ctx.ui.setStatus("pi-pr", undefined);
89
+ if (controller.signal.aborted || context !== ctx) return;
90
+ render(ctx, pullRequest);
91
+ refreshFailureReported = false;
250
92
  } finally {
251
93
  if (active !== controller) return;
252
94
  active = undefined;
253
95
  if (queued) {
254
96
  queued = false;
255
- await refresh();
97
+ refreshInBackground();
256
98
  }
257
99
  }
258
100
  };
259
101
 
102
+ const refreshInBackground = (): void => {
103
+ void refresh().catch(reportRefreshFailure);
104
+ };
105
+
260
106
  pi.on("session_start", async (_event, ctx) => {
261
- if (!ctx.hasUI) return;
262
107
  stop();
108
+ if (!ctx.hasUI) return;
263
109
  context = ctx;
264
110
  await refresh();
265
- if (context === ctx) timer = setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
111
+ if (context === ctx) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
266
112
  });
267
113
 
268
114
  pi.on("session_shutdown", stop);
@@ -270,55 +116,20 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
270
116
  pi.on("tool_result", async (event, ctx) => {
271
117
  if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
272
118
  const command = event.input.command;
273
- if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command))) await refresh(true);
119
+ if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command))) {
120
+ await refresh().catch(reportRefreshFailure);
121
+ }
274
122
  });
275
123
 
124
+ const commandHandler = createCommandHandler(pi);
276
125
  pi.registerCommand("pr", {
277
- description: "Open current branch pull request, or run creation workflow when absent",
278
- handler: async (_args, ctx) => {
126
+ description: "Run the current branch pull request next step",
127
+ handler: async (args, ctx) => {
279
128
  if (!ctx.hasUI) return;
280
- const execute = async (action: string, command: string, args: string[]) => {
281
- let result;
282
- try {
283
- result = await pi.exec(command, args, { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
284
- } catch (error) {
285
- throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
286
- }
287
- if (result.code !== 0) {
288
- const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was cancelled" : `exit code ${result.code}`);
289
- throw new Error(`${action} failed: ${detail}`);
290
- }
291
- return result;
292
- };
293
-
294
129
  try {
295
- const branch = (await execute("Read current branch", "git", ["branch", "--show-current"])).stdout.trim();
296
- if (!branch) throw new Error("Create pull request failed: current checkout has no branch");
297
- const remote = (await execute("Read push remote", "git", ["remote", "get-url", "--push", "origin"])).stdout.trim();
298
- if (!remote) throw new Error("Read push remote failed: origin has no push URL");
299
- const repository = await execute("Read push repository", "gh", ["repo", "view", remote, "--json", "nameWithOwner"]);
300
- const listed = await execute("Find pull requests", "gh", [
301
- "pr", "list", "--head", branch, "--state", "open", "--limit", "100", "--json", "number,headRepository",
302
- ]);
303
- const numbers = parseOpenPullRequestNumbers(listed.stdout, parseRepositoryName(repository.stdout));
304
- if (numbers.length > 1) throw new Error("Open pull request failed: multiple open pull requests found for current branch");
305
-
306
- if (numbers.length === 1) {
307
- await execute("Open pull request", "gh", ["pr", "view", String(numbers[0]), "--web"]);
308
- return;
309
- }
310
-
311
- const workflow = pi.getCommands().find((command) =>
312
- command.name === CREATE_PR_SKILL_COMMAND && command.source === "skill" && command.sourceInfo.origin === "package",
313
- );
314
- if (!workflow) throw new Error("Create pull request failed: bundled workflow is unavailable");
315
- if (ctx.isIdle()) {
316
- pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { expandPromptTemplates: true });
317
- } else {
318
- pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { deliverAs: "followUp", expandPromptTemplates: true });
319
- }
130
+ await commandHandler(args, ctx);
320
131
  } finally {
321
- void refresh(true);
132
+ refreshInBackground();
322
133
  }
323
134
  },
324
135
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "0.3.7",
4
- "description": "Show the current branch pull request status in the Pi footer.",
3
+ "version": "1.0.0",
4
+ "description": "Run /pr to automatically choose the current pull request's next safe lifecycle step: create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -16,6 +16,7 @@
16
16
  "license": "MIT",
17
17
  "files": [
18
18
  "LICENSE",
19
+ "docs/pr-routing.svg",
19
20
  "extensions",
20
21
  "skills",
21
22
  "README.md"
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: pi-pr-comment-sweep
3
+ description: Fetch GitHub pull request comments with gh, assess actionable feedback, then apply, validate, commit, push, and resolve scoped fixes without approval pauses. Use when asked to sweep, address, or fix PR comments end to end.
4
+ ---
5
+
6
+ # PR Comment Sweep
7
+
8
+ Address actionable feedback on current-branch PR or supplied PR number/URL.
9
+ Run standalone; never invoke, defer to, or modify Shipyard.
10
+
11
+ 1. Resolve `<skill>` as the absolute directory containing the effective
12
+ `SKILL.md` loaded by Pi. It is a placeholder to substitute, never a literal
13
+ path; use it for bundled scripts and references. Set optional `$PR` and
14
+ require a clean tracked tree. On resume, dirt, or head mismatch, read
15
+ [Sweep recovery](<skill>/references/recovery.md>) and stop unless its
16
+ checkpoint or adoption rules pass. For a fresh sweep:
17
+
18
+ ```bash
19
+ SNAPSHOT=$(mktemp)
20
+ if [ -n "${PR:-}" ]; then
21
+ node "<skill>/scripts/pr-feedback.mjs" target --pr "$PR"
22
+ node "<skill>/scripts/pr-feedback.mjs" fetch --pr "$PR" --out "$SNAPSHOT"
23
+ else
24
+ node "<skill>/scripts/pr-feedback.mjs" target
25
+ node "<skill>/scripts/pr-feedback.mjs" fetch --out "$SNAPSHOT"
26
+ fi
27
+ PR=$(node "<skill>/scripts/pr-feedback.mjs" snapshot-url --snapshot "$SNAPSHOT")
28
+ ```
29
+
30
+ Target verification requires authenticated `gh`, an open PR, exact local/PR
31
+ head, and one matching configured push target. Inspect base, full diff, and raw
32
+ snapshot. Maintain recovery reference's one-line checkpoint after each
33
+ phase.
34
+ 2. Initial fetch prints all conversation comments, review bodies, unresolved
35
+ current/outdated threads, replies, and status. Later fetches into a copied
36
+ snapshot print additions, edits, and state changes only; raw JSON remains complete.
37
+ Follow [Thread triage](<skill>/references/thread-triage.md>) and ledger every item as
38
+ `actionable`, `non-actionable`, or `blocked`, with evidence, smallest fix,
39
+ and regression. Never reconstruct GraphQL pagination.
40
+ 3. Verify feedback against code, callers, tests, and PR intent. For SDK/framework
41
+ claims, inspect installed types and runtime caller; missing public capability
42
+ is `blocked`, never a private-API or cast workaround. Invocation authorizes
43
+ scoped edits, commits, push, and addressed-thread resolution. Stop only for
44
+ unclear behavior, conflicting feedback, material product choice, or scope
45
+ expansion.
46
+ 4. Inspect resulting diff and run smallest relevant non-destructive validation.
47
+ Stage only inspected paths; stop on failed or unavailable required validation
48
+ unless user accepts risk. Never change secrets or unrelated work. Never
49
+ hand-edit generated files: change source, run canonical generator for required
50
+ tracked artifacts, then inspect both. Never wait or poll checks.
51
+ 5. Commit accepted fixes with scoped Conventional Commit message(s). With no
52
+ fixes, do not commit or push. After clean-tree validation, run
53
+ `node "<skill>/scripts/pr-feedback.mjs" push --snapshot "$SNAPSHOT"`. The
54
+ helper revalidates the configured destination, PR identity, and local HEAD.
55
+ It pushes the captured OID once with no fallback. Then capture the swept head:
56
+
57
+ ```bash
58
+ EXPECTED_HEAD=$(git rev-parse --verify 'HEAD^{commit}')
59
+ ```
60
+ 6. Copy baseline before final fetch:
61
+
62
+ ```bash
63
+ FINAL_SNAPSHOT=$(mktemp)
64
+ cp "$SNAPSHOT" "$FINAL_SNAPSHOT"
65
+ node "<skill>/scripts/pr-feedback.mjs" fetch --pr "$PR" --out "$FINAL_SNAPSHOT"
66
+ ```
67
+
68
+ Assess delta, then resolve all addressed IDs in one command, repeating flag:
69
+ `node "<skill>/scripts/pr-feedback.mjs" resolve --pr "$PR" --expected-head
70
+ "$EXPECTED_HEAD" --thread "$ID1" --thread "$ID2"`. Before each resolution,
71
+ the helper re-resolves the checkout's configured push target and exact open PR.
72
+ Never resolve non-actionable or blocked threads. Re-fetch once into `FINAL_SNAPSHOT`, assess
73
+ late delta, batch any newly addressed IDs, then run
74
+ `node "<skill>/scripts/pr-feedback.mjs" checks --pr "$PR" --expected-head
75
+ "$EXPECTED_HEAD"` once. Read-only
76
+ transient retries are allowed; mutation retries and polling are not. Do not
77
+ reply unless explicitly requested.
78
+ 7. Report `PR | actioned | resolved IDs | skipped IDs | pending IDs | checks |
79
+ commits | push | blockers`.
@@ -0,0 +1,49 @@
1
+ # Sweep Recovery
2
+
3
+ Read only for resume or PR-head mismatch.
4
+
5
+ ## Checkpoint
6
+
7
+ Only ignored `.context/progress.md` may change under `.context/`. Keep one JSON
8
+ line under `## PR comment sweep`; never stage it:
9
+
10
+ ```json
11
+ {"workflow":"pi-pr-comment-sweep","pr":"URL","snapshot":"/tmp/file","final_snapshot":null,"head":"SHA","phase":"triage","owned":[],"ledger":{},"checks":[],"commit":null,"pushed":false,"resolved":[]}
12
+ ```
13
+
14
+ Use phases `triage`, `editing`, `validated`, `committed`, `pushed`, or `resolved`.
15
+ Update after each phase; keep repository-relative owned paths, thread verdicts in
16
+ `ledger`, exact validation results, commit SHA, push state, and resolved IDs.
17
+
18
+ Resume only when workflow and PR match, snapshot exists and names that PR, local
19
+ `HEAD` equals `head`, and tracked dirty paths equal `owned`. Skip completed
20
+ phases and continue with next one. Stop on missing, malformed, or conflicting
21
+ state; never absorb unknown changes. Commands requiring a clean tree remain
22
+ blocked until tracked work is committed or otherwise restored by user.
23
+
24
+ ## Adopt PR head
25
+
26
+ Only exact user words **“Adopt PR head”** authorize this path. Inspect
27
+ `gh pr view "$PR" --json url,headRepository,headRefName,headRefOid`. Validate the
28
+ PR URL, full head OID, and ref. Choose exactly one push URL whose normalized
29
+ GitHub host/owner/repository matches `headRepository`. Run:
30
+
31
+ ```bash
32
+ git fetch --no-write-fetch-head --no-tags "$PUSH_URL" "$PR_HEAD_SHA"
33
+ git cat-file -e "$PR_HEAD_SHA^{commit}"
34
+ git log --oneline HEAD.."$PR_HEAD_SHA"
35
+ git diff --stat HEAD.."$PR_HEAD_SHA"
36
+ git merge-base --is-ancestor HEAD "$PR_HEAD_SHA"
37
+ STATUS=$(git status --porcelain=v1 --untracked-files=all) || exit 1
38
+ test -z "$STATUS" || exit 1
39
+ for STATE in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD REVERT_HEAD sequencer; do
40
+ STATE_PATH=$(git rev-parse --git-path "$STATE") || exit 1
41
+ test -n "$STATE_PATH" && test ! -e "$STATE_PATH" || exit 1
42
+ done
43
+ git merge --ff-only "$PR_HEAD_SHA"
44
+ ```
45
+
46
+ Run the clean-tree and Git-operation checks immediately before the fast-forward.
47
+ Stop on an absent object, divergence, dirty tracked or untracked state, an active
48
+ Git operation, or an ambiguous push URL. Never stash, reset, or clean. Re-run
49
+ target verification and initial fetch after fast-forward.
@@ -0,0 +1,8 @@
1
+ # Thread Triage
2
+
3
+ - Resolved: ignore.
4
+ - Outdated: inspect current diff and source lines; re-anchor before deciding relevance.
5
+ - Open/current: record `actionable`, `non-actionable`, or `blocked` with evidence,
6
+ smallest fix, and regression check.
7
+ - Resolve only an addressed actionable thread after verified fix and head check.
8
+ Never resolve non-actionable or blocked threads; report their IDs as pending.