@rtorcato/repo-tooling 3.21.1 → 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.
@@ -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
- console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
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
- if (installedVersion && isNewerVersion(installedVersion, shipped.version)) {
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
- const carried = assets ?? (await readLockfile(dir))?.assets;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.21.1",
3
+ "version": "3.22.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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
@@ -389,9 +437,13 @@ worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec
389
437
  not `ai-changes`, assign it and clear the stale review flag:
390
438
 
391
439
  ```bash
392
- 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"}
393
442
  ```
394
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
+
395
447
  It lands in the user's *Assigned to you* view, and the labels then read as state rather
396
448
  than noise — `ai-ok-code, ai-ok-sec` with no `ai-review` means **waiting on you**. Both
397
449
  halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
@@ -451,7 +503,7 @@ no other branch of this pass assigns it, which leaves it in no *Assigned to you*
451
503
  view at all:
452
504
 
453
505
  ```bash
454
- gh pr edit <N> --add-assignee @me
506
+ gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
455
507
  ```
456
508
 
457
509
  Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
@@ -536,7 +588,7 @@ Only then:
536
588
  REMOVED=1 # every removal in this pass sets this
537
589
  git -C "$ROOT" worktree remove --force "$WT_DIR" # the path found above, not a rebuilt one
538
590
  git -C "$ROOT" branch -D "$BRANCH" 2>/dev/null
539
- 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
540
592
  # Still OPEN means the PR said only `Refs #N`; a `Closes #N` issue is already closed.
541
593
  if [ "$(gh issue view <N> --json state -q .state)" = OPEN ]; then
542
594
  gh issue edit <N> --add-assignee @me
@@ -570,7 +622,7 @@ work must never be reaped out from under itself.
570
622
 
571
623
  | Stalled | Condition | Do |
572
624
  |---|---|---|
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`) |
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`) |
574
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 |
575
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`) |
576
628
 
@@ -755,10 +807,13 @@ intended; one that died after posting is now recovered instead of duplicated.
755
807
  issue. Apply the label immediately before the spawn, not after:
756
808
 
757
809
  ```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 # then spawn security-expert
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
760
812
  ```
761
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
+
762
817
  Without the claim there is no window in which "a reviewer is running" is visible.
763
818
  A reviewer applies its verdict label only at the *end*, after reading the diff and
764
819
  posting its comment, so from spawn until then the labels are indistinguishable
@@ -1016,8 +1071,10 @@ and a blank line — naming what each round changed and why the reviewer kept ob
1016
1071
  then:
1017
1072
 
1018
1073
  ```bash
1019
- gh issue edit <M> --add-label ai-blocked --remove-label ai-wip --add-assignee @me
1020
- gh pr edit <N> --add-assignee @me --remove-label ai-review
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"}
1021
1078
  ```
1022
1079
 
1023
1080
  Leave the worktree and PR in place for the human; a ping-pong stall is the case where
@@ -1135,9 +1192,13 @@ Take the first `slots` issues. For each, **claim it first** so a concurrent tick
1135
1192
  can't double-pick:
1136
1193
 
1137
1194
  ```bash
1138
- 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"}
1139
1197
  ```
1140
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
+
1141
1202
  Dropping `ai-ready` is half the claim, not tidiness — the diagram above is a
1142
1203
  transition, not an accumulation. An issue left carrying both re-enters the queue
1143
1204
  the instant `ai-wip` clears for any reason other than the PR closing it, and the
@@ -1274,9 +1335,13 @@ Then spawn a background implementer agent:
1274
1335
  > If you cannot finish, hand it back so a human can see it:
1275
1336
  >
1276
1337
  > ```bash
1277
- > 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>
1278
1340
  > ```
1279
1341
  >
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
+ >
1280
1345
  > Then comment why. **Leave your worktree in place — never run
1281
1346
  > `git worktree remove`.** Pass 2 of the next tick reaps it (the issue is no
1282
1347
  > 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}\` — it stops a concurrent
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