@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.
- package/AGENTS.md +10 -1
- package/dist/base/agent-user.js +1 -1
- package/dist/base/ai-loop-identity.js +80 -0
- package/dist/base/fixers.js +17 -0
- package/dist/base/github-settings.js +2 -1
- package/dist/cli/commands/fix.js +20 -12
- package/dist/cli/commands/loop-env.js +76 -0
- package/dist/cli/commands/loop-guard.js +1 -1
- package/dist/cli/generators/git.js +3 -3
- package/dist/cli/index.js +11 -0
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +275 -684
|
@@ -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
|
-
|
|
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
|
|
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.**
|
|
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`.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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
|
|
135
|
-
|
|
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
|
|
169
|
-
purpose
|
|
170
|
-
|
|
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.**
|
|
215
|
-
|
|
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
|
|
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
|
|
224
|
-
#
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
```
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
`
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
```
|
|
258
|
-
|
|
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.**
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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`
|
|
296
|
-
|
|
297
|
-
agent
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
358
|
-
|
|
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
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
423
|
-
the owner
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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*,
|
|
442
|
-
by hand
|
|
443
|
-
|
|
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.
|
|
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.**
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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.**
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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.
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
607
|
-
|
|
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
|
|
612
|
-
|
|
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 —
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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`.**
|
|
654
|
-
|
|
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`
|
|
671
|
-
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
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.**
|
|
703
|
-
|
|
704
|
-
|
|
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
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
767
|
-
whole command
|
|
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
|
|
785
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
849
|
-
agent that
|
|
850
|
-
|
|
851
|
-
|
|
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.
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
**
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
891
|
-
|
|
892
|
-
|
|
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.
|
|
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 —
|
|
668
|
+
#### Last thing in the pass — `loop guard` again
|
|
927
669
|
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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
|
-
|
|
951
|
-
|
|
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
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
- **The
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
`
|
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
|
1229
|
-
|
|
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.**
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
|
1325
|
-
|
|
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
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
|
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
|
|
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
|
|
1379
|
-
|
|
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
|
|
1395
|
-
|
|
1396
|
-
|
|
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**
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
|
1449
|
-
the
|
|
1450
|
-
|
|
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`.**
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
|
1496
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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/`,
|
|
1539
|
-
|
|
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
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
|
1617
|
-
>
|
|
1618
|
-
>
|
|
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
|
>
|