@henryqw/pi-pr 4.0.6 → 4.0.9

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/README.md CHANGED
@@ -44,9 +44,12 @@ Run `/pr` in a GitHub checkout. It reads fresh pull request and local state, the
44
44
 
45
45
  For creation, put an optional base branch first. For example, run `/pr --base release/2026 Keep the title concise.` The base is a branch name, not a host or repository. Only creation accepts the base and remaining guidance. Other routes reject them instead of ignoring them.
46
46
 
47
+ Run `/pr --feedback` to explicitly start or resume a feedback sweep. Use it for actionable conversation comments that do not select the sweep automatically. It cannot be combined with a base, other options, or instructions.
48
+
47
49
  | Surface | Type | Purpose |
48
50
  | --- | --- | --- |
49
51
  | `/pr [--base BRANCH] [creation instructions]` | command | Run the current pull request's next safe route. |
52
+ | `/pr --feedback` | command | Explicitly start or resume the guarded feedback sweep. |
50
53
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
51
54
  | Widget | ui | Show one action hint or transient routing status. |
52
55
 
@@ -64,17 +67,18 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
64
67
 
65
68
  | Current condition | `/pr` route |
66
69
  | --- | --- |
67
- | No current-branch pull request, no published matching ref, safe Git push configuration, and a commit ahead of the selected base | Start pull-request creation. |
70
+ | No current-branch pull request, no published matching ref, safe Git push configuration, and a commit or ordinary pending work | Start pull-request creation. |
68
71
  | One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
69
72
  | Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
70
73
  | Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
71
74
  | GitHub Actions job failed | Run the CI fix workflow when the same local prerequisite holds. |
72
75
  | External check or commit status failed | Show `CI failed` as a no-action blocker. |
73
- | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
76
+ | Changes requested or unresolved review threads | Start or resume the package comment sweep when the same local prerequisite holds. |
77
+ | Explicit `/pr --feedback` on an open, configured pull request | Start or resume the sweep when the tree is clean and local HEAD equals the PR head. |
74
78
  | No-action state | Report the state without taking action. |
75
79
  | Merge-ready pull request | Ask for final confirmation, recheck fresh state, and squash-merge if confirmed. |
76
80
 
77
- `pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires at least one committed change ahead. Dirty work alone does not enable creation. If the current branch is the selected base, pi-pr stays silent because GitHub cannot create a pull request from a ref to itself.
81
+ `pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires a commit ahead or ordinary pending work, including untracked files. A Git operation in progress does not count as pending work. If the current branch is the selected base, pi-pr stays silent because GitHub cannot create a pull request from a ref to itself.
78
82
 
79
83
  The base always comes from validated `origin`. The head may use that repository or a fork with the same GitHub source. Base and head must use the same GitHub host. Other fork relationships stop before mutation.
80
84
 
@@ -90,6 +94,12 @@ The creation workflow repeats destination, remote OID, PR, and configuration che
90
94
 
91
95
  Each helper workflow receives a random run ID and its first action. The run stays bound to one session, canonical worktree, route, and fresh authority. Helper calls from another run, session, worktree, or route fail.
92
96
 
97
+ For comment sweeps, `/pr` checks the package recovery file without changing it. It selects `start` when recovery is absent. It selects `resume` only when valid recovery matches the fresh route authority. Invalid recovery stays unchanged and blocks dispatch with its path and reason.
98
+
99
+ `/pr --feedback` uses the same discovery, reservation, recovery, and guard checks. It can select the sweep even when CI failure or merge readiness would otherwise select another route. Without the flag, route priority stays unchanged.
100
+
101
+ Direct skill or `pi_pr_*` tool calls cannot create route authority. Run `/pr` to reserve a fresh route.
102
+
93
103
  Only one helper run can exist at a time. Most runs expire when the agent settles. A create or branch-update conflict stays available for one user-guided continuation, then expires after that continuation settles. Session replacement and shutdown forget the run without aborting or cleaning a pending merge.
94
104
 
95
105
  After a `/pr` create workflow settles, the extension waits for a refresh that finds a configured current PR. It then prefixes the Herdr workspace label with `#<number> • `.
@@ -117,7 +127,7 @@ A missing pull request uses creation. For an existing pull request, the first ma
117
127
  5. Waiting or local safety block: no action.
118
128
  6. Merge-ready: allow clean local HEAD equal to or behind the PR head. Confirm, then merge directly.
119
129
 
120
- Ordinary conversation comments do not trigger a route or block a merge. Changes requested and unresolved review threads can select the package comment sweep.
130
+ Ordinary conversation comments do not trigger a route or block a merge. Changes requested and unresolved review threads can select the package comment sweep. Use `/pr --feedback` when a conversation comment needs action.
121
131
 
122
132
  The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
123
133
 
@@ -133,7 +143,7 @@ The footer and widget load once at session start. A directory outside a Git work
133
143
 
134
144
  They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles. There is no periodic presentation refresh, so external changes may leave the footer and widget stale indefinitely. `/pr` reads fresh state before routing or acting and remains authoritative.
135
145
 
136
- The create widget stays hidden until the local branch has a commit beyond its creation point. `/pr` replaces any hint with routing feedback while it selects a route. The feedback clears before route interaction. A dispatched workflow keeps the widget hidden until the agent settles. Direct and no-action routes refresh it after completion. A failed command restores the prior hint and schedules a refresh, except when fresh lookup hits the GitHub API quota: it shows the sanitized message `GitHub API rate limit exhausted; retry after GitHub resets it` and does not immediately retry.
146
+ The create widget stays hidden on a clean branch with no commit ahead. It appears for a commit ahead or ordinary pending work. It stays hidden during a Git operation and when the current branch is the selected base. `/pr` replaces any hint with routing feedback while it selects a route. The feedback clears before route interaction. A dispatched workflow keeps the widget hidden until the agent settles. Direct and no-action routes refresh it after completion. A failed command restores the prior hint and schedules a refresh, except when fresh lookup hits the GitHub API quota: it shows the sanitized message `GitHub API rate limit exhausted; retry after GitHub resets it` and does not immediately retry.
137
147
 
138
148
  Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
139
149
 
@@ -147,9 +157,9 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
147
157
 
148
158
  ## Limits and recovery
149
159
 
150
- - `/pr` accepts creation syntax only as a leading `--base BRANCH`, followed by optional creation guidance. It does not open a browser.
160
+ - `/pr` accepts either standalone `--feedback` or creation syntax with leading `--base BRANCH` and optional guidance. It rejects unknown or conflicting options. It does not open a browser.
151
161
  - It does not run `/done` or `/sweep`.
152
- - Presentation refreshes do not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
162
+ - Presentation refreshes do not auto-triage comments or start a workflow. The package comment sweep starts or resumes only when an explicit `/pr` selects it.
153
163
  - It does not enable auto-merge or add a merge queue.
154
164
  - It does not rebase the local branch, overwrite concurrent remote updates, delete branches, or clean up worktrees. Creation uses exact leases plus ancestry checks; an empty lease is only an atomic absence check.
155
165
  - Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
@@ -11,21 +11,26 @@ import {
11
11
  } from "./pr-github.ts";
12
12
  import {
13
13
  deriveNextStep,
14
+ deriveRouteDecision,
15
+ type FeedbackRouteBlocker,
14
16
  type NextStep,
15
17
  type PullRequestTarget,
18
+ type RouteIntent,
16
19
  } from "./pr-routing.ts";
17
20
 
18
21
  export type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
19
22
 
20
- const WORKFLOWS: Record<WorkflowNextStep, { command: string; action: string }> = {
21
- create: { command: "skill:pi-pr-create", action: "prepare" },
22
- "update-branch": { command: "skill:pi-pr-update-branch", action: "merge" },
23
- sweep: { command: "skill:pi-pr-comment-sweep", action: "start" },
24
- "fix-ci": { command: "skill:pi-pr-fix-ci", action: "collect" },
23
+ const WORKFLOWS: Record<WorkflowNextStep, { command: string }> = {
24
+ create: { command: "skill:pi-pr-create" },
25
+ "update-branch": { command: "skill:pi-pr-update-branch" },
26
+ sweep: { command: "skill:pi-pr-comment-sweep" },
27
+ "fix-ci": { command: "skill:pi-pr-fix-ci" },
25
28
  };
26
29
  export type WorkflowReservation =
27
30
  | { route: "create"; target: PullRequestTarget; base?: string }
28
31
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
32
+ export type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect";
33
+ export type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction };
29
34
 
30
35
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
31
36
  export type PrCommandInvocation = ((nextStep: NextStep) => void) & {
@@ -42,7 +47,7 @@ export type WorkflowPromptIdentity = Readonly<{
42
47
  route: WorkflowNextStep;
43
48
  skill: string;
44
49
  runId: string;
45
- action: string;
50
+ action: WorkflowLaunchAction;
46
51
  }>;
47
52
 
48
53
  export type PrCommandDependencies = {
@@ -52,25 +57,49 @@ export type PrCommandDependencies = {
52
57
  reservation: WorkflowReservation,
53
58
  ctx: ExtensionCommandContext,
54
59
  invocation?: PrCommandInvocation,
55
- ) => Promise<string>;
60
+ ) => Promise<WorkflowReservationResult>;
56
61
  markWorkflowPromptQueued?: (identity: WorkflowPromptIdentity, queued: boolean) => void;
57
62
  releaseWorkflow?: (runId: string, invocation?: PrCommandInvocation) => void;
58
63
  };
59
64
 
60
65
  type ParsedPrArguments = {
61
66
  base?: string;
67
+ intent: RouteIntent;
62
68
  instructions: string;
63
69
  };
64
70
 
65
71
  function parsePrArguments(args: string): ParsedPrArguments {
66
72
  const leading = args.trimStart();
73
+ const trimmed = leading.trim();
74
+ if (leading.startsWith("--feedback=")) throw new Error("/pr feedback syntax is --feedback");
75
+ if (/^--feedback(?:\s|$)/.test(leading)) {
76
+ if (trimmed !== "--feedback") throw new Error("/pr --feedback cannot be combined with other options or instructions");
77
+ return { intent: "feedback", instructions: "" };
78
+ }
79
+ if (/(?:^|\s)--feedback(?=\s|=|$)/.test(leading)) {
80
+ throw new Error("/pr --feedback cannot be combined with other options or instructions");
81
+ }
67
82
  if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base <branch>");
68
- if (!leading.startsWith("--base") || !/^--base(?:\s|$)/.test(leading)) {
69
- return { instructions: args.trim() };
83
+ if (/^--base(?:\s|$)/.test(leading)) {
84
+ const value = /^--base\s+(\S+)/.exec(leading);
85
+ if (!value) throw new Error("/pr --base requires a branch");
86
+ const instructions = leading.slice(value[0].length).trim();
87
+ if (instructions.startsWith("--")) throw new Error(`Unknown /pr option: ${instructions.split(/\s+/, 1)[0]}`);
88
+ return { base: value[1]!, intent: "automatic", instructions };
89
+ }
90
+ if (leading.startsWith("--")) throw new Error(`Unknown /pr option: ${leading.split(/\s+/, 1)[0]}`);
91
+ return { intent: "automatic", instructions: args.trim() };
92
+ }
93
+
94
+ function feedbackBlockerMessage(blocker: FeedbackRouteBlocker): string {
95
+ switch (blocker.kind) {
96
+ case "discovery-blocked": return "/pr --feedback is blocked because pull request discovery is unsafe";
97
+ case "pull-request-unavailable": return "/pr --feedback requires a current pull request";
98
+ case "pull-request-not-open": return "/pr --feedback requires an open pull request";
99
+ case "target-not-configured": return "/pr --feedback requires a configured pull request target; run /pr to link the branch first";
100
+ case "worktree-dirty": return "/pr --feedback is blocked by a dirty worktree";
101
+ case "head-not-equal": return `/pr --feedback is blocked by local HEAD ${blocker.relation}`;
70
102
  }
71
- const value = /^--base\s+(\S+)/.exec(leading);
72
- if (!value) throw new Error("/pr --base requires a branch");
73
- return { base: value[1]!, instructions: leading.slice(value[0].length).trim() };
74
103
  }
75
104
 
76
105
  function workflowReservation(
@@ -96,7 +125,7 @@ function packageWorkflowCommand(pi: PrCommandPi, route: WorkflowNextStep) {
96
125
  candidate.sourceInfo.origin === "package"
97
126
  );
98
127
  if (!command) throw new Error(`${workflow.command} failed: bundled workflow is unavailable`);
99
- return { command, action: workflow.action };
128
+ return command;
100
129
  }
101
130
 
102
131
  async function dispatchWorkflow(
@@ -113,10 +142,11 @@ async function dispatchWorkflow(
113
142
  const workflow = packageWorkflowCommand(pi, route);
114
143
  let runId: string | undefined;
115
144
  try {
116
- runId = await reserve(reservation, ctx, invocation);
145
+ const reserved = await reserve(reservation, ctx, invocation);
146
+ runId = reserved.runId;
117
147
  invocation?.assertCurrent();
118
148
  const queued = !ctx.isIdle();
119
- const identity = { route, skill: workflow.command.name, runId, action: workflow.action };
149
+ const identity = { route, skill: workflow.name, runId, action: reserved.action };
120
150
  markPromptQueued(identity, queued);
121
151
  const options = queued
122
152
  ? { deliverAs: "followUp" as const, expandPromptTemplates: true }
@@ -254,11 +284,13 @@ export function createPrCommandHandler(
254
284
  const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
255
285
  ? onRouteResolved as PrCommandInvocation
256
286
  : undefined;
257
- const { base, instructions } = parsePrArguments(args);
287
+ const { base, intent, instructions } = parsePrArguments(args);
258
288
  const discovery = await load(pi, ctx, undefined, undefined, base);
259
289
  commandInvocation?.assertCurrent();
260
- const nextStep = deriveNextStep(discovery);
290
+ const decision = deriveRouteDecision(discovery, intent);
291
+ const nextStep = decision.nextStep;
261
292
  onRouteResolved?.(nextStep);
293
+ if (decision.kind === "feedback-blocked") throw new Error(feedbackBlockerMessage(decision.blocker));
262
294
  if (base !== undefined && nextStep !== "create") {
263
295
  throw new Error("/pr --base is accepted only for pull request creation");
264
296
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, realpath, rm } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
2
+ import { realpath, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  extensionConfigDir,
@@ -27,6 +27,7 @@ import {
27
27
  type PullRequestLoadContext,
28
28
  } from "./pr-github.ts";
29
29
  import {
30
+ inspectGitOperation,
30
31
  isAncestor,
31
32
  parseNulPaths,
32
33
  parseStatusSnapshot,
@@ -46,7 +47,6 @@ export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
46
47
 
47
48
  const STATE_VERSION = 1;
48
49
  const STATE_FILE = "state.json";
49
- const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
50
50
  const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
51
51
  const CHECK_MAX_COUNT = 32;
52
52
  const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
@@ -231,6 +231,16 @@ function sameLinkage(expected: SweepAuthority, current: SweepAuthority, remoteHe
231
231
  current.target.remoteOid === remoteHead;
232
232
  }
233
233
 
234
+ function recoveryMatchesRouteAuthority(state: SweepState, suppliedAuthority: SweepAuthority): boolean {
235
+ const permittedHeads = new Set<string>();
236
+ if (state.attempts.push.state !== "applied") permittedHeads.add(state.original.lease);
237
+ if (
238
+ (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
239
+ state.publicationHead
240
+ ) permittedHeads.add(state.publicationHead);
241
+ return [...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head));
242
+ }
243
+
234
244
  function feedbackMatchesAuthority(snapshot: FeedbackSnapshot, authority: SweepAuthority, head: string): boolean {
235
245
  const current = snapshot.pullRequest;
236
246
  return current.id === authority.id && current.number === authority.number && current.url === authority.url &&
@@ -575,6 +585,17 @@ export class PullRequestCommentSweep {
575
585
  return (await this.location()).path;
576
586
  }
577
587
 
588
+ async recoveryLaunchAction(): Promise<"start" | "resume"> {
589
+ if (!this.suppliedAuthority) throw new Error("Comment sweep recovery inspection requires route authority");
590
+ const location = await this.location();
591
+ const state = await this.loadIfPresent(location);
592
+ if (!state) return "start";
593
+ if (!recoveryMatchesRouteAuthority(state, this.suppliedAuthority)) {
594
+ throw new Error(`Comment sweep recovery is preserved at ${location.path}: recovery does not match freshly discovered route authority`);
595
+ }
596
+ return "resume";
597
+ }
598
+
578
599
  private async loadState(location: Awaited<ReturnType<PullRequestCommentSweep["location"]>>): Promise<SweepState> {
579
600
  const raw = await readTextFileBounded(location.path, SWEEP_RECOVERY_MAX_BYTES, { signal: this.signal });
580
601
  let value: unknown;
@@ -619,23 +640,8 @@ export class PullRequestCommentSweep {
619
640
  }
620
641
 
621
642
  private async requireNoGitOperation(): Promise<void> {
622
- const paths = await runChecked(this.exec, "git", [
623
- "rev-parse", ...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
624
- ], this.options());
625
- const normalized = paths.stdout.replace(/\r\n/g, "\n");
626
- const values = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
627
- if (values.length !== GIT_OPERATION_STATES.length || values.some((path) => !path)) {
628
- throw new Error("Git operation state path resolution returned invalid output");
629
- }
630
- for (const [index, path] of values.entries()) {
631
- try {
632
- await lstat(resolve(this.cwd, path));
633
- throw new Error(`${GIT_OPERATION_STATES[index]} is in progress`);
634
- } catch (error) {
635
- if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
636
- throw error;
637
- }
638
- }
643
+ const operation = await inspectGitOperation(this.exec, this.options());
644
+ if (operation !== null) throw new Error(`${operation} is in progress`);
639
645
  }
640
646
 
641
647
  private async localPaths(): Promise<string[]> {
@@ -789,17 +795,10 @@ export class PullRequestCommentSweep {
789
795
  async resume(): Promise<SweepStatus> {
790
796
  return await withWorktreeLock(this.cwd, async () => {
791
797
  if (!this.suppliedAuthority) throw new Error("Comment sweep resume requires route authority");
792
- const suppliedAuthority = this.suppliedAuthority;
793
798
  const location = await this.location();
794
799
  const state = await this.loadState(location);
795
- const permittedHeads = new Set<string>();
796
- if (state.attempts.push.state !== "applied") permittedHeads.add(state.original.lease);
797
- if (
798
- (state.attempts.push.state === "attempting" || state.attempts.push.state === "unknown" || state.attempts.push.state === "applied") &&
799
- state.publicationHead
800
- ) permittedHeads.add(state.publicationHead);
801
- if (![...permittedHeads].some((head) => sameLinkage(state.authority, suppliedAuthority, head))) {
802
- throw new Error("Comment sweep recovery does not match supplied route authority");
800
+ if (!recoveryMatchesRouteAuthority(state, this.suppliedAuthority)) {
801
+ throw new Error(`Comment sweep recovery is preserved at ${location.path}: recovery does not match supplied route authority`);
803
802
  }
804
803
  await this.reconcile(state);
805
804
  state.attempts.resolutions = state.attempts.resolutions.filter(({ state: attempt }) => attempt !== "blocked");
@@ -15,7 +15,10 @@ import {
15
15
  type PullRequestLoadContext,
16
16
  type PullRequestPublication,
17
17
  } from "./pr-github.ts";
18
- import type { PullRequestTarget } from "./pr-routing.ts";
18
+ import {
19
+ isPullRequestCreationEligible,
20
+ type PullRequestTarget,
21
+ } from "./pr-routing.ts";
19
22
  import {
20
23
  assertOnlyDeclaredStatusChanged,
21
24
  inspectWorktree,
@@ -221,13 +224,14 @@ export class PullRequestCreator {
221
224
  return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
222
225
  }
223
226
 
224
- private async freshNone(): Promise<void> {
227
+ private async freshNone() {
225
228
  const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
226
229
  if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
227
230
  throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
228
231
  }
229
232
  const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
230
233
  if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
234
+ return discovery;
231
235
  }
232
236
 
233
237
  private async liveBase(): Promise<string> {
@@ -270,8 +274,11 @@ export class PullRequestCreator {
270
274
  this.target,
271
275
  this.explicitBase,
272
276
  );
273
- if (preflight.ahead === 0) {
274
- throw new Error("PR creation requires at least one commit ahead of the selected base");
277
+ if (preflight.worktree === "operation") {
278
+ throw new Error("PR creation cannot prepare while a Git operation is in progress");
279
+ }
280
+ if (!isPullRequestCreationEligible({ ...preflight, relation: "distinct-ref" })) {
281
+ throw new Error("PR creation requires at least one commit ahead of the selected base or pending work");
275
282
  }
276
283
  const { base } = preflight;
277
284
  this.state.base = {
@@ -286,11 +293,14 @@ export class PullRequestCreator {
286
293
  "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, base.oid,
287
294
  ], this.options());
288
295
  await runChecked(this.exec, "git", ["cat-file", "-e", `${base.oid}^{commit}`], this.options());
289
- await this.freshNone();
296
+ const fresh = await this.freshNone();
290
297
  if (await readHead(this.exec, this.options()) !== preflight.head) {
291
298
  throw new Error("PR creation cancelled: local HEAD changed during prepare");
292
299
  }
293
300
  if (await this.liveBase() !== base.oid) throw new Error("PR creation cancelled: base ref moved during prepare");
301
+ if (!isPullRequestCreationEligible(fresh.branch)) {
302
+ throw new Error("PR creation cancelled: branch no longer has a committed change or pending work");
303
+ }
294
304
  this.state.phase = "prepared";
295
305
  return { kind: "prepared", base: { ...this.state.base }, mergeBase: base.mergeBase };
296
306
  }, { agentDir: this.agentDir, signal: this.signal });
@@ -37,6 +37,7 @@ export type ExecOptions = {
37
37
  export type Exec = (command: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
38
38
 
39
39
  export type AttemptState = "none" | "attempting" | "applied" | "blocked" | "unknown";
40
+ export type GitWorktreeState = "clean" | "dirty" | "operation";
40
41
 
41
42
  function positiveInteger(value: number, label: string): number {
42
43
  if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${label} must be a positive safe integer`);
@@ -236,9 +237,8 @@ export async function runChecked(
236
237
  return result;
237
238
  }
238
239
 
239
- /** Inspect both porcelain state and Git operation markers without mutating the repository. */
240
- export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise<"clean" | "dirty"> {
241
- const status = await runChecked(exec, "git", ["status", "--porcelain=v1", "--untracked-files=all"], options);
240
+ /** Return the active Git operation marker without mutating the repository. */
241
+ export async function inspectGitOperation(exec: Exec, options: ExecOptions): Promise<string | null> {
242
242
  const stateOutput = await runChecked(exec, "git", [
243
243
  "rev-parse",
244
244
  ...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
@@ -251,7 +251,7 @@ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise
251
251
  for (const [index, path] of statePaths.entries()) {
252
252
  try {
253
253
  await lstat(resolve(options.cwd, path));
254
- return "dirty";
254
+ return GIT_OPERATION_STATES[index]!;
255
255
  } catch (error) {
256
256
  if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
257
257
  const code = error && typeof error === "object" && typeof (error as NodeJS.ErrnoException).code === "string"
@@ -260,9 +260,21 @@ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise
260
260
  throw new Error(`Git operation state inspection failed for ${GIT_OPERATION_STATES[index]}: ${code}`);
261
261
  }
262
262
  }
263
+ return null;
264
+ }
265
+
266
+ /** Distinguish ordinary pending work from an in-progress Git operation. */
267
+ export async function inspectWorktreeState(exec: Exec, options: ExecOptions): Promise<GitWorktreeState> {
268
+ const status = await runChecked(exec, "git", ["status", "--porcelain=v1", "--untracked-files=all"], options);
269
+ if (await inspectGitOperation(exec, options) !== null) return "operation";
263
270
  return status.stdout === "" ? "clean" : "dirty";
264
271
  }
265
272
 
273
+ /** Inspect both porcelain state and Git operation markers without mutating the repository. */
274
+ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise<"clean" | "dirty"> {
275
+ return await inspectWorktreeState(exec, options) === "clean" ? "clean" : "dirty";
276
+ }
277
+
266
278
  /** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace. */
267
279
  export async function withWorktreeLock<T>(
268
280
  cwd: string,
@@ -5,7 +5,11 @@ import type {
5
5
  import { lstatSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { inspectLocalMergeSafety } from "./pr-merge.ts";
8
- import { withWorktreeLock } from "./pr-execution.ts";
8
+ import {
9
+ inspectWorktreeState,
10
+ withWorktreeLock,
11
+ type GitWorktreeState,
12
+ } from "./pr-execution.ts";
9
13
  import type {
10
14
  CiStatus,
11
15
  LocalMergeSafety,
@@ -112,10 +116,11 @@ export type PullRequestCreationPreflight = {
112
116
  mergeBase: string;
113
117
  };
114
118
  ahead: number;
119
+ worktree: GitWorktreeState;
115
120
  };
116
121
 
117
122
  type CreationPreflightResult =
118
- | { kind: "same-ref" }
123
+ | { kind: "same-ref"; worktree: GitWorktreeState }
119
124
  | { kind: "distinct-ref"; preflight: PullRequestCreationPreflight };
120
125
 
121
126
  type CreationIdentity = {
@@ -1158,6 +1163,24 @@ function parseCreationAhead(output: string): number {
1158
1163
  return ahead;
1159
1164
  }
1160
1165
 
1166
+ async function inspectCreationWorktree(
1167
+ pi: Pick<ExtensionAPI, "exec">,
1168
+ context: PullRequestLoadContext,
1169
+ ): Promise<GitWorktreeState> {
1170
+ try {
1171
+ return await inspectWorktreeState(
1172
+ async (command, args) => await invoke(pi, context, "Inspect creation worktree", command, args),
1173
+ { cwd: context.cwd, signal: context.signal },
1174
+ );
1175
+ } catch (error) {
1176
+ if (error instanceof PullRequestLoadError) throw error;
1177
+ return fail(
1178
+ "Inspect creation worktree",
1179
+ error instanceof Error ? error.message : "inspection failed",
1180
+ );
1181
+ }
1182
+ }
1183
+
1161
1184
  async function preflightCreation(
1162
1185
  pi: Pick<ExtensionAPI, "exec">,
1163
1186
  context: PullRequestLoadContext,
@@ -1176,7 +1199,8 @@ async function preflightCreation(
1176
1199
  : await validateCreationRef(pi, context, explicitBaseRef);
1177
1200
  const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
1178
1201
  const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
1179
- if (relation === "same-ref") return { kind: relation };
1202
+ const worktree = await inspectCreationWorktree(pi, context);
1203
+ if (relation === "same-ref") return { kind: relation, worktree };
1180
1204
  const trackingRef = `refs/remotes/origin/${baseRef}`;
1181
1205
  await execute(pi, context, "Fetch creation base", "git", [
1182
1206
  "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
@@ -1208,6 +1232,7 @@ async function preflightCreation(
1208
1232
  mergeBase,
1209
1233
  },
1210
1234
  ahead,
1235
+ worktree,
1211
1236
  },
1212
1237
  };
1213
1238
  }
@@ -1236,7 +1261,9 @@ async function creationDiscovery(
1236
1261
  return {
1237
1262
  kind: "none",
1238
1263
  creationTarget: target,
1239
- branch: { ahead: result.kind === "same-ref" ? 0 : result.preflight.ahead },
1264
+ branch: result.kind === "same-ref"
1265
+ ? { ahead: 0, worktree: result.worktree, relation: result.kind }
1266
+ : { ahead: result.preflight.ahead, worktree: result.preflight.worktree, relation: result.kind },
1240
1267
  };
1241
1268
  }
1242
1269
 
@@ -40,6 +40,8 @@ export type PullRequestTarget = {
40
40
 
41
41
  export type BranchCreationState = {
42
42
  ahead: number;
43
+ worktree: "clean" | "dirty" | "operation";
44
+ relation: "same-ref" | "distinct-ref";
43
45
  };
44
46
 
45
47
  export type DiscoveryIssue =
@@ -59,6 +61,17 @@ export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
59
61
  | { kind: "inactive" };
60
62
 
61
63
  export type NextStep = "create" | "link-branch" | "blocked" | "none" | "update-branch" | "sweep" | "fix-ci" | "merge";
64
+ export type RouteIntent = "automatic" | "feedback";
65
+ export type FeedbackRouteBlocker =
66
+ | { kind: "discovery-blocked" }
67
+ | { kind: "pull-request-unavailable" }
68
+ | { kind: "pull-request-not-open" }
69
+ | { kind: "target-not-configured" }
70
+ | { kind: "worktree-dirty" }
71
+ | { kind: "head-not-equal"; relation: Exclude<LocalHeadRelation, "equal"> };
72
+ export type RouteDecision =
73
+ | { kind: "selected"; nextStep: NextStep }
74
+ | { kind: "feedback-blocked"; nextStep: "blocked"; blocker: FeedbackRouteBlocker };
62
75
 
63
76
  function localMutationSafe(local: LocalMergeSafety): boolean {
64
77
  return local.worktree === "clean" && local.head === "equal";
@@ -88,12 +101,52 @@ export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<Nex
88
101
  return "merge";
89
102
  }
90
103
 
91
- export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
104
+ export function isPullRequestCreationEligible(branch: BranchCreationState): boolean {
105
+ return branch.relation === "distinct-ref" && branch.worktree !== "operation" &&
106
+ (branch.ahead > 0 || branch.worktree === "dirty");
107
+ }
108
+
109
+ function deriveAutomaticNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
92
110
  if (discovery.kind === "inactive") return "none";
93
111
  if (discovery.kind === "blocked") return "blocked";
94
- if (discovery.kind === "none") return discovery.branch.ahead > 0 ? "create" : "none";
112
+ if (discovery.kind === "none") return isPullRequestCreationEligible(discovery.branch) ? "create" : "none";
95
113
  if (discovery.pullRequest.target.provenance === "inferred") {
96
114
  return discovery.pullRequest.lifecycle === "open" ? "link-branch" : "none";
97
115
  }
98
116
  return derivePullRequestNextStep(discovery.pullRequest);
99
117
  }
118
+
119
+ export function deriveRouteDecision(
120
+ discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>,
121
+ intent: RouteIntent = "automatic",
122
+ ): RouteDecision {
123
+ if (intent === "automatic") return { kind: "selected", nextStep: deriveAutomaticNextStep(discovery) };
124
+ if (discovery.kind === "blocked") {
125
+ return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "discovery-blocked" } };
126
+ }
127
+ if (discovery.kind !== "current") {
128
+ return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "pull-request-unavailable" } };
129
+ }
130
+ const pullRequest = discovery.pullRequest;
131
+ if (pullRequest.lifecycle !== "open") {
132
+ return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "pull-request-not-open" } };
133
+ }
134
+ if (pullRequest.target.provenance !== "configured") {
135
+ return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "target-not-configured" } };
136
+ }
137
+ if (pullRequest.local.worktree !== "clean") {
138
+ return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "worktree-dirty" } };
139
+ }
140
+ if (pullRequest.local.head !== "equal") {
141
+ return {
142
+ kind: "feedback-blocked",
143
+ nextStep: "blocked",
144
+ blocker: { kind: "head-not-equal", relation: pullRequest.local.head },
145
+ };
146
+ }
147
+ return { kind: "selected", nextStep: "sweep" };
148
+ }
149
+
150
+ export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
151
+ return deriveRouteDecision(discovery).nextStep;
152
+ }
package/extensions/pr.ts CHANGED
@@ -140,7 +140,7 @@ const FixCiParameters = Type.Union([
140
140
 
141
141
  type UpdateBranchWorkflow = Pick<PullRequestBranchUpdater, "state" | "merge" | "continue" | "publish">;
142
142
  type CreateWorkflow = Pick<PullRequestCreator, "state" | "prepare" | "merge" | "continue" | "push" | "publish">;
143
- type SweepWorkflow = Pick<PullRequestCommentSweep, "start" | "resume" | "show" | "record" | "publish" | "refresh" | "resolve" | "finalize">;
143
+ type SweepWorkflow = Pick<PullRequestCommentSweep, "recoveryLaunchAction" | "start" | "resume" | "show" | "record" | "publish" | "refresh" | "resolve" | "finalize">;
144
144
  type FixCiWorkflow = Pick<PullRequestCiFixer, "collect" | "publish">;
145
145
 
146
146
  type WorkflowContextBase = {
@@ -337,7 +337,7 @@ export default function pullRequestExtension(
337
337
  loadCurrentPullRequest: load,
338
338
  }),
339
339
  };
340
- break;
340
+ return { runId, action: "merge" };
341
341
  case "create":
342
342
  workflowContext = {
343
343
  ...common,
@@ -350,9 +350,9 @@ export default function pullRequestExtension(
350
350
  loadCurrentPullRequest: load,
351
351
  }),
352
352
  };
353
- break;
354
- case "sweep":
355
- workflowContext = {
353
+ return { runId, action: "prepare" };
354
+ case "sweep": {
355
+ const selected: Extract<WorkflowContext, { route: "sweep" }> = {
356
356
  ...common,
357
357
  route: "sweep",
358
358
  workflow: createCommentSweep({
@@ -362,7 +362,17 @@ export default function pullRequestExtension(
362
362
  loadCurrentPullRequest: load,
363
363
  }),
364
364
  };
365
- break;
365
+ workflowContext = selected;
366
+ try {
367
+ const action = await selected.workflow.recoveryLaunchAction();
368
+ invocation.assertCurrent();
369
+ if (workflowContext !== selected) throw new Error("PR workflow session changed during recovery inspection");
370
+ return { runId, action };
371
+ } catch (error) {
372
+ clearWorkflow(selected);
373
+ throw error;
374
+ }
375
+ }
366
376
  case "fix-ci":
367
377
  workflowContext = {
368
378
  ...common,
@@ -374,9 +384,8 @@ export default function pullRequestExtension(
374
384
  loadCurrentPullRequest: load,
375
385
  }),
376
386
  };
377
- break;
387
+ return { runId, action: "collect" };
378
388
  }
379
- return common.runId;
380
389
  };
381
390
 
382
391
  const markWorkflowPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]> = (identity, queued) => {
@@ -403,7 +412,7 @@ export default function pullRequestExtension(
403
412
  ) => {
404
413
  signal?.throwIfAborted();
405
414
  const selected = workflowContext;
406
- if (!selected) throw new Error("No PR workflow is active");
415
+ if (!selected) throw new Error("No PR workflow is active; run /pr to discover and reserve the current route");
407
416
  if (selected.runId !== runId) throw new Error("PR workflow runId is wrong or stale");
408
417
  if (selected.sessionGeneration !== sessionGeneration) throw new Error("PR workflow session is stale");
409
418
  if (selected.route !== route) throw new Error(`PR workflow route is ${selected.route}, not ${route}`);
@@ -774,7 +783,7 @@ export default function pullRequestExtension(
774
783
  releaseWorkflow,
775
784
  });
776
785
  pi.registerCommand("pr", {
777
- description: "[--base <branch>] [instructions] — Run the current branch pull request next step",
786
+ description: "[--feedback | --base <branch> [instructions]] — Run the current branch pull request next step",
778
787
  handler: async (args, ctx) => {
779
788
  if (!ctx.hasUI || !context) return;
780
789
  const generation = sessionGeneration;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "4.0.6",
3
+ "version": "4.0.9",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,9 +9,10 @@ Use the package-owned comment-sweep workflow. It exposes these closed actions:
9
9
  `start`, `resume`, `show`, `record`, `publish`, `refresh`, `resolve`, and
10
10
  `finalize`.
11
11
 
12
- 1. Call `start` for a fresh current-branch pull request. Call `resume` only for
13
- saved work. Never replace or delete blocked recovery state by hand. See
14
- [Sweep recovery](references/recovery.md).
12
+ 1. Use the `start` or `resume` action supplied by `/pr`. `/pr` checks saved work
13
+ and chooses the action. Do not change it. Direct skill or tool calls cannot
14
+ create route authority; run `/pr` instead. Never replace or delete blocked
15
+ recovery state by hand. See [Sweep recovery](references/recovery.md).
15
16
  2. Use `show` for one feedback ID at a time. Inspect every conversation
16
17
  comment, review, thread, and thread comment. Follow
17
18
  [Thread triage](references/thread-triage.md).
@@ -10,10 +10,14 @@ The file is private, bounded to 1 MiB, and replaced atomically. It contains the
10
10
  frozen PR authority, original head and lease, complete feedback, exact ledger,
11
11
  owned paths, and mutation attempts.
12
12
 
13
- Use `resume` when this file exists. Resume checks the canonical worktree, local
14
- changes, PR linkage, and remote head. It reconciles an attempted push or thread
15
- resolution before issuing a new epoch and run ID. Calls from the old run then
16
- fail.
13
+ Run `/pr` to enter recovery. After fresh route discovery, `/pr` checks this file
14
+ without changing it. It selects `start` when the file is absent. It selects
15
+ `resume` only when valid recovery matches the fresh route authority.
16
+
17
+ Resume checks the canonical worktree, local changes, PR linkage, and remote
18
+ head again under its lock. It reconciles an attempted push or thread resolution
19
+ before issuing a new epoch and run ID. Calls from the old run then fail. Direct
20
+ skill or tool calls cannot create route authority.
17
21
 
18
22
  A completed post-publish `refresh` stores the new complete snapshot before any
19
23
  replacement ledger. Recovery keeps that snapshot in `refresh-pending`, with its
@@ -22,7 +26,9 @@ Use `show` with the resumed guard to inspect each frozen item. Then use `record`
22
26
  without `ownedPaths` to supply exact complete coverage for that snapshot.
23
27
  Resolution and finalization remain blocked until this record succeeds.
24
28
 
25
- Malformed or oversized recovery is preserved and blocks the workflow. Never
26
- repair, move, replace, or delete it automatically. An unknown mutation is never
27
- replayed. If reconciliation cannot prove its exact result, stop and report the
28
- state path and blocker.
29
+ Malformed, oversized, obsolete, wrong-worktree, or route-mismatched recovery is
30
+ preserved and blocks dispatch. Never repair, move, replace, or delete it
31
+ automatically. Report the state path and exact blocker.
32
+
33
+ An unknown mutation is never replayed. If reconciliation cannot prove its exact
34
+ result, stop and report the state path and blocker.
@@ -7,7 +7,7 @@ description: Prepare and publish the current branch pull request with determinis
7
7
 
8
8
  Use only `pi_pr_create` for base selection, merge, push, upstream, and GitHub work. Do not repeat its Git or GitHub checks with shell tools.
9
9
 
10
- Start with the prompted `prepare` action. It uses the saved branch base: explicit `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then validated `origin` default. It requires a committed change ahead. Dirty work alone cannot start this route. The base is always `origin`; fork heads must share its GitHub source and host. Apply trailing prompt guidance only to the PR content.
10
+ Start with the prompted `prepare` action. It uses the saved branch base: explicit `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then validated `origin` default. It requires a committed change ahead or ordinary pending work, including untracked files. It rejects an in-progress Git operation and a head ref equal to the selected base ref. The base is always `origin`; fork heads must share its GitHub source and host. Apply trailing prompt guidance only to the PR content.
11
11
 
12
12
  After `prepare`, inspect the returned base and merge-base. Separate and commit coherent pending work. Preserve coherent staging. Exclude `.context/` and unrelated changes. Stop when separation is unsafe.
13
13