@rtorcato/repo-tooling 3.21.1 → 3.22.1

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.
@@ -366,7 +366,11 @@ export const BASE_FIXERS = [
366
366
  for (const name of SHIPPED_SKILLS) {
367
367
  const result = await installClaudeSkill(dir, name, { force: forceSkills });
368
368
  if (result.status === 'declined-downgrade') {
369
- console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
369
+ // Say what was compared, not what is newer: the version is a label,
370
+ // and on a git checkout it can understate the content behind it
371
+ // (#522). Naming the escape hatch matters for exactly that case.
372
+ console.error(chalk.yellow(` skipped — ${result.file} is stamped ${result.installedVersion}, above the ${result.shippedVersion} this package reports; not overwritten`));
373
+ console.error(chalk.yellow(' overwrite anyway: fix claude-skills --force-skills'));
370
374
  continue;
371
375
  }
372
376
  if (result.status === 'declined-fork') {
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import fs from 'fs-extra';
11
+ import { realGitExec } from '../../base/git-identity.js';
11
12
  import { getPackageRoot } from '../utils/copy-preset.js';
12
13
  import { shellQuote } from '../utils/shell.js';
13
14
  /**
@@ -127,13 +128,41 @@ export function isNewerVersion(a, b) {
127
128
  }
128
129
  return false;
129
130
  }
131
+ /**
132
+ * The version to stamp, given what `package.json` claims.
133
+ *
134
+ * In a published tarball that field is authoritative — `@semantic-release/npm`
135
+ * rewrites it before packing. In a *git checkout* it is not: this repo runs
136
+ * semantic-release without `@semantic-release/git` (#417), so nothing ever
137
+ * writes the released version back and the field sits at whatever it was last
138
+ * hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
139
+ * ten minor versions of drift.
140
+ *
141
+ * That mattered because the stamp feeds the downgrade guard below: a skill
142
+ * installed from npm could not be updated from a local checkout, and the
143
+ * refusal claimed the installed copy was "newer" when only its *label* was.
144
+ *
145
+ * So when the package root is a git checkout, take the nearest tag as well and
146
+ * keep whichever is higher. Monotonic on purpose — this can only ever raise the
147
+ * answer, so a tagless, shallow, or git-less environment keeps today's
148
+ * behaviour rather than silently stamping something lower.
149
+ */
150
+ export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
151
+ if (!(await fs.pathExists(path.join(root, '.git'))))
152
+ return pkgVersion;
153
+ const described = await git(['describe', '--tags', '--abbrev=0'], root);
154
+ const tag = described?.trim().replace(/^v/, '');
155
+ if (!tag || !/^\d+\.\d+/.test(tag))
156
+ return pkgVersion;
157
+ return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
158
+ }
130
159
  /** The skill source and the package version that will be stamped into it. */
131
- export async function readShippedSkill(name = SHIPPED_SKILL) {
160
+ export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
132
161
  const root = getPackageRoot();
133
162
  const file = path.join(root, 'skills', name, 'SKILL.md');
134
163
  const content = await fs.readFile(file, 'utf8');
135
164
  const pkg = await fs.readJson(path.join(root, 'package.json'));
136
- return { content, version: String(pkg.version), file };
165
+ return { content, version: await resolveShippedVersion(root, String(pkg.version), git), file };
137
166
  }
138
167
  /**
139
168
  * The command a human runs to see what their fork changed, before deciding
@@ -187,7 +216,12 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { forc
187
216
  installedVersion,
188
217
  shippedVersion: shipped.version,
189
218
  };
190
- if (installedVersion && isNewerVersion(installedVersion, shipped.version)) {
219
+ // `force` overrides this too, not just the fork check below. It already
220
+ // overrides the *stronger* protection — overwriting content someone
221
+ // deliberately edited — so refusing on a version comparison while allowing
222
+ // that was backwards, and left no escape hatch at all when the comparison
223
+ // was wrong (#522).
224
+ if (!force && installedVersion && isNewerVersion(installedVersion, shipped.version)) {
191
225
  return { ...base, status: 'declined-downgrade' };
192
226
  }
193
227
  // Only `pristine` content is provably ours to replace. Anything else is a
@@ -66,13 +66,18 @@ export async function writeLockfile(dir, config, assets) {
66
66
  if (!valid) {
67
67
  throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
68
68
  }
69
- const carried = assets ?? (await readLockfile(dir))?.assets;
69
+ // One read, because everything not rebuilt from `config` has to be carried
70
+ // forward explicitly — this object is constructed from scratch, so any key
71
+ // not named here is dropped by the next `fix lockfile`.
72
+ const existing = await readLockfile(dir);
73
+ const carried = assets ?? existing?.assets;
70
74
  const filepath = path.join(dir, LOCKFILE_NAME);
71
75
  const lockfile = {
72
76
  $schema: LOCKFILE_SCHEMA_URL,
73
77
  version: LOCKFILE_VERSION,
74
78
  config,
75
79
  ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
80
+ ...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
76
81
  writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
77
82
  writtenAt: new Date().toISOString(),
78
83
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.21.1",
3
+ "version": "3.22.1",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -207,6 +207,54 @@ pass.** A session can be pinned to a worktree, so the orchestrator can find itse
207
207
  inside one it did not choose. `--git-common-dir` resolves to the main checkout's
208
208
  `.git` from anywhere, including a worktree, so `ROOT` is correct either way.
209
209
 
210
+ **Resolve `AGENT_USER` — the account in-flight work is assigned to.** Optional:
211
+ unset, every step below that would assign it simply does nothing, and assignment
212
+ behaves exactly as it did before this existed.
213
+
214
+ ```bash
215
+ # Repo config first — committed, so it travels with the repo and survives a new
216
+ # machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile.
217
+ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
218
+ # A typo would fail every `gh` edit for the whole tick, so prove it is assignable
219
+ # once, here. 204 = yes, 404 = no; push access is what qualifies an account.
220
+ [ -n "$AGENT_USER" ] && { gh api "repos/$OWNER_REPO/assignees/$AGENT_USER" --silent 2>/dev/null || {
221
+ echo "⚠ agentUser '$AGENT_USER' is not an assignable collaborator — assigning nothing"
222
+ AGENT_USER=""; }; }
223
+ ```
224
+
225
+ **It lives in the repo, not a shell profile.** The agent account is a
226
+ collaborator on *this* repo, so a machine-wide env var is both the wrong
227
+ granularity and invisible — forgotten on a new laptop, with the only symptom
228
+ being that assignment quietly stops. In `.repo-tooling.json` it is committed,
229
+ reviewable, and carried forward by `fix lockfile`:
230
+
231
+ ```json
232
+ { "aiLoop": { "agentUser": "your-bot-account" } }
233
+ ```
234
+
235
+ Every later use is `${AGENT_USER:+--add-assignee "$AGENT_USER"}`, which expands
236
+ to nothing when it is empty — so there is one code path, not two.
237
+
238
+ **The point is that assignee answers "whose turn is it", which no label does
239
+ well.** Today an issue an agent is mid-way through and an issue nobody has
240
+ touched are both assigned to no one, so the *Assigned to you* view is only ever
241
+ half the story:
242
+
243
+ | State | Assignee |
244
+ |---|---|
245
+ | issue `ai-ready`, unclaimed | nobody |
246
+ | issue `ai-wip` — an agent is implementing it | `AGENT_USER` |
247
+ | PR `ai-review` / `ai-changes` — an agent is reviewing or fixing | `AGENT_USER` |
248
+ | PR passed both reviews, waiting to merge | the human |
249
+ | `ai-blocked`, declined, or held | the human |
250
+
251
+ `@me` cannot express this: it resolves to whichever token is running, and the
252
+ agents authenticate as the owner, so `@me` is *always* the human. That is why
253
+ this is a separate name rather than a reuse.
254
+
255
+ Note the web UI's assignee picker can show a stale list that omits a
256
+ freshly-added collaborator; `repos/{repo}/assignees` is the authority.
257
+
210
258
  **Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
211
259
  `core.bare = true` on its own, repeatedly — four times in one session, some occurrences
212
260
  immediately after a `worktree remove` and some with nothing removed at all. The trigger
@@ -361,8 +409,11 @@ it behind a hidden marker and upsert:
361
409
 
362
410
  ```bash
363
411
  MARKER='<!-- ai-issue-loop:decision -->'
412
+ ME=$(gh api user --jq .login) # the identity every loop agent posts as
364
413
  ID=$(gh api "repos/$OWNER_REPO/issues/<N>/comments" \
365
- --jq "[.[] | select((.body // \"\") | startswith(\"$MARKER\"))] | .[0].id // empty")
414
+ | jq -r --arg me "$ME" --arg marker "$MARKER" \
415
+ '[.[] | select(.user.login == $me and ((.body // "") | startswith($marker)))]
416
+ | .[0].id // empty')
366
417
  if [ -n "$ID" ]; then
367
418
  gh api -X PATCH "repos/$OWNER_REPO/issues/comments/$ID" -f body="$MARKER
368
419
  $TEXT"
@@ -372,10 +423,24 @@ $TEXT"
372
423
  fi
373
424
  ```
374
425
 
426
+ **The author gate is the same one Pass 3's verdict read uses, and for the same
427
+ reason.** Anyone can comment on a public PR, so selecting by marker prefix alone
428
+ lets a stranger who posts `<!-- ai-issue-loop:decision -->` first own the slot
429
+ forever: `.[0]` takes the *oldest* match, the token has repo-write so the `PATCH`
430
+ succeeds, and every decision this loop ever reaches lands inside a
431
+ stranger-authored comment while the loop never posts one of its own. `.user.login`
432
+ against `gh api user`, not `author_association`, for the reason spelled out in
433
+ Pass 3.
434
+
375
435
  `// empty` is load-bearing: `.[0].id` on an empty array is `null`, which `jq -r`
376
436
  prints as the four characters `null` — a non-empty string that passes `[ -n ]` and
377
437
  sends the `PATCH` to comment id `null`. The upsert would then never post anything,
378
- silently, which is the one failure mode worse than duplicates.
438
+ silently, which is the one failure mode worse than duplicates. `(.body // "")` is
439
+ load-bearing for the mirror-image reason: a null body throws, aborting the filter
440
+ and emptying `ID`, which re-enters the duplicate-comment branch this whole section
441
+ exists to prevent. Both values come in through `--arg` rather than shell
442
+ interpolation, so the marker and login are jq *data* and cannot be parsed as
443
+ filter syntax.
379
444
 
380
445
  One comment per PR, edited in place, so the timeline shows the *current* reason
381
446
  rather than a log of every tick that ever ran. What it says — and whether to say
@@ -389,9 +454,13 @@ worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec
389
454
  not `ai-changes`, assign it and clear the stale review flag:
390
455
 
391
456
  ```bash
392
- gh pr edit <N> --add-assignee @me --remove-label ai-review
457
+ gh pr edit <N> --add-assignee @me --remove-label ai-review \
458
+ ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
393
459
  ```
394
460
 
461
+ Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
462
+ you says you both owe it something, which is the one thing never true here.
463
+
395
464
  It lands in the user's *Assigned to you* view, and the labels then read as state rather
396
465
  than noise — `ai-ok-code, ai-ok-sec` with no `ai-review` means **waiting on you**. Both
397
466
  halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
@@ -451,7 +520,7 @@ no other branch of this pass assigns it, which leaves it in no *Assigned to you*
451
520
  view at all:
452
521
 
453
522
  ```bash
454
- gh pr edit <N> --add-assignee @me
523
+ gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
455
524
  ```
456
525
 
457
526
  Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
@@ -536,7 +605,7 @@ Only then:
536
605
  REMOVED=1 # every removal in this pass sets this
537
606
  git -C "$ROOT" worktree remove --force "$WT_DIR" # the path found above, not a rebuilt one
538
607
  git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null
539
- gh issue edit <N> --remove-label ai-wip 2>/dev/null
608
+ gh issue edit <N> --remove-label ai-wip ${AGENT_USER:+--remove-assignee "$AGENT_USER"} 2>/dev/null
540
609
  # Still OPEN means the PR said only `Refs #N`; a `Closes #N` issue is already closed.
541
610
  if [ "$(gh issue view <N> --json state -q .state)" = OPEN ]; then
542
611
  gh issue edit <N> --add-assignee @me
@@ -570,7 +639,7 @@ work must never be reaped out from under itself.
570
639
 
571
640
  | Stalled | Condition | Do |
572
641
  |---|---|---|
573
- | Implementer died | issue `ai-wip` ≥45min, **and no PR exists** for `ai-<N>-<slug>` | `gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me`, comment, remove the worktree (and set `REMOVED=1`) |
642
+ | Implementer died | issue `ai-wip` ≥45min, **and no PR exists** for `ai-<N>-<slug>` | `gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}`, comment, remove the worktree (and set `REMOVED=1`) |
574
643
  | Reviewer died | PR `ai-reviewing-code` (or `ai-reviewing-sec`) ≥45min with no matching `ai-ok-*` and no `ai-changes` | `gh pr edit <N> --remove-label <the claim that stalled>` — drop **that** label, not a fixed one; a stalled `ai-reviewing-sec` cleared as `ai-reviewing-code` leaves the dead claim in place and the reviewer never re-spawns. Dropping the claim is what lets Pass 3 re-spawn it, and they're cheap and diff-scoped. If that claim has been applied ≥3 times, `ai-blocked` instead |
575
644
  | Orphan worktree | `"$WT_ROOT"/ai-<N>-*` whose issue is not `ai-wip` and has no open PR | remove the worktree and branch (and set `REMOVED=1`) |
576
645
 
@@ -755,10 +824,13 @@ intended; one that died after posting is now recovered instead of duplicated.
755
824
  issue. Apply the label immediately before the spawn, not after:
756
825
 
757
826
  ```bash
758
- gh pr edit <N> --add-label ai-reviewing-code # then spawn code-reviewer
759
- gh pr edit <N> --add-label ai-reviewing-sec # then spawn security-expert
827
+ gh pr edit <N> --add-label ai-reviewing-code ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn code-reviewer
828
+ gh pr edit <N> --add-label ai-reviewing-sec ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn security-expert
760
829
  ```
761
830
 
831
+ Assigning `AGENT_USER` on the claim is idempotent — both arms adding the same
832
+ account is one assignee, and Pass 1 removes it at the handoff.
833
+
762
834
  Without the claim there is no window in which "a reviewer is running" is visible.
763
835
  A reviewer applies its verdict label only at the *end*, after reading the diff and
764
836
  posting its comment, so from spawn until then the labels are indistinguishable
@@ -1016,8 +1088,10 @@ and a blank line — naming what each round changed and why the reviewer kept ob
1016
1088
  then:
1017
1089
 
1018
1090
  ```bash
1019
- gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
1020
- gh pr edit <N> --add-assignee @me --remove-label ai-review
1091
+ gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
1092
+ ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
1093
+ gh pr edit <N> --add-assignee @me --remove-label ai-review \
1094
+ ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
1021
1095
  ```
1022
1096
 
1023
1097
  Leave the worktree and PR in place for the human; a ping-pong stall is the case where
@@ -1128,16 +1202,30 @@ does not stack duplicates:
1128
1202
 
1129
1203
  ```bash
1130
1204
  gh issue view <N> --json comments \
1131
- --jq '[.comments[] | select(.body | startswith("🤖 *Automated — triage"))] | length'
1205
+ | jq -r --arg me "$(gh api user --jq .login)" \
1206
+ '[.comments[]
1207
+ | select(.author.login == $me and ((.body // "") | startswith("🤖 *Automated — triage")))]
1208
+ | length'
1132
1209
  ```
1133
1210
 
1211
+ Gated on the loop's own login for the same reason as the decision upsert above,
1212
+ inverted: anyone can comment on a public issue, so ungated a stranger who opens
1213
+ with that header *suppresses* the decline comment and the issue is left labelled
1214
+ with nothing on the timeline saying why. `.author.login` here, not `.user.login`
1215
+ — `gh issue view --json` is GraphQL and names the field differently from the REST
1216
+ payload the upsert reads.
1217
+
1134
1218
  Take the first `slots` issues. For each, **claim it first** so a concurrent tick
1135
1219
  can't double-pick:
1136
1220
 
1137
1221
  ```bash
1138
- gh issue edit <N> --add-label ai-wip --remove-label ai-ready
1222
+ gh issue edit <N> --add-label ai-wip --remove-label ai-ready \
1223
+ ${AGENT_USER:+--add-assignee "$AGENT_USER"}
1139
1224
  ```
1140
1225
 
1226
+ Assigning here is what makes the issue list honest: from this moment an agent
1227
+ owns the work, and an unassigned `ai-ready` issue is genuinely untouched.
1228
+
1141
1229
  Dropping `ai-ready` is half the claim, not tidiness — the diagram above is a
1142
1230
  transition, not an accumulation. An issue left carrying both re-enters the queue
1143
1231
  the instant `ai-wip` clears for any reason other than the PR closing it, and the
@@ -1274,9 +1362,13 @@ Then spawn a background implementer agent:
1274
1362
  > If you cannot finish, hand it back so a human can see it:
1275
1363
  >
1276
1364
  > ```bash
1277
- > gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
1365
+ > gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
1366
+ > <the orchestrator substitutes `--remove-assignee <AGENT_USER>` here, or nothing>
1278
1367
  > ```
1279
1368
  >
1369
+ > Handing back means the issue stops being the agent's: the human must end up the
1370
+ > only assignee, or the list still reads as though something is working on it.
1371
+ >
1280
1372
  > Then comment why. **Leave your worktree in place — never run
1281
1373
  > `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
1282
1374
  > longer `ai-wip` and has no open PR, so it matches the orphan rule) and rebuilds
@@ -42,6 +42,12 @@ argument.
42
42
  Filter the PR list to those carrying an `ai-*` label — a PR without one is
43
43
  not in the pipeline and the loop will never touch it.
44
44
 
45
+ **Read the assignee as "whose turn"**, when the loop is configured with an
46
+ agent account (`AI_LOOP_AGENT`): that account assigned means an agent is
47
+ working or reviewing, the human assigned means it is waiting on them, and
48
+ nobody assigned means queued. Say which in the report rather than listing raw
49
+ logins — "waiting on you" beats "assignee: someone".
50
+
45
51
  3. **Work out each PR's next move** from its labels, so the report says what
46
52
  happens rather than just listing state:
47
53
 
@@ -47,6 +47,12 @@ ROOT=$(git rev-parse --path-format=absolute --git-common-dir)/..; ROOT=$(cd "$RO
47
47
  WT_ROOT="$(dirname "$ROOT")/$(basename "$ROOT")-worktrees"
48
48
  R=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
49
49
  git -C "$ROOT" fetch --prune
50
+
51
+ # Optional: the account in-flight work is assigned to, so `assignee` says whose
52
+ # turn it is. Unset → nothing below assigns, exactly as before. See the
53
+ # ai-issue-loop skill's Pass 0 for why this is repo config rather than an env var.
54
+ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
55
+ [ -n "$AGENT_USER" ] && { gh api "repos/$R/assignees/$AGENT_USER" --silent 2>/dev/null || AGENT_USER=""; }
50
56
  ```
51
57
 
52
58
  `R` comes from the working directory's remote and is the only repo touched —
@@ -112,7 +118,8 @@ carrying both re-enters the queue the instant `ai-wip` clears):
112
118
 
113
119
  ```bash
114
120
  for n in <numbers>; do
115
- gh issue edit -R "$R" $n --add-label ai-wip --remove-label ai-ready
121
+ gh issue edit -R "$R" $n --add-label ai-wip --remove-label ai-ready \
122
+ ${AGENT_USER:+--add-assignee "$AGENT_USER"}
116
123
  SLUG="ai-$n-<3-4 kebab words from the title>"
117
124
  mkdir -p "$WT_ROOT"
118
125
  git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
@@ -133,9 +140,12 @@ and why.
133
140
  Call `Workflow` with the script below, passing the selected issues as `args`:
134
141
 
135
142
  ```
136
- Workflow({args: {repo: R, issues: [{number, title, slug, worktree}, …]}, script: …})
143
+ Workflow({args: {repo: R, agentUser: AGENT_USER, issues: [{number, title, slug, worktree}, …]}, script: …})
137
144
  ```
138
145
 
146
+ Pass `agentUser` as the empty string when `AGENT_USER` is unset — the script
147
+ tests it, so an empty value simply drops every assign.
148
+
139
149
  ```js
140
150
  export const meta = {
141
151
  name: 'ai-workflow',
@@ -205,8 +215,9 @@ leave the worktree in place, and return pr: null.`,
205
215
 
206
216
  (r, i) => !r?.pr ? [] : parallel(REVIEWERS.map((v) => () => agent(
207
217
  `Review GitHub PR #${r.pr} in ${args.repo}. First claim your arm:
208
- \`gh pr edit ${r.pr} --add-label ${v.claim}\` — it stops a concurrent
209
- ai-issue-loop tick spawning a duplicate of you.
218
+ \`gh pr edit ${r.pr} --add-label ${v.claim}${args.agentUser ? ` --add-assignee ${args.agentUser}` : ''}\` — the label
219
+ stops a concurrent ai-issue-loop tick spawning a duplicate of you, and the
220
+ assignee says the PR is the machine's turn until Pass 1 hands it back.
210
221
 
211
222
  Read exactly three things and nothing else: \`gh pr view ${r.pr}\`,
212
223
  \`gh pr diff ${r.pr}\`, and \`gh issue view ${i.number}\`. Do not explore the