@rtorcato/repo-tooling 3.21.0 → 3.22.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.
@@ -14,7 +14,9 @@ description: |
14
14
 
15
15
  One **tick** of an unattended pipeline: `ai-ready` issue → worktree → PR → two
16
16
  agent reviews → **assigned to you to merge** → worktree removed on the next tick.
17
- Only Dependabot PRs merge themselves; see Pass 1.
17
+ Only Dependabot PRs merge themselves, plus — on a repo whose `release` environment
18
+ requires reviewers — a fully-passed issue PR. See Pass 1. Whenever the loop
19
+ declines to merge, it says why in a comment on the PR.
18
20
 
19
21
  **All state lives in GitHub labels.** A tick is a stateless, idempotent pass over
20
22
  that state, so a missed tick, a crash, or a restart costs nothing. Never keep
@@ -101,6 +103,8 @@ is work someone would plausibly do gets filed as its own issue labelled
101
103
  a link. It does **not** earn `ai-notes` — later work does not decide this merge.
102
104
  An observation is not a follow-up. Prose in a merged PR's comments is
103
105
  archaeology, which is how every follow-up left there so far has died on merge.
106
+ The checkable test: writing "optional", "residual" or "non-blocking" in a
107
+ `### Before merging` section means that finding belongs in an issue instead.
104
108
 
105
109
  First run in a repo, create any that are missing (`gh label create` is a no-op
106
110
  error if it exists — ignore that):
@@ -155,7 +159,10 @@ alongside its verdict label. They are transient — a claim outliving its review
155
159
  means the agent died, which is Pass 2's stall reaping, not a state of the PR.
156
160
 
157
161
  Only the Dependabot arm merges itself, and only when no reviewer left `ai-notes`.
158
- An issue PR ends at *assigned to you* and waits there — `ai-ok-code, ai-ok-sec`
162
+ The one exception is a repo gated by a `release` environment with
163
+ `required_reviewers`, where the issue arm may also auto-merge under the same
164
+ conditions — see Pass 1.
165
+ On an ungated repo an issue PR ends at *assigned to you* and waits there — `ai-ok-code, ai-ok-sec`
159
166
  with no `ai-review` is the loop's way of saying done. Add `ai-notes` and it means
160
167
  done, but open the comments first.
161
168
 
@@ -200,6 +207,54 @@ pass.** A session can be pinned to a worktree, so the orchestrator can find itse
200
207
  inside one it did not choose. `--git-common-dir` resolves to the main checkout's
201
208
  `.git` from anywhere, including a worktree, so `ROOT` is correct either way.
202
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
+
203
258
  **Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
204
259
  `core.bare = true` on its own, repeatedly — four times in one session, some occurrences
205
260
  immediately after a `worktree remove` and some with nothing removed at all. The trigger
@@ -238,6 +293,12 @@ check skips a linked worktree, whose `.git` is a file.
238
293
  refuses with `error: could not lock config file .git/config: Operation not permitted` —
239
294
  observed. Aborting beats reporting a healthy repo while it stays broken.
240
295
 
296
+ **A failed repair halts the tick — the whole tick, not the command.** The `exit 1`
297
+ only ends one shell call; you are an agent reading a doc, not a shell honouring an
298
+ exit code. If the repair fails, run **no further passes** — report the failure via
299
+ Pass 5 and stop. Carrying on into Pass 4 branches every new worktree off a broken
300
+ `ROOT`, which is exactly the state that produced the #500 mass-deletion commit.
301
+
241
302
  Never use a relative path like `ai-*`. From inside a worktree it matches nothing, and
242
303
  the failure is **silent**: Pass 2 concludes there is nothing to clean, every worktree
243
304
  survives, `ai-wip` is never cleared, and slots leak until the loop reports `idle`
@@ -291,11 +352,84 @@ passes, never the report.
291
352
 
292
353
  ### Pass 1 — merge
293
354
 
294
- **Only Dependabot PRs merge unattended.** Everything else — every PR this loop
295
- opened from an `ai-ready` issue — stops here for a human even when both reviewers
296
- pass, because merging `main` fires semantic-release and publishes to npm. A
297
- `chore(deps)` squash subject cuts no release, which is what makes the Dependabot
298
- case safe. Count human-gated PRs as `ready` for Pass 5.
355
+ **Only Dependabot PRs merge unattended, unless the repo has a real publish gate.**
356
+ Everything else — every PR this loop opened from an `ai-ready` issue — stops here
357
+ for a human even when both reviewers pass, because merging `main` fires
358
+ semantic-release and publishes to npm. A `chore(deps)` squash subject cuts no
359
+ release, which is what makes the Dependabot case safe. Count human-gated PRs as
360
+ `ready` for Pass 5.
361
+
362
+ **The exception is a `release` environment with `required_reviewers`.** There a
363
+ human still stands between the merge and npm, so an unattended merge costs a
364
+ revert at worst rather than a publish. Probe for it, and **fail closed**:
365
+
366
+ ```bash
367
+ gh api repos/$OWNER_REPO/environments \
368
+ --jq '[.environments[] | select(.name=="release")
369
+ | .protection_rules[]? | select(.type=="required_reviewers")] | length'
370
+ ```
371
+
372
+ Non-zero → a non-Dependabot PR may auto-merge, but only carrying **all** of: both
373
+ `ai-ok-code` and `ai-ok-sec`, no `ai-notes`, no `ai-changes`, and
374
+ `mergeStateStatus: CLEAN`. Zero, or the call errors, or `gh` lacks access to that
375
+ endpoint → hand the PR over exactly as below.
376
+
377
+ **The environment alone is not the gate — confirm the publish job references
378
+ it.** An environment nothing declares gates nothing while reading as a gate in
379
+ both this probe and the GitHub UI, and the arm would then auto-merge a PR that
380
+ publishes unattended:
381
+
382
+ ```bash
383
+ grep -rl 'environment: release' "$ROOT/.github/workflows" || echo "not wired — no auto-merge"
384
+ ```
385
+
386
+ Empty → treat the repo as ungated, same as a zero probe. (`repo-tooling doctor`'s
387
+ *Release environment* check reports this exact misconfiguration.)
388
+
389
+ Three things the gate does **not** change:
390
+
391
+ - **Review still comes first.** Both reviewers must pass before any merge — already
392
+ this pass's contract. The gate relaxes only *who may merge after a pass*, never
393
+ *whether a review happened*.
394
+ - **`ai-notes` still blocks an unattended merge.** A reviewer who passed but left
395
+ something to read means a human reads it.
396
+ - **Order is still load-bearing.** If `autoMergeRequest != null` the merge can beat
397
+ the review, so Pass 0's disarm step applies unchanged.
398
+
399
+ Be plain about the residual risk: even gated, this lands code on `main` unattended,
400
+ and the only quality signal is two reviewers that — per the limits above — see the
401
+ diff only, with no repo-wide exploration. For `chore(deps)` that is proportionate.
402
+ For feature code it means a bad merge is a revert on `main`, not a caught mistake.
403
+ That, and not the npm publish, is the trade actually being made here.
404
+
405
+ **Every comment this pass leaves goes through one idempotent marker comment.** The
406
+ loop is stateless and ticks every 15 minutes, so a naive `gh pr comment` puts a
407
+ *duplicate* on the PR every tick — a PR left over a weekend collects ~200. Write
408
+ it behind a hidden marker and upsert:
409
+
410
+ ```bash
411
+ MARKER='<!-- ai-issue-loop:decision -->'
412
+ ID=$(gh api "repos/$OWNER_REPO/issues/<N>/comments" \
413
+ --jq "[.[] | select((.body // \"\") | startswith(\"$MARKER\"))] | .[0].id // empty")
414
+ if [ -n "$ID" ]; then
415
+ gh api -X PATCH "repos/$OWNER_REPO/issues/comments/$ID" -f body="$MARKER
416
+ $TEXT"
417
+ else
418
+ gh pr comment <N> -R "$OWNER_REPO" --body "$MARKER
419
+ $TEXT"
420
+ fi
421
+ ```
422
+
423
+ `// empty` is load-bearing: `.[0].id` on an empty array is `null`, which `jq -r`
424
+ prints as the four characters `null` — a non-empty string that passes `[ -n ]` and
425
+ sends the `PATCH` to comment id `null`. The upsert would then never post anything,
426
+ silently, which is the one failure mode worse than duplicates.
427
+
428
+ One comment per PR, edited in place, so the timeline shows the *current* reason
429
+ rather than a log of every tick that ever ran. What it says — and whether to say
430
+ anything at all — is the comment-budget table at the top of this file; the marker
431
+ is only the *how*. `$TEXT` opens with the standard `🤖 *Automated …*` header and
432
+ leads with what to do.
299
433
 
300
434
  **Hand a ready PR over properly.** "Merge it yourself" is only actionable if the user
301
435
  can find it, and a PR sitting in a list of open PRs looks identical to one still being
@@ -303,17 +437,23 @@ worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec
303
437
  not `ai-changes`, assign it and clear the stale review flag:
304
438
 
305
439
  ```bash
306
- gh pr edit <N> --add-assignee @me --remove-label ai-review
440
+ gh pr edit <N> --add-assignee @me --remove-label ai-review \
441
+ ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
307
442
  ```
308
443
 
444
+ Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
445
+ you says you both owe it something, which is the one thing never true here.
446
+
309
447
  It lands in the user's *Assigned to you* view, and the labels then read as state rather
310
448
  than noise — `ai-ok-code, ai-ok-sec` with no `ai-review` means **waiting on you**. Both
311
449
  halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
312
450
  finished PR keeps wearing `ai-review` forever and looks mid-review. Idempotent, so
313
451
  re-running a tick is harmless. Take no other action — do not merge, and **post no
314
- comment**: nothing is wrong, so those three labels are the whole message. A
315
- comment is how the loop records what a label cannot; a clean PR has nothing to
316
- record.
452
+ comment on a clean handoff**: nothing is wrong, so those three labels are the
453
+ whole message. A comment is how the loop records what a label cannot; a clean PR
454
+ has nothing to record. An `ai-notes` handoff is the exception per the budget
455
+ table — ≤10 lines through the marker upsert, linking the reviewer's
456
+ `### Before merging` rather than restating it.
317
457
 
318
458
  **Never strip `ai-notes` here.** It is the whole point of the handoff: it has to
319
459
  survive to the moment of merging, which is the moment it is for. A ready PR reads
@@ -344,9 +484,11 @@ a required check, so nothing in the check list looked wrong either.
344
484
 
345
485
  Diff-scoped reviewers cannot catch this — they never see CI. So when a
346
486
  both-passed PR is not `CLEAN`, do not assign it as ready. Send it back, and
347
- **comment why** — ≤10 lines, leading with what must change, then the failing
348
- check and its error. The reviewers passed it, so the fix-round implementer would
349
- otherwise read the comments and find no instruction to act on.
487
+ **comment why** through the marker upsert — ≤10 lines, leading with what must
488
+ change, then the failing check and its error. The reviewers passed it, so the
489
+ fix-round implementer would otherwise read the comments and find no instruction
490
+ to act on. Name what unblocks it — `BEHIND` wants a rebase, `DIRTY` wants the
491
+ conflict resolved, `BLOCKED` wants the specific check or ruleset named.
350
492
 
351
493
  ```bash
352
494
  gh pr edit <N> --add-label ai-changes \
@@ -361,7 +503,7 @@ no other branch of this pass assigns it, which leaves it in no *Assigned to you*
361
503
  view at all:
362
504
 
363
505
  ```bash
364
- gh pr edit <N> --add-assignee @me
506
+ gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
365
507
  ```
366
508
 
367
509
  Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
@@ -443,9 +585,10 @@ git -C "$ROOT" log origin/main --oneline -20 | grep -q "(#<PR>)" || {
443
585
  Only then:
444
586
 
445
587
  ```bash
588
+ REMOVED=1 # every removal in this pass sets this
446
589
  git -C "$ROOT" worktree remove --force "$WT_DIR" # the path found above, not a rebuilt one
447
590
  git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null
448
- gh issue edit <N> --remove-label ai-wip 2>/dev/null
591
+ gh issue edit <N> --remove-label ai-wip ${AGENT_USER:+--remove-assignee "$AGENT_USER"} 2>/dev/null
449
592
  # Still OPEN means the PR said only `Refs #N`; a `Closes #N` issue is already closed.
450
593
  if [ "$(gh issue view <N> --json state -q .state)" = OPEN ]; then
451
594
  gh issue edit <N> --add-assignee @me
@@ -479,9 +622,9 @@ work must never be reaped out from under itself.
479
622
 
480
623
  | Stalled | Condition | Do |
481
624
  |---|---|---|
482
- | 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 |
625
+ | 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`) |
483
626
  | 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 |
484
- | Orphan worktree | `"$WT_ROOT"/ai-<N>-*` whose issue is not `ai-wip` and has no open PR | remove the worktree and branch |
627
+ | 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`) |
485
628
 
486
629
  The **no PR exists** condition on the first row is what makes reaping safe. An
487
630
  agent that got as far as opening a PR has handed off to the label state machine
@@ -526,7 +669,66 @@ checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is
526
669
  why the re-check belongs here: it catches a flip after this pass's removals and before
527
670
  Pass 4 branches every new worktree off a broken `ROOT`. Keep the timestamp in both log
528
671
  lines; which pass emitted one, and when, is the only instrumentation likely to pin the
529
- trigger down.
672
+ trigger down. Pass 0's halt rule applies unchanged: a failed repair ends the tick.
673
+
674
+ #### Last thing in the pass — rebuild the main checkout's `node_modules`
675
+
676
+ **Removing a worktree can destroy the main checkout's `node_modules/.bin`.** Its
677
+ modules dir is a symlink into the main checkout, so a pnpm run from *inside* a
678
+ worktree anchors the **main checkout's** `.bin` shims at the **worktree** path.
679
+ `worktree remove --force` then deletes them, leaving `$ROOT/node_modules/.bin`
680
+ with zero entries and the repo unbuildable:
681
+
682
+ ```
683
+ Error: Cannot find module '/…/browser-common-worktrees/ai-145-…/node_modules/.pnpm/typescript@7.0.2/node_modules/typescript/bin/tsc'
684
+ husky - pre-push script failed (code 1)
685
+ ```
686
+
687
+ The give-away is the path: a binary in the main checkout resolving into a
688
+ worktree that no longer exists. Nothing in the loop notices — no pass runs the
689
+ toolchain — so it surfaces arbitrarily later, in the human's next `git push`, as
690
+ a broken repo with no visible connection to the loop. Observed on
691
+ `browser-common` #145 → PR #147, where the implementer had been explicitly warned
692
+ in its prompt not to run a bare `pnpm install`. **It happened anyway**, and any
693
+ pnpm invocation that touches the store is enough — so agent discipline is the
694
+ wrong place for this guard. So is a dangling-link probe: `.bin` shims sit *below*
695
+ `node_modules`, and a `-maxdepth 1` scan reports a clean tree while every binary
696
+ is gone.
697
+
698
+ So run it after the removals, **once per tick, as the last thing in this pass**,
699
+ and only when nothing else is using the shared tree:
700
+
701
+ ```bash
702
+ LIVE=$(find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null)
703
+ if [ "$REMOVED" = 1 ] && [ -f "$ROOT/pnpm-lock.yaml" ]; then
704
+ if [ -z "$LIVE" ]; then
705
+ (cd "$ROOT" && pnpm install --frozen-lockfile --config.confirmModulesPurge=false)
706
+ else
707
+ echo "rebuild deferred — $(echo "$LIVE" | wc -l | tr -d ' ') worktree(s) still live"
708
+ fi
709
+ fi
710
+ ```
711
+
712
+ Three conditions, each load-bearing:
713
+
714
+ - **`$REMOVED`** — set by every removal path above, merged-PR cleanup *and* stall
715
+ reaping. A reaped worktree needs this most: its agent died mid-command, so it is
716
+ the likeliest to have left the main checkout anchored at a path about to vanish.
717
+ - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
718
+ - **`$LIVE` empty** — the repair *purges* the shared modules dir, which would be
719
+ yanked out from under any agent still running in a surviving worktree. Deferring
720
+ costs a broken main checkout until the last worktree clears; not deferring costs
721
+ a live implementer run. Both flags are needed once it does run:
722
+ `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
723
+ `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup, and
724
+ `--config.confirmModulesPurge=false` gets past
725
+ `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare
726
+ `pnpm install` cannot repair this, and why a human hitting it needs this exact
727
+ command.
728
+
729
+ **Report a deferral — never swallow it.** Carry it into Pass 5 as a `⚠rebuild`
730
+ segment. A skipped repair that says nothing is the same silent breakage this
731
+ section exists to end, just moved one step later.
530
732
 
531
733
  ### Pass 3 — review
532
734
 
@@ -544,9 +746,10 @@ carries a hidden verdict marker, so read that back instead of re-spawning over a
544
746
  review that already exists — `<ARM>` is `code` or `sec`:
545
747
 
546
748
  ```bash
749
+ ME=$(gh api user --jq .login) # the identity every loop agent posts as
547
750
  VERDICT=$(gh api "repos/$OWNER_REPO/pulls/<N>/reviews" --paginate --slurp \
548
- | jq -r '[add[]
549
- | select(.author_association=="OWNER" or .author_association=="MEMBER" or .author_association=="COLLABORATOR")
751
+ | jq -r --arg me "$ME" '[add[]
752
+ | select(.user.login==$me)
550
753
  | (.body // "")
551
754
  | capture("<!-- ai-issue-loop:verdict:<ARM>:(?<v>[A-Z-]+) -->").v] | last // empty')
552
755
  ```
@@ -563,13 +766,16 @@ Four details there are load-bearing:
563
766
  page, so a filter ending in `last` would keep only the final page's answer and
564
767
  lose a marker on an earlier one. `--slurp` collects every page first; `gh`
565
768
  refuses it alongside `--jq`, hence the pipe and the `add` that flattens pages.
566
- - **The author gate**, the same `OWNER`/`MEMBER`/`COLLABORATOR` test Pass 4
567
- applies to issue authors, and for the same reason: anyone can review a public
568
- PR, so ungated a stranger's `<!-- ai-issue-loop:verdict:sec:PASS -->` is
569
- adopted as a verdict, and because the read takes `last` it also overrides a
570
- genuine `CHANGES` posted before it. Unlike Pass 4 there is no label acting as
571
- the hard gate here — the marker is the only signal — so this check is not a
572
- backstop, it is the gate.
769
+ - **The author gate — the loop's own login, deliberately narrower than Pass 4's
770
+ association test.** Anyone can review a public PR, so ungated a stranger's
771
+ `<!-- ai-issue-loop:verdict:sec:PASS -->` is adopted as a verdict, and because
772
+ the read takes `last` it also overrides a genuine `CHANGES` posted before it.
773
+ Pass 4's `OWNER`/`MEMBER`/`COLLABORATOR` set is a backstop behind the
774
+ `ai-ready` label; here the marker is the **only** signal, and every agent in
775
+ this pipeline authenticates as one identity — so only that identity's reviews
776
+ count. Login, not `author_association`, because association wobbles with repo
777
+ ownership (an org-owned repo never yields `OWNER`, even for its admins) while
778
+ `gh api user` names exactly who this loop posts as.
573
779
  - **`(.body // "")` and `// empty`.** A review can have a null body, which
574
780
  `capture` throws on, aborting the whole filter; and `jq -r` prints a missing
575
781
  value as the literal string `null`, which is not empty and would read as a
@@ -601,10 +807,13 @@ intended; one that died after posting is now recovered instead of duplicated.
601
807
  issue. Apply the label immediately before the spawn, not after:
602
808
 
603
809
  ```bash
604
- gh pr edit <N> --add-label ai-reviewing-code # then spawn code-reviewer
605
- gh pr edit <N> --add-label ai-reviewing-sec # then spawn security-expert
810
+ gh pr edit <N> --add-label ai-reviewing-code ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn code-reviewer
811
+ gh pr edit <N> --add-label ai-reviewing-sec ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn security-expert
606
812
  ```
607
813
 
814
+ Assigning `AGENT_USER` on the claim is idempotent — both arms adding the same
815
+ account is one assignee, and Pass 1 removes it at the handoff.
816
+
608
817
  Without the claim there is no window in which "a reviewer is running" is visible.
609
818
  A reviewer applies its verdict label only at the *end*, after reading the diff and
610
819
  posting its comment, so from spawn until then the labels are indistinguishable
@@ -691,9 +900,15 @@ Reviewer prompt template:
691
900
  > ```bash
692
901
  > gh issue create --label ai-suggested --title "<what to do>" --body "🤖 *Automated — \`<your agent type>\` via ai-issue-loop.*
693
902
  >
694
- > Surfaced reviewing #<N>. <What, and why it matters. A few lines.>"
903
+ > Surfaced reviewing #<N>. <What. Why it matters. A one-line fix sketch.>"
695
904
  > ```
696
905
  >
906
+ > **Cap the issue body at 10 lines.** The title is the action; the body is
907
+ > what/why/fix-sketch and nothing else — no options tables, no "why this was
908
+ > not blocking" essays, no restated diff. The full analysis already lives in
909
+ > your review comment, and GitHub's cross-link points there; a triage queue
910
+ > that takes a minute per item gets read, one that takes five gets skipped.
911
+ >
697
912
  > Then put `Follow-up: #<new>` on one line in the body above `### Before
698
913
  > merging` and keep it out of that section, so it does not pull `ai-notes` in —
699
914
  > later work is not a merge gate. GitHub cross-links the two, so the trail
@@ -849,14 +1064,17 @@ gh api "repos/$OWNER_REPO/issues/<N>/timeline" \
849
1064
  --jq '[.[] | select(.event=="labeled" and .label.name=="ai-changes")] | length'
850
1065
  ```
851
1066
 
852
- If that count is **≥ 3**, stop looping. Comment the reason on the PR — opening with
1067
+ If that count is **≥ 3**, stop looping. Comment the reason on the PR — through the
1068
+ Pass 1 marker upsert, opening with
853
1069
  `🤖 *Automated — \`ai-issue-loop\` Pass 3.*`
854
1070
  and a blank line — naming what each round changed and why the reviewer kept objecting,
855
1071
  then:
856
1072
 
857
1073
  ```bash
858
- gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
859
- gh pr edit <N> --add-assignee @me --remove-label ai-review
1074
+ gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
1075
+ ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
1076
+ gh pr edit <N> --add-assignee @me --remove-label ai-review \
1077
+ ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
860
1078
  ```
861
1079
 
862
1080
  Leave the worktree and PR in place for the human; a ping-pong stall is the case where
@@ -926,7 +1144,23 @@ real work to reach is lost.
926
1144
  The comment opens with the standard `🤖 *Automated …*` header — see the top of this
927
1145
  file. Then, in the body — **this is the one comment exempt from the ≤10-line
928
1146
  budget, and only this one.** Declining is a hard handoff whose whole value is the
929
- reasoning; do not reach for this shape on a PR handoff:
1147
+ reasoning; do not reach for this shape on a PR handoff.
1148
+
1149
+ **Lead with a `## To lift this hold` section, before anything else.** It must be
1150
+ readable in five seconds — the reasoning that follows is *why*; this is *what to
1151
+ do*. A decline that buries the action under three paragraphs leaves the reader
1152
+ knowing an agent declined but not what is now expected of them, which is the same
1153
+ dead end as not commenting at all. Make it executable without reading further:
1154
+
1155
+ - **Enumerate the options as a table**, one row each, with what an agent would do
1156
+ once that option is chosen. Two to four rows. Genuinely one path → one sentence.
1157
+ - **State the label move explicitly** — "say which in a comment, then swap
1158
+ `holding` for `ai-ready`". The reader never works out the unblock themselves.
1159
+ - **Flag anything time-sensitive** with a ⏳ line — a decision cheap now and
1160
+ expensive later is exactly what a skimming reader needs to see.
1161
+
1162
+ The reasoning below that — in a `<details>` block so it never pushes the action
1163
+ off screen:
930
1164
 
931
1165
  - **Why an agent cannot finish it**, concretely. "Not suitable" is useless. Name
932
1166
  the blocker: binary assets it cannot author, a force-push past branch
@@ -937,6 +1171,10 @@ reasoning; do not reach for this shape on a PR handoff:
937
1171
  - **Whether it is terminal**, when the right answer is to do nothing at all — so
938
1172
  the next triage pass does not reopen the question.
939
1173
 
1174
+ The lead-with-the-action shape (not the length exemption) applies to every
1175
+ comment that hands a decision back — `ai-blocked` from a stall or a ping-pong
1176
+ stop included. What to do first; justification underneath.
1177
+
940
1178
  If the issue was already labelled, drop `ai-ready` in the same breath; leaving it
941
1179
  means the next tick picks it straight back up. Do **not** use `ai-blocked` for
942
1180
  this — that label means *an agent tried and got stuck*, and spending it on an
@@ -954,9 +1192,13 @@ Take the first `slots` issues. For each, **claim it first** so a concurrent tick
954
1192
  can't double-pick:
955
1193
 
956
1194
  ```bash
957
- gh issue edit <N> --add-label ai-wip --remove-label ai-ready
1195
+ gh issue edit <N> --add-label ai-wip --remove-label ai-ready \
1196
+ ${AGENT_USER:+--add-assignee "$AGENT_USER"}
958
1197
  ```
959
1198
 
1199
+ Assigning here is what makes the issue list honest: from this moment an agent
1200
+ owns the work, and an unassigned `ai-ready` issue is genuinely untouched.
1201
+
960
1202
  Dropping `ai-ready` is half the claim, not tidiness — the diagram above is a
961
1203
  transition, not an accumulation. An issue left carrying both re-enters the queue
962
1204
  the instant `ai-wip` clears for any reason other than the PR closing it, and the
@@ -972,7 +1214,54 @@ kebab-case words from the title:
972
1214
  SLUG="ai-<N>-<slug>"
973
1215
  mkdir -p "$WT_ROOT"
974
1216
  git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
975
- ln -s "$ROOT/node_modules" "$WT_ROOT/$SLUG/node_modules" # replaces worktree.symlinkDirectories
1217
+ ```
1218
+
1219
+ **Then give it dependencies — and the choice matters.** Symlinking is the cheap
1220
+ path, but it is only correct for an issue confined to an app:
1221
+
1222
+ ```bash
1223
+ # Only for app/docs-only issues — nothing under a workspace package.
1224
+ # Replaces worktree.symlinkDirectories. Link the workspace packages too, not just
1225
+ # the root — with only the root linked, `pnpm verify` ENOENTs at the treeshake step
1226
+ # because apps/*/node_modules is missing, and the agent cannot self-verify.
1227
+ ln -s "$ROOT/node_modules" "$WT_ROOT/$SLUG/node_modules"
1228
+ for app in "$ROOT"/apps/*/; do
1229
+ [ -d "$app/node_modules" ] || continue
1230
+ ln -s "$app/node_modules" "$WT_ROOT/$SLUG/apps/$(basename "$app")/node_modules"
1231
+ done
1232
+ ```
1233
+
1234
+ **For anything touching a workspace package, do not symlink — install for real:**
1235
+
1236
+ ```bash
1237
+ (cd "$WT_ROOT/$SLUG" && pnpm install)
1238
+ ```
1239
+
1240
+ Those symlinks share the **root** and `apps/*`. pnpm workspaces keep the
1241
+ resolution that matters in each `packages/<name>/node_modules`, which is not
1242
+ symlinked and does not exist in a fresh worktree. Measured on `api-common`
1243
+ 2026-08-20: the main checkout had per-package `node_modules` in **37 of 37**
1244
+ packages, the worktree had **1**. So `pnpm --filter <pkg> typecheck` there fails
1245
+ with `Cannot find module` rather than the real error — the agent cannot reproduce
1246
+ the bug, and the environment looks like the issue's fault. Issue #201 was handed
1247
+ back `ai-blocked` this way, well-diagnosed and untouched.
1248
+
1249
+ **Never force `pnpm install` against a symlinked tree.** It wants to purge and
1250
+ rebuild the modules dir (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), which
1251
+ mutates the **main checkout's** `node_modules` — shared by every other worktree
1252
+ and yanked out from under any agent mid-typecheck. `CI=true` and
1253
+ `--config.confirmModulesPurge=false` both silence that prompt; neither makes it
1254
+ safe. A real install in an unsymlinked worktree costs a duplicate `node_modules`
1255
+ and is the price of isolation. Pass 2's rebuild is the one sanctioned exception,
1256
+ and only because it is gated on no worktree surviving.
1257
+
1258
+ **Once per repo, exclude the symlink from git.** Repos ignore `node_modules/`
1259
+ *with a trailing slash*, which does not match a symlink — so the link shows as
1260
+ untracked in every worktree and a `git add -A` commits it. `.git/info/exclude`
1261
+ is shared by all worktrees and never committed:
1262
+
1263
+ ```bash
1264
+ grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$ROOT/.git/info/exclude"
976
1265
  ```
977
1266
 
978
1267
  **No implementer ever calls `EnterWorktree` — in any form.** This is deliberate; do
@@ -1021,15 +1310,17 @@ Then spawn a background implementer agent:
1021
1310
  > step or committed build output.
1022
1311
  > 4. Do the work. Conventional Commits within the branch.
1023
1312
  >
1024
- > **Do not run `pnpm install`.** This worktree's `node_modules` is a symlink to
1025
- > the main checkout, so pnpm sees a foreign directory it must purge first and
1026
- > aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`. Dependencies are
1027
- > already present — run tests, lint and build directly. If the work *is* a
1028
- > dependency change, `pnpm install --lockfile-only` updates `pnpm-lock.yaml`
1029
- > without touching `node_modules`. When that leaves a verification step you
1030
- > cannot run, say so in the PR body — name the command you could not run and
1031
- > why — so the reviewer knows CI is the only check on it rather than assuming
1032
- > you ran it.
1313
+ > **Do not run `pnpm install`.** Dependencies are already present — the
1314
+ > orchestrator either symlinked them or ran a real install; run tests, lint
1315
+ > and build directly. If `node_modules` is a symlink, pnpm sees a foreign
1316
+ > directory it must purge first and aborts with
1317
+ > `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — and forcing past that prompt
1318
+ > rewrites the **main checkout's** modules, shared by every other worktree.
1319
+ > If the work *is* a dependency change, `pnpm install --lockfile-only`
1320
+ > updates `pnpm-lock.yaml` without touching `node_modules`. When that leaves
1321
+ > a verification step you cannot run, say so in the PR body — name the
1322
+ > command you could not run and why — so the reviewer knows CI is the only
1323
+ > check on it rather than assuming you ran it.
1033
1324
  > 5. Push and open the PR. The title must be a Conventional Commit — it becomes
1034
1325
  > the squash subject on `main` and, in repos using semantic-release, decides
1035
1326
  > whether a release goes out at all. Body must contain `Closes #<N>`.
@@ -1044,12 +1335,20 @@ Then spawn a background implementer agent:
1044
1335
  > If you cannot finish, hand it back so a human can see it:
1045
1336
  >
1046
1337
  > ```bash
1047
- > gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
1338
+ > gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
1339
+ > <the orchestrator substitutes `--remove-assignee <AGENT_USER>` here, or nothing>
1048
1340
  > ```
1049
1341
  >
1050
- > Then comment why, and `git worktree remove --force` your worktree. The comment
1051
- > **must** open with this exact line, then a blank line — you authenticate as the
1052
- > owner, so without it the issue reads as if they wrote it themselves:
1342
+ > Handing back means the issue stops being the agent's: the human must end up the
1343
+ > only assignee, or the list still reads as though something is working on it.
1344
+ >
1345
+ > Then comment why. **Leave your worktree in place — never run
1346
+ > `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
1347
+ > longer `ai-wip` and has no open PR, so it matches the orphan rule) and rebuilds
1348
+ > the main checkout's `node_modules` in the same pass, which a bare removal here
1349
+ > would silently break. The comment **must** open with this exact line, then a
1350
+ > blank line — you authenticate as the owner, so without it the issue reads as if
1351
+ > they wrote it themselves:
1053
1352
  >
1054
1353
  > `🤖 *Automated — implementer via ai-issue-loop.*`
1055
1354
  >
@@ -1076,6 +1375,7 @@ Middle dot separated, zero segments omitted, stall counts first with a `⚠`:
1076
1375
  |---|---|
1077
1376
  | Work in flight | `2wip·1rev·1merge` |
1078
1377
  | Something stalled | `⚠1blocked·1ci-red·2wip` |
1378
+ | Pass 2 deferred a rebuild | `⚠rebuild·2wip` |
1079
1379
  | Nothing at all | `idle` |
1080
1380
 
1081
1381
  Then diff against last tick and decide whether to notify:
@@ -1103,21 +1403,22 @@ elsewhere the tick still completes and only loses the desktop toast. On Linux
1103
1403
  swap in `notify-send "ai-issue-loop" "$SUMMARY"` behind the same `|| true`. The
1104
1404
  statusline file below is plain text and works anywhere.
1105
1405
 
1106
- When `SUMMARY` carries a `⚠` (anything `blocked` or `ci-red`), append
1406
+ When `SUMMARY` carries a `⚠` (anything `blocked`, `ci-red`, or `rebuild`), append
1107
1407
  `sound name "Basso"` so a stall is audibly different from routine progress.
1108
1408
 
1109
- Write the file **last**, both lines:
1409
+ Write the file **last** — summary, idle counter, and the sorted `ai-suggested`
1410
+ numbers the digest rule above compares against:
1110
1411
 
1111
1412
  ```bash
1112
- printf '%s\n%s\n' "$SUMMARY" "$IDLE" > "$STATUS"
1413
+ printf '%s\n%s\n%s\n' "$SUMMARY" "$IDLE" "$SUGGESTED" > "$STATUS"
1113
1414
  ```
1114
1415
 
1115
1416
  The statusline segment reads line 1 and hides itself once the file is older than
1116
1417
  20 minutes, so a dead loop stops claiming work is in flight.
1117
1418
 
1118
1419
  `ai-notes` does **not** get a `SUMMARY` segment and must never borrow the `⚠` —
1119
- that mark means `blocked` or `ci-red`, a stall the loop cannot resolve, and a PR
1120
- that passed both reviews is not stalled.
1420
+ that mark means `blocked`, `ci-red`, or a deferred `rebuild`: a stall the loop
1421
+ cannot resolve this tick. A PR that passed both reviews is not stalled.
1121
1422
 
1122
1423
  Finally, print to the transcript: `SUMMARY` plus at most five lines — merged,
1123
1424
  cleaned up, sent to review, picked up, blocked. Nothing else; this repeats every
@@ -1125,6 +1426,13 @@ cleaned up, sent to review, picked up, blocked. Nothing else; this repeats every
1125
1426
  which ones need reading before they are merged — that is the one place the notes
1126
1427
  reach a human who is not already looking at GitHub.
1127
1428
 
1429
+ **End with the triage digest** — the open `ai-suggested` queue, one line per
1430
+ issue, straight from `gh issue list --label ai-suggested --state open --json
1431
+ number,title`. No new state, no extra prose: the queue only ever shrinks when a
1432
+ human promotes or closes an item, and a list scanned in one glance is what makes
1433
+ that happen. Skip the digest when the queue is empty or unchanged since the last
1434
+ tick (compare against a third line in `$STATUS`: the sorted issue numbers).
1435
+
1128
1436
  ---
1129
1437
 
1130
1438
  ## Driving it