@rtorcato/repo-tooling 3.21.1 → 3.22.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/dist/base/fixers.js
CHANGED
|
@@ -366,7 +366,11 @@ export const BASE_FIXERS = [
|
|
|
366
366
|
for (const name of SHIPPED_SKILLS) {
|
|
367
367
|
const result = await installClaudeSkill(dir, name, { force: forceSkills });
|
|
368
368
|
if (result.status === 'declined-downgrade') {
|
|
369
|
-
|
|
369
|
+
// Say what was compared, not what is newer: the version is a label,
|
|
370
|
+
// and on a git checkout it can understate the content behind it
|
|
371
|
+
// (#522). Naming the escape hatch matters for exactly that case.
|
|
372
|
+
console.error(chalk.yellow(` skipped — ${result.file} is stamped ${result.installedVersion}, above the ${result.shippedVersion} this package reports; not overwritten`));
|
|
373
|
+
console.error(chalk.yellow(' overwrite anyway: fix claude-skills --force-skills'));
|
|
370
374
|
continue;
|
|
371
375
|
}
|
|
372
376
|
if (result.status === 'declined-fork') {
|
|
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import fs from 'fs-extra';
|
|
11
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
11
12
|
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
12
13
|
import { shellQuote } from '../utils/shell.js';
|
|
13
14
|
/**
|
|
@@ -127,13 +128,41 @@ export function isNewerVersion(a, b) {
|
|
|
127
128
|
}
|
|
128
129
|
return false;
|
|
129
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The version to stamp, given what `package.json` claims.
|
|
133
|
+
*
|
|
134
|
+
* In a published tarball that field is authoritative — `@semantic-release/npm`
|
|
135
|
+
* rewrites it before packing. In a *git checkout* it is not: this repo runs
|
|
136
|
+
* semantic-release without `@semantic-release/git` (#417), so nothing ever
|
|
137
|
+
* writes the released version back and the field sits at whatever it was last
|
|
138
|
+
* hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
|
|
139
|
+
* ten minor versions of drift.
|
|
140
|
+
*
|
|
141
|
+
* That mattered because the stamp feeds the downgrade guard below: a skill
|
|
142
|
+
* installed from npm could not be updated from a local checkout, and the
|
|
143
|
+
* refusal claimed the installed copy was "newer" when only its *label* was.
|
|
144
|
+
*
|
|
145
|
+
* So when the package root is a git checkout, take the nearest tag as well and
|
|
146
|
+
* keep whichever is higher. Monotonic on purpose — this can only ever raise the
|
|
147
|
+
* answer, so a tagless, shallow, or git-less environment keeps today's
|
|
148
|
+
* behaviour rather than silently stamping something lower.
|
|
149
|
+
*/
|
|
150
|
+
export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
|
|
151
|
+
if (!(await fs.pathExists(path.join(root, '.git'))))
|
|
152
|
+
return pkgVersion;
|
|
153
|
+
const described = await git(['describe', '--tags', '--abbrev=0'], root);
|
|
154
|
+
const tag = described?.trim().replace(/^v/, '');
|
|
155
|
+
if (!tag || !/^\d+\.\d+/.test(tag))
|
|
156
|
+
return pkgVersion;
|
|
157
|
+
return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
|
|
158
|
+
}
|
|
130
159
|
/** The skill source and the package version that will be stamped into it. */
|
|
131
|
-
export async function readShippedSkill(name = SHIPPED_SKILL) {
|
|
160
|
+
export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
|
|
132
161
|
const root = getPackageRoot();
|
|
133
162
|
const file = path.join(root, 'skills', name, 'SKILL.md');
|
|
134
163
|
const content = await fs.readFile(file, 'utf8');
|
|
135
164
|
const pkg = await fs.readJson(path.join(root, 'package.json'));
|
|
136
|
-
return { content, version: String(pkg.version), file };
|
|
165
|
+
return { content, version: await resolveShippedVersion(root, String(pkg.version), git), file };
|
|
137
166
|
}
|
|
138
167
|
/**
|
|
139
168
|
* The command a human runs to see what their fork changed, before deciding
|
|
@@ -187,7 +216,12 @@ export async function installClaudeSkill(skillsDir, name = SHIPPED_SKILL, { forc
|
|
|
187
216
|
installedVersion,
|
|
188
217
|
shippedVersion: shipped.version,
|
|
189
218
|
};
|
|
190
|
-
|
|
219
|
+
// `force` overrides this too, not just the fork check below. It already
|
|
220
|
+
// overrides the *stronger* protection — overwriting content someone
|
|
221
|
+
// deliberately edited — so refusing on a version comparison while allowing
|
|
222
|
+
// that was backwards, and left no escape hatch at all when the comparison
|
|
223
|
+
// was wrong (#522).
|
|
224
|
+
if (!force && installedVersion && isNewerVersion(installedVersion, shipped.version)) {
|
|
191
225
|
return { ...base, status: 'declined-downgrade' };
|
|
192
226
|
}
|
|
193
227
|
// Only `pristine` content is provably ours to replace. Anything else is a
|
|
@@ -66,13 +66,18 @@ export async function writeLockfile(dir, config, assets) {
|
|
|
66
66
|
if (!valid) {
|
|
67
67
|
throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
// One read, because everything not rebuilt from `config` has to be carried
|
|
70
|
+
// forward explicitly — this object is constructed from scratch, so any key
|
|
71
|
+
// not named here is dropped by the next `fix lockfile`.
|
|
72
|
+
const existing = await readLockfile(dir);
|
|
73
|
+
const carried = assets ?? existing?.assets;
|
|
70
74
|
const filepath = path.join(dir, LOCKFILE_NAME);
|
|
71
75
|
const lockfile = {
|
|
72
76
|
$schema: LOCKFILE_SCHEMA_URL,
|
|
73
77
|
version: LOCKFILE_VERSION,
|
|
74
78
|
config,
|
|
75
79
|
...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
|
|
80
|
+
...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
|
|
76
81
|
writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
|
|
77
82
|
writtenAt: new Date().toISOString(),
|
|
78
83
|
};
|
package/package.json
CHANGED
|
@@ -207,6 +207,54 @@ pass.** A session can be pinned to a worktree, so the orchestrator can find itse
|
|
|
207
207
|
inside one it did not choose. `--git-common-dir` resolves to the main checkout's
|
|
208
208
|
`.git` from anywhere, including a worktree, so `ROOT` is correct either way.
|
|
209
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
|
+
|
|
210
258
|
**Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
|
|
211
259
|
`core.bare = true` on its own, repeatedly — four times in one session, some occurrences
|
|
212
260
|
immediately after a `worktree remove` and some with nothing removed at all. The trigger
|
|
@@ -361,8 +409,11 @@ it behind a hidden marker and upsert:
|
|
|
361
409
|
|
|
362
410
|
```bash
|
|
363
411
|
MARKER='<!-- ai-issue-loop:decision -->'
|
|
412
|
+
ME=$(gh api user --jq .login) # the identity every loop agent posts as
|
|
364
413
|
ID=$(gh api "repos/$OWNER_REPO/issues/<N>/comments" \
|
|
365
|
-
|
|
414
|
+
| jq -r --arg me "$ME" --arg marker "$MARKER" \
|
|
415
|
+
'[.[] | select(.user.login == $me and ((.body // "") | startswith($marker)))]
|
|
416
|
+
| .[0].id // empty')
|
|
366
417
|
if [ -n "$ID" ]; then
|
|
367
418
|
gh api -X PATCH "repos/$OWNER_REPO/issues/comments/$ID" -f body="$MARKER
|
|
368
419
|
$TEXT"
|
|
@@ -372,10 +423,24 @@ $TEXT"
|
|
|
372
423
|
fi
|
|
373
424
|
```
|
|
374
425
|
|
|
426
|
+
**The author gate is the same one Pass 3's verdict read uses, and for the same
|
|
427
|
+
reason.** Anyone can comment on a public PR, so selecting by marker prefix alone
|
|
428
|
+
lets a stranger who posts `<!-- ai-issue-loop:decision -->` first own the slot
|
|
429
|
+
forever: `.[0]` takes the *oldest* match, the token has repo-write so the `PATCH`
|
|
430
|
+
succeeds, and every decision this loop ever reaches lands inside a
|
|
431
|
+
stranger-authored comment while the loop never posts one of its own. `.user.login`
|
|
432
|
+
against `gh api user`, not `author_association`, for the reason spelled out in
|
|
433
|
+
Pass 3.
|
|
434
|
+
|
|
375
435
|
`// empty` is load-bearing: `.[0].id` on an empty array is `null`, which `jq -r`
|
|
376
436
|
prints as the four characters `null` — a non-empty string that passes `[ -n ]` and
|
|
377
437
|
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.
|
|
438
|
+
silently, which is the one failure mode worse than duplicates. `(.body // "")` is
|
|
439
|
+
load-bearing for the mirror-image reason: a null body throws, aborting the filter
|
|
440
|
+
and emptying `ID`, which re-enters the duplicate-comment branch this whole section
|
|
441
|
+
exists to prevent. Both values come in through `--arg` rather than shell
|
|
442
|
+
interpolation, so the marker and login are jq *data* and cannot be parsed as
|
|
443
|
+
filter syntax.
|
|
379
444
|
|
|
380
445
|
One comment per PR, edited in place, so the timeline shows the *current* reason
|
|
381
446
|
rather than a log of every tick that ever ran. What it says — and whether to say
|
|
@@ -389,9 +454,13 @@ worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec
|
|
|
389
454
|
not `ai-changes`, assign it and clear the stale review flag:
|
|
390
455
|
|
|
391
456
|
```bash
|
|
392
|
-
gh pr edit <N> --add-assignee @me --remove-label ai-review
|
|
457
|
+
gh pr edit <N> --add-assignee @me --remove-label ai-review \
|
|
458
|
+
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
393
459
|
```
|
|
394
460
|
|
|
461
|
+
Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
|
|
462
|
+
you says you both owe it something, which is the one thing never true here.
|
|
463
|
+
|
|
395
464
|
It lands in the user's *Assigned to you* view, and the labels then read as state rather
|
|
396
465
|
than noise — `ai-ok-code, ai-ok-sec` with no `ai-review` means **waiting on you**. Both
|
|
397
466
|
halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
|
|
@@ -451,7 +520,7 @@ no other branch of this pass assigns it, which leaves it in no *Assigned to you*
|
|
|
451
520
|
view at all:
|
|
452
521
|
|
|
453
522
|
```bash
|
|
454
|
-
gh pr edit <N> --add-assignee @me
|
|
523
|
+
gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
455
524
|
```
|
|
456
525
|
|
|
457
526
|
Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
|
|
@@ -536,7 +605,7 @@ Only then:
|
|
|
536
605
|
REMOVED=1 # every removal in this pass sets this
|
|
537
606
|
git -C "$ROOT" worktree remove --force "$WT_DIR" # the path found above, not a rebuilt one
|
|
538
607
|
git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null
|
|
539
|
-
gh issue edit <N> --remove-label ai-wip 2>/dev/null
|
|
608
|
+
gh issue edit <N> --remove-label ai-wip ${AGENT_USER:+--remove-assignee "$AGENT_USER"} 2>/dev/null
|
|
540
609
|
# Still OPEN means the PR said only `Refs #N`; a `Closes #N` issue is already closed.
|
|
541
610
|
if [ "$(gh issue view <N> --json state -q .state)" = OPEN ]; then
|
|
542
611
|
gh issue edit <N> --add-assignee @me
|
|
@@ -570,7 +639,7 @@ work must never be reaped out from under itself.
|
|
|
570
639
|
|
|
571
640
|
| Stalled | Condition | Do |
|
|
572
641
|
|---|---|---|
|
|
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`) |
|
|
642
|
+
| 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`) |
|
|
574
643
|
| 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 |
|
|
575
644
|
| 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`) |
|
|
576
645
|
|
|
@@ -755,10 +824,13 @@ intended; one that died after posting is now recovered instead of duplicated.
|
|
|
755
824
|
issue. Apply the label immediately before the spawn, not after:
|
|
756
825
|
|
|
757
826
|
```bash
|
|
758
|
-
gh pr edit <N> --add-label ai-reviewing-code # then spawn code-reviewer
|
|
759
|
-
gh pr edit <N> --add-label ai-reviewing-sec
|
|
827
|
+
gh pr edit <N> --add-label ai-reviewing-code ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn code-reviewer
|
|
828
|
+
gh pr edit <N> --add-label ai-reviewing-sec ${AGENT_USER:+--add-assignee "$AGENT_USER"} # then spawn security-expert
|
|
760
829
|
```
|
|
761
830
|
|
|
831
|
+
Assigning `AGENT_USER` on the claim is idempotent — both arms adding the same
|
|
832
|
+
account is one assignee, and Pass 1 removes it at the handoff.
|
|
833
|
+
|
|
762
834
|
Without the claim there is no window in which "a reviewer is running" is visible.
|
|
763
835
|
A reviewer applies its verdict label only at the *end*, after reading the diff and
|
|
764
836
|
posting its comment, so from spawn until then the labels are indistinguishable
|
|
@@ -1016,8 +1088,10 @@ and a blank line — naming what each round changed and why the reviewer kept ob
|
|
|
1016
1088
|
then:
|
|
1017
1089
|
|
|
1018
1090
|
```bash
|
|
1019
|
-
gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
|
|
1020
|
-
|
|
1091
|
+
gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
|
|
1092
|
+
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
1093
|
+
gh pr edit <N> --add-assignee @me --remove-label ai-review \
|
|
1094
|
+
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
1021
1095
|
```
|
|
1022
1096
|
|
|
1023
1097
|
Leave the worktree and PR in place for the human; a ping-pong stall is the case where
|
|
@@ -1128,16 +1202,30 @@ does not stack duplicates:
|
|
|
1128
1202
|
|
|
1129
1203
|
```bash
|
|
1130
1204
|
gh issue view <N> --json comments \
|
|
1131
|
-
|
|
1205
|
+
| jq -r --arg me "$(gh api user --jq .login)" \
|
|
1206
|
+
'[.comments[]
|
|
1207
|
+
| select(.author.login == $me and ((.body // "") | startswith("🤖 *Automated — triage")))]
|
|
1208
|
+
| length'
|
|
1132
1209
|
```
|
|
1133
1210
|
|
|
1211
|
+
Gated on the loop's own login for the same reason as the decision upsert above,
|
|
1212
|
+
inverted: anyone can comment on a public issue, so ungated a stranger who opens
|
|
1213
|
+
with that header *suppresses* the decline comment and the issue is left labelled
|
|
1214
|
+
with nothing on the timeline saying why. `.author.login` here, not `.user.login`
|
|
1215
|
+
— `gh issue view --json` is GraphQL and names the field differently from the REST
|
|
1216
|
+
payload the upsert reads.
|
|
1217
|
+
|
|
1134
1218
|
Take the first `slots` issues. For each, **claim it first** so a concurrent tick
|
|
1135
1219
|
can't double-pick:
|
|
1136
1220
|
|
|
1137
1221
|
```bash
|
|
1138
|
-
gh issue edit <N> --add-label ai-wip --remove-label ai-ready
|
|
1222
|
+
gh issue edit <N> --add-label ai-wip --remove-label ai-ready \
|
|
1223
|
+
${AGENT_USER:+--add-assignee "$AGENT_USER"}
|
|
1139
1224
|
```
|
|
1140
1225
|
|
|
1226
|
+
Assigning here is what makes the issue list honest: from this moment an agent
|
|
1227
|
+
owns the work, and an unassigned `ai-ready` issue is genuinely untouched.
|
|
1228
|
+
|
|
1141
1229
|
Dropping `ai-ready` is half the claim, not tidiness — the diagram above is a
|
|
1142
1230
|
transition, not an accumulation. An issue left carrying both re-enters the queue
|
|
1143
1231
|
the instant `ai-wip` clears for any reason other than the PR closing it, and the
|
|
@@ -1274,9 +1362,13 @@ Then spawn a background implementer agent:
|
|
|
1274
1362
|
> If you cannot finish, hand it back so a human can see it:
|
|
1275
1363
|
>
|
|
1276
1364
|
> ```bash
|
|
1277
|
-
> gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
|
|
1365
|
+
> gh issue edit <N> --add-label ai-blocked --remove-label ai-wip --add-assignee @me \
|
|
1366
|
+
> <the orchestrator substitutes `--remove-assignee <AGENT_USER>` here, or nothing>
|
|
1278
1367
|
> ```
|
|
1279
1368
|
>
|
|
1369
|
+
> Handing back means the issue stops being the agent's: the human must end up the
|
|
1370
|
+
> only assignee, or the list still reads as though something is working on it.
|
|
1371
|
+
>
|
|
1280
1372
|
> Then comment why. **Leave your worktree in place — never run
|
|
1281
1373
|
> `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
|
|
1282
1374
|
> longer `ai-wip` and has no open PR, so it matches the orphan rule) and rebuilds
|
|
@@ -42,6 +42,12 @@ argument.
|
|
|
42
42
|
Filter the PR list to those carrying an `ai-*` label — a PR without one is
|
|
43
43
|
not in the pipeline and the loop will never touch it.
|
|
44
44
|
|
|
45
|
+
**Read the assignee as "whose turn"**, when the loop is configured with an
|
|
46
|
+
agent account (`AI_LOOP_AGENT`): that account assigned means an agent is
|
|
47
|
+
working or reviewing, the human assigned means it is waiting on them, and
|
|
48
|
+
nobody assigned means queued. Say which in the report rather than listing raw
|
|
49
|
+
logins — "waiting on you" beats "assignee: someone".
|
|
50
|
+
|
|
45
51
|
3. **Work out each PR's next move** from its labels, so the report says what
|
|
46
52
|
happens rather than just listing state:
|
|
47
53
|
|
|
@@ -47,6 +47,12 @@ ROOT=$(git rev-parse --path-format=absolute --git-common-dir)/..; ROOT=$(cd "$RO
|
|
|
47
47
|
WT_ROOT="$(dirname "$ROOT")/$(basename "$ROOT")-worktrees"
|
|
48
48
|
R=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
|
49
49
|
git -C "$ROOT" fetch --prune
|
|
50
|
+
|
|
51
|
+
# Optional: the account in-flight work is assigned to, so `assignee` says whose
|
|
52
|
+
# turn it is. Unset → nothing below assigns, exactly as before. See the
|
|
53
|
+
# ai-issue-loop skill's Pass 0 for why this is repo config rather than an env var.
|
|
54
|
+
AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.aiLoop.agentUser // empty' "$ROOT/.repo-tooling.json" 2>/dev/null)}"
|
|
55
|
+
[ -n "$AGENT_USER" ] && { gh api "repos/$R/assignees/$AGENT_USER" --silent 2>/dev/null || AGENT_USER=""; }
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
`R` comes from the working directory's remote and is the only repo touched —
|
|
@@ -112,7 +118,8 @@ carrying both re-enters the queue the instant `ai-wip` clears):
|
|
|
112
118
|
|
|
113
119
|
```bash
|
|
114
120
|
for n in <numbers>; do
|
|
115
|
-
gh issue edit -R "$R" $n --add-label ai-wip --remove-label ai-ready
|
|
121
|
+
gh issue edit -R "$R" $n --add-label ai-wip --remove-label ai-ready \
|
|
122
|
+
${AGENT_USER:+--add-assignee "$AGENT_USER"}
|
|
116
123
|
SLUG="ai-$n-<3-4 kebab words from the title>"
|
|
117
124
|
mkdir -p "$WT_ROOT"
|
|
118
125
|
git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
|
|
@@ -133,9 +140,12 @@ and why.
|
|
|
133
140
|
Call `Workflow` with the script below, passing the selected issues as `args`:
|
|
134
141
|
|
|
135
142
|
```
|
|
136
|
-
Workflow({args: {repo: R, issues: [{number, title, slug, worktree}, …]}, script: …})
|
|
143
|
+
Workflow({args: {repo: R, agentUser: AGENT_USER, issues: [{number, title, slug, worktree}, …]}, script: …})
|
|
137
144
|
```
|
|
138
145
|
|
|
146
|
+
Pass `agentUser` as the empty string when `AGENT_USER` is unset — the script
|
|
147
|
+
tests it, so an empty value simply drops every assign.
|
|
148
|
+
|
|
139
149
|
```js
|
|
140
150
|
export const meta = {
|
|
141
151
|
name: 'ai-workflow',
|
|
@@ -205,8 +215,9 @@ leave the worktree in place, and return pr: null.`,
|
|
|
205
215
|
|
|
206
216
|
(r, i) => !r?.pr ? [] : parallel(REVIEWERS.map((v) => () => agent(
|
|
207
217
|
`Review GitHub PR #${r.pr} in ${args.repo}. First claim your arm:
|
|
208
|
-
\`gh pr edit ${r.pr} --add-label ${v.claim}\` —
|
|
209
|
-
ai-issue-loop tick spawning a duplicate of you
|
|
218
|
+
\`gh pr edit ${r.pr} --add-label ${v.claim}${args.agentUser ? ` --add-assignee ${args.agentUser}` : ''}\` — the label
|
|
219
|
+
stops a concurrent ai-issue-loop tick spawning a duplicate of you, and the
|
|
220
|
+
assignee says the PR is the machine's turn until Pass 1 hands it back.
|
|
210
221
|
|
|
211
222
|
Read exactly three things and nothing else: \`gh pr view ${r.pr}\`,
|
|
212
223
|
\`gh pr diff ${r.pr}\`, and \`gh issue view ${i.number}\`. Do not explore the
|