@henryqw/pi-pr 6.0.7 → 6.2.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/README.md CHANGED
@@ -12,7 +12,17 @@ Requires an authenticated GitHub CLI session (`gh auth login`) and a checkout on
12
12
 
13
13
  ## Feedback snapshots
14
14
 
15
- `pr-feedback.mjs fetch --out FILE` prints a compact feedback index. The index
15
+ Run the bundled read-only diagnostic CLI from the installed package directory:
16
+
17
+ ```bash
18
+ node skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs fetch [--pr PR] (--out FILE | --json)
19
+ node skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs show --snapshot FILE --id ID
20
+ node skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs checks [--pr PR] --expected-head SHA
21
+ node skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs self-test
22
+ ```
23
+
24
+ `checks` verifies the current pull request and its checks against the full head SHA; `self-test`
25
+ checks local CLI behavior. `pr-feedback.mjs fetch --out FILE` prints a compact feedback index. The index
16
26
  includes IDs, kinds, states, authors, locations, and parent IDs as needed. It
17
27
  does not print comment or review bodies.
18
28
 
@@ -42,16 +52,13 @@ GitHub, use the network, or write files.
42
52
 
43
53
  ## Use
44
54
 
45
- Run `/pr` in a GitHub checkout. It reads fresh pull request and local state, then runs one route. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
46
-
47
- 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.
55
+ Run `/pr` in a GitHub checkout. It reads fresh pull request and local state, then continues through safe routes in one invocation until it needs external input, meets an ambiguous blocker, waits for CI or review, or merges. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
48
56
 
49
- 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.
57
+ `/pr` takes no flags, prose, or base argument. The package selects the branch base from one `branch.<branch>.gh-merge-base` setting or the validated `origin` default branch. After each published change, the extension rediscovers fresh GitHub mergeability, feedback, and CI before another action; no remembered flags or second `/pr` are needed. A stopped workflow may be resumed with a new `/pr` after the blocker is addressed.
50
58
 
51
59
  | Surface | Type | Purpose |
52
60
  | --- | --- | --- |
53
- | `/pr [--base BRANCH] [creation instructions]` | command | Run the current pull request's next safe route. |
54
- | `/pr --feedback` | command | Explicitly start or resume the guarded feedback sweep. |
61
+ | `/pr` | command | Inspect and run the current pull request's next safe route. |
55
62
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
56
63
  | Widget | ui | Show one action hint or transient routing status. |
57
64
 
@@ -63,32 +70,33 @@ The widget switches to `⠋ Checking pull request…` as soon as `/pr` starts di
63
70
 
64
71
  Each footer entry is one linked `PR #number` plus one plain-language status: `N unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `base update required`, `merge conflict`, `merge-ready`, `merged`, or `closed`. Colors support the text; they do not carry meaning alone.
65
72
 
66
- ![Flowchart showing /pr reading fresh GitHub and local state, choosing the first matching condition, and stopping after one route](./docs/pr-routing.svg)
73
+ ![Flowchart showing /pr reading fresh GitHub and local state and choosing the next safe route](./docs/pr-routing.svg)
67
74
 
68
75
  ### Routes
69
76
 
70
77
  | Current condition | `/pr` route |
71
78
  | --- | --- |
72
79
  | No current-branch pull request, no published matching ref, safe Git push configuration, and a commit or ordinary pending work | Start pull-request creation. |
73
- | One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
80
+ | One open pull request inferred from a published matching ref | Revalidate the exact `remote/ref`, link the local branch without another prompt, then rediscover and continue. |
74
81
  | Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
75
- | 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. |
82
+ | Open PR with intended uncommitted or ahead local work | Only when local HEAD descends from the published PR head: inspect, scope, commit if needed, validate and push the exact OID with the saved lease. A behind or diverged HEAD blocks commits. Stop and report when ownership is ambiguous or unrelated work cannot be separated. |
83
+ | Confirmed merge conflict | Rebase onto the pinned base commit when the tree is clean and local HEAD equals the PR head. Resolve conflicts only with clear intent; otherwise stop and report. A previously verified rebase resumes guarded publication instead of rewriting HEAD again. |
76
84
  | GitHub Actions job failed | Run the CI fix workflow when the same local prerequisite holds. |
77
85
  | External check or commit status failed | Show `CI failed` as a no-action blocker. |
78
86
  | Changes requested or unresolved review threads | Start or resume the package comment sweep when the same local prerequisite holds. |
79
- | 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. |
87
+ | New standalone feedback (including conversation comments) | Start or resume a guarded sweep when the tree is clean and local HEAD equals the PR head. |
80
88
  | No-action state | Report the state without taking action. |
81
- | Merge-ready pull request | Ask for final confirmation, recheck fresh state, and squash-merge if confirmed. |
89
+ | Merge-ready pull request | Recheck fresh state and squash-merge without another prompt. |
82
90
 
83
- `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.
91
+ `pi-pr-create` selects its base in this order: 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.
84
92
 
85
93
  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.
86
94
 
87
- It merges the captured base commit before validation and push. It resolves clear conflicts and stops when the base or conflict intent is ambiguous.
95
+ It does not merge or rebase the base during creation. The package helper inspects and commits selected pending paths, including both sides of a staged rename. After a clean verification and relevant validation, it pushes the captured OID.
88
96
 
89
97
  A configured target never changes branch upstream settings. Without a target, the helper pushes the captured OID to the local branch ref on validated `origin` and fetches its tracking ref. It leaves upstream unset. It creates or updates and validates the exact PR before it sets and verifies upstream. A failed setup rolls back only unchanged helper-owned settings. If configuration changed concurrently, it stops without overwriting it. Retrying `publish` resumes setup without another push or PR mutation.
90
98
 
91
- Without a configured push target, discovery checks validated remotes for the same branch ref. One exact open PR becomes an inferred target. `/pr` names the exact `remote/ref` and asks before linking it. The extension revalidates the branch, PR, remote OID, and Git configuration before mutation. It rolls back its upstream and remote-tracking changes if final verification fails.
99
+ Without a configured push target, discovery checks validated remotes for the same branch ref. One exact open PR becomes an inferred target. `/pr` links the single exact `remote/ref` without a separate confirmation, then rediscovers the same configured PR before continuing. The extension revalidates the branch, PR, remote OID, and Git configuration before mutation. It rolls back its upstream and remote-tracking changes if final verification fails.
92
100
 
93
101
  Multiple candidate remotes, multiple matching PRs, OID mismatches, and unsafe Git push configuration block routing. A published ref with no PR also blocks creation. If no candidate ref exists, creation uses only a validated `origin` destination.
94
102
 
@@ -96,13 +104,13 @@ The creation workflow repeats destination, remote OID, PR, and configuration che
96
104
 
97
105
  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.
98
106
 
99
- 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.
107
+ 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. One `/pr` inspects all feedback, records a complete disposition ledger and exact owned paths, then makes scoped fixes, validates, and publishes without a second approval. New sweeps require a clean worktree at the original head before recording the plan; publication validates the clean scoped commit. Recovery preserves the saved plan and checks owned paths and remote authority before continuing. After publication, the helper carries unchanged decisions through a fresh feedback snapshot; new or edited feedback remains blocked for a later fix cycle. It does not expand path ownership or push again in the same sweep.
100
108
 
101
- `/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.
109
+ A flagless `/pr` reads complete standalone and inline feedback before merge or waiting when the tree is clean and HEAD equals the configured PR head. It compares feedback against the last finalized sweep. New or edited feedback selects the guarded sweep; comments already assessed in that sweep do not. Immediately before merging, it checks again and cancels if feedback arrived meanwhile. A malformed attention marker is preserved and blocks routing rather than silently losing triage history.
102
110
 
103
111
  Direct skill or `pi_pr_*` tool calls cannot create route authority. Run `/pr` to reserve a fresh route.
104
112
 
105
- 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.
113
+ Only one helper run can exist at a time. Most runs expire when the agent settles. A 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 rebase. A verified rebase can be resumed through a fresh `/pr` run; an unverified rebase intent requires manual recovery, never an automatic retry.
106
114
 
107
115
  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> • `.
108
116
 
@@ -116,26 +124,26 @@ If Herdr lookup, JSON validation, or rename fails, the PR and normal UI refresh
116
124
 
117
125
  Current-branch discovery reads pull requests associated with the exact push repository ref. It does not run a global branch search. It finds a fork-head PR whose base is an upstream repository. A unique historical match uses the exact remote push-ref OID, not local HEAD.
118
126
 
119
- A no-action state includes drafts, merged or closed pull requests, unsupported failed CI, pending review, and blocked merge policy. Running CI blocks merge but not other mutating workflows. A dirty tree or mismatched local HEAD also blocks a mutating workflow.
127
+ A no-action state includes drafts, merged or closed pull requests, unsupported failed CI, pending review, and blocked merge policy. Running CI blocks merge but not other mutating workflows. A matching verified branch-update recovery takes priority, then matching comment-sweep recovery. Otherwise a dirty tree or ahead local HEAD selects the scoped local publication helper first. A behind or diverged head remains a blocker unless matching recovery can resume.
120
128
 
121
129
  ### Route priority
122
130
 
123
- A missing pull request uses creation. For an existing pull request, the first matching condition wins:
131
+ A missing pull request uses creation. For an existing configured open, non-draft pull request, verified branch-update recovery is checked first, then matching sweep recovery. They can resume guarded publication or owned edits before the local clean/equal gate. Otherwise, the first matching condition wins:
124
132
 
125
133
  1. Merged, closed, or draft: no action.
126
- 2. Base update required or merge conflict. Run only with a clean tree and equal local and PR heads.
127
- 3. A failed GitHub Actions job. Apply the same local prerequisite. Other failed checks remain blockers.
128
- 4. Changes requested or unresolved review threads. Apply the same local prerequisite.
129
- 5. Waiting or local safety block: no action.
130
- 6. Merge-ready: allow clean local HEAD equal to or behind the PR head. Confirm, then merge directly.
134
+ 2. Dirty worktree or ahead local HEAD: scope and publish intended local work. In-progress Git operations block its helper; unrelated pending paths require an ownership decision.
135
+ 3. Confirmed merge conflict: rebase onto the pinned base OID only with a clean, equal local HEAD. A behind base alone never triggers a rebase.
136
+ 4. Diagnosable GitHub Actions failure: run CI fix with the same local prerequisite.
137
+ 5. Changes requested or unresolved review threads: run the comment sweep.
138
+ 6. New or edited standalone feedback, including conversation comments and review bodies: triage and fix scoped issues without another approval.
139
+ 7. Running CI, pending review, blocked policy, or unsafe local merge state: wait or report the blocker.
140
+ 8. Merge-ready: allow a clean local HEAD equal to or behind the PR head. Revalidate, then squash-merge directly.
131
141
 
132
- 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.
142
+ The sweep finalizes its feedback marker only after checking the complete refreshed generation. GitHub offers no resolution control for standalone comments or review bodies; they are triaged and reported, not marked resolved.
133
143
 
134
144
  The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
135
145
 
136
- After publishing, `refresh` freezes the complete latest feedback and returns only IDs and kinds. Use `show` to inspect every fresh item.
137
-
138
- A second guarded `record` must cover that exact snapshot before resolution or finalization. It keeps the paths from the initial record.
146
+ After publishing, `refresh` freezes the complete latest feedback, retains unchanged decisions, and blocks new or edited actionable items for the next `/pr` cycle. It needs no second record or approval. `resolve` selects eligible unresolved review threads with no blocked children, posts a commit URL for addressed threads or a one-sentence ledger reason for non-actionable threads, and verifies the returned reply ID before resolving. The helper checks feedback capacity and leaves blocked threads open. If a reply response is lost without a saved ID, recovery stops without replaying or guessing from a matching comment body. `finalize` uses its saved projection and rechecks complete live feedback. Standalone comments and review bodies have no GitHub resolution state; report them without claiming they were resolved.
139
147
 
140
148
  The sweep runs existing non-destructive checks on the clean committed `HEAD` before publishing. Finalization reruns them as a later state guard.
141
149
 
@@ -143,7 +151,7 @@ The sweep runs existing non-destructive checks on the clean committed `HEAD` bef
143
151
 
144
152
  PR discovery starts in the background at session start, so the Pi footer appears before the PR status is ready. When switching sessions, the previous PR status and action hint clear immediately; the new ones appear when discovery finishes. A directory outside a Git worktree stays silent. The UI shows `PR · status unavailable` for other discovery failures and reports only a generic error.
145
153
 
146
- 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` cancels any pending presentation lookup and reads fresh state before routing or acting; it remains authoritative.
154
+ They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. A successful terminal helper action redispatches through fresh discovery before settlement; an incomplete or failed helper does not chain. 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` cancels any pending presentation lookup and reads fresh state before routing or acting; it remains authoritative.
147
155
 
148
156
  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.
149
157
 
@@ -159,23 +167,39 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
159
167
 
160
168
  ## Limits and recovery
161
169
 
162
- - `/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.
170
+ A comment sweep keeps a private, atomically replaced recovery file under
171
+ `<agent-dir>/config/pi-pr/sweep/<worktree-id>/state.json`. It contains the frozen PR identity,
172
+ original head and lease, full feedback, ledger, owned paths, and mutation attempts. After a push,
173
+ `refresh` stores the new complete snapshot before a replacement ledger; `show` and `record` must
174
+ cover it before thread mutations or finalization. Resume rechecks local state and the remote head
175
+ and reconciles attempted mutations before issuing a new run. Malformed or mismatched recovery is
176
+ preserved and blocks dispatch; never remove it or replay an uncertain mutation to continue.
177
+
178
+ A branch update keeps a private recovery file under `<agent-dir>/config/pi-pr/update-branch/`.
179
+ It records the original lease before rewriting HEAD, then the verified HEAD after checking the clean
180
+ branch against the pinned base. A fresh `/pr` run checks that record and the branch before returning
181
+ the verified result for validation and exact-lease publication; it never repeats Git rebase.
182
+ If a rebase ended without verification, the record is preserved and routing stops for manual
183
+ recovery. After an uncertain push, publication checks the exact remote postcondition without
184
+ replaying the push. Malformed or mismatched records stay unchanged and block routing.
185
+
186
+ - `/pr` accepts no arguments and does not open a browser.
163
187
  - It does not run `/done` or `/sweep`.
164
- - 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.
188
+ - 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. New or blocked standalone comments can select the sweep again after fresh inspection.
165
189
  - It does not enable auto-merge or add a merge queue.
166
- - 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.
190
+ - It rebases only after a confirmed conflict. It never rebases merely because the base is behind, overwrites concurrent remote updates, deletes branches, or cleans up worktrees. Creation uses exact leases and an empty lease is only an atomic absence check.
167
191
  - Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
168
192
  - Presentation fetches use that exact push URL and exact advertised OID. They do not use shared fetch state.
169
- - A pull request that GitHub reports as behind requires a base update.
193
+ - GitHub may block merging while the base is behind. That alone does not authorize a rebase.
170
194
  - Direct merges always use squash. GitHub rejects the mutation if repository policy does not allow it.
171
195
  - Before merge, `/pr` fetches the exact head OID from the validated push URL without shared fetch state.
172
196
  - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
173
- - A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
197
+ - A conflict rebase resolves the base repository ref directly. It stops if that ref moves before rebase or force-with-lease push. Branches with merge commits since the fork point cannot be rebased automatically; preserve their merge resolutions manually.
174
198
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
175
199
  - CI repair resolves workflow runs from check-suite IDs. It does not treat HTML details links as identity.
176
200
  - It streams a bounded failed-step log tail and runs one narrow local reproducer before editing.
177
201
  - Before push, CI repair revalidates the saved destination, open PR, failure evidence, and repair HEAD.
178
202
  - An already-published local HEAD needs no second push.
179
- - Direct merge requires final confirmation and a fresh readiness check.
203
+ - Direct merge requires a fresh readiness check and an exact head OID; `/pr` is the authorization, not a separate confirmation dialog.
180
204
  - After a successful merge, the create widget stays hidden until a new local commit.
181
205
  - Only authenticated GitHub.com and GitHub Enterprise repositories are supported.
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="pr-routing-title pr-routing-desc">
3
- <title id="pr-routing-title">How /pr picks one route</title>
4
- <desc id="pr-routing-desc">The /pr command creates a pull request when needed, gates branch update, feedback sweep, and CI repair on a clean worktree with local HEAD equal to the PR head, and permits direct merge when clean local HEAD is equal to or behind the PR head.</desc>
3
+ <title id="pr-routing-title">How /pr picks each next route</title>
4
+ <desc id="pr-routing-desc">The /pr command creates a pull request when needed, scopes local work before publication, rebases only confirmed conflicts, and checks new feedback before merge, and permits direct merge when clean local HEAD is equal to or behind the PR head.</desc>
5
5
  <defs>
6
6
  <style>@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&amp;family=Geist:wght@400;500;600&amp;family=Geist+Mono:wght@400;500;600&amp;display=swap");</style>
7
7
  <marker id="pr-routing-arrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
@@ -18,10 +18,10 @@
18
18
  <rect width="1280" height="720" fill="#f0eee9"/>
19
19
 
20
20
  <!-- Header -->
21
- <text x="80" y="68" fill="#101828" font-size="28" font-weight="400" font-family="'Instrument Serif', serif">How /pr picks one route</text>
22
- <text x="80" y="88" fill="#4c5665" font-size="8" font-family="'Geist', sans-serif">Fresh state enters a strict, top-down priority ladder.</text>
21
+ <text x="80" y="68" fill="#101828" font-size="28" font-weight="400" font-family="'Instrument Serif', serif">How /pr picks each next route</text>
22
+ <text x="80" y="88" fill="#4c5665" font-size="8" font-family="'Geist', sans-serif">Fresh state enters a strict, top-down priority ladder after each completed route.</text>
23
23
  <text x="1200" y="48" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" text-anchor="end" letter-spacing="0.12em">FIRST MATCHING CONDITION WINS</text>
24
- <text x="1200" y="68" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" text-anchor="end" letter-spacing="0.12em">AT MOST ONE ROUTE</text>
24
+ <text x="1200" y="68" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" text-anchor="end" letter-spacing="0.12em">FRESH AFTER EACH ROUTE</text>
25
25
 
26
26
  <!-- Continuation arrows, drawn before nodes -->
27
27
  <g fill="none" stroke="#4c5665" stroke-width="1.2" marker-end="url(#pr-routing-arrow)">
@@ -67,10 +67,10 @@
67
67
  <line x1="704" y1="296" x2="704" y2="328" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
68
68
  <text x="148" y="320" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">3</text>
69
69
  <text x="200" y="304" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
70
- <text x="200" y="324" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Base behind or conflicted</text>
70
+ <text x="200" y="324" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Dirty/ahead work or confirmed conflict</text>
71
71
  <text x="728" y="304" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
72
- <text x="728" y="324" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Update if clean + equal</text>
73
- <text x="1136" y="324" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
72
+ <text x="728" y="324" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Publish work or rebase conflict</text>
73
+ <text x="1136" y="324" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">safe authority required</text>
74
74
 
75
75
  <!-- 4 -->
76
76
  <rect x="120" y="352" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
@@ -89,7 +89,7 @@
89
89
  <line x1="704" y1="424" x2="704" y2="456" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
90
90
  <text x="148" y="448" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">5</text>
91
91
  <text x="200" y="432" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
92
- <text x="200" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Changes requested or unresolved threads</text>
92
+ <text x="200" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Changes requested or new feedback</text>
93
93
  <text x="728" y="432" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
94
94
  <text x="728" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Sweep if clean + equal</text>
95
95
  <text x="1136" y="452" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
@@ -103,7 +103,7 @@
103
103
  <text x="200" y="516" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Other waiting or merge-local block</text>
104
104
  <text x="728" y="496" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
105
105
  <text x="728" y="516" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">No action</text>
106
- <text x="1136" y="516" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">CI running, review or policy pending, dirty, ahead, or diverged</text>
106
+ <text x="1136" y="516" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">CI running, review or policy pending, behind, or diverged</text>
107
107
 
108
108
  <!-- 7 -->
109
109
  <rect x="120" y="544" width="1040" height="48" rx="6" fill="rgba(29,78,216,0.08)" stroke="#1d4ed8" stroke-width="1.2"/>
@@ -121,7 +121,7 @@
121
121
  <line x1="80" y1="636" x2="80" y2="664" stroke="#4c5665" stroke-width="1.2" marker-end="url(#pr-routing-arrow)"/>
122
122
  <text x="104" y="640" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">DOWNWARD CONNECTOR</text>
123
123
  <text x="104" y="664" fill="#101828" font-size="8" font-family="'Geist', sans-serif">Otherwise, continue to the next condition.</text>
124
- <text x="668" y="660" fill="#101828" font-size="28" font-weight="400" font-family="'Instrument Serif', serif">1×</text>
125
- <text x="724" y="640" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ONE-ROUTE RULE</text>
126
- <text x="724" y="664" fill="#101828" font-size="8" font-family="'Geist', sans-serif">At most one route: first matching condition wins, then /pr stops.</text>
124
+ <text x="668" y="660" fill="#101828" font-size="28" font-weight="400" font-family="'Instrument Serif', serif">↻</text>
125
+ <text x="724" y="640" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONTINUATION RULE</text>
126
+ <text x="724" y="664" fill="#101828" font-size="8" font-family="'Geist', sans-serif">The first match runs; completed routes return to fresh discovery.</text>
127
127
  </svg>
@@ -1,8 +1,9 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
- ExtensionCommandContext,
3
+ ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { executeGitHubMerge } from "./pr-merge.ts";
6
+ import { needsFeedbackAttention } from "./pr-feedback-attention.ts";
6
7
  import {
7
8
  linkInferredPullRequest,
8
9
  loadCurrentPullRequest,
@@ -11,35 +12,34 @@ import {
11
12
  } from "./pr-github.ts";
12
13
  import {
13
14
  deriveNextStep,
14
- deriveRouteDecision,
15
- type FeedbackRouteBlocker,
16
15
  type NextStep,
17
16
  type PullRequestTarget,
18
- type RouteIntent,
19
17
  } from "./pr-routing.ts";
20
18
 
21
- type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
19
+ type WorkflowNextStep = Extract<NextStep, "create" | "publish-work" | "update-branch" | "sweep" | "fix-ci">;
22
20
 
23
21
  const WORKFLOWS: Record<WorkflowNextStep, string> = {
24
22
  create: "skill:pi-pr-create",
23
+ "publish-work": "skill:pi-pr-publish-work",
25
24
  "update-branch": "skill:pi-pr-update-branch",
26
25
  sweep: "skill:pi-pr-comment-sweep",
27
26
  "fix-ci": "skill:pi-pr-fix-ci",
28
27
  };
29
28
  type WorkflowReservation =
30
- | { route: "create"; target: PullRequestTarget; base?: string }
29
+ | { route: "create"; target: PullRequestTarget }
31
30
  | { route: Exclude<WorkflowNextStep, "create">; pullRequest: CurrentPullRequest };
32
- type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect";
31
+ type WorkflowLaunchAction = "prepare" | "inspect" | "rebase" | "start" | "resume" | "collect";
33
32
  type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction };
34
33
 
35
34
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
36
35
  export type PrCommandInvocation = ((nextStep: NextStep) => void) & {
37
36
  sessionGeneration: number;
38
37
  assertCurrent(): void;
38
+ completedRoutes?: ReadonlySet<string>;
39
39
  };
40
40
  export type PrCommandHandler = (
41
41
  args: string,
42
- ctx: ExtensionCommandContext,
42
+ ctx: ExtensionContext,
43
43
  onRouteResolved?: PrCommandInvocation | ((nextStep: NextStep) => void),
44
44
  ) => Promise<NextStep>;
45
45
 
@@ -52,67 +52,27 @@ export type WorkflowPromptIdentity = Readonly<{
52
52
 
53
53
  export type PrCommandDependencies = {
54
54
  loadCurrentPullRequest?: typeof loadCurrentPullRequest;
55
+ needsFeedbackAttention?: typeof needsFeedbackAttention;
55
56
  linkInferredPullRequest?: typeof linkInferredPullRequest;
57
+ inspectSweepRecovery?: (pullRequest: CurrentPullRequest, ctx: ExtensionContext) => Promise<boolean>;
58
+ inspectBranchRecovery?: (pullRequest: CurrentPullRequest, ctx: ExtensionContext) => Promise<boolean>;
56
59
  reserveWorkflow?: (
57
60
  reservation: WorkflowReservation,
58
- ctx: ExtensionCommandContext,
61
+ ctx: ExtensionContext,
59
62
  invocation?: PrCommandInvocation,
60
63
  ) => Promise<WorkflowReservationResult>;
61
64
  markWorkflowPromptQueued?: (identity: WorkflowPromptIdentity, queued: boolean) => void;
62
65
  releaseWorkflow?: (runId: string, invocation?: PrCommandInvocation) => void;
63
66
  };
64
67
 
65
- type ParsedPrArguments = {
66
- base?: string;
67
- intent: RouteIntent;
68
- instructions: string;
69
- };
70
-
71
- function parsePrArguments(args: string): ParsedPrArguments {
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
- }
82
- if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base <branch>");
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}`;
102
- }
103
- }
104
-
105
68
  function workflowReservation(
106
69
  nextStep: WorkflowNextStep,
107
70
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
108
- base: string | undefined,
109
- instructions: string,
110
71
  ): WorkflowReservation {
111
72
  if (nextStep === "create") {
112
73
  if (discovery.kind !== "none") throw new Error("/pr create failed: creation target is unavailable");
113
- return { route: "create", target: discovery.creationTarget, ...(base === undefined ? {} : { base }) };
74
+ return { route: "create", target: discovery.creationTarget };
114
75
  }
115
- if (instructions) throw new Error("The current /pr helper route does not accept instructions");
116
76
  if (discovery.kind !== "current") throw new Error(`/pr ${nextStep} failed: pull request is unavailable`);
117
77
  return { route: nextStep, pullRequest: discovery.pullRequest };
118
78
  }
@@ -130,14 +90,13 @@ function packageWorkflowCommand(pi: PrCommandPi, route: WorkflowNextStep) {
130
90
 
131
91
  async function dispatchWorkflow(
132
92
  pi: PrCommandPi,
133
- ctx: ExtensionCommandContext,
93
+ ctx: ExtensionContext,
134
94
  route: WorkflowNextStep,
135
95
  reservation: WorkflowReservation,
136
96
  invocation: PrCommandInvocation | undefined,
137
97
  reserve: NonNullable<PrCommandDependencies["reserveWorkflow"]>,
138
98
  markPromptQueued: NonNullable<PrCommandDependencies["markWorkflowPromptQueued"]>,
139
99
  release: NonNullable<PrCommandDependencies["releaseWorkflow"]>,
140
- instructions: string,
141
100
  ): Promise<void> {
142
101
  const workflow = packageWorkflowCommand(pi, route);
143
102
  let runId: string | undefined;
@@ -152,7 +111,7 @@ async function dispatchWorkflow(
152
111
  ? { deliverAs: "followUp" as const, expandPromptTemplates: true }
153
112
  : { expandPromptTemplates: true };
154
113
  invocation?.assertCurrent();
155
- pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}${instructions ? ` ${instructions}` : ""}`, options);
114
+ pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}`, options);
156
115
  } catch (error) {
157
116
  if (runId !== undefined) release(runId, invocation);
158
117
  throw error;
@@ -169,7 +128,7 @@ function noActionNotification(pullRequest: CurrentPullRequest): { message: strin
169
128
  if (pullRequest.conditions.ci === "failure-blocked") {
170
129
  return { message: `PR #${pullRequest.number} has a failed CI check that cannot run the CI fix workflow`, type: "warning" };
171
130
  }
172
- const mutatingWorkflowSelected = pullRequest.conditions.baseUpdateRequired || pullRequest.conditions.conflict ||
131
+ const mutatingWorkflowSelected = pullRequest.conditions.conflict ||
173
132
  pullRequest.conditions.changesRequested || pullRequest.conditions.unresolvedThreads > 0 ||
174
133
  pullRequest.conditions.ci === "failure";
175
134
  if (mutatingWorkflowSelected && pullRequest.local.worktree === "dirty") {
@@ -207,16 +166,11 @@ function isSameConfirmedMerge(current: CurrentPullRequest, fresh: CurrentPullReq
207
166
 
208
167
  async function mergePullRequest(
209
168
  pi: PrCommandPi,
210
- ctx: ExtensionCommandContext,
169
+ ctx: ExtensionContext,
211
170
  current: CurrentPullRequest,
212
171
  load: typeof loadCurrentPullRequest,
213
- ): Promise<boolean> {
214
- const confirmed = await ctx.ui.confirm(
215
- `Merge PR #${current.number}?`,
216
- "Method: squash.",
217
- );
218
- if (!confirmed) return false;
219
-
172
+ needsFeedback: typeof needsFeedbackAttention,
173
+ ): Promise<void> {
220
174
  await executeGitHubMerge({
221
175
  exec: (command, args, options) => pi.exec(command, args, {
222
176
  ...options,
@@ -241,32 +195,27 @@ async function mergePullRequest(
241
195
  if (deriveNextStep(discovery) !== "merge") {
242
196
  throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`);
243
197
  }
198
+ if (await needsFeedback(fresh, { cwd: ctx.cwd, signal: ctx.signal, load })) {
199
+ throw new Error(`PR #${fresh.number} merge cancelled: new feedback needs review`);
200
+ }
244
201
  },
245
202
  });
246
- return true;
247
203
  }
248
204
 
249
205
  async function linkPullRequest(
250
206
  pi: PrCommandPi,
251
- ctx: ExtensionCommandContext,
207
+ ctx: ExtensionContext,
252
208
  current: CurrentPullRequest,
253
209
  load: typeof loadCurrentPullRequest,
254
210
  link: typeof linkInferredPullRequest,
255
- ): Promise<void> {
256
- const targetName = `${current.target.remote}/${current.target.ref}`;
257
- const confirmed = await ctx.ui.confirm(
258
- `Link pull request branch to ${targetName}?`,
259
- `Set ${targetName} as the push target for this branch.`,
260
- );
261
- if (!confirmed) return;
262
-
211
+ ): Promise<CurrentPullRequest> {
263
212
  const discovery = await load(pi, ctx);
264
213
  if (
265
214
  discovery.kind !== "current" ||
266
215
  discovery.pullRequest.target.provenance !== "inferred" ||
267
216
  !samePullRequestSnapshot(current, discovery.pullRequest)
268
217
  ) throw new Error("Link branch cancelled: inferred pull request context changed");
269
- await link(pi, ctx, discovery.pullRequest);
218
+ return await link(pi, ctx, discovery.pullRequest);
270
219
  }
271
220
 
272
221
  export function createPrCommandHandler(
@@ -274,32 +223,51 @@ export function createPrCommandHandler(
274
223
  dependencies: PrCommandDependencies = {},
275
224
  ): PrCommandHandler {
276
225
  const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
226
+ const needsFeedback = dependencies.needsFeedbackAttention ?? needsFeedbackAttention;
277
227
  const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest;
228
+ const inspectSweepRecovery = dependencies.inspectSweepRecovery ?? (async () => false);
229
+ const inspectBranchRecovery = dependencies.inspectBranchRecovery ?? (async () => false);
278
230
  const reserve = dependencies.reserveWorkflow ?? (async () => {
279
231
  throw new Error("/pr workflow tools are unavailable");
280
232
  });
281
233
  const markPromptQueued = dependencies.markWorkflowPromptQueued ?? (() => {});
282
234
  const release = dependencies.releaseWorkflow ?? (() => {});
283
- return async (args, ctx, onRouteResolved) => {
235
+ const handle = async (
236
+ args: string, ctx: ExtensionContext,
237
+ onRouteResolved?: PrCommandInvocation | ((nextStep: NextStep) => void),
238
+ linkedAuthority?: CurrentPullRequest,
239
+ ): Promise<NextStep> => {
284
240
  const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
285
241
  ? onRouteResolved as PrCommandInvocation
286
242
  : undefined;
287
- const { base, intent, instructions } = parsePrArguments(args);
288
- const discovery = await load(pi, ctx, undefined, undefined, base);
243
+ if (args.trim()) throw new Error("/pr does not accept arguments");
244
+ const discovery = await load(pi, ctx);
289
245
  commandInvocation?.assertCurrent();
290
- const decision = deriveRouteDecision(discovery, intent);
291
- if (decision.kind === "feedback-blocked") {
292
- onRouteResolved?.("blocked");
293
- throw new Error(feedbackBlockerMessage(decision.blocker));
246
+ if (linkedAuthority && (discovery.kind !== "current" || discovery.pullRequest.target.provenance !== "configured" ||
247
+ !isSameConfirmedMerge(linkedAuthority, discovery.pullRequest))) {
248
+ throw new Error("Link branch continuation cancelled: configured pull request context changed");
294
249
  }
295
- const nextStep = decision.nextStep;
296
- onRouteResolved?.(nextStep);
297
- if (base !== undefined && nextStep !== "create") {
298
- throw new Error("/pr --base is accepted only for pull request creation");
250
+ let nextStep = deriveNextStep(discovery);
251
+ if (discovery.kind === "current" && discovery.pullRequest.lifecycle === "open" &&
252
+ !discovery.pullRequest.conditions.draft && discovery.pullRequest.target.provenance === "configured" &&
253
+ await inspectBranchRecovery(discovery.pullRequest, ctx)) {
254
+ nextStep = "update-branch";
255
+ commandInvocation?.assertCurrent();
256
+ }
257
+ if (nextStep !== "update-branch" && discovery.kind === "current" && discovery.pullRequest.lifecycle === "open" &&
258
+ !discovery.pullRequest.conditions.draft && discovery.pullRequest.target.provenance === "configured" &&
259
+ await inspectSweepRecovery(discovery.pullRequest, ctx)) {
260
+ nextStep = "sweep";
261
+ commandInvocation?.assertCurrent();
299
262
  }
300
- if (instructions && !(nextStep in WORKFLOWS)) {
301
- throw new Error("The current /pr route does not accept instructions");
263
+ if (discovery.kind === "current" && (nextStep === "merge" || nextStep === "none") &&
264
+ discovery.pullRequest.lifecycle === "open" && !discovery.pullRequest.conditions.draft &&
265
+ discovery.pullRequest.target.provenance === "configured" &&
266
+ discovery.pullRequest.local.worktree === "clean" && discovery.pullRequest.local.head === "equal") {
267
+ if (await needsFeedback(discovery.pullRequest, { cwd: ctx.cwd, signal: ctx.signal, load })) nextStep = "sweep";
268
+ commandInvocation?.assertCurrent();
302
269
  }
270
+ onRouteResolved?.(nextStep);
303
271
  if (discovery.kind === "inactive") return nextStep;
304
272
  if (discovery.kind === "blocked") {
305
273
  return nextStep;
@@ -313,17 +281,23 @@ export function createPrCommandHandler(
313
281
  }
314
282
  if (nextStep === "link-branch") {
315
283
  if (discovery.kind !== "current") throw new Error("/pr link failed: pull request is unavailable");
316
- await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
317
- return nextStep;
284
+ const linked = await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
285
+ if (linked.target.provenance !== "configured") throw new Error("Link branch failed: target was not configured");
286
+ return await handle("", ctx, onRouteResolved, linked);
318
287
  }
319
288
  if (nextStep === "merge") {
320
289
  if (discovery.kind !== "current") throw new Error("/pr merge failed: pull request is unavailable");
321
- return await mergePullRequest(pi, ctx, discovery.pullRequest, load) ? "merge" : "none";
290
+ await mergePullRequest(pi, ctx, discovery.pullRequest, load, needsFeedback);
291
+ return "merge";
322
292
  }
323
293
 
324
294
  if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
325
295
  const route = nextStep as WorkflowNextStep;
326
- const reservation = workflowReservation(route, discovery, base, instructions);
296
+ if (commandInvocation?.completedRoutes?.has(route)) {
297
+ ctx.ui.notify(`PR ${route} already ran; inspect fresh state before retrying`, "warning");
298
+ return "none";
299
+ }
300
+ const reservation = workflowReservation(route, discovery);
327
301
  await dispatchWorkflow(
328
302
  pi,
329
303
  ctx,
@@ -333,8 +307,8 @@ export function createPrCommandHandler(
333
307
  reserve,
334
308
  markPromptQueued,
335
309
  release,
336
- route === "create" ? instructions : "",
337
310
  );
338
311
  return nextStep;
339
312
  };
313
+ return handle;
340
314
  }