@rtorcato/repo-tooling 3.20.0 → 3.21.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.
@@ -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
 
@@ -238,6 +245,12 @@ check skips a linked worktree, whose `.git` is a file.
238
245
  refuses with `error: could not lock config file .git/config: Operation not permitted` —
239
246
  observed. Aborting beats reporting a healthy repo while it stays broken.
240
247
 
248
+ **A failed repair halts the tick — the whole tick, not the command.** The `exit 1`
249
+ only ends one shell call; you are an agent reading a doc, not a shell honouring an
250
+ exit code. If the repair fails, run **no further passes** — report the failure via
251
+ Pass 5 and stop. Carrying on into Pass 4 branches every new worktree off a broken
252
+ `ROOT`, which is exactly the state that produced the #500 mass-deletion commit.
253
+
241
254
  Never use a relative path like `ai-*`. From inside a worktree it matches nothing, and
242
255
  the failure is **silent**: Pass 2 concludes there is nothing to clean, every worktree
243
256
  survives, `ai-wip` is never cleared, and slots leak until the loop reports `idle`
@@ -291,11 +304,84 @@ passes, never the report.
291
304
 
292
305
  ### Pass 1 — merge
293
306
 
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.
307
+ **Only Dependabot PRs merge unattended, unless the repo has a real publish gate.**
308
+ Everything else — every PR this loop opened from an `ai-ready` issue — stops here
309
+ for a human even when both reviewers pass, because merging `main` fires
310
+ semantic-release and publishes to npm. A `chore(deps)` squash subject cuts no
311
+ release, which is what makes the Dependabot case safe. Count human-gated PRs as
312
+ `ready` for Pass 5.
313
+
314
+ **The exception is a `release` environment with `required_reviewers`.** There a
315
+ human still stands between the merge and npm, so an unattended merge costs a
316
+ revert at worst rather than a publish. Probe for it, and **fail closed**:
317
+
318
+ ```bash
319
+ gh api repos/$OWNER_REPO/environments \
320
+ --jq '[.environments[] | select(.name=="release")
321
+ | .protection_rules[]? | select(.type=="required_reviewers")] | length'
322
+ ```
323
+
324
+ Non-zero → a non-Dependabot PR may auto-merge, but only carrying **all** of: both
325
+ `ai-ok-code` and `ai-ok-sec`, no `ai-notes`, no `ai-changes`, and
326
+ `mergeStateStatus: CLEAN`. Zero, or the call errors, or `gh` lacks access to that
327
+ endpoint → hand the PR over exactly as below.
328
+
329
+ **The environment alone is not the gate — confirm the publish job references
330
+ it.** An environment nothing declares gates nothing while reading as a gate in
331
+ both this probe and the GitHub UI, and the arm would then auto-merge a PR that
332
+ publishes unattended:
333
+
334
+ ```bash
335
+ grep -rl 'environment: release' "$ROOT/.github/workflows" || echo "not wired — no auto-merge"
336
+ ```
337
+
338
+ Empty → treat the repo as ungated, same as a zero probe. (`repo-tooling doctor`'s
339
+ *Release environment* check reports this exact misconfiguration.)
340
+
341
+ Three things the gate does **not** change:
342
+
343
+ - **Review still comes first.** Both reviewers must pass before any merge — already
344
+ this pass's contract. The gate relaxes only *who may merge after a pass*, never
345
+ *whether a review happened*.
346
+ - **`ai-notes` still blocks an unattended merge.** A reviewer who passed but left
347
+ something to read means a human reads it.
348
+ - **Order is still load-bearing.** If `autoMergeRequest != null` the merge can beat
349
+ the review, so Pass 0's disarm step applies unchanged.
350
+
351
+ Be plain about the residual risk: even gated, this lands code on `main` unattended,
352
+ and the only quality signal is two reviewers that — per the limits above — see the
353
+ diff only, with no repo-wide exploration. For `chore(deps)` that is proportionate.
354
+ For feature code it means a bad merge is a revert on `main`, not a caught mistake.
355
+ That, and not the npm publish, is the trade actually being made here.
356
+
357
+ **Every comment this pass leaves goes through one idempotent marker comment.** The
358
+ loop is stateless and ticks every 15 minutes, so a naive `gh pr comment` puts a
359
+ *duplicate* on the PR every tick — a PR left over a weekend collects ~200. Write
360
+ it behind a hidden marker and upsert:
361
+
362
+ ```bash
363
+ MARKER='<!-- ai-issue-loop:decision -->'
364
+ ID=$(gh api "repos/$OWNER_REPO/issues/<N>/comments" \
365
+ --jq "[.[] | select((.body // \"\") | startswith(\"$MARKER\"))] | .[0].id // empty")
366
+ if [ -n "$ID" ]; then
367
+ gh api -X PATCH "repos/$OWNER_REPO/issues/comments/$ID" -f body="$MARKER
368
+ $TEXT"
369
+ else
370
+ gh pr comment <N> -R "$OWNER_REPO" --body "$MARKER
371
+ $TEXT"
372
+ fi
373
+ ```
374
+
375
+ `// empty` is load-bearing: `.[0].id` on an empty array is `null`, which `jq -r`
376
+ prints as the four characters `null` — a non-empty string that passes `[ -n ]` and
377
+ 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.
379
+
380
+ One comment per PR, edited in place, so the timeline shows the *current* reason
381
+ rather than a log of every tick that ever ran. What it says — and whether to say
382
+ anything at all — is the comment-budget table at the top of this file; the marker
383
+ is only the *how*. `$TEXT` opens with the standard `🤖 *Automated …*` header and
384
+ leads with what to do.
299
385
 
300
386
  **Hand a ready PR over properly.** "Merge it yourself" is only actionable if the user
301
387
  can find it, and a PR sitting in a list of open PRs looks identical to one still being
@@ -311,9 +397,11 @@ than noise — `ai-ok-code, ai-ok-sec` with no `ai-review` means **waiting on yo
311
397
  halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
312
398
  finished PR keeps wearing `ai-review` forever and looks mid-review. Idempotent, so
313
399
  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.
400
+ comment on a clean handoff**: nothing is wrong, so those three labels are the
401
+ whole message. A comment is how the loop records what a label cannot; a clean PR
402
+ has nothing to record. An `ai-notes` handoff is the exception per the budget
403
+ table — ≤10 lines through the marker upsert, linking the reviewer's
404
+ `### Before merging` rather than restating it.
317
405
 
318
406
  **Never strip `ai-notes` here.** It is the whole point of the handoff: it has to
319
407
  survive to the moment of merging, which is the moment it is for. A ready PR reads
@@ -344,9 +432,11 @@ a required check, so nothing in the check list looked wrong either.
344
432
 
345
433
  Diff-scoped reviewers cannot catch this — they never see CI. So when a
346
434
  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.
435
+ **comment why** through the marker upsert — ≤10 lines, leading with what must
436
+ change, then the failing check and its error. The reviewers passed it, so the
437
+ fix-round implementer would otherwise read the comments and find no instruction
438
+ to act on. Name what unblocks it — `BEHIND` wants a rebase, `DIRTY` wants the
439
+ conflict resolved, `BLOCKED` wants the specific check or ruleset named.
350
440
 
351
441
  ```bash
352
442
  gh pr edit <N> --add-label ai-changes \
@@ -443,6 +533,7 @@ git -C "$ROOT" log origin/main --oneline -20 | grep -q "(#<PR>)" || {
443
533
  Only then:
444
534
 
445
535
  ```bash
536
+ REMOVED=1 # every removal in this pass sets this
446
537
  git -C "$ROOT" worktree remove --force "$WT_DIR" # the path found above, not a rebuilt one
447
538
  git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null
448
539
  gh issue edit <N> --remove-label ai-wip 2>/dev/null
@@ -479,9 +570,9 @@ work must never be reaped out from under itself.
479
570
 
480
571
  | Stalled | Condition | Do |
481
572
  |---|---|---|
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 |
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`) |
483
574
  | 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 |
575
+ | 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
576
 
486
577
  The **no PR exists** condition on the first row is what makes reaping safe. An
487
578
  agent that got as far as opening a PR has handed off to the label state machine
@@ -526,7 +617,66 @@ checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is
526
617
  why the re-check belongs here: it catches a flip after this pass's removals and before
527
618
  Pass 4 branches every new worktree off a broken `ROOT`. Keep the timestamp in both log
528
619
  lines; which pass emitted one, and when, is the only instrumentation likely to pin the
529
- trigger down.
620
+ trigger down. Pass 0's halt rule applies unchanged: a failed repair ends the tick.
621
+
622
+ #### Last thing in the pass — rebuild the main checkout's `node_modules`
623
+
624
+ **Removing a worktree can destroy the main checkout's `node_modules/.bin`.** Its
625
+ modules dir is a symlink into the main checkout, so a pnpm run from *inside* a
626
+ worktree anchors the **main checkout's** `.bin` shims at the **worktree** path.
627
+ `worktree remove --force` then deletes them, leaving `$ROOT/node_modules/.bin`
628
+ with zero entries and the repo unbuildable:
629
+
630
+ ```
631
+ Error: Cannot find module '/…/browser-common-worktrees/ai-145-…/node_modules/.pnpm/typescript@7.0.2/node_modules/typescript/bin/tsc'
632
+ husky - pre-push script failed (code 1)
633
+ ```
634
+
635
+ The give-away is the path: a binary in the main checkout resolving into a
636
+ worktree that no longer exists. Nothing in the loop notices — no pass runs the
637
+ toolchain — so it surfaces arbitrarily later, in the human's next `git push`, as
638
+ a broken repo with no visible connection to the loop. Observed on
639
+ `browser-common` #145 → PR #147, where the implementer had been explicitly warned
640
+ in its prompt not to run a bare `pnpm install`. **It happened anyway**, and any
641
+ pnpm invocation that touches the store is enough — so agent discipline is the
642
+ wrong place for this guard. So is a dangling-link probe: `.bin` shims sit *below*
643
+ `node_modules`, and a `-maxdepth 1` scan reports a clean tree while every binary
644
+ is gone.
645
+
646
+ So run it after the removals, **once per tick, as the last thing in this pass**,
647
+ and only when nothing else is using the shared tree:
648
+
649
+ ```bash
650
+ LIVE=$(find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null)
651
+ if [ "$REMOVED" = 1 ] && [ -f "$ROOT/pnpm-lock.yaml" ]; then
652
+ if [ -z "$LIVE" ]; then
653
+ (cd "$ROOT" && pnpm install --frozen-lockfile --config.confirmModulesPurge=false)
654
+ else
655
+ echo "rebuild deferred — $(echo "$LIVE" | wc -l | tr -d ' ') worktree(s) still live"
656
+ fi
657
+ fi
658
+ ```
659
+
660
+ Three conditions, each load-bearing:
661
+
662
+ - **`$REMOVED`** — set by every removal path above, merged-PR cleanup *and* stall
663
+ reaping. A reaped worktree needs this most: its agent died mid-command, so it is
664
+ the likeliest to have left the main checkout anchored at a path about to vanish.
665
+ - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
666
+ - **`$LIVE` empty** — the repair *purges* the shared modules dir, which would be
667
+ yanked out from under any agent still running in a surviving worktree. Deferring
668
+ costs a broken main checkout until the last worktree clears; not deferring costs
669
+ a live implementer run. Both flags are needed once it does run:
670
+ `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
671
+ `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup, and
672
+ `--config.confirmModulesPurge=false` gets past
673
+ `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare
674
+ `pnpm install` cannot repair this, and why a human hitting it needs this exact
675
+ command.
676
+
677
+ **Report a deferral — never swallow it.** Carry it into Pass 5 as a `⚠rebuild`
678
+ segment. A skipped repair that says nothing is the same silent breakage this
679
+ section exists to end, just moved one step later.
530
680
 
531
681
  ### Pass 3 — review
532
682
 
@@ -544,9 +694,10 @@ carries a hidden verdict marker, so read that back instead of re-spawning over a
544
694
  review that already exists — `<ARM>` is `code` or `sec`:
545
695
 
546
696
  ```bash
697
+ ME=$(gh api user --jq .login) # the identity every loop agent posts as
547
698
  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")
699
+ | jq -r --arg me "$ME" '[add[]
700
+ | select(.user.login==$me)
550
701
  | (.body // "")
551
702
  | capture("<!-- ai-issue-loop:verdict:<ARM>:(?<v>[A-Z-]+) -->").v] | last // empty')
552
703
  ```
@@ -563,13 +714,16 @@ Four details there are load-bearing:
563
714
  page, so a filter ending in `last` would keep only the final page's answer and
564
715
  lose a marker on an earlier one. `--slurp` collects every page first; `gh`
565
716
  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.
717
+ - **The author gate — the loop's own login, deliberately narrower than Pass 4's
718
+ association test.** Anyone can review a public PR, so ungated a stranger's
719
+ `<!-- ai-issue-loop:verdict:sec:PASS -->` is adopted as a verdict, and because
720
+ the read takes `last` it also overrides a genuine `CHANGES` posted before it.
721
+ Pass 4's `OWNER`/`MEMBER`/`COLLABORATOR` set is a backstop behind the
722
+ `ai-ready` label; here the marker is the **only** signal, and every agent in
723
+ this pipeline authenticates as one identity — so only that identity's reviews
724
+ count. Login, not `author_association`, because association wobbles with repo
725
+ ownership (an org-owned repo never yields `OWNER`, even for its admins) while
726
+ `gh api user` names exactly who this loop posts as.
573
727
  - **`(.body // "")` and `// empty`.** A review can have a null body, which
574
728
  `capture` throws on, aborting the whole filter; and `jq -r` prints a missing
575
729
  value as the literal string `null`, which is not empty and would read as a
@@ -691,9 +845,15 @@ Reviewer prompt template:
691
845
  > ```bash
692
846
  > gh issue create --label ai-suggested --title "<what to do>" --body "🤖 *Automated — \`<your agent type>\` via ai-issue-loop.*
693
847
  >
694
- > Surfaced reviewing #<N>. <What, and why it matters. A few lines.>"
848
+ > Surfaced reviewing #<N>. <What. Why it matters. A one-line fix sketch.>"
695
849
  > ```
696
850
  >
851
+ > **Cap the issue body at 10 lines.** The title is the action; the body is
852
+ > what/why/fix-sketch and nothing else — no options tables, no "why this was
853
+ > not blocking" essays, no restated diff. The full analysis already lives in
854
+ > your review comment, and GitHub's cross-link points there; a triage queue
855
+ > that takes a minute per item gets read, one that takes five gets skipped.
856
+ >
697
857
  > Then put `Follow-up: #<new>` on one line in the body above `### Before
698
858
  > merging` and keep it out of that section, so it does not pull `ai-notes` in —
699
859
  > later work is not a merge gate. GitHub cross-links the two, so the trail
@@ -849,7 +1009,8 @@ gh api "repos/$OWNER_REPO/issues/<N>/timeline" \
849
1009
  --jq '[.[] | select(.event=="labeled" and .label.name=="ai-changes")] | length'
850
1010
  ```
851
1011
 
852
- If that count is **≥ 3**, stop looping. Comment the reason on the PR — opening with
1012
+ If that count is **≥ 3**, stop looping. Comment the reason on the PR — through the
1013
+ Pass 1 marker upsert, opening with
853
1014
  `🤖 *Automated — \`ai-issue-loop\` Pass 3.*`
854
1015
  and a blank line — naming what each round changed and why the reviewer kept objecting,
855
1016
  then:
@@ -926,7 +1087,23 @@ real work to reach is lost.
926
1087
  The comment opens with the standard `🤖 *Automated …*` header — see the top of this
927
1088
  file. Then, in the body — **this is the one comment exempt from the ≤10-line
928
1089
  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:
1090
+ reasoning; do not reach for this shape on a PR handoff.
1091
+
1092
+ **Lead with a `## To lift this hold` section, before anything else.** It must be
1093
+ readable in five seconds — the reasoning that follows is *why*; this is *what to
1094
+ do*. A decline that buries the action under three paragraphs leaves the reader
1095
+ knowing an agent declined but not what is now expected of them, which is the same
1096
+ dead end as not commenting at all. Make it executable without reading further:
1097
+
1098
+ - **Enumerate the options as a table**, one row each, with what an agent would do
1099
+ once that option is chosen. Two to four rows. Genuinely one path → one sentence.
1100
+ - **State the label move explicitly** — "say which in a comment, then swap
1101
+ `holding` for `ai-ready`". The reader never works out the unblock themselves.
1102
+ - **Flag anything time-sensitive** with a ⏳ line — a decision cheap now and
1103
+ expensive later is exactly what a skimming reader needs to see.
1104
+
1105
+ The reasoning below that — in a `<details>` block so it never pushes the action
1106
+ off screen:
930
1107
 
931
1108
  - **Why an agent cannot finish it**, concretely. "Not suitable" is useless. Name
932
1109
  the blocker: binary assets it cannot author, a force-push past branch
@@ -937,6 +1114,10 @@ reasoning; do not reach for this shape on a PR handoff:
937
1114
  - **Whether it is terminal**, when the right answer is to do nothing at all — so
938
1115
  the next triage pass does not reopen the question.
939
1116
 
1117
+ The lead-with-the-action shape (not the length exemption) applies to every
1118
+ comment that hands a decision back — `ai-blocked` from a stall or a ping-pong
1119
+ stop included. What to do first; justification underneath.
1120
+
940
1121
  If the issue was already labelled, drop `ai-ready` in the same breath; leaving it
941
1122
  means the next tick picks it straight back up. Do **not** use `ai-blocked` for
942
1123
  this — that label means *an agent tried and got stuck*, and spending it on an
@@ -972,7 +1153,54 @@ kebab-case words from the title:
972
1153
  SLUG="ai-<N>-<slug>"
973
1154
  mkdir -p "$WT_ROOT"
974
1155
  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
1156
+ ```
1157
+
1158
+ **Then give it dependencies — and the choice matters.** Symlinking is the cheap
1159
+ path, but it is only correct for an issue confined to an app:
1160
+
1161
+ ```bash
1162
+ # Only for app/docs-only issues — nothing under a workspace package.
1163
+ # Replaces worktree.symlinkDirectories. Link the workspace packages too, not just
1164
+ # the root — with only the root linked, `pnpm verify` ENOENTs at the treeshake step
1165
+ # because apps/*/node_modules is missing, and the agent cannot self-verify.
1166
+ ln -s "$ROOT/node_modules" "$WT_ROOT/$SLUG/node_modules"
1167
+ for app in "$ROOT"/apps/*/; do
1168
+ [ -d "$app/node_modules" ] || continue
1169
+ ln -s "$app/node_modules" "$WT_ROOT/$SLUG/apps/$(basename "$app")/node_modules"
1170
+ done
1171
+ ```
1172
+
1173
+ **For anything touching a workspace package, do not symlink — install for real:**
1174
+
1175
+ ```bash
1176
+ (cd "$WT_ROOT/$SLUG" && pnpm install)
1177
+ ```
1178
+
1179
+ Those symlinks share the **root** and `apps/*`. pnpm workspaces keep the
1180
+ resolution that matters in each `packages/<name>/node_modules`, which is not
1181
+ symlinked and does not exist in a fresh worktree. Measured on `api-common`
1182
+ 2026-08-20: the main checkout had per-package `node_modules` in **37 of 37**
1183
+ packages, the worktree had **1**. So `pnpm --filter <pkg> typecheck` there fails
1184
+ with `Cannot find module` rather than the real error — the agent cannot reproduce
1185
+ the bug, and the environment looks like the issue's fault. Issue #201 was handed
1186
+ back `ai-blocked` this way, well-diagnosed and untouched.
1187
+
1188
+ **Never force `pnpm install` against a symlinked tree.** It wants to purge and
1189
+ rebuild the modules dir (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), which
1190
+ mutates the **main checkout's** `node_modules` — shared by every other worktree
1191
+ and yanked out from under any agent mid-typecheck. `CI=true` and
1192
+ `--config.confirmModulesPurge=false` both silence that prompt; neither makes it
1193
+ safe. A real install in an unsymlinked worktree costs a duplicate `node_modules`
1194
+ and is the price of isolation. Pass 2's rebuild is the one sanctioned exception,
1195
+ and only because it is gated on no worktree surviving.
1196
+
1197
+ **Once per repo, exclude the symlink from git.** Repos ignore `node_modules/`
1198
+ *with a trailing slash*, which does not match a symlink — so the link shows as
1199
+ untracked in every worktree and a `git add -A` commits it. `.git/info/exclude`
1200
+ is shared by all worktrees and never committed:
1201
+
1202
+ ```bash
1203
+ grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$ROOT/.git/info/exclude"
976
1204
  ```
977
1205
 
978
1206
  **No implementer ever calls `EnterWorktree` — in any form.** This is deliberate; do
@@ -1021,15 +1249,17 @@ Then spawn a background implementer agent:
1021
1249
  > step or committed build output.
1022
1250
  > 4. Do the work. Conventional Commits within the branch.
1023
1251
  >
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.
1252
+ > **Do not run `pnpm install`.** Dependencies are already present — the
1253
+ > orchestrator either symlinked them or ran a real install; run tests, lint
1254
+ > and build directly. If `node_modules` is a symlink, pnpm sees a foreign
1255
+ > directory it must purge first and aborts with
1256
+ > `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — and forcing past that prompt
1257
+ > rewrites the **main checkout's** modules, shared by every other worktree.
1258
+ > If the work *is* a dependency change, `pnpm install --lockfile-only`
1259
+ > updates `pnpm-lock.yaml` without touching `node_modules`. When that leaves
1260
+ > a verification step you cannot run, say so in the PR body — name the
1261
+ > command you could not run and why — so the reviewer knows CI is the only
1262
+ > check on it rather than assuming you ran it.
1033
1263
  > 5. Push and open the PR. The title must be a Conventional Commit — it becomes
1034
1264
  > the squash subject on `main` and, in repos using semantic-release, decides
1035
1265
  > whether a release goes out at all. Body must contain `Closes #<N>`.
@@ -1047,9 +1277,13 @@ Then spawn a background implementer agent:
1047
1277
  > gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
1048
1278
  > ```
1049
1279
  >
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:
1280
+ > Then comment why. **Leave your worktree in place — never run
1281
+ > `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
1282
+ > longer `ai-wip` and has no open PR, so it matches the orphan rule) and rebuilds
1283
+ > the main checkout's `node_modules` in the same pass, which a bare removal here
1284
+ > would silently break. The comment **must** open with this exact line, then a
1285
+ > blank line — you authenticate as the owner, so without it the issue reads as if
1286
+ > they wrote it themselves:
1053
1287
  >
1054
1288
  > `🤖 *Automated — implementer via ai-issue-loop.*`
1055
1289
  >
@@ -1076,6 +1310,7 @@ Middle dot separated, zero segments omitted, stall counts first with a `⚠`:
1076
1310
  |---|---|
1077
1311
  | Work in flight | `2wip·1rev·1merge` |
1078
1312
  | Something stalled | `⚠1blocked·1ci-red·2wip` |
1313
+ | Pass 2 deferred a rebuild | `⚠rebuild·2wip` |
1079
1314
  | Nothing at all | `idle` |
1080
1315
 
1081
1316
  Then diff against last tick and decide whether to notify:
@@ -1103,21 +1338,22 @@ elsewhere the tick still completes and only loses the desktop toast. On Linux
1103
1338
  swap in `notify-send "ai-issue-loop" "$SUMMARY"` behind the same `|| true`. The
1104
1339
  statusline file below is plain text and works anywhere.
1105
1340
 
1106
- When `SUMMARY` carries a `⚠` (anything `blocked` or `ci-red`), append
1341
+ When `SUMMARY` carries a `⚠` (anything `blocked`, `ci-red`, or `rebuild`), append
1107
1342
  `sound name "Basso"` so a stall is audibly different from routine progress.
1108
1343
 
1109
- Write the file **last**, both lines:
1344
+ Write the file **last** — summary, idle counter, and the sorted `ai-suggested`
1345
+ numbers the digest rule above compares against:
1110
1346
 
1111
1347
  ```bash
1112
- printf '%s\n%s\n' "$SUMMARY" "$IDLE" > "$STATUS"
1348
+ printf '%s\n%s\n%s\n' "$SUMMARY" "$IDLE" "$SUGGESTED" > "$STATUS"
1113
1349
  ```
1114
1350
 
1115
1351
  The statusline segment reads line 1 and hides itself once the file is older than
1116
1352
  20 minutes, so a dead loop stops claiming work is in flight.
1117
1353
 
1118
1354
  `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.
1355
+ that mark means `blocked`, `ci-red`, or a deferred `rebuild`: a stall the loop
1356
+ cannot resolve this tick. A PR that passed both reviews is not stalled.
1121
1357
 
1122
1358
  Finally, print to the transcript: `SUMMARY` plus at most five lines — merged,
1123
1359
  cleaned up, sent to review, picked up, blocked. Nothing else; this repeats every
@@ -1125,6 +1361,13 @@ cleaned up, sent to review, picked up, blocked. Nothing else; this repeats every
1125
1361
  which ones need reading before they are merged — that is the one place the notes
1126
1362
  reach a human who is not already looking at GitHub.
1127
1363
 
1364
+ **End with the triage digest** — the open `ai-suggested` queue, one line per
1365
+ issue, straight from `gh issue list --label ai-suggested --state open --json
1366
+ number,title`. No new state, no extra prose: the queue only ever shrinks when a
1367
+ human promotes or closes an item, and a list scanned in one glance is what makes
1368
+ that happen. Skip the digest when the queue is empty or unchanged since the last
1369
+ tick (compare against a third line in `$STATUS`: the sorted issue numbers).
1370
+
1128
1371
  ---
1129
1372
 
1130
1373
  ## Driving it
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: ai-loop-status
3
+ description: |
4
+ Show what the ai-issue-loop pipeline is doing right now — read-only. Use when
5
+ the user asks "what's the loop doing", "loop status", "is anything blocked",
6
+ or invokes `/ai-loop-status`. Never applies a label, merges a PR, or spawns
7
+ an agent. Takes an optional `owner/repo` argument; defaults to the current
8
+ repo. GitHub only (`gh`) — not GitLab.
9
+ ---
10
+
11
+ # ai-loop-status
12
+
13
+ Show what the `ai-issue-loop` pipeline is doing right now. Arguments: $ARGUMENTS
14
+
15
+ Read-only — this never applies a label, merges a PR, or spawns an agent. To
16
+ actually advance the pipeline, run `/ai-issue-loop`. Because it is read-only, it
17
+ is the one loop tool allowed to point at another repo via an `owner/repo`
18
+ argument.
19
+
20
+ ## Steps
21
+
22
+ 1. **Resolve the repo** — if $ARGUMENTS names one (`owner/repo`), use it;
23
+ otherwise the current directory's. GitHub only — bail in one line if the
24
+ remote is GitLab:
25
+
26
+ ```bash
27
+ R=${ARG:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}
28
+ ```
29
+
30
+ 2. **Read the pipeline state from labels.** The loop keeps no state anywhere
31
+ else, so these queries are the ground truth even after a crash, a restart, or
32
+ a missed tick:
33
+
34
+ ```bash
35
+ gh issue list -R "$R" --state open --label ai-wip --json number,title
36
+ gh pr list -R "$R" --state open --json number,title,labels,autoMergeRequest,assignees
37
+ gh issue list -R "$R" --state open --label ai-ready --json number,title
38
+ gh issue list -R "$R" --state open --label ai-blocked --json number,title
39
+ gh issue list -R "$R" --state open --label ai-suggested --json number,title
40
+ ```
41
+
42
+ Filter the PR list to those carrying an `ai-*` label — a PR without one is
43
+ not in the pipeline and the loop will never touch it.
44
+
45
+ 3. **Work out each PR's next move** from its labels, so the report says what
46
+ happens rather than just listing state:
47
+
48
+ - `ai-review` alone → waiting on reviewers; name which arm is outstanding
49
+ (`ai-ok-code` missing → `code-reviewer`, `ai-ok-sec` missing →
50
+ `security-expert`), and whether it is claimed (`ai-reviewing-code` /
51
+ `ai-reviewing-sec` mean a reviewer is running right now)
52
+ - both `ai-ok-*`, no `ai-review` → **waiting on the human to merge**; add
53
+ "read the comments first" when `ai-notes` rides along. Only Dependabot
54
+ PRs — or issue PRs on a repo whose `release` environment has
55
+ `required_reviewers` — auto-merge.
56
+ - `autoMergeRequest` set → queued; GitHub is holding it for required checks
57
+ - `ai-changes` → a fix round is due. Count prior rounds, because the 3rd one
58
+ stops the loop and marks the issue `ai-blocked`:
59
+
60
+ ```bash
61
+ gh api "repos/$R/issues/<N>/timeline" \
62
+ --jq '[.[] | select(.event=="labeled" and .label.name=="ai-changes")] | length'
63
+ ```
64
+
65
+ 4. **Check the worktrees** — one per in-flight issue, removed by the loop's
66
+ Pass 2 after its PR merges. They live in a **sibling** directory of the
67
+ repo (plus a legacy in-repo path); flag any whose issue is no longer
68
+ `ai-wip` as a stale leftover the next tick will clean up. Only meaningful
69
+ when `$R` is the current repo:
70
+
71
+ ```bash
72
+ ROOT=$(git rev-parse --path-format=absolute --git-common-dir)/..; ROOT=$(cd "$ROOT" && pwd)
73
+ find "$(dirname "$ROOT")/$(basename "$ROOT")-worktrees" "$ROOT/.claude/worktrees" \
74
+ -maxdepth 1 -name 'ai-*' -type d 2>/dev/null
75
+ ```
76
+
77
+ 5. **Check the schedule** — if a scheduler is available (e.g. `CronList`),
78
+ report whether an `/ai-issue-loop` job is actually scheduled, its cadence,
79
+ and whether it dies with the session. A pipeline with labels but no job is
80
+ stalled, and that is the single most likely reason nothing is moving.
81
+
82
+ 6. **Verify the merge gate only when something looks stuck** — skip these on a
83
+ healthy run, they are noise:
84
+
85
+ ```bash
86
+ gh api "repos/$R" --jq '{allow_squash_merge, allow_merge_commit, allow_rebase_merge, allow_auto_merge, delete_branch_on_merge}'
87
+ gh api "repos/$R/branches/main/protection" --jq '{contexts: .required_status_checks.contexts, reviews: .required_pull_request_reviews}'
88
+ ```
89
+
90
+ `required_pull_request_reviews` **must** be null. The agents authenticate as
91
+ the user's own `gh`, and GitHub refuses self-approval, so any required-review
92
+ rule deadlocks every PR the loop opens — the PRs sit there looking merely
93
+ slow.
94
+
95
+ 7. **Report** — format as:
96
+
97
+ ```
98
+ ai-issue-loop — <repo> — <date>
99
+
100
+ Schedule: every 15m (session-only) (or: NOT SCHEDULED)
101
+
102
+ In flight (2/6 slots):
103
+ #41 add a --json flag to doctor PR #58 ai-review, waiting on security-expert
104
+ #43 fix the nvmrc fallback PR #59 ready — waiting on you to merge
105
+
106
+ Queued (ai-ready, unclaimed): #44, #45
107
+ Suggested (agent triage queue): #46, #47
108
+ Blocked (needs a human): #38 (3 fix rounds, gave up)
109
+ Worktrees: 2 (or: 1 stale — issue #40 closed)
110
+ ```
111
+
112
+ End with one line naming what the next tick will actually do — "next tick:
113
+ picks up #44, hands #59 to you" — or `idle — nothing to do`. If nothing is
114
+ labelled `ai-ready` at all, say so plainly: the loop is idling by design,
115
+ not broken.