@rtorcato/repo-tooling 3.38.3 → 3.40.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.
@@ -35,31 +35,18 @@ approval is impossible**. Approval is therefore a *label*, and the repo's requir
35
35
  status checks stay the real merge gate.
36
36
 
37
37
  Never run `gh pr review --approve`. Never set `required_pull_request_reviews` on
38
- the protected branch — it would deadlock every PR.
39
-
40
- **If you ever switch to real approvals** — a second GitHub account reviewing as
41
- someone else, so `--approve` works and the `ai-ok-*` labels become unnecessary —
42
- first check what else writes your branch protection. Any repo-settings tool that
43
- treats `required_pull_request_reviews` as drift will PUT it back to `null` on its
44
- next run, because required review deadlocks solo Dependabot auto-merge. Your
45
- approval rule vanishes, merges hand themselves back to the labels, and nothing in
46
- that tool's output ties the change to this pipeline. `@rtorcato/repo-tooling`,
47
- which ships this skill, is one such tool — its repo-settings standard asserts
48
- `required_pull_request_reviews: null`, so change that standard before you rely on
49
- real approvals.
38
+ the protected branch — it would deadlock every PR. (`repo-tooling`'s repo-settings
39
+ standard asserts `required_pull_request_reviews: null`, so switching to real
40
+ approvals means changing that standard first.)
50
41
 
51
42
  The same constraint makes everything an agent posts *look* hand-written by the
52
43
  owner. So **every comment any agent leaves — review, blocked, gave-up, declined —
53
44
  opens with a `🤖 *Automated …*` italic header line naming which agent wrote it**,
54
- then a blank line. Name the agent and stop there: a detailed security review
55
- under a human's avatar misrepresents who reviewed the code, but *why* it wears
56
- that avatar is read once and then reread on every comment forever.
45
+ then a blank line. Name the agent and stop there.
57
46
 
58
47
  `🤖 *Automated — <which agent> via ai-issue-loop.*`
59
48
 
60
- **Comment budget: ≤10 lines, and a clean outcome gets no comment at all.** A
61
- 40-line comment on every PR trains the reader to skip all of them, including the
62
- one that matters — the same failure mode as `ai-notes` on every PR, below. Link
49
+ **Comment budget: ≤10 lines, and a clean outcome gets no comment at all.** Link
63
50
  the reviewer's `### Before merging` rather than restating it; a paraphrase is
64
51
  drift with a second copy to maintain.
65
52
 
@@ -93,22 +80,17 @@ drift with a second copy to maintain.
93
80
 
94
81
  **`ai-notes` is advisory and never blocks.** It rides *alongside* a pass label,
95
82
  never instead of one, and it never sends a PR back — a finding that should block
96
- an issue PR is `ai-changes`. It exists because a pass label currently means both "clean" and
97
- "I found something real but would not hold the PR over it", and those two are
98
- indistinguishable in the *Assigned to you* view where merges actually happen.
99
- The bar is a finding that **changes what a human would do at merge time**: a
100
- semver implication, a deliberate omission, a question only they can answer. Not
101
- observations, not praise, not restating the diff. `ai-notes` on every PR is the
102
- failure mode — it trains the reader to ignore it, which is worse than not having
103
- it.
83
+ an issue PR is `ai-changes`. The bar is a finding that **changes what a human
84
+ would do at merge time**: a semver implication, a deliberate omission, a
85
+ question only they can answer. Not observations, not praise, not restating the
86
+ diff. `ai-notes` on every PR is the failure mode — it trains the reader to
87
+ ignore it.
104
88
 
105
89
  **Follow-up work is an issue, not a note.** A finding that clears that bar *and*
106
90
  is work someone would plausibly do gets filed as its own issue labelled
107
91
  `ai-suggested`, by the reviewer that found it; the PR comment keeps one line and
108
92
  a link. It does **not** earn `ai-notes` — later work does not decide this merge.
109
- An observation is not a follow-up. Prose in a merged PR's comments is
110
- archaeology, which is how every follow-up left there so far has died on merge.
111
- The checkable test: writing "optional", "residual" or "non-blocking" in a
93
+ An observation is not a follow-up. The checkable test: writing "optional", "residual" or "non-blocking" in a
112
94
  `### Before merging` section means that finding belongs in an issue instead.
113
95
 
114
96
  First run in a repo, create any that are missing (`gh label create` is a no-op
@@ -131,19 +113,14 @@ gh label create merge-ready -c '#8250df' -d 'Both agent reviews passed and the P
131
113
  gh label create ai-suggested -c '#c2e0c6' -d 'Follow-up surfaced by an agent review — triage queue, never auto-picked'
132
114
  ```
133
115
 
134
- Bootstrap only. `gh label create` **cannot repair a label that already exists** —
135
- re-running this block against a hand-created `ai-ready` leaves whatever colour
136
- the web picker gave it, which is how six repos ended up with `ai-ready` rendering
137
- identically to `ai-blocked` (rtorcato/repo-tooling#446). To repair drift:
116
+ Bootstrap only — `gh label create` **cannot repair a label that already
117
+ exists**. To repair colour/description drift:
138
118
 
139
119
  ```bash
140
120
  npx @rtorcato/repo-tooling doctor --json # "AI loop labels" reports colour/description drift
141
121
  npx @rtorcato/repo-tooling fix labels # repairs it with `gh label edit`
142
122
  ```
143
123
 
144
- `src/base/labels.ts` in repo-tooling owns the canonical table and a test asserts
145
- this block matches it, so the two cannot diverge.
146
-
147
124
  Also once per repo, keep the status file out of git:
148
125
 
149
126
  ```bash
@@ -165,12 +142,9 @@ alongside the label it ends on — a verdict for a reviewer, `ai-review` for the
165
142
  round. They are transient — a claim outliving its agent means it died, which is
166
143
  Pass 2's stall reaping, not a state of the PR.
167
144
 
168
- Nothing in this diagram merges itself. Dependabot PRs are absent from it on
169
- purpose — their own workflow merges them, outside this loop entirely (#593). The
170
- one arm that can merge unattended is a repo gated by a `release` environment with
171
- `required_reviewers`, where a human still stands between the merge and the
172
- registry — see Pass 1.
173
- On an ungated repo an issue PR ends at *assigned to you* and waits there —
145
+ Nothing in this diagram merges itself, and Dependabot PRs are absent from it on
146
+ purpose. The one arm that can merge unattended is a repo gated by a `release`
147
+ environment with `required_reviewers` — see Pass 1. On an ungated repo an issue PR ends at *assigned to you* and waits there —
174
148
  `merge-ready` is the loop's way of saying done. Add `ai-notes` and it means
175
149
  done, but open the comments first.
176
150
 
@@ -182,7 +156,6 @@ These exist because the loop runs unattended against a monthly usage cap.
182
156
  - **Reviewers see the diff only** — `gh pr view` + `gh pr diff` + the issue body.
183
157
  No repo-wide exploration, no Explore agents.
184
158
  - **2 fix rounds per PR.** On the 3rd `ai-changes`, stop and mark `ai-blocked`.
185
- Reviewer↔implementer ping-pong is the one unbounded token sink here.
186
159
  - **An idle tick spawns zero agents.** Bail out early and say one line.
187
160
 
188
161
  ---
@@ -211,18 +184,15 @@ repos are fine for checking a dependency; writes are not. GitHub only —
211
184
  bail in one line if the remote is GitLab.
212
185
 
213
186
  **`ROOT` is load-bearing — resolve it first and use it for every path in every
214
- pass.** A session can be pinned to a worktree, so the orchestrator can find itself
215
- inside one it did not choose. `--git-common-dir` resolves to the main checkout's
216
- `.git` from anywhere, including a worktree, so `ROOT` is correct either way.
187
+ pass.** `--git-common-dir` resolves to the main checkout's `.git` from anywhere,
188
+ including a worktree the session may be pinned to, so `ROOT` is correct either way.
217
189
 
218
190
  **Resolve `AGENT_USER` — the account in-flight work is assigned to.** Optional:
219
- unset, every step below that would assign it simply does nothing, and assignment
220
- behaves exactly as it did before this existed.
191
+ unset, every step below that would assign it simply does nothing.
221
192
 
222
193
  ```bash
223
- # Repo config first — committed, so it travels with the repo and survives a new
224
- # machine. `AI_LOOP_AGENT` overrides it for a repo with no lockfile. The flat
225
- # `.aiLoop` fallback reads a pre-v4 lockfile that hasn't migrated yet (#559).
194
+ # Repo config first; `AI_LOOP_AGENT` overrides it for a repo with no lockfile.
195
+ # The flat `.aiLoop` fallback reads a pre-v4 lockfile (#559).
226
196
  AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
227
197
  # A typo would fail every `gh` edit for the whole tick, so prove it is assignable
228
198
  # once, here. 204 = yes, 404 = no; push access is what qualifies an account.
@@ -231,33 +201,31 @@ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUs
231
201
  AGENT_USER=""; }; }
232
202
  ```
233
203
 
234
- **Then prove `gh` is *authenticating as* that account — this one halts the
235
- tick.** Assignability passes no matter who is calling, so on a machine where the
236
- agent identity was never configured both checks above are green while `gh` is
237
- the owner: worktrees, commits, PRs and reviews all land under the owner's
238
- account, and the split only shows up in `git log` afterwards (#601).
204
+ It lives in `.repo-tooling.json`, not a shell profile — committed, reviewable,
205
+ and carried forward by `fix lockfile`:
239
206
 
240
- ```bash
241
- # Exit 0 continue, non-zero halt — also covers the bare-checkout repair. The
242
- # identity check is skipped entirely when no agentUser is declared.
243
- npx @rtorcato/repo-tooling loop guard --root "$ROOT" || exit 1
207
+ ```json
208
+ { "rules": { "aiLoop": { "agentUser": "your-bot-account" } } }
244
209
  ```
245
210
 
246
- Configured intent that is not met is a misconfiguration, not a degraded mode —
247
- which is why this halts where the assignability check merely warns. Fix it by
248
- pointing `gh` at the agent account on this machine, or by removing
249
- `rules.aiLoop.agentUser`.
250
-
251
- **It lives in the repo, not a shell profile.** The agent account is a
252
- collaborator on *this* repo, so a machine-wide env var is both the wrong
253
- granularity and invisible — forgotten on a new laptop, with the only symptom
254
- being that assignment quietly stops. In `.repo-tooling.json` it is committed,
255
- reviewable, and carried forward by `fix lockfile`:
211
+ **Then run `loop guard` — it halts the tick on failure.** It repairs a main
212
+ checkout that has gone `core.bare = true` (which corrupts every worktree commit
213
+ into a whole-repo deletion), refuses to touch a genuinely bare clone or a linked
214
+ worktree, and — when `agentUser` is declared — proves `gh` is *authenticating
215
+ as* that account, which the assignability check above cannot. It ignores an
216
+ exported `GIT_DIR` / `GIT_WORK_TREE`.
256
217
 
257
- ```json
258
- { "rules": { "aiLoop": { "agentUser": "your-bot-account" } } }
218
+ ```bash
219
+ # Exit 0 continue; 1 = bare repair failed, 2 = root unrepairable or wrong gh identity.
220
+ npx @rtorcato/repo-tooling loop guard --root "$ROOT" || exit 1
259
221
  ```
260
222
 
223
+ **A non-zero exit halts the whole tick, not the command.** The `exit 1` only
224
+ ends one shell call; you are an agent reading a doc, not a shell honouring an
225
+ exit code. Run **no further passes** — report the failure via Pass 5 and stop.
226
+ An identity mismatch is fixed by pointing `gh` at the agent account on this
227
+ machine (`fix ai-loop-identity`), or by removing `rules.aiLoop.agentUser`.
228
+
261
229
  Every later use is `${AGENT_USER:+--add-assignee} ${AGENT_USER:+"$AGENT_USER"}`, which expands
262
230
  to nothing when it is empty — so there is one code path, not two. **Keep the flag
263
231
  and the value in separate expansions.** The one-expansion form
@@ -265,10 +233,9 @@ and the value in separate expansions.** The one-expansion form
265
233
  in zsh, where `gh` receives `--add-assignee bot` as a single argument and
266
234
  rejects it.
267
235
 
268
- **Resolve `HUMAN_USER` too — the person work is handed back to.** Needs no
269
- config: on a personal repo the owner *is* the person. On an organisation repo
270
- `.owner.login` is the org, which is not a human, so it resolves to empty and
271
- every handoff below assigns nobody rather than something meaningless.
236
+ **Resolve `HUMAN_USER` too — the person work is handed back to.** On a personal
237
+ repo the owner *is* the person; on an organisation repo it resolves to empty and
238
+ every handoff below assigns nobody.
272
239
 
273
240
  ```bash
274
241
  HUMAN_USER=$(gh api "repos/$OWNER_REPO" --jq 'if .owner.type == "User" then .owner.login else "" end')
@@ -279,10 +246,7 @@ Later uses are `${HUMAN_USER:+--add-assignee} ${HUMAN_USER:+"$HUMAN_USER"}`, the
279
246
  errors — skip the call entirely in that case** rather than letting it fail the
280
247
  tick.
281
248
 
282
- **The point is that assignee answers "whose turn is it", which no label does
283
- well.** Today an issue an agent is mid-way through and an issue nobody has
284
- touched are both assigned to no one, so the *Assigned to you* view is only ever
285
- half the story:
249
+ Assignee answers "whose turn is it":
286
250
 
287
251
  | State | Assignee |
288
252
  |---|---|
@@ -292,122 +256,35 @@ half the story:
292
256
  | PR passed both reviews, waiting to merge | the human |
293
257
  | `ai-blocked`, declined, or held | the human |
294
258
 
295
- `@me` cannot express either end: it resolves to whichever token is running, and
296
- the identity check above *requires* that token to be `AGENT_USER` whenever an
297
- agent account is declared — so `@me` is the agent precisely where the last two
298
- rows want the human (#606). Both are therefore named explicitly, and `@me`
299
- appears nowhere in this skill.
300
-
301
- Note the web UI's assignee picker can show a stale list that omits a
302
- freshly-added collaborator; `repos/{repo}/assignees` is the authority.
303
-
304
- **Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
305
- `core.bare = true` on its own, repeatedly — four times in one session, some occurrences
306
- immediately after a `worktree remove` and some with nothing removed at all. The trigger
307
- is unidentified, so this is detection and repair only:
308
-
309
- ```bash
310
- if [ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
311
- echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
312
- env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
313
- echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
314
- fi
315
- ```
316
-
317
- **It corrupts commits — this is not a cosmetic error message.** A bare main checkout
318
- wipes a worktree's index while every file sits untouched on disk, and the next commit
319
- faithfully records the whole repository as deleted. PR #500 died that way: a diff of
320
- `0 additions, 67703 deletions` across 359 files, not one of which had moved.
321
-
322
- **Test stdout, not the exit code.** `rev-parse --is-inside-work-tree` exits `0` either
323
- way and only *prints* the answer, so an exit-code probe is dead code. Verified on git
324
- 2.55.0:
325
-
326
- | repo state | `--is-inside-work-tree` | `.git` |
327
- |---|---|---|
328
- | healthy checkout | `true`, exit 0 | directory |
329
- | **wrongly bare** | `false`, **exit 0** | directory |
330
- | genuinely bare | `false`, exit 0 | absent |
331
- | linked worktree | `true`, exit 0 | file |
332
-
333
- **`.git` must be a directory before repairing.** A genuinely bare repo prints `false`
334
- too, and nothing else separates the two — this skill ships to users' `~/.claude/skills/`,
335
- where "repairing" someone's real bare clone is the damage rather than the fix. The same
336
- check skips a linked worktree, whose `.git` is a file.
337
-
338
- **`GIT_DIR` and `GIT_WORK_TREE` beat `-C`, so unset them.** When either is exported —
339
- some tooling wrappers do — git ignores `-C "$ROOT"` and operates on whatever they
340
- point at, so the probe would diagnose a *different* repo and the repair would write
341
- that repo's `.git/config`. Both failures are silent, and both are worse than the bug
342
- being guarded against. This skill installs into arbitrary users' `~/.claude/skills/`,
343
- so the caller's environment is not ours to assume; `env -u` scopes the unset to the
344
- one command rather than to the tick.
345
-
346
- **Fail loudly.** The repair writes `$ROOT/.git/config`, which a restrictive sandbox
347
- refuses with `error: could not lock config file .git/config: Operation not permitted` —
348
- observed. Aborting beats reporting a healthy repo while it stays broken.
349
-
350
- **A failed repair halts the tick — the whole tick, not the command.** The `exit 1`
351
- only ends one shell call; you are an agent reading a doc, not a shell honouring an
352
- exit code. If the repair fails, run **no further passes** — report the failure via
353
- Pass 5 and stop. Carrying on into Pass 4 branches every new worktree off a broken
354
- `ROOT`, which is exactly the state that produced the #500 mass-deletion commit.
259
+ `@me` appears nowhere in this skill: it resolves to whichever token is running,
260
+ which `loop guard` requires to be `AGENT_USER` whenever one is declared — the
261
+ agent precisely where the last two rows want the human (#606).
262
+ `repos/{repo}/assignees` is the authority on who is assignable; the web UI's
263
+ picker can be stale.
355
264
 
356
265
  Never use a relative path like `ai-*`. From inside a worktree it matches nothing, and
357
- the failure is **silent**: Pass 2 concludes there is nothing to clean, every worktree
358
- survives, `ai-wip` is never cleared, and slots leak until the loop reports `idle`
359
- forever while being wedged. Nothing in the report looks wrong. Always `"$WT_ROOT/..."`.
266
+ the failure is **silent**: Pass 2 concludes there is nothing to clean and slots leak
267
+ while the loop reports `idle`. Always `"$WT_ROOT/..."`.
360
268
 
361
269
  **Worktrees live in `WT_ROOT`, a sibling of the repo — never inside it.** A worktree
362
270
  under `$ROOT/.claude/worktrees/…` sits on a path most repos exclude from their own
363
- tooling, and it fails silently rather than loudly. Observed on `js-common`, whose
364
- `biome.json` carries `"!**/.claude"`:
365
-
366
- ```
367
- worktree at .claude/worktrees/ai-82-… → biome: Checked 0 files
368
- same repo at ../js-common-worktrees/issue-76 → biome: Checked 141 files
369
- ```
370
-
371
- So the pre-commit hook linted **nothing** in any agent worktree — failing with a
372
- misleading "No files were processed" that reads like a tooling glitch rather than a
373
- disabled gate. Every agent commit landed unchecked. A sibling directory sits outside
374
- the repo, where no `.gitignore`, Biome `includes`, ESLint ignore, or `tsconfig`
375
- exclude can accidentally swallow it.
271
+ tooling (e.g. Biome's `"!**/.claude"`), so the pre-commit hook silently lints
272
+ nothing there. A sibling directory sits outside the repo, where no `.gitignore`,
273
+ Biome `includes`, ESLint ignore, or `tsconfig` exclude can swallow it.
376
274
 
377
275
  If any command is refused with *"this session is isolated in the worktree …"*, this
378
- session is pinned to a worktree — a tick started from inside one, or a pin left over
379
- from an earlier session. Call `ExitWorktree({action: "keep"})` — **`keep`, never
276
+ session is pinned to a worktree. Call `ExitWorktree({action: "keep"})` — **`keep`, never
380
277
  `remove`**, an implementer may still be working in there — and carry on with the rest
381
278
  of the tick.
382
279
 
383
280
  **Leave Dependabot PRs alone.** They are not adopted, not labelled, not reviewed
384
- and not merged by this loop.
385
-
386
- **Why, because it reads as a gap:** `dependabot-automerge.yml` arms auto-merge when
387
- the PR *opens*, and GitHub merges the moment checks go green. A tick runs up to 15
388
- minutes later, so on any repo where CI beats the next tick the merge already
389
- happened — the review arm was decorative on every repo that scaffolds the workflow
390
- (#593, observed on `js-common` #271).
391
-
392
- Arming auto-merge from this loop instead would fix the race and cost more than it
393
- buys: dependency updates would then only land while the loop is alive, and a loop
394
- that is merely unscheduled would stall every bump with nothing reporting why.
395
-
396
- The gate that remains is stronger than the reviewer was. The workflow's own
397
- predicate refuses anything appearing in a non-private package's `dependencies`,
398
- `optionalDependencies` or `peerDependencies`, allows only the `dev-minor` group or
399
- the `github-actions` ecosystem at patch or minor, and fails closed when no
400
- dependency names are reported. It computes that from the checked-out manifests,
401
- where the reviewer had to infer it from a PR body GitHub truncates at 65535
402
- characters — the same policy, derived more reliably.
403
-
404
- **Adopt agent-opened PRs.** A PR an agent opens outside Pass 4 — one
405
- with no `ai-ready` issue behind it — carries no `ai-*` label, so it matches no pass
406
- and is therefore assigned by nothing: it never reaches *Assigned to you*, which is
407
- the view where merges actually happen. Observed on #548, which passed all five
408
- required checks and read *Able to merge* while its assignees read *No one—assign
409
- yourself*. Label it `ai-review` and Pass 1 hands it over on the existing path once
410
- both arms pass — no second assignment rule is needed:
281
+ and not merged by this loop — `dependabot-automerge.yml` arms auto-merge at PR-open
282
+ and its own predicate is the gate (#593).
283
+
284
+ **Adopt agent-opened PRs.** A PR an agent opens outside Pass 4 — one with no
285
+ `ai-ready` issue behind it — carries no `ai-*` label, so no pass ever assigns it
286
+ and it never reaches *Assigned to you*. Label it `ai-review` and Pass 1 hands it
287
+ over on the existing path once both arms pass:
411
288
 
412
289
  ```bash
413
290
  ME=$(gh api user --jq .login) # the identity every loop agent opens PRs as
@@ -419,15 +296,11 @@ gh pr list --state open --json number,author,labels,body \
419
296
  | .number'
420
297
  ```
421
298
 
422
- **The `🤖` header is the discriminator, not the login.** Every agent authenticates as
423
- the owner's own `gh`, so author login alone cannot tell a PR an agent opened from one
424
- the owner wrote by hand — and a PR the owner wrote themselves must not be swept in,
425
- which would put two reviewers on work nobody asked to have reviewed. The header is
426
- wire format, the same as the `<!-- ai-issue-loop:* -->` markers: every PR body this
427
- pipeline writes opens with `🤖 *Automated …*` or `🤖 *Opened by …*`, so match it and
428
- do not redefine it. `(.body // "")` is load-bearing for the reason Pass 1's upsert
429
- spells out — a null body throws and empties the whole filter, here adopting nothing
430
- rather than everything.
299
+ **The `🤖` header is the discriminator, not the login** — every agent authenticates
300
+ as the owner, so login alone would sweep in PRs the owner wrote by hand. The header
301
+ is wire format, like the `<!-- ai-issue-loop:* -->` markers: every PR body this
302
+ pipeline writes opens with `🤖 *Automated …*` or `🤖 *Opened by …*`. `(.body // "")`
303
+ is load-bearing: a null body throws and empties the whole filter.
431
304
 
432
305
  If there are no open PRs carrying any `ai-*` label, no eligible `ai-ready` issues
433
306
  (Pass 4's query), **and** no `ai-*` worktree left on disk, skip straight to Pass 5
@@ -438,17 +311,9 @@ find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/de
438
311
  ```
439
312
 
440
313
  **The third condition is not implied by the other two.** Pass 2's cleanup is keyed
441
- off worktrees *on disk*, never off open PRs, so the moment the last open PR is merged
442
- by hand both of the other conditions go true while its worktree is still present and
443
- its issue still carries `ai-wip` — the label only Pass 2 ever clears. Every later tick
444
- meets the same two conditions, so the worktree and the label survive indefinitely
445
- while the loop reports `idle`. The two leaks also protect each other: the
446
- orphan-worktree rule that would otherwise reap it matches only a worktree *whose issue
447
- is not `ai-wip`*, and the stale label is exactly what stops it. Observed 2026-08-26 —
448
- #541 and #542 closed and their PRs merged, both worktrees still on disk, both issues
449
- still `ai-wip`. No concurrency slot leaks (the cap counts *open* `ai-wip` issues); what
450
- leaks is disk, an issue list that reads as though agents are still working, and Pass
451
- 2's `node_modules` rebuild, which is gated on `REMOVED=1` and so never runs.
314
+ off worktrees *on disk*, and only Pass 2 clears `ai-wip` — so once the last open PR
315
+ is merged by hand, skipping on the first two conditions alone would leave its
316
+ worktree and `ai-wip` label in place forever while the loop reports `idle`.
452
317
 
453
318
  ### Pass 1 — merge
454
319
 
@@ -497,19 +362,11 @@ Three things the gate does **not** change:
497
362
  the review. Nowhere but this arm does the loop let an issue PR auto-merge, and
498
363
  only after both verdicts, so one found already armed without both `ai-ok-*`
499
364
  labels was armed by someone else — run `gh pr merge <N> --disable-auto` before anything
500
- else touches it. (#605 removed the Pass 0 disarm step this line used to point
501
- at, along with the Dependabot arm it served.)
502
-
503
- Be plain about the residual risk: even gated, this lands code on `main` unattended,
504
- and the only quality signal is two reviewers that — per the limits above — see the
505
- diff only, with no repo-wide exploration. For `chore(deps)` that is proportionate.
506
- For feature code it means a bad merge is a revert on `main`, not a caught mistake.
507
- That, and not the npm publish, is the trade actually being made here.
365
+ else touches it.
508
366
 
509
- **Every comment this pass leaves goes through one idempotent marker comment.** The
510
- loop is stateless and ticks every 15 minutes, so a naive `gh pr comment` puts a
511
- *duplicate* on the PR every tick — a PR left over a weekend collects ~200. Write
512
- it behind a hidden marker and upsert:
367
+ **Every comment this pass leaves goes through one idempotent marker comment.** A
368
+ naive `gh pr comment` puts a *duplicate* on the PR every tick. Write it behind a
369
+ hidden marker and upsert:
513
370
 
514
371
  ```bash
515
372
  MARKER='<!-- ai-issue-loop:decision -->'
@@ -527,34 +384,22 @@ $TEXT"
527
384
  fi
528
385
  ```
529
386
 
530
- **The author gate is the same one Pass 3's verdict read uses, and for the same
531
- reason.** Anyone can comment on a public PR, so selecting by marker prefix alone
532
- lets a stranger who posts `<!-- ai-issue-loop:decision -->` first own the slot
533
- forever: `.[0]` takes the *oldest* match, the token has repo-write so the `PATCH`
534
- succeeds, and every decision this loop ever reaches lands inside a
535
- stranger-authored comment while the loop never posts one of its own. `.user.login`
536
- against `gh api user`, not `author_association`, for the reason spelled out in
537
- Pass 3.
538
-
539
- `// empty` is load-bearing: `.[0].id` on an empty array is `null`, which `jq -r`
540
- prints as the four characters `null` — a non-empty string that passes `[ -n ]` and
541
- sends the `PATCH` to comment id `null`. The upsert would then never post anything,
542
- silently, which is the one failure mode worse than duplicates. `(.body // "")` is
543
- load-bearing for the mirror-image reason: a null body throws, aborting the filter
544
- and emptying `ID`, which re-enters the duplicate-comment branch this whole section
545
- exists to prevent. Both values come in through `--arg` rather than shell
546
- interpolation, so the marker and login are jq *data* and cannot be parsed as
547
- filter syntax.
548
-
549
- One comment per PR, edited in place, so the timeline shows the *current* reason
550
- rather than a log of every tick that ever ran. What it says — and whether to say
551
- anything at all — is the comment-budget table at the top of this file; the marker
552
- is only the *how*. `$TEXT` opens with the standard `🤖 *Automated …*` header and
553
- leads with what to do.
554
-
555
- **Hand a ready PR over properly.** "Merge it yourself" is only actionable if the user
556
- can find it, and a PR sitting in a list of open PRs looks identical to one still being
557
- worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec` —
387
+ Load-bearing details, keep all of them:
388
+
389
+ - **The author gate** (`.user.login == $me`) — anyone can comment on a public PR,
390
+ so matching the marker alone lets a stranger's comment own the slot and swallow
391
+ every later decision. Login, not `author_association` — see Pass 3.
392
+ - **`// empty`** — `jq -r` prints a missing id as the string `null`, which passes
393
+ `[ -n ]` and PATCHes comment id `null`, so nothing is ever posted.
394
+ - **`(.body // "")`** — a null body throws, empties `ID`, and re-enters the
395
+ duplicate branch.
396
+ - **`--arg`, not shell interpolation** — the marker and login stay jq *data*.
397
+
398
+ What it says — and whether to say anything at all — is the comment-budget table
399
+ at the top of this file. `$TEXT` opens with the standard `🤖 *Automated …*` header
400
+ and leads with what to do.
401
+
402
+ **Hand a ready PR over properly.** For every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec` —
558
403
  or `merge-ready` already, from an earlier tick — and not `ai-changes`, assign it,
559
404
  label it, and clear the labels the handoff supersedes — **but only
560
405
  after the `mergeStateStatus` probe below reports `CLEAN`**. That ordering is what
@@ -567,50 +412,30 @@ gh pr edit <N> ${HUMAN_USER:+--add-assignee} ${HUMAN_USER:+"$HUMAN_USER"} --add-
567
412
  ${AGENT_USER:+--remove-assignee} ${AGENT_USER:+"$AGENT_USER"}
568
413
  ```
569
414
 
570
- **`merge-ready` replaces the pass pair — it does not join it.** A handed-off PR
571
- wearing `ai-ok-code`, `ai-ok-sec` *and* `merge-ready` says one thing three times,
572
- and the reader has to know which of the three is the strongest before they can
573
- act on any of them. `merge-ready` asserts strictly more than the pair (both
574
- reviews passed **and** `CLEAN`), so the pair carries no information once it is
575
- applied — a ready PR's whole vocabulary is the two-row table below.
576
-
577
- Consequently **`merge-ready` satisfies every later test for the `ai-ok-*` pair** —
578
- the gated-repo auto-merge arm above and this pass's own
579
- selector on the next tick. The pair stays the in-flight signal Pass 3 writes and
580
- reads; it is only at the handoff that it stops being the thing anyone looks at.
581
-
582
- Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
583
- you says you both owe it something, which is the one thing never true here.
584
-
585
- It lands in the user's *Assigned to you* view, and the labels then read as state rather
586
- than noise — `merge-ready` means **waiting on you**, filterable at a glance where an
587
- absence never was. Every removal
588
- matters: Pass 3 only ever *adds* its labels, so without them a finished PR keeps
589
- wearing `ai-review` forever and looks mid-review while three green-ish labels
590
- argue about who passed what. Idempotent, so re-running a tick is harmless.
415
+ **`merge-ready` replaces the pass pair — it does not join it.** It asserts
416
+ strictly more (both reviews passed **and** `CLEAN`), so **`merge-ready`
417
+ satisfies every later test for the `ai-ok-*` pair** — the gated-repo auto-merge
418
+ arm above and this pass's own selector on the next tick. The pair stays the
419
+ in-flight signal Pass 3 writes and reads. Every removal in that edit matters:
420
+ Pass 3 only ever *adds* labels, so without them a finished PR keeps wearing
421
+ `ai-review` forever, and a still-assigned agent reads as still owing work.
422
+ Idempotent, so re-running a tick is harmless.
591
423
 
592
424
  **`merge-ready` is derived state — reconcile it every tick.** `CLEAN` stays the
593
425
  source the loop computes from; the label only mirrors it. A PR carrying
594
426
  `merge-ready` while no longer `CLEAN`, or carrying `ai-changes`, gets it stripped
595
427
  (`gh pr edit <N> --remove-label merge-ready`) — and the two send-back blocks
596
- below strip it as part of the same edit. That is
597
- what keeps a stateless 15-minute loop from letting the label lie after `main`
598
- moves. Take no other action — do not merge, and **post no
599
- comment on a clean handoff**: nothing is wrong, so that one label is the
600
- whole message. A comment is how the loop records what a label cannot; a clean PR
601
- has nothing to record. An `ai-notes` handoff is the exception per the budget
602
- table — ≤10 lines through the marker upsert, linking the reviewer's
428
+ below strip it as part of the same edit. Take no other action — do not merge, and
429
+ **post no comment on a clean handoff**. An `ai-notes` handoff is the exception per
430
+ the budget table — ≤10 lines through the marker upsert, linking the reviewer's
603
431
  `### Before merging` rather than restating it.
604
432
 
605
433
  **Reconcile on `CLEAN` only — never on a missing `ai-ok-*`.** The handoff strips
606
- that pair itself, so a rule that stripped `merge-ready` whenever a pass label was
607
- absent would undo the tick before it on every handed-off PR, leaving it with no
608
- labels at all, matching no selector in any pass, and assigned to a human with
609
- nothing saying why it is theirs.
434
+ that pair itself, so a rule keyed on the pair would undo the previous tick's
435
+ handoff and leave the PR with no labels, matching no selector in any pass.
610
436
 
611
- **Never strip `ai-notes` here.** It is the whole point of the handoff: it has to
612
- survive to the moment of merging, which is the moment it is for. A ready PR reads
613
- one of two ways, and the difference must be legible without opening anything:
437
+ **Never strip `ai-notes` here.** It has to survive to the moment of merging. A
438
+ ready PR reads one of two ways:
614
439
 
615
440
  | Labels | Means |
616
441
  |---|---|
@@ -618,29 +443,19 @@ one of two ways, and the difference must be legible without opening anything:
618
443
  | `merge-ready`, `ai-notes` | Passed, but open the comments first. |
619
444
 
620
445
  **Check it can actually merge before calling it ready.** The `ai-ok-*` labels
621
- report the *agent review* verdict and nothing more — they say nothing about
622
- whether GitHub will accept the merge. The two are independent, and a PR that
623
- passed both reviews can still be unmergeable:
446
+ report the *agent review* verdict and nothing more — a PR that passed both
447
+ reviews can still be unmergeable (e.g. blocked by a ruleset that is not a
448
+ required check):
624
449
 
625
450
  ```bash
626
451
  gh pr view <N> --json mergeStateStatus,mergeable --jq '{state:.mergeStateStatus, mergeable}'
627
452
  ```
628
453
 
629
- `BLOCKED`, `DIRTY` (conflicts), or `BEHIND` means handing it over as "ready" is a
630
- lie the human only discovers when the merge button refuses. Observed on
631
- `js-common` #197: it carried `ai-ok-code, ai-ok-sec, ai-notes` and read as ready,
632
- while the active `code-scanning-main` ruleset
633
- (`security_alerts_threshold: high_or_higher`) blocked it — the PR had introduced
634
- a high CodeQL alert **in a test file it added**. Every *required* check was green
635
- (`lint`, `typecheck`, `build`, `test (22)`, `test (24)`), and the ruleset is not
636
- a required check, so nothing in the check list looked wrong either.
637
-
638
- Diff-scoped reviewers cannot catch this — they never see CI. So when a
639
- both-passed PR is not `CLEAN`, do not assign it as ready. Send it back, and
640
- **comment why** through the marker upsert — ≤10 lines, leading with what must
641
- change, then the failing check and its error. The reviewers passed it, so the
642
- fix-round implementer would otherwise read the comments and find no instruction
643
- to act on. Name what unblocks it — `BEHIND` wants a rebase, `DIRTY` wants the
454
+ When a both-passed PR is `BLOCKED`, `DIRTY` (conflicts), or `BEHIND`, do not
455
+ assign it as ready. Send it back, and **comment why** through the marker upsert —
456
+ ≤10 lines, leading with what must change, then the failing check and its error;
457
+ the fix-round implementer otherwise finds no instruction to act on. Name what
458
+ unblocks it — `BEHIND` wants a rebase, `DIRTY` wants the
644
459
  conflict resolved, `BLOCKED` wants the specific check or ruleset named.
645
460
 
646
461
  ```bash
@@ -650,11 +465,8 @@ gh pr edit <N> --add-label ai-changes \
650
465
 
651
466
  Count it as `rev`, not `ready`. A merge conflict (`DIRTY`) takes the same route.
652
467
 
653
- **Assign any Dependabot PR carrying `ai-changes`.** Nothing produces that state
654
- any more — this loop stopped labelling bot PRs (#593) — but a tick from before
655
- that change can have stranded one, and it is waiting on a human from the moment
656
- the label landed, in no *Assigned to you* view at all. A legacy sweep, cheap to
657
- keep and self-retiring once the last one is handled:
468
+ **Assign any Dependabot PR carrying `ai-changes`.** A legacy sweep — nothing
469
+ produces that state any more (#593), but an older tick can have stranded one:
658
470
 
659
471
  ```bash
660
472
  # Both empty (org repo, no agentUser) would leave `gh pr edit <N>` with no flags,
@@ -667,20 +479,14 @@ fi
667
479
 
668
480
  Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
669
481
 
670
- **This pass never merges a Dependabot PR.** `dependabot-automerge.yml` arms
671
- auto-merge at PR-open for the bumps its predicate allows — dev-only, non-shipping,
672
- patch or minor. Everything it declines (a major, anything reaching consumers) is
673
- declined *because* a human should look, so a second unattended merger here would
674
- only re-open the hole the predicate exists to close. Count a Dependabot PR as
482
+ **This pass never merges a Dependabot PR.** Everything `dependabot-automerge.yml`
483
+ declines is declined *because* a human should look. Count a Dependabot PR as
675
484
  `merge` when a later tick finds it merged; otherwise leave it for the human.
676
485
 
677
486
  **CI red on an issue PR is a send-back, not a wait.** Reviewers are diff-scoped
678
- and never see CI, so both arms happily pass a PR whose `build` failed two minutes
679
- after it opened — and nothing else in the pipeline was ever going to dispatch a
680
- fix. Observed on #543 (2026-08-26): the human found it via the red ✗ on the PR
681
- page, which is precisely the noticing this loop exists to do. `ai-changes` **is**
682
- the send-back label; Pass 3 dispatches the fix-round implementer off it, under
683
- the same 2-round budget.
487
+ and never see CI, so nothing else dispatches a fix. `ai-changes` **is** the
488
+ send-back label; Pass 3 dispatches the fix-round implementer off it, under the
489
+ same 2-round budget.
684
490
 
685
491
  So for every open **non-Dependabot** PR carrying any `ai-*` label, with a
686
492
  completed `FAILURE` on a **required** check:
@@ -699,21 +505,14 @@ gh pr checks <N> --required --json name,state,link 2>/dev/null \
699
505
  what changed or why.
700
506
 
701
507
  **Write that excerpt to a file and pass `--body-file`; never interpolate the
702
- log into the command.** A failing job prints whatever the branch told it to,
703
- and on a public repo the branch is a stranger's — so the excerpt is untrusted
704
- bytes that a contributor chooses. Inline `--body "$(gh run view …)"` puts
705
- megabytes of it, control characters and all, through the shell and past
706
- GitHub's comment size cap. The same rule already governs reviewer verdicts
707
- further down; this is the one other place a body is assembled from output
708
- nobody in this pipeline wrote. Trim to the failing lines before writing.
508
+ log into the command.** The log is untrusted bytes a contributor's branch
509
+ chose — inline `--body "$(gh run view …)"` puts control characters and
510
+ megabytes of it through the shell. Trim to the failing lines before writing.
709
511
  3. Count it as `ci-red` for Pass 5, which carries the `⚠`.
710
512
 
711
- **Say in the comment that the fix may not be code.** #543's failure was the
712
- dogfood check finding a *bootstrap* gap — a label present in the canonical table
713
- and not yet on the repo — where the fix was `gh label create` / `fix labels`, or
714
- an `ACCEPTED` entry in `scripts/dogfood.mjs`, and never a branch edit. The
715
- implementer has repo-write, so leave that path open; a comment that assumes the
716
- branch is at fault steers it into editing code that is not wrong.
513
+ **Say in the comment that the fix may not be code** — a red check can be a repo
514
+ bootstrap gap (a missing label → `fix labels`) rather than a branch defect, and
515
+ the implementer has repo-write, so leave that path open.
717
516
 
718
517
  Two carve-outs, both so the loop does not fight itself:
719
518
 
@@ -727,25 +526,14 @@ Two carve-outs, both so the loop does not fight itself:
727
526
  round budget within the hour and mark the issue `ai-blocked` before any agent
728
527
  had done anything.
729
528
 
730
- **`--required`, not the whole rollup.** `statusCheckRollup` also carries optional
731
- and third-party contexts, and an advisory check going red is not a broken PR —
732
- sending one back spends a fix round to change nothing. The required set is the
733
- actual merge gate, and `gh` already resolves which checks are in it. Dropping
734
- `ai-review` in step 1 is the mirror of what a `CHANGES` verdict does: leaving it
735
- on would have Pass 3 spawn reviewers *and* a fix round against one PR, reviewing
736
- a diff that is being rewritten underneath them. The implementer re-adds it when
737
- it pushes.
738
-
739
- No new label. `ai-changes` plus that comment already say "sent back, and why";
740
- if telling a review-rejected PR from a CI-rejected one in the list view ever
741
- matters, add a `ci-failing` rider on top of `ai-changes` then, not speculatively
742
- now.
743
-
744
- **A Dependabot PR is the exception — flag it, never send it back.** This loop
745
- does not review, label or merge bot PRs, but a red one that its own workflow
746
- already armed will sit queued forever, and only a human can choose between a fix
747
- and a close. Reporting it is the one thing this loop still does for Dependabot.
748
- Count these as `ci-red`; take no other action:
529
+ **`--required`, not the whole rollup** — an advisory check going red is not a
530
+ broken PR, and sending one back spends a fix round to change nothing. Dropping
531
+ `ai-review` in step 1 keeps Pass 3 from spawning reviewers *and* a fix round
532
+ against one PR; the implementer re-adds it when it pushes.
533
+
534
+ **A Dependabot PR is the exception — flag it, never send it back.** A red one its
535
+ own workflow already armed sits queued forever, and only a human can choose
536
+ between a fix and a close. Count these as `ci-red`; take no other action:
749
537
 
750
538
  ```bash
751
539
  gh pr list --state open --json number,autoMergeRequest,statusCheckRollup \
@@ -763,13 +551,8 @@ and globbing only the new root would find nothing and leak every one of them sil
763
551
  WT_DIRS=$(find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null)
764
552
  ```
765
553
 
766
- **Use `find`, not `ls` with globs.** Under zsh a glob that matches nothing aborts the
767
- whole command before `ls` ever runs — so with one root still empty, `ls -d "$WT_ROOT"/ai-*
768
- "$ROOT"/.claude/worktrees/ai-*` returns *nothing at all* and every worktree in the other
769
- root leaks. `2>/dev/null` does not save you; the failure happens at expansion. `find`
770
- tolerates a missing directory and does its own matching.
771
-
772
- Drop the legacy path once that `find` stops returning anything under the repo.
554
+ **Use `find`, not `ls` with globs** — under zsh a glob that matches nothing aborts
555
+ the whole command at expansion, so one empty root leaks every worktree in the other.
773
556
 
774
557
  For each directory found, get its issue number from the `ai-<N>-<slug>` name and find
775
558
  the PR:
@@ -781,12 +564,8 @@ PR=$(gh pr list --head "$SLUG" --state all --json number,state --jq '.[0]')
781
564
  [ -z "$PR" ] && PR=$(gh pr list --head "worktree-$SLUG" --state all --json number,state --jq '.[0]')
782
565
  ```
783
566
 
784
- The `worktree-` fallback is legacy. `EnterWorktree({name})` sometimes prefixed the
785
- branch while the directory kept the plain name, so a single `--head` lookup would
786
- intermittently find nothing and leak the worktree — PR #151 came out as
787
- `worktree-ai-85-…` this way. Pass 4 now creates the branch itself with an explicit
788
- name, so new worktrees can't drift; keep the fallback until no pre-existing ones
789
- remain.
567
+ The `worktree-` fallback is legacy (branches `EnterWorktree` once prefixed); keep
568
+ it until no pre-existing ones remain.
790
569
 
791
570
  If the PR is merged or closed, **confirm the work is actually on `main` before
792
571
  removing anything.** A squash-merged branch always looks like it has unmerged
@@ -816,19 +595,14 @@ fi
816
595
  A closed-unmerged PR is the exception: there is no squash to find, so skip the
817
596
  confirmation and remove — the work was abandoned deliberately.
818
597
 
819
- The issue itself closes from the PR body's `Closes #N`, so both edits are normally
820
- no-ops on a closed issue. A PR that said only `Refs #N` leaves it **open**, which is
821
- what the state check catches. The work has landed, so it must not go back in
822
- the queue; pickup already dropped `ai-ready`, and assigning it is what stops a
823
- merged issue sitting unowned instead (#429 had to be moved to `holding` by hand).
824
- This pass is what frees concurrency slots, so it must run before Pass 4.
598
+ A PR that said only `Refs #N` leaves the issue **open**, which is what the state
599
+ check catches: the work has landed, so it must not go back in the queue — it goes
600
+ to the human instead. This pass is what frees concurrency slots, so it must run
601
+ before Pass 4.
825
602
 
826
- **Then reap the stalled.** Nothing can time out an agent: the Agent tool takes no
827
- timeout, and an agent whose session died leaves its labels behind with no process
828
- to finish them. Six of those and the loop is permanently full while looking
829
- merely busy. So instead of a timeout, check how long a label has sat without its
830
- expected transition — GitHub timestamps every application, so this needs no state
831
- of our own:
603
+ **Then reap the stalled.** Nothing can time out an agent, and one whose session
604
+ died leaves its labels behind. So check how long a label has sat without its
605
+ expected transition — GitHub timestamps every application:
832
606
 
833
607
  ```bash
834
608
  gh api "repos/$OWNER_REPO/issues/<N>/timeline" --paginate \
@@ -845,12 +619,10 @@ work must never be reaped out from under itself.
845
619
  | Fix implementer died | PR `ai-fixing` ≥45min and still `ai-changes` — it never got as far as relabelling to `ai-review` | `gh pr edit <N> --remove-label ai-fixing`, which is what lets Pass 3 dispatch the round again. If `ai-fixing` has been applied ≥3 times, `ai-blocked` on the linked issue instead — a round that dies every time is not one more spawn away from working. Leave the worktree: it holds whatever the dead implementer committed |
846
620
  | 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`) |
847
621
 
848
- The **no PR exists** condition on the first row is what makes reaping safe. An
849
- agent that got as far as opening a PR has handed off to the label state machine
850
- and is no longer the thing being waited on; only a run that produced nothing is
851
- presumed dead. Reaping deliberately does **not** restore `ai-ready` — `ai-blocked`
852
- means a human decides when the issue re-enters the queue, and the removed worktree
853
- means their re-label starts clean. The other two `ai-blocked` exits, Pass 3's
622
+ The **no PR exists** condition on the first row is what makes reaping safe: an
623
+ agent that opened a PR has handed off to the label state machine. Reaping
624
+ deliberately does **not** restore `ai-ready` — `ai-blocked` means a human decides
625
+ when the issue re-enters the queue. The other two `ai-blocked` exits, Pass 3's
854
626
  ping-pong stop and an implementer handing back, leave it off for the same reason.
855
627
 
856
628
  **Every `ai-blocked` must say why, and land in front of a human.** So reaping always
@@ -859,26 +631,19 @@ does three things together — label, assign, comment — and the comment opens
859
631
  `🤖 *Automated — \`ai-issue-loop\` Pass 2 (stall reaping).*`
860
632
 
861
633
  then a blank line. State which stall rule fired, how long the label sat, and whether a
862
- worktree was removed. A bare `ai-blocked` with no explanation is worse than no label:
863
- it reads as a considered judgement when it was actually a timeout. Pass 5's `⚠` then
864
- puts it in the statusline and fires a notification with a sound.
865
-
866
- **Reaping is not always the right call — say so when it isn't.** The rule assumes a
867
- dead agent, but a stale `ai-wip` can also come from a run that was cancelled
868
- deliberately, in which case the work is fine and only the claim is stale. If you know
869
- the cause and it is benign, **return it to the queue** — `gh issue edit <N> --add-label
870
- ai-ready --remove-label ai-wip`, no `ai-blocked` — so Pass 4 picks it straight back up,
871
- and say in the comment that you re-queued it, that you deviated, and why. Re-adding
872
- `ai-ready` is not optional: pickup cleared it, so clearing `ai-wip` alone drops the
873
- issue out of the queue silently, which is the worse failure. `ai-blocked` means *a
874
- human must look*; do not spend it on a claim you already understand.
875
-
876
- **Then decay the triage queue.** `ai-suggested` is the one queue nothing ever
877
- removes from — no pass picks it up, so it only grows, and a queue that only grows
878
- is a guilt list that makes Pass 5's digest unreadable. So it expires: any
879
- `ai-suggested` issue **untouched for 30 days** is closed here. "Untouched" is the
880
- issue's `updatedAt` — a comment, a label change, or a reopen all bump it, so
881
- anything a human has engaged with survives another 30 days for free.
634
+ worktree was removed.
635
+
636
+ **Reaping is not always the right call — say so when it isn't.** A stale `ai-wip`
637
+ can also come from a run cancelled deliberately. If you know the cause and it is
638
+ benign, **return it to the queue** — `gh issue edit <N> --add-label ai-ready
639
+ --remove-label ai-wip`, no `ai-blocked` — and say in the comment that you
640
+ re-queued it, that you deviated, and why. Re-adding `ai-ready` is not optional:
641
+ pickup cleared it, so clearing `ai-wip` alone drops the issue out of the queue
642
+ silently.
643
+
644
+ **Then decay the triage queue.** Any `ai-suggested` issue **untouched for 30
645
+ days** is closed here. "Untouched" is the issue's `updatedAt` — a comment, a
646
+ label change, or a reopen all bump it.
882
647
 
883
648
  ```bash
884
649
  gh issue list --label ai-suggested --state open --limit 100 --json number,updatedAt,labels \
@@ -887,11 +652,9 @@ gh issue list --label ai-suggested --state open --limit 100 --json number,update
887
652
  ```
888
653
 
889
654
  `fromdateiso8601`/`now` inside jq on purpose — `date -d '30 days ago'` is GNU-only
890
- and silently wrong on macOS's BSD `date`, which is exactly the class of bug that
891
- would expire the whole queue in one tick. The label filter is the other guard: an
892
- item a human promoted still carries `ai-suggested`, and closing a queued
893
- `ai-ready` issue because nobody commented on it is the one unrecoverable mistake
894
- this rule can make.
655
+ and silently wrong on macOS. The label filter matters too: a promoted item still
656
+ carries `ai-suggested`, and closing a queued `ai-ready` issue is the one
657
+ unrecoverable mistake this rule can make.
895
658
 
896
659
  Close each with the reason attached, in one call:
897
660
 
@@ -900,87 +663,38 @@ gh issue close <N> --comment '🤖 *Automated — `ai-issue-loop` Pass 2.* Uncla
900
663
  ```
901
664
 
902
665
  Closing is cheap and reversible: the issue keeps its body and its label, so
903
- reviving one is a click. That is what makes an automatic close proportionate here
904
- where `ai-blocked` would not be — nothing is lost, only the queue is honest.
905
-
906
- **Then re-check `core.bare`** — the same probe as Pass 0, against the same `ROOT`:
907
-
908
- ```bash
909
- if [ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
910
- echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
911
- env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
912
- echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
913
- fi
914
- ```
915
-
916
- The `env -u` prefix carries the same weight here as in Pass 0, and for the same
917
- reason — keep it on both lines.
918
-
919
- This is the last pass that *removes* worktrees, not the tick's last touch on the main
920
- checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is exactly
921
- why the re-check belongs here: it catches a flip after this pass's removals and before
922
- Pass 4 branches every new worktree off a broken `ROOT`. Keep the timestamp in both log
923
- lines; which pass emitted one, and when, is the only instrumentation likely to pin the
924
- trigger down. Pass 0's halt rule applies unchanged: a failed repair ends the tick.
666
+ reviving one is a click.
925
667
 
926
- #### Last thing in the pass — rebuild the main checkout's `node_modules`
668
+ #### Last thing in the pass — `loop guard` again
927
669
 
928
- **Removing a worktree can destroy the main checkout's `node_modules/.bin`.** Its
929
- modules dir is a symlink into the main checkout, so a pnpm run from *inside* a
930
- worktree anchors the **main checkout's** `.bin` shims at the **worktree** path.
931
- `worktree remove --force` then deletes them, leaving `$ROOT/node_modules/.bin`
932
- with zero entries and the repo unbuildable:
670
+ Run it once more, after every removal above and before Pass 4 branches new
671
+ worktrees off `ROOT`:
933
672
 
934
- ```
935
- Error: Cannot find module '/…/browser-common-worktrees/ai-145-…/node_modules/.pnpm/typescript@7.0.2/node_modules/typescript/bin/tsc'
936
- husky - pre-push script failed (code 1)
673
+ ```bash
674
+ GUARD=$(npx @rtorcato/repo-tooling loop guard --root "$ROOT" ${REMOVED:+--removed} --json) || exit 1
675
+ printf '%s' "$GUARD" | jq -r '.messages[]'
676
+ REBUILD=$(printf '%s' "$GUARD" | jq -r .rebuild)
937
677
  ```
938
678
 
939
- The give-away is the path: a binary in the main checkout resolving into a
940
- worktree that no longer exists. Nothing in the loop notices — no pass runs the
941
- toolchain — so it surfaces arbitrarily later, in the human's next `git push`, as
942
- a broken repo with no visible connection to the loop. Observed on
943
- `browser-common` #145 → PR #147, where the implementer had been explicitly warned
944
- in its prompt not to run a bare `pnpm install`. **It happened anyway**, and any
945
- pnpm invocation that touches the store is enough — so agent discipline is the
946
- wrong place for this guard. So is a dangling-link probe: `.bin` shims sit *below*
947
- `node_modules`, and a `-maxdepth 1` scan reports a clean tree while every binary
948
- is gone.
679
+ It does two things:
949
680
 
950
- So run it after the removals, **once per tick, as the last thing in this pass**,
951
- and only when nothing else is using the shared tree:
681
+ - **Re-checks `core.bare`** — the flip has been seen right after a
682
+ `worktree remove`. Pass 0's halt rule applies unchanged: a non-zero exit ends
683
+ the tick.
684
+ - **Rebuilds the main checkout's `node_modules` when `--removed`.** Removing a
685
+ worktree can empty `$ROOT/node_modules/.bin` (a pnpm run inside a worktree
686
+ anchors the main checkout's shims at the worktree path), surfacing later as
687
+ `Cannot find module '…-worktrees/ai-…'` in the human's `git push`. It runs
688
+ `pnpm install --frozen-lockfile --config.confirmModulesPurge=false` only when
689
+ `pnpm-lock.yaml` exists **and** no `ai-*` worktree is still live — the rebuild
690
+ purges the shared modules dir out from under any running agent — and defers
691
+ otherwise.
952
692
 
953
- ```bash
954
- LIVE=$(find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null)
955
- if [ "$REMOVED" = 1 ] && [ -f "$ROOT/pnpm-lock.yaml" ]; then
956
- if [ -z "$LIVE" ]; then
957
- (cd "$ROOT" && pnpm install --frozen-lockfile --config.confirmModulesPurge=false)
958
- else
959
- echo "rebuild deferred — $(echo "$LIVE" | wc -l | tr -d ' ') worktree(s) still live"
960
- fi
961
- fi
962
- ```
693
+ Set `REMOVED=1` on **every** removal path — merged-PR cleanup *and* stall reaping.
963
694
 
964
- Three conditions, each load-bearing:
965
-
966
- - **`$REMOVED`** — set by every removal path above, merged-PR cleanup *and* stall
967
- reaping. A reaped worktree needs this most: its agent died mid-command, so it is
968
- the likeliest to have left the main checkout anchored at a path about to vanish.
969
- - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
970
- - **`$LIVE` empty** — the repair *purges* the shared modules dir, which would be
971
- yanked out from under any agent still running in a surviving worktree. Deferring
972
- costs a broken main checkout until the last worktree clears; not deferring costs
973
- a live implementer run. Both flags are needed once it does run:
974
- `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
975
- `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup, and
976
- `--config.confirmModulesPurge=false` gets past
977
- `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare
978
- `pnpm install` cannot repair this, and why a human hitting it needs this exact
979
- command.
980
-
981
- **Report a deferral — never swallow it.** Carry it into Pass 5 as a `⚠rebuild`
982
- segment. A skipped repair that says nothing is the same silent breakage this
983
- section exists to end, just moved one step later.
695
+ **Report a deferral or failure — never swallow it.** `REBUILD` of `deferred` or
696
+ `rebuild-failed` carries into Pass 5 as a `⚠rebuild` segment; neither changes the
697
+ exit code.
984
698
 
985
699
  ### Pass 3 — review
986
700
 
@@ -999,10 +713,9 @@ only adds its own system prompt on top. Never skip a review because the named
999
713
  type is missing (#611).
1000
714
 
1001
715
  **Before spawning either, check whether it already posted.** A missing verdict
1002
- label does not mean the review is missing: on #497 both reviewers posted
1003
- complete reviews and then went idle, labelling nothing. Every review comment
1004
- carries a hidden verdict marker, so read that back instead of re-spawning over a
1005
- review that already exists — `<ARM>` is `code` or `sec`:
716
+ label does not mean the review is missing — a reviewer can post and die before
717
+ labelling. Every review carries a hidden verdict marker, so read that back
718
+ instead of re-spawning — `<ARM>` is `code` or `sec`:
1006
719
 
1007
720
  ```bash
1008
721
  ME=$(gh api user --jq .login) # the identity every loop agent posts as
@@ -1016,43 +729,22 @@ VERDICT=$(gh api "repos/$OWNER_REPO/pulls/<N>/reviews" --paginate --slurp \
1016
729
 
1017
730
  Five details there are load-bearing:
1018
731
 
1019
- - **`pulls/<N>/reviews`, because the prompt posts with `gh pr review --comment`.**
1020
- That creates a *review*, which never appears under `issues/<N>/comments`. The
1021
- prompt and this query have to name the same endpoint or the marker is
1022
- unfindable and every tick re-spawns both arms — so the prompt below now pins
1023
- the command, since a reviewer reaching for `gh pr comment` instead posts
1024
- somewhere this never looks.
1025
- - **`--slurp`, not `--paginate` with `--jq`.** `--paginate` runs `--jq` once per
1026
- page, so a filter ending in `last` would keep only the final page's answer and
1027
- lose a marker on an earlier one. `--slurp` collects every page first; `gh`
1028
- refuses it alongside `--jq`, hence the pipe and the `add` that flattens pages.
1029
- - **The author gate — the loop's own login, deliberately narrower than Pass 4's
1030
- association test.** Anyone can review a public PR, so ungated a stranger's
1031
- `<!-- ai-issue-loop:verdict:sec:PASS -->` is adopted as a verdict, and because
1032
- the read takes `last` it also overrides a genuine `CHANGES` posted before it.
1033
- Pass 4's `OWNER`/`MEMBER`/`COLLABORATOR` set is a backstop behind the
1034
- `ai-ready` label; here the marker is the **only** signal, and every agent in
1035
- this pipeline authenticates as one identity — so only that identity's reviews
1036
- count. Login, not `author_association`, because association wobbles with repo
1037
- ownership (an org-owned repo never yields `OWNER`, even for its admins) while
1038
- `gh api user` names exactly who this loop posts as.
1039
- - **The head gate — `.commit_id==$head`, so a verdict expires with the diff it
1040
- read.** Every review carries the commit it was submitted against; ungated, the
1041
- read takes `last` over all of them, so after a fix round the newest marker is
1042
- still the *pre-fix* one and the tick adopts a verdict about a diff that no
1043
- longer exists. Both directions bite: a stale `CHANGES` re-applies `ai-changes`
1044
- for a finding the fix round already resolved, burning a round of two and
1045
- pushing the PR toward `ai-blocked` over nothing; a stale `PASS` is worse, since
1046
- it marks a rewritten diff reviewed when nothing read it. Scoped to the head, an
1047
- older marker reads as absent and that arm re-spawns — which is already the
1048
- behaviour for an arm that never posted. **This does not cost the #497 recovery
1049
- case** the read exists for: a reviewer that died between posting and labelling
1050
- posted against the head that is still current, so its marker still matches.
1051
- Only genuinely stale markers stop matching, which is the point.
1052
- - **`(.body // "")` and `// empty`.** A review can have a null body, which
1053
- `capture` throws on, aborting the whole filter; and `jq -r` prints a missing
1054
- value as the literal string `null`, which is not empty and would read as a
1055
- verdict.
732
+ - **`pulls/<N>/reviews`** — the prompt posts with `gh pr review --comment`, which
733
+ creates a *review*, never an `issues/<N>/comments` entry. Both must name the
734
+ same endpoint or every tick re-spawns both arms.
735
+ - **`--slurp`, not `--paginate` with `--jq`** — `--jq` runs once per page, so
736
+ `last` would lose a marker on an earlier page. `gh` refuses `--slurp` with
737
+ `--jq`, hence the pipe and the `add`.
738
+ - **The author gate — the loop's own login.** Anyone can review a public PR, and
739
+ here the marker is the **only** signal, so a stranger's `PASS` marker would be
740
+ adopted and override a genuine `CHANGES`. Login, not `author_association`,
741
+ which wobbles with repo ownership (an org repo never yields `OWNER`).
742
+ - **The head gate — `.commit_id==$head`**, so a verdict expires with the diff it
743
+ read. Otherwise a pre-fix `CHANGES` burns a fix round over nothing, or a
744
+ pre-fix `PASS` marks a rewritten diff reviewed. A reviewer that died between
745
+ posting and labelling posted against the current head, so it still matches.
746
+ - **`(.body // "")` and `// empty`** — a null body throws in `capture`, and
747
+ `jq -r` prints a missing value as the string `null`, which reads as a verdict.
1056
748
 
1057
749
  Then, for that arm — `<claim>` being `ai-reviewing-code` or `ai-reviewing-sec`,
1058
750
  `<pass>` being `ai-ok-code` or `ai-ok-sec`:
@@ -1063,18 +755,9 @@ Then, for that arm — `<claim>` being `ai-reviewing-code` or `ai-reviewing-sec`
1063
755
  - **`CHANGES`** — `gh pr edit <N> --add-label ai-changes --remove-label ai-review --remove-label <claim>`
1064
756
 
1065
757
  Adoption is per reviewer, so a tick that finds one arm posted and the other
1066
- missing does both: it applies the first's verdict off its comment and spawns
1067
- only the second. That is the whole point of reading the artifact — the comment
1068
- is what a human reads at merge time, so making it the thing the loop reads too
1069
- leaves one source for one fact, with no separate reply to be lost or to
1070
- contradict it.
1071
-
1072
- This is the second half of Pass 2's dead-reviewer rule rather than a rival to
1073
- it. Pass 2 only ever drops a stalled *claim*; it never judges whether a review
1074
- happened. Dropping the claim is what makes an arm eligible here, and this lookup
1075
- is what then decides between adopting and re-spawning. An agent that died before
1076
- posting leaves no marker and so re-spawns, which is what that rule always
1077
- intended; one that died after posting is now recovered instead of duplicated.
758
+ missing applies the first's verdict and spawns only the second. Pass 2's
759
+ dead-reviewer rule only drops a stalled *claim*; this lookup then decides between
760
+ adopting and re-spawning.
1078
761
 
1079
762
  **Claim first, then spawn** — the same shape Pass 4 uses before picking up an
1080
763
  issue. Apply the label immediately before the spawn, not after:
@@ -1087,21 +770,10 @@ gh pr edit <N> --add-label ai-reviewing-sec ${AGENT_USER:+--add-assignee} ${AGE
1087
770
  Assigning `AGENT_USER` on the claim is idempotent — both arms adding the same
1088
771
  account is one assignee, and Pass 1 removes it at the handoff.
1089
772
 
1090
- Without the claim there is no window in which "a reviewer is running" is visible.
1091
- A reviewer applies its verdict label only at the *end*, after reading the diff and
1092
- posting its comment, so from spawn until then the labels are indistinguishable
1093
- from "nobody has started" — and a 15-minute tick is comfortably shorter than a
1094
- review. A tick landing in that gap spawns a duplicate of every reviewer in flight:
1095
- two agents read the same diff and post two review comments under the owner's
1096
- avatar, and the verdicts race, one applying `ai-ok-code` while the other applies
1097
- `ai-changes` and leaves the PR contradictory for Pass 1 to interpret. On a full
1098
- queue that is a dozen duplicated reviewers against the monthly cap the limits
1099
- section exists to protect.
1100
-
1101
- Two labels rather than one, because the reviewers are spawned independently and a
1102
- single flag could not say *which* was already running. The reviewer clears its own
1103
- claim alongside its verdict, so a claim never outlives its run; if one does, the
1104
- agent died and Pass 2's stall reaping drops it.
773
+ Without the claim, a tick landing mid-review spawns a duplicate of every
774
+ reviewer in flight, and their verdicts race. The reviewer clears its own claim
775
+ alongside its verdict; a claim outliving its run means the agent died, and Pass
776
+ 2's stall reaping drops it.
1105
777
 
1106
778
  Reviewer prompt template:
1107
779
 
@@ -1225,18 +897,13 @@ Reviewer prompt template:
1225
897
  > disagreed with it would be a second source for one fact. One line back to the
1226
898
  > orchestrator is plenty; the comment body is capped separately, above.
1227
899
 
1228
- **Dependabot PRs get no reviewer.** Pass 0 does not adopt them and this pass
1229
- spawns no arm for them: the scaffolded `dependabot-automerge.yml` decides which
1230
- bumps merge, and it decides before a tick could run. See Pass 0 for why a
1231
- reviewer racing that workflow never gated anything (#593).
900
+ **Dependabot PRs get no reviewer** — `dependabot-automerge.yml` decides which
901
+ bumps merge (#593).
1232
902
 
1233
903
  **A Dependabot PR labelled `ai-changes` is terminal — never spawn a fix round for
1234
- it.** Nothing produces that state any more (#593), so this is a guard against a
1235
- label an older tick left behind. There is no linked issue to mark `ai-blocked`
1236
- and no worktree to enter, and an agent has no business rewriting a bot's
1237
- lockfile. Pass 1 assigns it and counts it as `rev`; here it simply waits for a
1238
- human. Everything below applies only to PRs this loop opened from an `ai-ready`
1239
- issue.
904
+ it.** There is no linked issue and no worktree, and an agent has no business
905
+ rewriting a bot's lockfile. Pass 1 assigns it; here it simply waits for a human.
906
+ Everything below applies only to PRs this loop opened from an `ai-ready` issue.
1240
907
 
1241
908
  **PRs labelled `ai-changes`, and not already `ai-fixing`** — that claim means an
1242
909
  implementer is mid-round; skip the PR entirely. Count prior `ai-changes`
@@ -1270,12 +937,8 @@ and for the same reason. Apply the label immediately before the spawn, not after
1270
937
  gh pr edit <N> --add-label ai-fixing ${AGENT_USER:+--add-assignee} ${AGENT_USER:+"$AGENT_USER"} # then spawn the implementer
1271
938
  ```
1272
939
 
1273
- A fix round runs longer than a 15-minute tick — on #565, `ai-changes` at 17:35 and
1274
- the push at 17:38 — and until that push the PR reads `ai-changes` with no claim,
1275
- which is exactly this selector. A tick landing in the gap spawns a second
1276
- implementer, and that is worse than a duplicated reviewer: the two share one
1277
- worktree and one branch, so they race each other's commits and `git -C`
1278
- operations rather than merely posting two comments.
940
+ Without it, a tick landing before the push spawns a second implementer into the
941
+ same worktree and branch, racing the first's commits.
1279
942
 
1280
943
  Then spawn one background implementer agent:
1281
944
 
@@ -1321,25 +984,17 @@ gh api "repos/$OWNER_REPO/issues?labels=ai-ready&state=open" \
1321
984
  Both filters matter. The `ai-ready` label is the hard gate (on a public repo only
1322
985
  collaborators can apply labels); the author-association check is the backstop.
1323
986
 
1324
- `holding` marks a gate issue — one that closes on a human judgement call rather
1325
- than on work landing, so there is nothing for an agent to implement. It is
1326
- excluded here as belt-and-braces: such an issue should not carry `ai-ready` in
1327
- the first place, but then mislabelling it costs nothing. Unlike `ai-blocked` (an
1328
- agent tried and got stuck), `holding` says *no agent should ever start*, and it
1329
- shows up in the issue list so a human triaging does not re-litigate it either.
987
+ `holding` marks a gate issue — one that closes on human judgement, so *no agent
988
+ should ever start* it. Excluded here as belt-and-braces.
1330
989
 
1331
- `ai-suggested` is deliberately *not* filtered. An agent's own suggestion carries
1332
- only `ai-suggested`, so it never matches `labels=ai-ready` — the loop cannot feed
1333
- itself work. Promoting one is a human adding `ai-ready`, and the item keeps
1334
- `ai-suggested` (Pass 2 relies on that), so excluding the label here would strand
1335
- every promoted issue in the queue forever (#608).
990
+ `ai-suggested` is deliberately *not* filtered: a promoted suggestion keeps the
991
+ label alongside the `ai-ready` a human added, and excluding it would strand every
992
+ promoted issue forever (#608).
1336
993
 
1337
994
  **Declining an issue is a visible act — comment, never just skip.** Whenever an
1338
995
  agent decides an issue should *not* go to the pipeline — triaging which issues to
1339
996
  label `ai-ready`, or dropping one that is already labelled — say so on the issue
1340
- itself. A silent skip is indistinguishable from an issue nobody looked at, so the
1341
- same issue gets re-triaged from scratch every time, and the reasoning that took
1342
- real work to reach is lost.
997
+ itself, or it gets re-triaged from scratch every time.
1343
998
 
1344
999
  The comment opens with the standard `🤖 *Automated …*` header — see the top of this
1345
1000
  file. Then, in the body — **this is the one comment exempt from the ≤10-line
@@ -1347,10 +1002,7 @@ budget, and only this one.** Declining is a hard handoff whose whole value is th
1347
1002
  reasoning; do not reach for this shape on a PR handoff.
1348
1003
 
1349
1004
  **Lead with a `## To lift this hold` section, before anything else.** It must be
1350
- readable in five seconds — the reasoning that follows is *why*; this is *what to
1351
- do*. A decline that buries the action under three paragraphs leaves the reader
1352
- knowing an agent declined but not what is now expected of them, which is the same
1353
- dead end as not commenting at all. Make it executable without reading further:
1005
+ readable in five seconds and executable without reading further:
1354
1006
 
1355
1007
  - **Enumerate the options as a table**, one row each, with what an agent would do
1356
1008
  once that option is chosen. Two to four rows. Genuinely one path → one sentence.
@@ -1375,10 +1027,8 @@ The lead-with-the-action shape (not the length exemption) applies to every
1375
1027
  comment that hands a decision back — `ai-blocked` from a stall or a ping-pong
1376
1028
  stop included. What to do first; justification underneath.
1377
1029
 
1378
- If the issue was already labelled, drop `ai-ready` in the same breath; leaving it
1379
- means the next tick picks it straight back up. Do **not** use `ai-blocked` for
1380
- this — that label means *an agent tried and got stuck*, and spending it on an
1381
- issue no agent ever started makes the blocked queue meaningless.
1030
+ If the issue was already labelled, drop `ai-ready` in the same breath. Do **not**
1031
+ use `ai-blocked` for this — that label means *an agent tried and got stuck*.
1382
1032
 
1383
1033
  Check for an existing decline comment before posting, so a repeated triage pass
1384
1034
  does not stack duplicates:
@@ -1391,25 +1041,15 @@ gh issue view <N> --json comments \
1391
1041
  | length'
1392
1042
  ```
1393
1043
 
1394
- Gated on the loop's own login for the same reason as the decision upsert above,
1395
- inverted: anyone can comment on a public issue, so ungated a stranger who opens
1396
- with that header *suppresses* the decline comment and the issue is left labelled
1397
- with nothing on the timeline saying why. `.author.login` here, not `.user.login`
1398
- — `gh issue view --json` is GraphQL and names the field differently from the REST
1399
- payload the upsert reads.
1044
+ Gated on the loop's own login so a stranger's comment opening with that header
1045
+ cannot *suppress* the decline. `.author.login` here, not `.user.login` — `gh issue
1046
+ view --json` is GraphQL and names the field differently from REST.
1400
1047
 
1401
1048
  **Then drop any candidate that overlaps a file with one already picked this
1402
- tick** — the same rule `ai-workflow` step 2 applies, and it matters more here
1403
- because nobody is watching. Two agents branch off the same `origin/main`, both
1404
- rewrite one file, and the second PR to merge hands a human two agent-authored
1405
- diffs to reconcile hours later (#594).
1406
-
1407
- Read each candidate's body for the paths it names — that is what the `body` field
1408
- in the query above is for — and skip one naming a path a higher-placed candidate
1409
- already names. An issue body is not a file list, so this is a heuristic, not a
1410
- proof; it costs nothing and catches the common case. Count generated files, too:
1411
- on a repo where editing a skill regenerates `AGENTS.md`, two issues touching
1412
- different modules still collide there.
1049
+ tick** (#594). Read each candidate's body for the paths it names and skip one
1050
+ naming a path a higher-placed candidate already names — a heuristic, not a proof.
1051
+ Count generated files, too: on a repo where editing a skill regenerates
1052
+ `AGENTS.md`, two issues touching different modules still collide there.
1413
1053
 
1414
1054
  A skipped candidate is **waiting its turn, not declined** — leave `ai-ready` on
1415
1055
  it, post no comment, and let the next tick take it. The decline shape above is
@@ -1423,16 +1063,10 @@ gh issue edit <N> --add-label ai-wip --remove-label ai-ready \
1423
1063
  ${AGENT_USER:+--add-assignee} ${AGENT_USER:+"$AGENT_USER"}
1424
1064
  ```
1425
1065
 
1426
- Assigning here is what makes the issue list honest: from this moment an agent
1427
- owns the work, and an unassigned `ai-ready` issue is genuinely untouched.
1428
-
1429
- Dropping `ai-ready` is half the claim, not tidiness — the diagram above is a
1430
- transition, not an accumulation. An issue left carrying both re-enters the queue
1431
- the instant `ai-wip` clears for any reason other than the PR closing it, and the
1432
- next tick spawns an agent to re-implement work already sitting in an open PR
1433
- (#458, #467, #461, #452, all in one session). Every path that legitimately returns
1434
- an issue to the queue therefore re-adds `ai-ready` explicitly; Pass 2's benign-stall
1435
- path is the only one, and a human does the rest.
1066
+ Dropping `ai-ready` is half the claim, not tidiness — an issue carrying both
1067
+ re-enters the queue the instant `ai-wip` clears, and the next tick re-implements
1068
+ work already in an open PR. Every path that returns an issue to the queue re-adds
1069
+ `ai-ready` explicitly; Pass 2's benign-stall path is the only one.
1436
1070
 
1437
1071
  **Then create the worktree yourself**, before spawning anything. `<slug>` is 3–4
1438
1072
  kebab-case words from the title:
@@ -1445,10 +1079,9 @@ git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
1445
1079
 
1446
1080
  **Then give it dependencies — from the repo's own symlink list.** `fix ai` writes
1447
1081
  `worktree.symlinkDirectories` into `.claude/settings.json`: the root
1448
- `node_modules`, plus one entry per workspace package that has one, globbed from
1449
- the repo's *own* `pnpm-workspace.yaml` / `package.json` `workspaces` (#406). That
1450
- list is the single source of truth for what a worktree needs linked. Read it and
1451
- do the linking here:
1082
+ `node_modules`, plus one entry per workspace package that has one. That list is
1083
+ the single source of truth for what a worktree needs linked. Read it and do the
1084
+ linking here:
1452
1085
 
1453
1086
  ```bash
1454
1087
  DIRS=$(jq -r '.worktree.symlinkDirectories[]? // empty' "$ROOT/.claude/settings.json" 2>/dev/null)
@@ -1466,25 +1099,14 @@ done)
1466
1099
  [ -z "$MISSING" ] || echo "FATAL: $SLUG has no symlink for: $MISSING"
1467
1100
  ```
1468
1101
 
1469
- **Iterate line by line — never `for d in $DIRS`.** Your shell may be zsh, which
1470
- does not word-split an unquoted expansion: `$DIRS` arrives as *one* word with
1471
- embedded newlines, `[ -d ]` fails against that nonsense path, and the loop links
1472
- **nothing** (#585). Same class as the Pass 2 glob hazard above, and just as
1473
- silent — the `pnpm install` fallback is gated on `$DIRS` being *empty*, which it
1474
- is not, so the worktree gets neither links nor an install, and the implementer
1475
- meets `Cannot find module` on its first test run, reading as the issue's fault
1476
- rather than the harness's. That is what the `MISSING` assertion is for: if it
1477
- prints, do **not** spawn an implementer — run `pnpm install` in the worktree, or
1478
- return the issue to `ai-ready`, drop `ai-wip`, and move on.
1479
-
1480
- **Read the setting, do not rely on it.** `worktree.symlinkDirectories` is a
1481
- **Claude Code** setting, honoured by `EnterWorktree` — which this pipeline
1482
- forbids outright (see below) and replaces with a raw `git worktree add`. So the
1483
- setting is *inert for exactly the worktrees this loop creates*: `doctor` can
1484
- report `Claude worktree settings: ok` while every agent worktree gets its
1485
- dependencies by some other path, which is how #511/PR #526 ended up hand-installed.
1486
- Taking the list as data and doing the `ln -s` here is what makes that check mean
1487
- something for loop worktrees too, without either subsystem owning the other.
1102
+ **Iterate line by line — never `for d in $DIRS`.** zsh does not word-split an
1103
+ unquoted expansion, so that loop silently links **nothing** (#585). If the
1104
+ `MISSING` assertion prints, do **not** spawn an implementer — run `pnpm install`
1105
+ in the worktree, or return the issue to `ai-ready`, drop `ai-wip`, and move on.
1106
+
1107
+ `worktree.symlinkDirectories` is a Claude Code setting honoured only by
1108
+ `EnterWorktree`, which this pipeline forbids — so it is inert for loop worktrees
1109
+ unless read and linked here.
1488
1110
 
1489
1111
  **No list, or no `.claude/settings.json` → install for real instead:**
1490
1112
 
@@ -1492,42 +1114,20 @@ something for loop worktrees too, without either subsystem owning the other.
1492
1114
  [ -z "$DIRS" ] && (cd "$WT_ROOT/$SLUG" && pnpm install)
1493
1115
  ```
1494
1116
 
1495
- That fallback is safe precisely because nothing was symlinked — the hazard below
1496
- is `pnpm install` against a *symlinked* tree, not a real install in an isolated
1497
- one. It costs a duplicate `node_modules` and about ten seconds, since pnpm
1498
- hardlinks from the store. Run `npx @rtorcato/repo-tooling fix ai` in the repo to
1499
- get the faster path back.
1500
-
1501
- Why the list has to come from that file rather than a hand-rolled glob: pnpm
1502
- workspaces keep the resolution that matters in each
1503
- `packages/<name>/node_modules`, and an earlier version of this pass linked the
1504
- root and `apps/*` only — so it reproduced that gap on every repo that nests its
1505
- packages anywhere else. Measured on `api-common` 2026-08-20: the main checkout
1506
- had per-package `node_modules` in **37 of 37** packages, the worktree had **1**.
1507
- So `pnpm --filter <pkg> typecheck` there fails with `Cannot find module` rather
1508
- than the real error — the agent cannot reproduce the bug, and the environment
1509
- looks like the issue's fault. Issue #201 was handed back `ai-blocked` this way,
1510
- well-diagnosed and untouched. `workspaceSymlinkDirs` already globs the consuming
1511
- repo's own layout, so deriving the list is both shorter here and correct on repos
1512
- this file has never seen.
1117
+ That fallback is safe precisely because nothing was symlinked. Run
1118
+ `npx @rtorcato/repo-tooling fix ai` in the repo to get the faster path back.
1513
1119
 
1514
1120
  **Never force `pnpm install` against a symlinked tree.** It wants to purge and
1515
1121
  rebuild the modules dir (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), which
1516
- mutates the **main checkout's** `node_modules` — shared by every other worktree
1517
- and yanked out from under any agent mid-typecheck. `CI=true` and
1518
- `--config.confirmModulesPurge=false` both silence that prompt; neither makes it
1519
- safe. Now that the default path symlinks, this rule is **load-bearing rather than
1520
- advisory** — the implementer prompt below states it, and `browser-common` #145 →
1521
- PR #147 is what an implementer doing it anyway costs: the main checkout's `.bin`
1522
- emptied, surfacing arbitrarily later in a human's `git push`. Pass 2's rebuild is
1523
- the one sanctioned exception, and only because it is gated on no worktree
1524
- surviving.
1525
-
1526
- **Once per repo, exclude the symlinks from git.** Repos ignore `node_modules/`
1527
- *with a trailing slash*, which does not match a symlink — so every link shows as
1528
- untracked in every worktree and a `git add -A` commits it. The pattern below has
1529
- no slash, so it matches at any depth and covers the nested workspace links too.
1530
- `.git/info/exclude` is shared by all worktrees and never committed:
1122
+ mutates the **main checkout's** `node_modules` — shared by every other worktree.
1123
+ `CI=true` and `--config.confirmModulesPurge=false` both silence that prompt;
1124
+ neither makes it safe. Pass 2's `loop guard --removed` rebuild is the one
1125
+ sanctioned exception, gated on no worktree surviving.
1126
+
1127
+ **Once per repo, exclude the symlinks from git.** `node_modules/` with a trailing
1128
+ slash does not match a symlink, so a `git add -A` would commit every link. The
1129
+ pattern below has no slash, so it matches at any depth. `.git/info/exclude` is
1130
+ shared by all worktrees and never committed:
1531
1131
 
1532
1132
  ```bash
1533
1133
  grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$ROOT/.git/info/exclude"
@@ -1535,23 +1135,15 @@ grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$R
1535
1135
 
1536
1136
  **No implementer ever calls `EnterWorktree` — in any form.** This is deliberate; do
1537
1137
  not add the step back. `EnterWorktree({path})` only accepts worktrees under
1538
- `<repo>/.claude/worktrees/`, while Pass 0 deliberately puts them in a sibling
1539
- directory — the two rules are incompatible, so the call can only ever be refused.
1540
- `EnterWorktree({name})` does worse: it relocates *this* session as well — observed
1541
- five-plus times in one tick, each producing *"this session is isolated in the worktree
1138
+ `<repo>/.claude/worktrees/`, which Pass 0 forbids, and `EnterWorktree({name})`
1139
+ relocates *this* session too, producing *"this session is isolated in the worktree
1542
1140
  …"* refusals on unrelated orchestrator commands. Implementers work via
1543
- `git -C <absolute worktree path>` instead, which is what the prompt below says.
1544
- Creating the worktree here also fixes the `worktree-` branch-prefix drift, and it is
1545
- why the symlinks above are created explicitly *from* `worktree.symlinkDirectories`
1546
- rather than by `EnterWorktree` honouring it.
1547
-
1548
- **Spawn implementers one at a time — never two in the same message.** The worktree pin
1549
- is a property of the session, not of an agent, so concurrent spawns cross-pin: the
1550
- first to pin wins and its siblings inherit that tree. The failure is nasty rather than
1551
- loud — a mispinned agent can Read and Edit its *assigned* worktree perfectly well, but
1552
- every `git -C` aimed there is refused, so it does the whole implementation and only
1553
- then discovers it cannot commit, push, or open a PR. Reviewers are unaffected — they
1554
- never enter a worktree — and can still be launched concurrently.
1141
+ `git -C <absolute worktree path>` instead.
1142
+
1143
+ **Spawn implementers one at a time — never two in the same message.** The worktree
1144
+ pin is a property of the session, so concurrent spawns cross-pin, and a mispinned
1145
+ agent only discovers it cannot commit after doing the whole implementation.
1146
+ Reviewers never enter a worktree and can still be launched concurrently.
1555
1147
 
1556
1148
  Then spawn a background implementer agent:
1557
1149
 
@@ -1613,10 +1205,9 @@ Then spawn a background implementer agent:
1613
1205
  > only assignee, or the list still reads as though something is working on it.
1614
1206
  >
1615
1207
  > Then comment why. **Leave your worktree in place — never run
1616
- > `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
1617
- > longer `ai-wip` and has no open PR, so it matches the orphan rule) and rebuilds
1618
- > the main checkout's `node_modules` in the same pass, which a bare removal here
1619
- > would silently break. The comment **must** open with this exact line, then a
1208
+ > `git worktree remove`.** Pass 2 of the next tick reaps it and rebuilds the main
1209
+ > checkout's `node_modules` in the same pass, which a bare removal here would
1210
+ > silently break. The comment **must** open with this exact line, then a
1620
1211
  > blank line — you authenticate as the owner, so without it the issue reads as if
1621
1212
  > they wrote it themselves:
1622
1213
  >