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