@nvice-r/pr-commands 0.1.2 → 0.1.3

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
@@ -23,6 +23,8 @@ The interactive wizard:
23
23
  4. **Identity + token** — reads your API token from the keychain (or tells you how to add one).
24
24
  5. **Reviewers** — fetches the repo's default reviewers into an interactive checklist
25
25
  (`↑/↓` move · `space` toggle · `a` all · `n` none · `b` back · `enter` confirm; all pre-selected).
26
+ On reinstall, an existing roster is kept: its members are pre-selected instead, and entries
27
+ that aren't current default reviewers are carried over as-is.
26
28
  6. **Convention** — pick a preset, then review/edit its title-pattern, ticket-regex, and
27
29
  template-sections (each pre-filled from the preset, editable). A placeholder legend
28
30
  (`<ver>`, `<desc>`, `<ticket>`, …) is shown. Concrete values are written to config — `/pr`
@@ -44,7 +46,9 @@ Flags:
44
46
  | `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
45
47
  | `--agent <id>` | force skill install to this agent (repeatable): `claude-code`\|`antigravity`\|`codex`\|`opencode`. Default: auto-detect |
46
48
  | `--all` | install every ready provider binary, not just the detected one |
47
- | `--yes` | non-interactive: reviewers = all, convention = `trueid` |
49
+ | `--agent-dir <dir>` | repo folder for the reviewer roster (default: primary agent's — `.claude`/`.agent`/`.codex`/`.opencode`) |
50
+ | `--yes` | non-interactive: reviewers = existing roster if any, else all; convention = `trueid` |
51
+ | `--reset-reviewers` | ignore an existing roster and rebuild it from the repo's default reviewers |
48
52
  | `--dry-run` | print planned actions, write nothing |
49
53
  | `--help` | usage |
50
54
 
@@ -77,7 +81,7 @@ identically from every clone and every git worktree, no repo-local symlinks.
77
81
  | Shared library | `~/.local/bin/pr-common.sh` |
78
82
  | Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
79
83
  | Config | `~/.config/pr/config` |
80
- | Reviewer roster | `<main-repo-root>/.pr/reviewers.txt` (neutral, agent-agnostic; shared across worktrees) |
84
+ | Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
81
85
  | Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
82
86
  | Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
83
87
  | Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
@@ -115,6 +119,30 @@ convention=trueid # see presets
115
119
  Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
116
120
  guess. Re-run the installer to regenerate.
117
121
 
122
+ ### Per-project convention (`.agents/config/pr.yaml`)
123
+
124
+ When the repo has `.agents/config/pr.yaml` (this checkout, or the main checkout for worktrees),
125
+ `/pr` reads the convention from it instead of the user config. One file per project, so teams
126
+ with different conventions share the same skill:
127
+
128
+ ```yaml
129
+ title_pattern: "[<ver>] Feature/<ver>/<ticket> <desc>"
130
+ ticket_regex: "SC-(\\d+|NOTICKET)"
131
+ template_sections: [What Happened, Insight, Proof of Work]
132
+ body_rules: |
133
+ - Free-text project rules the skill follows literally.
134
+ base: develop # default base proposal
135
+ draft: ask # ask | true | false
136
+ push: [origin] # remotes pushed before create
137
+ fork: # optional cross-repo mode (`/pr cross`)
138
+ remote: social-commerce
139
+ repo: workspace/fork-repo
140
+ pre_pr: [] # commands run before confirm; non-zero exit stops /pr
141
+ post_pr: [jira-update] # sibling skills offered after create
142
+ ```
143
+
144
+ Without the file, the convention keys below (written by the wizard) are used.
145
+
118
146
  ### Convention presets
119
147
 
120
148
  | preset | title pattern | ticket regex | template sections |
@@ -135,14 +163,18 @@ template_sections=Context|Changes|Risk
135
163
 
136
164
  ### Reviewers
137
165
 
138
- Roster lives in a **neutral `.pr/` folder** under the main repo root — it's data the bash binary
139
- reads, not an agent artifact, so it's agent-agnostic (survives switching agents). Editable next to
140
- the code. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one roster:
166
+ Roster lives in the **primary agent's repo folder** under the main repo root — `.claude`,
167
+ `.agent` (Antigravity), `.codex` or `.opencode`, picked from the first agent you install for
168
+ (override with `--agent-dir`). The choice is saved as `agent_dir` in the config so the bash binary
169
+ reads the same path. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one
170
+ roster:
141
171
 
142
172
  ```
143
- <main-repo-root>/.pr/reviewers.txt
173
+ <main-repo-root>/<agent_dir>/pr-reviewers.txt
144
174
  ```
145
175
 
176
+ Switching primary agent = re-run the installer; the old file still loads as a fallback.
177
+
146
178
  You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
147
179
  trailing `# Name` comment optional:
148
180
 
@@ -151,9 +183,9 @@ trailing `# Name` comment optional:
151
183
  {fedcba98-7654-3210-fedc-ba9876543210} # Bob
152
184
  ```
153
185
 
154
- It's plain data — edit freely, no reinstall. Consider gitignoring `.pr/` unless the team wants the
155
- roster committed. Load order: `$BBPR_REVIEWERS` → `.pr/reviewers.txt` → `<agent_dir>/pr-reviewers.txt`
156
- (0.1.1) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
186
+ It's plain data — edit freely, no reinstall. Consider gitignoring the roster unless the team wants
187
+ it committed. Load order: `$BBPR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → `.pr/reviewers.txt`
188
+ (0.1.2) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
157
189
  legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
158
190
 
159
191
  ### Auth precedence (email)
package/assets/bin/bbpr CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bash
2
- # bbpr-version: 8
2
+ # bbpr-version: 9
3
3
  # bbpr — create a Bitbucket Cloud pull request (REST API v2.0).
4
4
  # Provider-specific layer; all git/config/creds/reviewer logic lives in pr-common.sh.
5
5
  # Usage: bbpr --title "..." [--body|--body-file] [--source b] [--dest b] [--repo w/s]
6
6
  # [--draft] [--reviewer uuid]... [--no-reviewers] [--email a] [--close-source]
7
+ # [--source-repo ws/fork] (cross-repo: PR opens in --repo/origin, from the fork)
7
8
 
8
9
  set -euo pipefail
9
10
 
@@ -35,7 +36,7 @@ fi
35
36
 
36
37
  # --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
37
38
  PAYLOAD="$(
38
- TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" \
39
+ TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
39
40
  DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
40
41
  REVIEWERS_JSON="$(printf '%s\n' "${FINAL_REVIEWERS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([{"uuid": l.strip()} for l in sys.stdin if l.strip()]))')" \
41
42
  python3 -c '
@@ -48,6 +49,8 @@ p = {
48
49
  "draft": os.environ["DRAFT"] == "true",
49
50
  "close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
50
51
  }
52
+ if os.environ["SOURCE_REPO"]:
53
+ p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
51
54
  revs = json.loads(os.environ["REVIEWERS_JSON"])
52
55
  if revs: p["reviewers"] = revs
53
56
  print(json.dumps(p))
@@ -56,7 +59,7 @@ print(json.dumps(p))
56
59
 
57
60
  URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
58
61
  echo "POST $URL"
59
- echo "source=$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
62
+ echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
60
63
 
61
64
  RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
62
65
  -H 'Content-Type: application/json' -d "$PAYLOAD")"
@@ -67,7 +70,7 @@ try: d = json.loads(sys.stdin.read())
67
70
  except Exception as e: print("non-JSON response", e); sys.exit(2)
68
71
  if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
69
72
  print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
70
- ')"
73
+ ')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
71
74
 
72
75
  NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
73
76
  NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # pr-common-version: 1
2
+ # pr-common-version: 2
3
3
  # pr-common.sh — shared library for provider PR binaries (bbpr/ghpr/glpr).
4
4
  # NOT executable on its own — sourced by a provider binary, which then builds the
5
5
  # provider-specific payload + POST. Everything here is provider-AGNOSTIC.
6
6
  #
7
7
  # Provides (after `pr_common_init "$@"`): global vars
8
- # TITLE BODY SOURCE DEST REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
8
+ # TITLE BODY SOURCE DEST REPO SOURCE_REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
9
9
  # FINAL_REVIEWERS[] (uuids, self already NOT dropped — provider does that)
10
10
  # Provides functions:
11
11
  # die, get_token, pr_load_config, pr_parse_args, pr_git_defaults,
@@ -38,7 +38,7 @@ pr_load_config() {
38
38
  esac
39
39
  done < "$CONFIG_FILE"
40
40
  : "${CFG_TOKEN_SRC:=keychain}"
41
- : "${CFG_AGENT_DIR:=.claude}" # agent folder holding the roster (relative to main repo root)
41
+ : "${CFG_AGENT_DIR:=.claude}" # primary agent's repo folder holding the roster (relative to main repo root)
42
42
  }
43
43
 
44
44
  # --- token retrieval, OS-switched (D1: OS is a function, not a binary axis) ---
@@ -61,10 +61,14 @@ get_token() {
61
61
 
62
62
  # --- arg parse (common flags across all providers) ---
63
63
  pr_parse_args() {
64
- TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""
64
+ TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""; SOURCE_REPO=""
65
65
  DRAFT="false"; CLOSE_SOURCE="false"; NO_REVIEWERS="false"; EMAIL_ARG=""
66
66
  EXTRA_REVIEWERS=()
67
67
  while [ $# -gt 0 ]; do
68
+ case "$1" in
69
+ --title|--body|--body-file|--source|--dest|--target|--repo|--source-repo|--reviewer|--email)
70
+ [ $# -ge 2 ] || die "$1 needs a value" ;;
71
+ esac
68
72
  case "$1" in
69
73
  --title) TITLE="$2"; shift 2 ;;
70
74
  --body) BODY="$2"; shift 2 ;;
@@ -72,6 +76,7 @@ pr_parse_args() {
72
76
  --source) SOURCE="$2"; shift 2 ;;
73
77
  --dest|--target) DEST="$2"; shift 2 ;;
74
78
  --repo) REPO="$2"; shift 2 ;;
79
+ --source-repo) SOURCE_REPO="$2"; shift 2 ;; # fork → cross-repo PR
75
80
  --draft) DRAFT="true"; shift ;;
76
81
  --close-source) CLOSE_SOURCE="true"; shift ;;
77
82
  --reviewer) EXTRA_REVIEWERS+=("$2"); shift 2 ;;
@@ -129,11 +134,11 @@ pr_load_reviewers() {
129
134
  # main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
130
135
  local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
131
136
  for cand in \
132
- ${mainroot:+"$mainroot/.pr/reviewers.txt"} \
133
137
  ${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
138
+ ${mainroot:+"$mainroot/.pr/reviewers.txt"} \
134
139
  ${mainroot:+"$mainroot/.pr-reviewers.txt"} \
135
140
  "$HOME/.config/pr/reviewers/${slug}.txt" \
136
- "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # neutral primary; agent-dir + root + global legacy fallbacks
141
+ "$HOME/.config/bbpr/bbpr-reviewers.txt"; do # agent-dir primary; 0.1.2 .pr/ + root + global legacy fallbacks
137
142
  [ -f "$cand" ] && { file="$cand"; break; }
138
143
  done
139
144
  fi
@@ -1,6 +1,8 @@
1
1
  Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
2
2
  auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
3
- Convention (title pattern, ticket regex, PR template) comes from `~/.config/pr/config`.
3
+ Convention (title pattern, ticket regex, PR template, body rules, push remotes, hooks) comes
4
+ from the repo's `.agents/config/pr.yaml` (one file per project, usually copied there by
5
+ `/landing`). Without that file, the convention keys in `~/.config/pr/config` are used.
4
6
 
5
7
  Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
6
8
  user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
@@ -16,14 +18,16 @@ skip the create steps entirely.
16
18
  /pr — provider-agnostic PR helper (@nvice-r/pr-commands)
17
19
 
18
20
  USAGE
19
- /pr [base-branch] [draft] Create a PR for the current branch (default).
21
+ /pr [base-branch] [draft] [cross]
22
+ Create a PR for the current branch (default).
20
23
  /pr update [PR#] [short] Rewrite an existing PR's description in place.
21
24
  /pr help Show this help.
22
25
 
23
26
  CREATE
24
27
  base-branch Any arg containing "/". If omitted, inferred from closest ancestor
25
28
  remote branch (saved per-branch after first run).
26
- draft Create as draft. Otherwise opens ready (asks to confirm).
29
+ draft Create as draft. Otherwise follows `draft:` in pr.yaml (default: ask).
30
+ cross Fork mode: open the PR in origin from the fork in pr.yaml `fork:`.
27
31
 
28
32
  UPDATE
29
33
  PR# PR number to rewrite. If omitted, resolved from the current branch's
@@ -31,9 +35,10 @@ UPDATE
31
35
  short Compress harder (caveman brevity): cut throat-clearing, restated ticket
32
36
  goals, filler connectors. Keeps every file + technical fact.
33
37
 
34
- CONFIG ~/.config/pr/config (os, token_src, email, convention)
38
+ CONVENTION <repo>/.agents/config/pr.yaml (per project; else convention keys in the user config)
39
+ CONFIG ~/.config/pr/config (os, token_src, email, agent_dir; legacy convention keys)
35
40
  PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
36
- REVIEWERS <main-repo-root>/.pr/reviewers.txt (per-repo, shared across worktrees, agent-agnostic)
41
+ REVIEWERS <main-repo-root>/<agent_dir>/pr-reviewers.txt (per-repo, shared across worktrees; agent_dir from config)
37
42
  SETUP npx @nvice-r/pr-commands
38
43
  ```
39
44
 
@@ -58,6 +63,16 @@ esac
58
63
 
59
64
  # c. matching binary installed?
60
65
  command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
66
+
67
+ # d. project convention file: this checkout first, then the main checkout (worktrees share it,
68
+ # because .agents/config/ is usually gitignored and only exists in the main checkout)
69
+ TOP="$(git rev-parse --show-toplevel)"
70
+ MAINROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
71
+ PR_YAML=""
72
+ for f in "$TOP/.agents/config/pr.yaml" "$MAINROOT/.agents/config/pr.yaml"; do
73
+ [ -f "$f" ] && { PR_YAML="$f"; break; }
74
+ done
75
+ echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
61
76
  ```
62
77
 
63
78
  - config missing → STOP: "run `npx @nvice-r/pr-commands`".
@@ -69,7 +84,23 @@ command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer,
69
84
  - `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
70
85
  - `git config --get branch.<CURR>.bbprBase` — saved base, if any
71
86
  - `git log --oneline -20`
72
- - read `convention=` from `$CFG` (default `trueid` if unset — but config presence already enforced in Step 0)
87
+ - read the **convention source**: `$PR_YAML` if Step 0 found one, else the convention keys in `$CFG`.
88
+ Every later step that says "convention" means this source.
89
+
90
+ **Convention keys** (YAML in `pr.yaml`; `key=value` in `$CFG`, where list values are pipe-separated):
91
+
92
+ | key | meaning | if missing |
93
+ |-----|---------|------------|
94
+ | `title_pattern` | title template with `<ver> <desc> <ticket> <type> <scope> <issue>` | ask the user |
95
+ | `ticket_regex` | ticket key in the branch name | no ticket |
96
+ | `template_sections` | body `## ` headings, in order | `Summary`, `Changes` |
97
+ | `body_rules` | extra project rules for the body (free text, follow literally) | none |
98
+ | `base` | default base proposal (Step 2 d) | ask |
99
+ | `draft` | `ask` / `true` / `false` | `ask` |
100
+ | `push` | remotes to push before create (Step 7.5) | none (assume pushed) |
101
+ | `fork` | `{remote, repo}` for cross-repo mode | cross mode unavailable |
102
+ | `pre_pr` | shell commands run before confirm (Step 6.5) | none |
103
+ | `post_pr` | sibling skills offered after create (Step 10) | none |
73
104
 
74
105
  ## Step 2 — determine base branch (priority)
75
106
  a. `$1` (any arg containing `/`) → use it.
@@ -85,7 +116,7 @@ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an a
85
116
  ```
86
117
  Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
87
118
  stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
88
- d. still ambiguous → ask.
119
+ d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
89
120
 
90
121
  The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
91
122
 
@@ -99,23 +130,30 @@ diff into seams, detect coupling (shared file/symbol → stacked; disjoint → i
99
130
  → one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
100
131
  split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
101
132
 
102
- ## Step 4 — draft title (from config, authoritative)
103
-
104
- Read `title_pattern=` and `ticket_regex=` directly from `$CFG` — the installer wrote concrete,
105
- possibly user-edited values, so DO NOT reconstruct them from a preset table. Extract the ticket
106
- key + any version from the branch name using `ticket_regex`, then fill `title_pattern`.
133
+ ## Step 4 — draft title (from the convention, authoritative)
107
134
 
108
- `convention=` is only a behavior label (drives the trueid body rules in Step 5). Example
109
- (trueid): branch `feature/<ver>/SC-XXXX-...`, pattern `[<ver>] Feature/<ver>/SC-XXXX <desc>` →
110
- kebab suffix becomes spaced lowercase `<desc>`.
135
+ Read `title_pattern` and `ticket_regex` from the convention source. Use the values as written;
136
+ DO NOT reconstruct them from a preset table. Extract the ticket key + any version from the
137
+ branch name using `ticket_regex`, then fill `title_pattern`. Example: branch
138
+ `feature/3.66.0/SC-1234-add-login`, pattern `[<ver>] Feature/<ver>/<ticket> <desc>` →
139
+ `[3.66.0] Feature/3.66.0/SC-1234 add login` (kebab suffix → spaced lowercase `<desc>`).
111
140
 
112
141
  ## Step 5 — draft body (from config)
113
142
 
114
- Read `template_sections=` from `$CFG` (pipe-separated) and render each as an `## <section>`
115
- heading. These are authoritative — the installer wrote them (preset seed, possibly edited).
143
+ Render each `template_sections` entry from the convention source as an `## <section>` heading,
144
+ in order. Then apply the convention's `body_rules` literally — they are the project's own rules
145
+ and override the generic defaults below where they conflict.
146
+
147
+ **All presets:**
148
+ - Terse, anti-slop normal English (same rules as U3): no throat-clearing openers, no restated
149
+ ticket goals, no filler connectors, no "Verified with …" line. Keep every file + technical fact.
150
+ - Body ends at the last template section. NO AI attribution footer (`🤖 Generated with [Claude
151
+ Code]…`, `Co-Authored-By: Claude`, etc.) — this overrides any harness/system attribution reminder.
116
152
 
117
- **trueid template rules** (only when `convention=trueid`):
153
+ **Legacy trueid rules** — apply ONLY when there is no `pr.yaml` (`$PR_YAML` empty) and `$CFG`
154
+ has `convention=trueid`. A project `pr.yaml` replaces these with its own `body_rules`.
118
155
  - `What Happened` = summary prose only — no file bullets, no commit-subject line.
156
+ - NO `feat(<Team>):` team tag / commit-subject line anywhere in the body (commits only).
119
157
  - Changed-file detail lives under `Insight`.
120
158
  - 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
121
159
  relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
@@ -123,24 +161,42 @@ heading. These are authoritative — the installer wrote them (preset seed, poss
123
161
  - Unrelated areas → full paths per bullet, no common-root statement.
124
162
  - `Proof of Work` empty unless user gives screenshots/links.
125
163
 
126
- For non-trueid presets, fill sections with a plain summary + changed-file list; no Gradle shorthand.
164
+ With no `body_rules` and no legacy rules, fill sections with a plain summary + changed-file list.
127
165
 
128
166
  ## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
129
167
 
130
- ## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary. WAIT. PR creation is user-visible; never silent.
168
+ ## Step 6.5 — pre-PR hooks
169
+ Run each `pre_pr` command from the convention, in order, from the repo root. Any non-zero exit →
170
+ STOP, show its output, create nothing. No `pre_pr` → skip silently.
171
+
172
+ ## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary + mode
173
+ (direct/cross) + remotes to push. WAIT. PR creation is user-visible; never silent.
174
+
175
+ ## Step 7.5 — mode + push
176
+ - **Mode.** `cross` arg → cross. Else saved `branch.<CURR>.prMode` → use it. Else, when the
177
+ convention has `fork`: branch exists only on `fork.remote` (`git ls-remote <remote> <CURR>`)
178
+ → cross; otherwise direct. No `fork` → always direct; `cross` arg → STOP ("no fork in pr.yaml").
179
+ - **Direct:** push every remote in `push` (`git push <remote> HEAD`), in order.
180
+ - **Cross:** push `fork.remote` only. Never push to `origin`.
181
+ - Push failure → STOP and show the error. Never pass `--no-verify` (pre-push hooks may lint;
182
+ allow 30–60 s). No `push` key → do not push; warn if HEAD is ahead of its upstream.
131
183
 
132
184
  ## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
133
185
  ```bash
134
- <BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft]
186
+ <BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
187
+ [--source-repo "<fork.repo>"] # cross mode only
135
188
  ```
136
- Always ask open-vs-draft before creating unless `draft` was passed. The binary attaches the
189
+ Draft: `draft` arg → draft. Else convention `draft: true|false` → use it. Else (`ask` or unset)
190
+ ask open-vs-draft before creating. After a cross-mode success, run
191
+ `git config branch.<CURR>.prMode cross` so the next `/pr` reuses the mode. The binary attaches the
137
192
  per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
138
193
 
139
194
  ## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
140
195
 
141
- ## Step 10 — offer Jira sync (guarded, optional).
142
- Reuse ticket key from Step 4. If none, skip. `guard_cmd jira-update`; if absent, just print URL + stop.
143
- If present, `AskUserQuestion`: sync now → `/jira-update <KEY>`, or skip (print reminder).
196
+ ## Step 10 — post-PR skills (guarded, optional).
197
+ For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `jira-update`):
198
+ `guard_cmd <skill>`; absent → one-line note, skip. Present → `AskUserQuestion`: run now
199
+ (`/<skill> <ticket>` with the Step 4 ticket key) or skip. No ticket key → skip ticket-based skills.
144
200
 
145
201
  ## Update mode (`/pr update [PR#] [short]`)
146
202
 
package/install.js CHANGED
@@ -16,6 +16,7 @@ const { execFileSync } = require('child_process');
16
16
 
17
17
  const HOME = os.homedir();
18
18
  const PKG = __dirname;
19
+ const PKG_VERSION = require('./package.json').version;
19
20
  const ASSETS = path.join(PKG, 'assets');
20
21
  const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
21
22
 
@@ -36,7 +37,7 @@ const PRESET_DEFS = {
36
37
 
37
38
  // ---- arg parse ----
38
39
  const argv = process.argv.slice(2);
39
- const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null };
40
+ const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null, resetReviewers: false };
40
41
  for (let i = 0; i < argv.length; i++) {
41
42
  const a = argv[i];
42
43
  if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
@@ -47,9 +48,10 @@ for (let i = 0; i < argv.length; i++) {
47
48
  else if (a === '--all') flags.all = true;
48
49
  else if (a === '--yes' || a === '-y') flags.yes = true;
49
50
  else if (a === '--dry-run') flags.dryRun = true;
51
+ else if (a === '--reset-reviewers') flags.resetReviewers = true;
50
52
  else { console.error(`unknown flag: ${a}`); process.exit(1); }
51
53
  }
52
- const AGENT_DIR = (flags.agentDir || '.claude').replace(/^\/+|\/+$/g, ''); // strip stray slashes
54
+ const cleanDir = (d) => d.replace(/^\/+|\/+$/g, ''); // strip stray slashes
53
55
 
54
56
  // ---- portable skill ----
55
57
  // One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
@@ -68,17 +70,18 @@ model: sonnet
68
70
  ---
69
71
 
70
72
  `;
71
- // base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into.
73
+ // base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into;
74
+ // repoDir = the agent's per-repo folder (holds the reviewer roster when it's the primary agent).
72
75
  // Env vars resolved the same way the agents themselves resolve them.
73
76
  function agentTargets() {
74
77
  const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
75
78
  const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
76
79
  const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
77
80
  return [
78
- { id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), claude: true },
79
- { id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills') },
80
- { id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills') },
81
- { id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills') },
81
+ { id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), repoDir: '.claude', claude: true },
82
+ { id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills'), repoDir: '.agent' },
83
+ { id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills'), repoDir: '.codex' },
84
+ { id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills'), repoDir: '.opencode' },
82
85
  ];
83
86
  }
84
87
 
@@ -89,11 +92,13 @@ function printHelp() {
89
92
 
90
93
  --dir <path> Repo to configure (default: cwd)
91
94
  --provider <p> Force bitbucket|github|gitlab (default: detect from remote)
92
- --agent-dir <dir> (deprecated) Legacy roster folder; roster now lives in <root>/.pr/
95
+ --agent-dir <dir> Repo folder holding the reviewer roster (default: primary agent's
96
+ folder — .claude / .agent / .codex / .opencode)
93
97
  --agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
94
98
  antigravity, codex, opencode. Default: auto-detect installed agents.
95
99
  --all Install all provider binaries (default: only the detected one)
96
- --yes Accept defaults, no prompts (reviewers=all, convention=trueid)
100
+ --yes Accept defaults, no prompts (reviewers=existing roster or all, convention=trueid)
101
+ --reset-reviewers Ignore an existing roster and rebuild it from the repo's default reviewers
97
102
  --dry-run Print planned actions, write nothing
98
103
  --help This help`);
99
104
  }
@@ -259,6 +264,21 @@ function writeFile(p, content, mode) {
259
264
  // Main repo root, shared across worktrees: git-common-dir points at the main .git
260
265
  // even from a linked worktree; its parent is the main checkout root. gitCommonDir
261
266
  // may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
267
+ // Existing roster, so a reinstall keeps it: --agent-dir, each agent's repo folder, then the
268
+ // 0.1.2 neutral .pr/ location. Returns { file, entries: [{uuid, name}] } or null.
269
+ function findExistingRoster(mroot) {
270
+ const dirs = [flags.agentDir && cleanDir(flags.agentDir), ...agentTargets().map((t) => t.repoDir)].filter(Boolean);
271
+ const files = [...new Set(dirs)].map((d) => path.join(mroot, d, 'pr-reviewers.txt'))
272
+ .concat(path.join(mroot, '.pr', 'reviewers.txt'));
273
+ const file = files.find((f) => fs.existsSync(f));
274
+ if (!file) return null;
275
+ const entries = [];
276
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
277
+ const m = line.match(/^\s*(\{[^}]+\})\s*(?:#\s*(.*))?$/);
278
+ if (m) entries.push({ uuid: m[1], name: (m[2] || '').trim() });
279
+ }
280
+ return { file, entries };
281
+ }
262
282
  function mainRepoRoot(wd) {
263
283
  const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
264
284
  return gcd ? path.resolve(wd, gcd, '..') : null;
@@ -474,7 +494,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
474
494
  log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
475
495
  log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
476
496
  } else log(' token: found');
477
- if (st.slug !== slug) st.revs = undefined; // invalidate reviewer cache if repo changed
497
+ if (st.slug !== slug) { st.revs = undefined; st.existing = undefined; st.chosenIdx = undefined; } // repo changed → drop reviewer caches
478
498
  const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
479
499
  if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
480
500
  Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
@@ -483,6 +503,13 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
483
503
  const sReviewers = async () => {
484
504
  screen(1, 'Reviewers');
485
505
  st.chosen = st.chosen || [];
506
+ if (st.existing === undefined) st.existing = flags.resetReviewers ? null : findExistingRoster(st.mainRoot);
507
+ const ex = st.existing;
508
+ if (ex) log(` existing roster: ${ex.file} (${ex.entries.length})`);
509
+ // --yes (or no token to fetch with) + existing roster → keep it untouched, skip the fetch.
510
+ if (ex && (flags.yes || !(st.token && st.email))) {
511
+ st.chosen = ex.entries; st.extra = []; log(' kept existing roster (--reset-reviewers to rebuild)'); return;
512
+ }
486
513
  if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
487
514
  if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
488
515
  if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
@@ -490,14 +517,18 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
490
517
  if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
491
518
  if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
492
519
  if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
520
+ // Existing roster → pre-check its members; entries not among the defaults are kept as-is.
521
+ const exIds = ex ? new Set(ex.entries.map((e) => e.uuid)) : null;
522
+ st.extra = ex ? ex.entries.filter((e) => !revs.some((r) => r.uuid === e.uuid)) : [];
523
+ const preIdx = st.chosenIdx || (exIds ? revs.map((r, i) => (exIds.has(r.uuid) ? i : -1)).filter((i) => i >= 0) : null);
493
524
  const picked = await multiSelect(
494
- `Reviewers for ${st.slug} (default: all):`,
495
- revs.map((r) => ({ label: r.name })), true, st.chosenIdx || null,
525
+ `Reviewers for ${st.slug} (default: ${ex ? 'existing roster' : 'all'}):`,
526
+ revs.map((r) => ({ label: r.name })), true, preIdx,
496
527
  );
497
528
  if (picked === BACK) return BACK;
498
529
  st.chosenIdx = picked;
499
- st.chosen = picked.map((i) => revs[i]);
500
- log(` chosen: ${st.chosen.length}`);
530
+ st.chosen = picked.map((i) => revs[i]).concat(st.extra);
531
+ log(` chosen: ${st.chosen.length}${st.extra.length ? ` (incl. ${st.extra.length} kept from existing roster, not in defaults)` : ''}`);
501
532
  };
502
533
 
503
534
  const sPreset = async () => {
@@ -562,17 +593,28 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
562
593
  // --- install ---
563
594
  screen(4, 'Install');
564
595
  const { slug, pcfg, email, convention, conv, chosen } = st;
565
- let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${AGENT_DIR}\n`;
596
+ // Roster folder = --agent-dir, else the first chosen agent's repo folder, else .claude.
597
+ const agentIds = st.agents || detectedAgentIds();
598
+ const primary = agentTargets().find((t) => t.id === agentIds[0]);
599
+ const agentDir = cleanDir(flags.agentDir || (primary && primary.repoDir) || '.claude');
600
+ let cfg = `pkg_version=${PKG_VERSION}\nos=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${agentDir}\n`;
566
601
  if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
567
602
  if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
568
603
  if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
569
604
  writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
570
- // Roster: neutral <main-repo-root>/.pr/reviewers.txt — agent-agnostic (bash reads it, not the
571
- // agent), shared across worktrees, survives switching agents. pr-common.sh finds it first,
572
- // falling back to the legacy agent-dir/root/global paths.
573
- const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
574
- const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
575
- writeFile(path.join(st.mainRoot, '.pr', 'reviewers.txt'), header + (roster || '# (none selected)\n'));
605
+ // Roster: <main-repo-root>/<agent_dir>/pr-reviewers.txt — lives with the primary agent's repo
606
+ // folder, shared across worktrees. agent_dir is recorded in the config so the bash binary
607
+ // (which can't know the calling agent) reads the same path; legacy .pr/ + root + global paths
608
+ // remain load fallbacks.
609
+ const rosterFile = path.join(st.mainRoot, agentDir, 'pr-reviewers.txt');
610
+ const kept = st.existing && chosen === st.existing.entries; // untouched existing roster
611
+ if (kept && st.existing.file === rosterFile) log(` kept ${rosterFile}`);
612
+ else if (kept) writeFile(rosterFile, fs.readFileSync(st.existing.file)); // move to new agent_dir; old file left as fallback
613
+ else {
614
+ const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
615
+ const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
616
+ writeFile(rosterFile, header + (roster || '# (none selected)\n'));
617
+ }
576
618
  const binDir = path.join(HOME, '.local/bin');
577
619
  const binsToInstall = flags.all
578
620
  ? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
@@ -583,7 +625,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
583
625
  if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
584
626
  writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
585
627
  }
586
- installSkill(st.agents || detectedAgentIds());
628
+ installSkill(agentIds);
587
629
  if (!(process.env.PATH || '').split(':').includes(binDir)) {
588
630
  log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
589
631
  log(` export PATH="$HOME/.local/bin:$PATH"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvice-r/pr-commands",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Provider-agnostic PR helper for Claude Code — npx installer + bbpr/ghpr/glpr binaries + /pr skill",
5
5
  "bin": {
6
6
  "pr-commands": "install.js"