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