@rtorcato/repo-tooling 3.24.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -24,6 +24,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
24
24
  | `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
25
25
  | `list --json` | ✅ | ✅ | Enumerate the library's surface area. Each entry has `{ name, description, exports, fixTarget }`. |
26
26
  | `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
27
+ | `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`). Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout — both non-zero halt the tick. |
27
28
 
28
29
  ## Recommended workflows
29
30
 
@@ -104,6 +105,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
104
105
  - `src/cli/commands/doctor.ts` — all checks and the public `runDoctor(dir)` / `evaluateNodeVersion(version)` / `nextStepSuggestions(results)`
105
106
  - `src/cli/commands/fix.ts` — `Fixer` interface, fixer registry, `fixCommand`
106
107
  - `src/cli/commands/fix-targets.ts` — shared check → fix target map (used by both doctor's footer and fix's lookup)
108
+ - `src/cli/commands/loop-guard.ts` — `loop guard`: the `--is-inside-work-tree` / `.git` invariant table and the `node_modules` rebuild gate, drained out of the ai-issue-loop skill's prose (#519)
107
109
  - `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
108
110
  - `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
109
111
 
package/README.md CHANGED
@@ -91,6 +91,7 @@ See the [Getting Started guide](https://rtorcato.github.io/repo-tooling/guides/g
91
91
  | `copy <config>` | Copy a single config file into the current project. | `npx @rtorcato/repo-tooling copy biome` |
92
92
  | `doctor` | Diagnose an existing project for missing or drifted tooling. | `npx @rtorcato/repo-tooling doctor` |
93
93
  | `fix [target]` | Apply scaffolders for what `doctor` flagged (`--yes`, `--dry-run`, `--diff`). | `npx @rtorcato/repo-tooling fix` |
94
+ | `loop guard` | Repair a main checkout that has gone `core.bare = true`, and gate the `node_modules` rebuild after a worktree removal. Exits `1` if the repair failed and `2` if the root is not a repairable checkout — see `--help`. | `npx @rtorcato/repo-tooling loop guard --root .` |
94
95
 
95
96
  Prefer to run the audit in CI? `doctor` also ships as a GitHub Action:
96
97
 
@@ -181,10 +182,11 @@ ln -sf ../../node_modules/@rtorcato/repo-tooling/tooling/claude/repo-tooling.md
181
182
 
182
183
  This repo is also a self-hosted Claude Code marketplace. Install the plugin to
183
184
  get six skills — `repo-tooling` (adopt/audit the presets via the CLI),
184
- `npm-publish` (never hand-cut a release), `ai-issue-loop` (the label-driven
185
- issue → PR pipeline), `ai-workflow` (burst the `ai-ready` queue in parallel
186
- worktrees), `ai-issue` (file agent-executable issues) and `ai-loop-status`
187
- (read-only pipeline status) — in any session:
185
+ `npm-publish` (never hand-cut a release), `ai-workflow` (**the entry point** to
186
+ the `ai-ready` issue → PR pipeline: bursts the queue in parallel worktrees, then
187
+ schedules the engine below), `ai-issue-loop` (that engine — one stateless tick;
188
+ it runs on a loop rather than being typed), `ai-issue` (file agent-executable
189
+ issues) and `ai-loop-status` (read-only pipeline status) — in any session:
188
190
 
189
191
  ```
190
192
  /plugin marketplace add rtorcato/repo-tooling
@@ -44,6 +44,30 @@ const DETAIL = {
44
44
  const HINT = 'Commits made with this identity will not link to your forge account, and the address cannot be added as a verified secondary email to fix them retroactively (#327). Set a real one: `git config --global user.email you@yourdomain.com` — or per-repo, drop `--global`.';
45
45
  const CHECK = 'Git identity';
46
46
  const GIT_TIMEOUT_MS = 5_000;
47
+ /**
48
+ * Git exports these to everything a hook runs, and they outrank `cwd`/`-C`: a
49
+ * child spawned from a `pre-push` answers about the *hook's* repository, not
50
+ * the directory it was handed. Harmless for a config read; not harmless for
51
+ * `loop guard`, whose whole job is deciding whether one specific checkout has
52
+ * gone bare (#519). Every caller here names its repo explicitly, so the
53
+ * ambient one is never what was meant.
54
+ */
55
+ const AMBIENT_REPO_VARS = [
56
+ 'GIT_DIR',
57
+ 'GIT_WORK_TREE',
58
+ 'GIT_INDEX_FILE',
59
+ 'GIT_COMMON_DIR',
60
+ 'GIT_OBJECT_DIRECTORY',
61
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
62
+ 'GIT_PREFIX',
63
+ 'GIT_NAMESPACE',
64
+ ];
65
+ function repoScopedEnv() {
66
+ const env = { ...process.env };
67
+ for (const key of AMBIENT_REPO_VARS)
68
+ delete env[key];
69
+ return env;
70
+ }
47
71
  /** Never rejects; a missing or failing git resolves to null. */
48
72
  export const realGitExec = (args, cwd) => new Promise((resolve) => {
49
73
  let settled = false;
@@ -56,7 +80,11 @@ export const realGitExec = (args, cwd) => new Promise((resolve) => {
56
80
  };
57
81
  // Args are internal constants, never user free-text — shell:false keeps
58
82
  // this injection-safe.
59
- const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
83
+ const child = spawn('git', args, {
84
+ cwd,
85
+ env: repoScopedEnv(),
86
+ stdio: ['ignore', 'pipe', 'ignore'],
87
+ });
60
88
  let stdout = '';
61
89
  const timer = setTimeout(() => {
62
90
  child.kill();
@@ -0,0 +1,186 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs-extra';
5
+ import { realGitExec } from '../../base/git-identity.js';
6
+ /**
7
+ * The invariant table from the skill, verified on git 2.55.0:
8
+ *
9
+ * | repo state | `--is-inside-work-tree` | `.git` |
10
+ * |---|---|---|
11
+ * | healthy checkout | `true`, exit 0 | directory |
12
+ * | **wrongly bare** | `false`, **exit 0** | directory |
13
+ * | genuinely bare | `false`, exit 0 | absent |
14
+ * | linked worktree | `true`, exit 0 | file |
15
+ *
16
+ * Two things it encodes. **Stdout, not the exit code** — `rev-parse
17
+ * --is-inside-work-tree` exits `0` either way and only *prints* the answer, so
18
+ * an exit-code probe is dead code (`insideWorkTree` is `null` only when git
19
+ * itself failed, i.e. there is no repo here). And **`.git` must be a directory
20
+ * before repairing** — a genuinely bare repo prints `false` too, and nothing
21
+ * else separates the two. This ships to consumers' machines, where "repairing"
22
+ * someone's real bare clone is the damage rather than the fix.
23
+ */
24
+ export function classifyRoot(insideWorkTree, gitEntry) {
25
+ if (insideWorkTree === null)
26
+ return 'not-a-repo';
27
+ if (insideWorkTree.trim() === 'true')
28
+ return 'work-tree';
29
+ if (gitEntry === 'directory')
30
+ return 'wrongly-bare';
31
+ // A linked worktree's `.git` is a file; its main checkout is where a repair
32
+ // belongs, and that is not the path we were handed.
33
+ if (gitEntry === 'file')
34
+ return 'linked-worktree';
35
+ return 'genuinely-bare';
36
+ }
37
+ /**
38
+ * `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
39
+ * `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup.
40
+ * `--config.confirmModulesPurge=false` gets past
41
+ * `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare `pnpm
42
+ * install` cannot repair this.
43
+ */
44
+ export const REBUILD_ARGS = [
45
+ 'install',
46
+ '--frozen-lockfile',
47
+ '--config.confirmModulesPurge=false',
48
+ ];
49
+ const realInstall = (cwd) => new Promise((resolve) => {
50
+ // stdout stays clean for the `--json` payload; pnpm's diagnostics are
51
+ // still visible on stderr.
52
+ const child = spawn('pnpm', [...REBUILD_ARGS], {
53
+ cwd,
54
+ stdio: ['ignore', 'ignore', 'inherit'],
55
+ });
56
+ child.on('close', (code) => resolve(code === 0));
57
+ child.on('error', () => resolve(false));
58
+ });
59
+ async function gitEntryKind(root) {
60
+ // lstat, not stat: the file/directory distinction is the whole discriminator.
61
+ try {
62
+ const stat = await fs.lstat(path.join(root, '.git'));
63
+ return stat.isDirectory() ? 'directory' : 'file';
64
+ }
65
+ catch {
66
+ return 'absent';
67
+ }
68
+ }
69
+ /** `ai-*` directories one level down, in either place worktrees are kept. */
70
+ async function findLive(dirs) {
71
+ const live = [];
72
+ for (const dir of dirs) {
73
+ // A missing worktree root is the normal case, not an error.
74
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
75
+ for (const entry of entries) {
76
+ if (entry.isDirectory() && entry.name.startsWith('ai-'))
77
+ live.push(path.join(dir, entry.name));
78
+ }
79
+ }
80
+ return live;
81
+ }
82
+ /** The sibling layout the skill uses: `<parent>/<name>-worktrees`. */
83
+ export function defaultWorktreeRoot(root) {
84
+ return path.join(path.dirname(root), `${path.basename(root)}-worktrees`);
85
+ }
86
+ const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
87
+ const UNUSABLE = {
88
+ 'genuinely-bare': 'main checkout is a genuinely bare repository (.git absent) — refusing to flip core.bare on a real bare clone',
89
+ 'linked-worktree': '--root points at a linked worktree (.git is a file), not the main checkout — repair belongs on the main checkout',
90
+ 'not-a-repo': 'not a git repository',
91
+ };
92
+ /**
93
+ * One tick's worth of guarding, as data. The command wrapper prints it and
94
+ * sets the exit code; tests call this directly.
95
+ */
96
+ export async function runLoopGuard(options = {}) {
97
+ const root = path.resolve(options.root ?? process.cwd());
98
+ const worktreeRoot = options.worktreeRoot
99
+ ? path.resolve(options.worktreeRoot)
100
+ : defaultWorktreeRoot(root);
101
+ const git = options.git ?? ((args) => realGitExec(args, root));
102
+ const install = options.install ?? realInstall;
103
+ const messages = [];
104
+ const state = classifyRoot(await git(['rev-parse', '--is-inside-work-tree']), await gitEntryKind(root));
105
+ let bare = 'healthy';
106
+ let exitCode = 0;
107
+ if (state === 'wrongly-bare') {
108
+ messages.push(`⚠ main checkout bare at ${stamp()} — repairing`);
109
+ // Writes $ROOT/.git/config, which a restrictive sandbox refuses with
110
+ // `error: could not lock config file .git/config`. Aborting beats
111
+ // reporting a healthy repo while it stays broken.
112
+ if ((await git(['config', 'core.bare', 'false'])) === null) {
113
+ bare = 'repair-failed';
114
+ exitCode = 1;
115
+ messages.push('⚠ repair FAILED — main checkout still bare');
116
+ }
117
+ else {
118
+ bare = 'repaired';
119
+ messages.push('main checkout repaired — core.bare is false');
120
+ }
121
+ }
122
+ else if (state !== 'work-tree') {
123
+ bare = 'unrepairable';
124
+ exitCode = 2;
125
+ messages.push(`⚠ ${UNUSABLE[state]}`);
126
+ }
127
+ else {
128
+ messages.push('main checkout is a work tree');
129
+ }
130
+ const live = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
131
+ const rebuild = await decideRebuild({ root, removed: options.removed === true, exitCode, live });
132
+ let outcome = rebuild;
133
+ if (rebuild === 'deferred') {
134
+ messages.push(`rebuild deferred — ${live.length} worktree(s) still live`);
135
+ }
136
+ else if (rebuild === 'rebuilt') {
137
+ messages.push('rebuilding the main checkout’s node_modules');
138
+ if (!(await install(root))) {
139
+ outcome = 'rebuild-failed';
140
+ // Not fatal: a broken .bin cannot corrupt a commit the way a bare
141
+ // checkout does. Loud, though — a silent skip is the breakage this
142
+ // guard exists to end.
143
+ messages.push('⚠ node_modules rebuild FAILED — main checkout may be unbuildable');
144
+ }
145
+ else {
146
+ messages.push('node_modules rebuilt');
147
+ }
148
+ }
149
+ return { root, worktreeRoot, state, bare, rebuild: outcome, live, exitCode, messages };
150
+ }
151
+ /**
152
+ * The three load-bearing conditions, kept separate from the run so a test can
153
+ * assert them without a pnpm install:
154
+ *
155
+ * - **`removed`** — set by every removal path, merged-PR cleanup *and* stall
156
+ * reaping. A reaped worktree needs this most: its agent died mid-command.
157
+ * - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
158
+ * - **no live worktrees** — the rebuild *purges* the shared modules dir, which
159
+ * would be yanked out from under any agent still running in a surviving
160
+ * worktree. Deferring costs a broken main checkout until the last worktree
161
+ * clears; not deferring costs a live implementer run.
162
+ */
163
+ export async function decideRebuild(input) {
164
+ if (!input.removed)
165
+ return 'not-requested';
166
+ // Nothing runs against a root we are about to halt the tick over.
167
+ if (input.exitCode !== 0)
168
+ return 'skipped-root-unusable';
169
+ if (!(await fs.pathExists(path.join(input.root, 'pnpm-lock.yaml'))))
170
+ return 'skipped-no-lockfile';
171
+ return input.live.length > 0 ? 'deferred' : 'rebuilt';
172
+ }
173
+ export async function loopGuardCommand(options) {
174
+ const result = await runLoopGuard(options);
175
+ if (options.json) {
176
+ console.log(JSON.stringify(result, null, 2));
177
+ }
178
+ else {
179
+ console.log();
180
+ for (const line of result.messages) {
181
+ console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
182
+ }
183
+ console.log();
184
+ }
185
+ process.exitCode = result.exitCode;
186
+ }
package/dist/cli/index.js CHANGED
@@ -6,6 +6,7 @@ import fs from 'fs-extra';
6
6
  import packageJson from '../../package.json' with { type: 'json' };
7
7
  import { doctorCommand } from './commands/doctor.js';
8
8
  import { fixCommand } from './commands/fix.js';
9
+ import { loopGuardCommand } from './commands/loop-guard.js';
9
10
  import { setupProject } from './commands/setup.js';
10
11
  import { copyPreset, PRESETS } from './utils/copy-preset.js';
11
12
  async function isSelfRepo(dir) {
@@ -346,6 +347,22 @@ program
346
347
  skillsDir: options.skillsDir,
347
348
  forceSkills: options.forceSkills,
348
349
  }));
350
+ // Mechanics the ai-issue-loop skill used to describe in prose-with-shell (#519).
351
+ // Deliberately outside the isSelfRepo hook below: the loop runs against this
352
+ // repo like any other, and a guard that refuses here would guard nothing.
353
+ const loop = program.command('loop').description('🔁 ai-issue-loop mechanics as tested commands');
354
+ loop
355
+ .command('guard')
356
+ .description('🛡️ Repair a wrongly-bare main checkout and gate the node_modules rebuild')
357
+ .option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
358
+ .option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
359
+ .option('--removed', 'A worktree was removed this tick — consider rebuilding node_modules')
360
+ .option('--json', 'Emit machine-readable JSON output')
361
+ .addHelpText('after', '\nExit codes:\n' +
362
+ ' 0 root is a usable work tree (healthy, or repaired in place) — continue the tick\n' +
363
+ ' 1 repair was attempted and failed; the root is still bare — halt the tick\n' +
364
+ ' 2 root is not a repairable main checkout (bare clone, linked worktree, or not a repo) — halt the tick\n')
365
+ .action(loopGuardCommand);
349
366
  program.hook('preAction', async (_, actionCommand) => {
350
367
  const name = actionCommand.name();
351
368
  if (name === 'setup' || name === 'doctor' || name === 'fix') {
@@ -1,4 +1,12 @@
1
1
  /**
2
+ * Quote a value as one literal argument **for a POSIX `sh` command line, and
3
+ * nothing else**. The result is wrong in `cmd.exe`, which does not treat `'` as
4
+ * a quote at all, and wrong in PowerShell, which does — but escapes an embedded
5
+ * `'` by doubling it rather than POSIX's `'\''`, so this output still mangles
6
+ * any value containing one. It is also wrong anywhere the value lands inside
7
+ * double quotes or an existing quoted string — it is a whole argument, not a
8
+ * fragment.
9
+ *
2
10
  * POSIX single-quote escaping — the only form correct for arbitrary bytes.
3
11
  * Inside single quotes every character is literal, so `'` is the sole one
4
12
  * needing care: end the quote, escape it, start a new one. Double quotes are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.24.0",
3
+ "version": "3.25.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": [
@@ -2,11 +2,14 @@
2
2
  name: ai-issue-loop
3
3
  model: sonnet
4
4
  description: |
5
- Run one tick of the label-driven GitHub issue pipeline: pick up `ai-ready`
6
- issues into per-issue worktrees, review the resulting PRs with other agents,
7
- and auto-merge once both reviewers pass. Use when the user says "run the
8
- issue loop", "work the ai-ready issues", "babysit the AI PRs", or invokes
9
- `/ai-issue-loop`. Designed to be driven by `/loop 15m /ai-issue-loop`.
5
+ **The engine behind `/ai-workflow` — normally you do not invoke this
6
+ directly.** One stateless tick over the GitHub label state: answer
7
+ `ai-changes` with a fix round, merge Dependabot PRs, hand passed issue PRs to
8
+ the human, clean up merged worktrees, reap stalled agents, and pick up any
9
+ remaining `ai-ready` issues. `/ai-workflow` is the entry point and schedules
10
+ this itself via `/loop 15m /ai-issue-loop`; reach for it directly only to
11
+ force a tick early — "run one tick", "babysit the AI PRs" — or when the user
12
+ invokes `/ai-issue-loop`. Only Dependabot PRs ever merge unattended.
10
13
  GitHub only (`gh`) — not GitLab.
11
14
  ---
12
15
 
@@ -263,9 +266,9 @@ immediately after a `worktree remove` and some with nothing removed at all. The
263
266
  is unidentified, so this is detection and repair only:
264
267
 
265
268
  ```bash
266
- if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
269
+ if [ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
267
270
  echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
268
- git -C "$ROOT" config core.bare false || {
271
+ env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
269
272
  echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
270
273
  fi
271
274
  ```
@@ -291,6 +294,14 @@ too, and nothing else separates the two — this skill ships to users' `~/.claud
291
294
  where "repairing" someone's real bare clone is the damage rather than the fix. The same
292
295
  check skips a linked worktree, whose `.git` is a file.
293
296
 
297
+ **`GIT_DIR` and `GIT_WORK_TREE` beat `-C`, so unset them.** When either is exported —
298
+ some tooling wrappers do — git ignores `-C "$ROOT"` and operates on whatever they
299
+ point at, so the probe would diagnose a *different* repo and the repair would write
300
+ that repo's `.git/config`. Both failures are silent, and both are worse than the bug
301
+ being guarded against. This skill installs into arbitrary users' `~/.claude/skills/`,
302
+ so the caller's environment is not ours to assume; `env -u` scopes the unset to the
303
+ one command rather than to the tick.
304
+
294
305
  **Fail loudly.** The repair writes `$ROOT/.git/config`, which a restrictive sandbox
295
306
  refuses with `error: could not lock config file .git/config: Operation not permitted` —
296
307
  observed. Aborting beats reporting a healthy repo while it stays broken.
@@ -348,9 +359,54 @@ and lacks either `ai-ok-*`, disarm it before labelling:
348
359
  gh pr merge <N> --disable-auto
349
360
  ```
350
361
 
351
- If there are no open PRs carrying any `ai-*` label **and** no eligible `ai-ready`
352
- issues (Pass 4's query), skip straight to Pass 5 with `SUMMARY=idle`. Skip the
353
- passes, never the report.
362
+ **Adopt agent-opened PRs the same way.** A PR an agent opens outside Pass 4 — one
363
+ with no `ai-ready` issue behind it — carries no `ai-*` label, so it matches no pass
364
+ and is therefore assigned by nothing: it never reaches *Assigned to you*, which is
365
+ the view where merges actually happen. Observed on #548, which passed all five
366
+ required checks and read *Able to merge* while its assignees read *No one—assign
367
+ yourself*. Label it `ai-review` and Pass 1 hands it over on the existing path once
368
+ both arms pass — no second assignment rule is needed:
369
+
370
+ ```bash
371
+ ME=$(gh api user --jq .login) # the identity every loop agent opens PRs as
372
+ gh pr list --state open --json number,author,labels,body \
373
+ | jq -r --arg me "$ME" \
374
+ '.[] | select(.author.login == $me)
375
+ | select([.labels[].name] | any(startswith("ai-")) | not)
376
+ | select((.body // "") | startswith("🤖 "))
377
+ | .number'
378
+ ```
379
+
380
+ **The `🤖` header is the discriminator, not the login.** Every agent authenticates as
381
+ the owner's own `gh`, so author login alone cannot tell a PR an agent opened from one
382
+ the owner wrote by hand — and a PR the owner wrote themselves must not be swept in,
383
+ which would put two reviewers on work nobody asked to have reviewed. The header is
384
+ wire format, the same as the `<!-- ai-issue-loop:* -->` markers: every PR body this
385
+ pipeline writes opens with `🤖 *Automated …*` or `🤖 *Opened by …*`, so match it and
386
+ do not redefine it. `(.body // "")` is load-bearing for the reason Pass 1's upsert
387
+ spells out — a null body throws and empties the whole filter, here adopting nothing
388
+ rather than everything.
389
+
390
+ If there are no open PRs carrying any `ai-*` label, no eligible `ai-ready` issues
391
+ (Pass 4's query), **and** no `ai-*` worktree left on disk, skip straight to Pass 5
392
+ with `SUMMARY=idle`. Skip the passes, never the report.
393
+
394
+ ```bash
395
+ find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null
396
+ ```
397
+
398
+ **The third condition is not implied by the other two.** Pass 2's cleanup is keyed
399
+ off worktrees *on disk*, never off open PRs, so the moment the last open PR is merged
400
+ by hand both of the other conditions go true while its worktree is still present and
401
+ its issue still carries `ai-wip` — the label only Pass 2 ever clears. Every later tick
402
+ meets the same two conditions, so the worktree and the label survive indefinitely
403
+ while the loop reports `idle`. The two leaks also protect each other: the
404
+ orphan-worktree rule that would otherwise reap it matches only a worktree *whose issue
405
+ is not `ai-wip`*, and the stale label is exactly what stops it. Observed 2026-08-26 —
406
+ #541 and #542 closed and their PRs merged, both worktrees still on disk, both issues
407
+ still `ai-wip`. No concurrency slot leaks (the cap counts *open* `ai-wip` issues); what
408
+ leaks is disk, an issue list that reads as though agents are still working, and Pass
409
+ 2's `node_modules` rebuild, which is gated on `REMOVED=1` and so never runs.
354
410
 
355
411
  ### Pass 1 — merge
356
412
 
@@ -719,13 +775,16 @@ where `ai-blocked` would not be — nothing is lost, only the queue is honest.
719
775
  **Then re-check `core.bare`** — the same probe as Pass 0, against the same `ROOT`:
720
776
 
721
777
  ```bash
722
- if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
778
+ if [ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
723
779
  echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
724
- git -C "$ROOT" config core.bare false || {
780
+ env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
725
781
  echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
726
782
  fi
727
783
  ```
728
784
 
785
+ The `env -u` prefix carries the same weight here as in Pass 0, and for the same
786
+ reason — keep it on both lines.
787
+
729
788
  This is the last pass that *removes* worktrees, not the tick's last touch on the main
730
789
  checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is exactly
731
790
  why the re-check belongs here: it catches a flip after this pass's removals and before
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  name: ai-workflow
3
3
  description: |
4
- Implement the `ai-ready` GitHub issue queue in parallel — one agent per issue,
5
- each in its own git worktree, ending at open PRs reviewed by two agents. Use
6
- when the user says "burst the queue", "work all the ai-ready issues in
7
- parallel", or invokes `/ai-workflow`. Hands off to the ai-issue-loop skill for
8
- fix rounds and merging. GitHub only (`gh`) — not GitLab.
4
+ **The entry point for the `ai-ready` issue pipeline — start here.** Implements
5
+ the queue in parallel, one agent per issue, each in its own git worktree,
6
+ ending at open PRs reviewed by two agents; then registers the ai-issue-loop
7
+ engine on a 15-minute loop to carry those PRs through fix rounds and cleanup.
8
+ Use when the user says "burst the queue", "run the AI pipeline", "work the
9
+ ai-ready issues", or invokes `/ai-workflow`. Never merges. GitHub only
10
+ (`gh`) — not GitLab.
9
11
  ---
10
12
 
11
13
  # ai-workflow