@rtorcato/repo-tooling 3.24.0 → 3.26.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
@@ -535,6 +535,100 @@ export async function checkClaudeSkills(skillsDir) {
535
535
  detail: `${SHIPPED_SKILLS.length} skills installed at ${[...versions].join(', ')}`,
536
536
  };
537
537
  }
538
+ /**
539
+ * The skills *this repo* declares it depends on — `requiredSkills` in
540
+ * `.repo-tooling.json` (#533). Where `checkClaudeSkills` above reports on the
541
+ * package's whole skill set as a machine-level nicety, this one is the repo
542
+ * asserting a dependency, so it names the skills the repo actually runs on and
543
+ * reports a stale installed copy against them.
544
+ *
545
+ * Staleness is the failure mode it exists for. Absence fails loudly the moment
546
+ * something reaches for the skill; a copy three releases behind runs to
547
+ * completion without complaint — observed 2026-08-26, an `ai-issue-loop` missing
548
+ * both its decision-comment security gate and its decay rule.
549
+ *
550
+ * Same severity rule as `checkClaudeSkills`, for the same reason: it probes the
551
+ * machine, not the repo, so it never returns `drift` or `missing`. A contributor
552
+ * with no Claude installed must not fail this repo's `doctor`.
553
+ *
554
+ * **Check and hint only.** The fixer writes into `~/`, and repo config that
555
+ * triggers writes outside the repo is the shape of a supply-chain attack even
556
+ * when the content is benign. doctor says stale; the human runs the fixer.
557
+ */
558
+ export async function checkRequiredSkills(names, skillsDir) {
559
+ const check = 'Required skills';
560
+ const hint = 'Run `npx @rtorcato/repo-tooling fix claude-skills` yourself to install or refresh them — add `--force-skills` to overwrite a locally modified copy. It writes to `~/.claude`, outside this repo, so nothing runs it for you.';
561
+ // A name outside SHIPPED_SKILLS has no shipped asset to hash against, and
562
+ // reading one would throw rather than report. The published schema rejects it
563
+ // in an editor; this is the runtime half of the same validation.
564
+ const unknown = names.filter((name) => !SHIPPED_SKILLS.includes(name));
565
+ if (unknown.length > 0) {
566
+ return {
567
+ check,
568
+ status: 'optional-missing',
569
+ detail: `.repo-tooling.json lists ${unknown.join(', ')}, which this package does not ship`,
570
+ hint: `requiredSkills accepts ${SHIPPED_SKILLS.join(', ')}`,
571
+ };
572
+ }
573
+ const statuses = [];
574
+ for (const name of names)
575
+ statuses.push([name, await claudeSkillStatus(name, skillsDir)]);
576
+ const missing = statuses.filter(([, s]) => !s.installed).map(([name]) => name);
577
+ // `needsInstall` is `behind && pristine`, so these two partitions are disjoint:
578
+ // a copy matching no shipped version is a fork, not something to update.
579
+ const stale = statuses.filter(([, s]) => s.installed && s.needsInstall);
580
+ const modified = statuses.filter(([, s]) => s.contentState && s.contentState !== 'pristine');
581
+ const parts = [
582
+ missing.length > 0 ? `not installed: ${missing.join(', ')}` : null,
583
+ ...stale.map(([name, s]) => `${name} is stale — installed ${s.installedVersion ?? 'unstamped'}, this package ships ${s.shippedVersion}`),
584
+ ...modified.map(([name, s]) => `${name} at ${s.file} matches no version this package has shipped`),
585
+ ].filter((part) => part !== null);
586
+ if (parts.length === 0) {
587
+ return {
588
+ check,
589
+ status: 'ok',
590
+ detail: `${names.length} required skill(s) installed and current: ${names.join(', ')}`,
591
+ };
592
+ }
593
+ return { check, status: 'optional-missing', detail: parts.join('; '), hint };
594
+ }
595
+ /**
596
+ * The MCP servers this repo's workflow assumes — `mcp.recommended` in
597
+ * `.repo-tooling.json` (#534). Informational in the strongest sense: it reports
598
+ * which recommended names the repo-scoped `.mcp.json` does not declare, and
599
+ * stops there.
600
+ *
601
+ * Never `drift`, never a CI failure, and deliberately no fixer — MCP servers
602
+ * execute code, so installing one from committed repo config would be an install
603
+ * directive rather than a recommendation. `.mcp.json` carries Claude Code's own
604
+ * first-use consent prompt; that is where the decision belongs.
605
+ *
606
+ * User-scoped MCP config is not probed at all. It is machine-private, and a
607
+ * server configured there is none of this repo's business.
608
+ */
609
+ export async function checkRecommendedMcp(dir, recommended) {
610
+ const check = 'Recommended MCP';
611
+ // Absent, malformed or unreadable all mean the same thing here — nothing is
612
+ // declared — and none of them is worth its own finding on an advisory check.
613
+ const declared = await fs
614
+ .readJson(path.join(dir, '.mcp.json'))
615
+ .then((raw) => Object.keys(raw?.mcpServers ?? {}))
616
+ .catch(() => []);
617
+ const absent = recommended.filter((entry) => !declared.includes(entry.name));
618
+ if (absent.length === 0) {
619
+ return {
620
+ check,
621
+ status: 'ok',
622
+ detail: `.mcp.json declares all ${recommended.length} recommended server(s): ${recommended.map((e) => e.name).join(', ')}`,
623
+ };
624
+ }
625
+ return {
626
+ check,
627
+ status: 'optional-missing',
628
+ detail: absent.map((e) => `${e.name} (${e.importance}) — ${e.why}`).join('; '),
629
+ hint: 'Advisory only. Add what you want to `.mcp.json` by hand; nothing here installs or enables an MCP server.',
630
+ };
631
+ }
538
632
  /**
539
633
  * Conventional Commits is a repo convention, not a JavaScript one — the config
540
634
  * file is the same in any repo that has node available to run commitlint, so
@@ -44,6 +44,53 @@ 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
+ * This is `git rev-parse --local-env-vars` verbatim — git's own answer, and what
56
+ * githooks(1) says to clear before touching a different repository. Do not
57
+ * curate it by hand: the first version of this list was assembled from the vars
58
+ * that looked repository-ish and missed the `GIT_CONFIG*` family, which
59
+ * redirects where `git config` reads *and writes* — the exact operation this
60
+ * module's callers perform.
61
+ *
62
+ * Kept byte-identical with `AMBIENT_GIT_REPO_VARS` in `scripts/lib/git-env.mjs`;
63
+ * a test asserts both cover what the installed git reports. Two copies because
64
+ * this one compiles into `dist/` for consumers and that one is loaded raw by
65
+ * `.mjs` scripts that run before any build.
66
+ */
67
+ export const AMBIENT_REPO_VARS = [
68
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
69
+ 'GIT_CONFIG',
70
+ 'GIT_CONFIG_PARAMETERS',
71
+ 'GIT_CONFIG_COUNT',
72
+ 'GIT_OBJECT_DIRECTORY',
73
+ 'GIT_DIR',
74
+ 'GIT_WORK_TREE',
75
+ 'GIT_IMPLICIT_WORK_TREE',
76
+ 'GIT_GRAFT_FILE',
77
+ 'GIT_INDEX_FILE',
78
+ 'GIT_NO_REPLACE_OBJECTS',
79
+ 'GIT_REPLACE_REF_BASE',
80
+ 'GIT_PREFIX',
81
+ 'GIT_SHALLOW_FILE',
82
+ 'GIT_COMMON_DIR',
83
+ // Not in git's local-env list: it scopes which refs are visible rather than
84
+ // which repository is used. Cleared anyway — a namespace inherited from a
85
+ // hook would hide refs from a command that meant to see all of them.
86
+ 'GIT_NAMESPACE',
87
+ ];
88
+ function repoScopedEnv() {
89
+ const env = { ...process.env };
90
+ for (const key of AMBIENT_REPO_VARS)
91
+ delete env[key];
92
+ return env;
93
+ }
47
94
  /** Never rejects; a missing or failing git resolves to null. */
48
95
  export const realGitExec = (args, cwd) => new Promise((resolve) => {
49
96
  let settled = false;
@@ -56,7 +103,11 @@ export const realGitExec = (args, cwd) => new Promise((resolve) => {
56
103
  };
57
104
  // Args are internal constants, never user free-text — shell:false keeps
58
105
  // this injection-safe.
59
- const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
106
+ const child = spawn('git', args, {
107
+ cwd,
108
+ env: repoScopedEnv(),
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ });
60
111
  let stdout = '';
61
112
  const timer = setTimeout(() => {
62
113
  child.kill();
@@ -20,7 +20,7 @@ import { checkGitIdentity } from '../../base/git-identity.js';
20
20
  import { checkCopiedAssets } from '../utils/copied-assets.js';
21
21
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
22
22
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
23
- import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
23
+ import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
24
24
  import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
25
25
  export { evaluateNodeVersion };
26
26
  const PACKAGE = '@rtorcato/repo-tooling';
@@ -193,6 +193,16 @@ async function runBaseChecks(dir, lock, opts) {
193
193
  results.push(await checkAiSetup(dir));
194
194
  // User-global, not repo state — see checkClaudeSkills on why it never returns drift.
195
195
  results.push(await checkClaudeSkills(opts.skillsDir));
196
+ // #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
197
+ // signal, so a repo that doesn't gets no line at all rather than an empty one.
198
+ if (lock?.aiLoop && lock.requiredSkills?.length) {
199
+ results.push(await checkRequiredSkills(lock.requiredSkills, opts.skillsDir));
200
+ }
201
+ // #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
202
+ // about MCP, which is not a finding.
203
+ if (lock?.mcp?.recommended?.length) {
204
+ results.push(await checkRecommendedMcp(dir, lock.mcp.recommended));
205
+ }
196
206
  results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
197
207
  results.push(await checkCoverageUpload(dir));
198
208
  return results;
@@ -49,6 +49,12 @@ export const FIX_TARGETS = {
49
49
  'Claude worktree settings': 'ai',
50
50
  'Claude skills': 'claude-skills',
51
51
  'Copied assets': 'copied-assets',
52
+ // `Required skills` (#533) and `Recommended MCP` (#534) are deliberately
53
+ // absent. Both are driven by committed repo config and both would act outside
54
+ // the repo — installing into `~/.claude`, enabling a code-executing MCP
55
+ // server. Staying out of this map keeps them out of `fix`'s footer suggestions
56
+ // and out of every lookup a fixer path makes; their own hints name the command
57
+ // a human runs by hand.
52
58
  };
53
59
  /**
54
60
  * Where the Swift module's fixers shadow (or extend) the JS-named defaults
@@ -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') {
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import packageJson from '../../../package.json' with { type: 'json' };
4
4
  import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
5
+ import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
5
6
  export const LOCKFILE_NAME = '.repo-tooling.json';
6
7
  // Package and bin name used before the js-tooling→repo-tooling rename (#272).
7
8
  // The bin no longer exists and the package is 404 on the registry, so any
@@ -17,6 +18,12 @@ export const LEGACY_LOCKFILE_NAME = `.${LEGACY_TOOL_NAME}.json`;
17
18
  // files carry no hashes, which reads as "not tracked", never as drift.
18
19
  export const LOCKFILE_VERSION = 3;
19
20
  const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/lockfile.json';
21
+ /**
22
+ * How much of the repo's workflow assumes a recommended MCP server (#534).
23
+ * Signals priority to a human reading the file, and nothing more — no code
24
+ * branches on it beyond printing it.
25
+ */
26
+ export const MCP_IMPORTANCE = ['nice-to-have', 'important', 'critical'];
20
27
  /**
21
28
  * JSON Schema for the lockfile, published with the docs site at the exact URL
22
29
  * every written lockfile's `$schema` points to (#529). The `satisfies` clauses
@@ -26,8 +33,9 @@ const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/loc
26
33
  * `pnpm schema:generate` and gated by tests/cli/utils/lockfile-schema.test.ts.
27
34
  *
28
35
  * A function, not a const: lockfile.ts sits in an import cycle with
29
- * setup-presets.ts (via the swift scaffolder), so CONFIG_SCHEMA is in its TDZ
30
- * while this module evaluates.
36
+ * setup-presets.ts (via the swift scaffolder) and with claude-skills.ts (via
37
+ * copy-preset.ts), so CONFIG_SCHEMA and SHIPPED_SKILLS are both in their TDZ
38
+ * while this module evaluates. Reading them here, at call time, is safe.
31
39
  */
32
40
  // ponytail: key sets are compiler-checked against the type; a changed field
33
41
  // *type* (string → number) still needs both lines edited by hand.
@@ -73,6 +81,42 @@ export function lockfileSchema() {
73
81
  },
74
82
  },
75
83
  },
84
+ requiredSkills: {
85
+ type: 'array',
86
+ items: { type: 'string', enum: SHIPPED_SKILLS },
87
+ description: 'Agent skills this repo\'s workflows depend on. doctor compares each installed copy\'s stamped hash against the shipped one and reports a missing or stale skill — always as "not configured", never drift, because it probes the machine rather than the repo. It never runs the fixer for you.',
88
+ },
89
+ mcp: {
90
+ type: 'object',
91
+ additionalProperties: false,
92
+ description: "Advisory MCP metadata: names, importance and reasons only, never an install directive. Executable server config belongs in the native .mcp.json, which carries Claude Code's own first-use consent prompt.",
93
+ properties: {
94
+ recommended: {
95
+ type: 'array',
96
+ description: "MCP servers this repo's workflow assumes. doctor reports which of them .mcp.json does not declare, informationally — it never installs or enables one.",
97
+ items: {
98
+ type: 'object',
99
+ additionalProperties: false,
100
+ required: ['name', 'importance', 'why'],
101
+ properties: {
102
+ name: {
103
+ type: 'string',
104
+ description: 'The server name as it would appear in .mcp.json.',
105
+ },
106
+ importance: {
107
+ type: 'string',
108
+ enum: MCP_IMPORTANCE,
109
+ description: "How much of the repo's workflow assumes the server.",
110
+ },
111
+ why: {
112
+ type: 'string',
113
+ description: 'One line on what the server is for — the thing .mcp.json structurally cannot say.',
114
+ },
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
76
120
  writtenBy: {
77
121
  type: 'string',
78
122
  description: 'Package name and version that last wrote this file.',
@@ -147,6 +191,8 @@ export async function writeLockfile(dir, config, assets) {
147
191
  config,
148
192
  ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
149
193
  ...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
194
+ ...(existing?.requiredSkills ? { requiredSkills: existing.requiredSkills } : {}),
195
+ ...(existing?.mcp ? { mcp: existing.mcp } : {}),
150
196
  writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
151
197
  writtenAt: new Date().toISOString(),
152
198
  };
@@ -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
@@ -1002,6 +1002,12 @@ export async function checkTreeshakeSetup(dir, pkg) {
1002
1002
  * one pays a full install before it can typecheck, lint or test — unless
1003
1003
  * `.claude/settings.json` tells Claude to symlink the directory from the main
1004
1004
  * checkout (#396). JS-only: the other language modules have nothing to symlink.
1005
+ *
1006
+ * Two consumers, one list (#527). Claude Code honours the setting only for
1007
+ * worktrees it creates itself (`EnterWorktree`); the shipped `ai-issue-loop`
1008
+ * skill creates its own with `git worktree add`, so it reads this same list and
1009
+ * makes the symlinks itself. That is why the check is worth passing on a repo
1010
+ * running the loop, where the setting alone would govern nothing.
1005
1011
  */
1006
1012
  export async function checkClaudeWorktreeSettings(dir) {
1007
1013
  const check = 'Claude worktree settings';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.24.0",
3
+ "version": "3.26.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
 
@@ -77,11 +80,11 @@ drift with a second copy to maintain.
77
80
  | `ai-review` | PR | Awaiting agent review. |
78
81
  | `ai-reviewing-code` | PR | `code-reviewer` claimed and running. Cleared with its verdict. |
79
82
  | `ai-reviewing-sec` | PR | `security-expert` claimed and running. Cleared with its verdict. |
80
- | `ai-ok-code` | PR | `code-reviewer` passed. |
81
- | `ai-ok-sec` | PR | `security-expert` passed. |
82
- | `ai-changes` | PR | A reviewer requested changes. Reviewers never apply it to a Dependabot PR. |
83
+ | `ai-ok-code` | PR | `code-reviewer` passed. In-flight only — Pass 1 strips it at handoff. |
84
+ | `ai-ok-sec` | PR | `security-expert` passed. In-flight only — Pass 1 strips it at handoff. |
85
+ | `ai-changes` | PR | A reviewer requested changes, **or** Pass 1 sent the PR back over CI. Reviewers never apply it to a Dependabot PR. |
83
86
  | `ai-notes` | PR | Passed, but a reviewer left something to read before merging. |
84
- | `merge-ready` | PR | Both agent reviews passed and the PR is mergeable — waiting on a human. Derived state; Pass 1 applies and strips it. |
87
+ | `merge-ready` | PR | Both agent reviews passed and the PR is mergeable — waiting on a human. Derived state; Pass 1 applies and strips it, and it **supersedes** the `ai-ok-*` pair rather than joining it. |
85
88
  | `ai-suggested` | issue | Follow-up a reviewer filed. A triage queue, never auto-picked. Pass 2 closes it after 30 days untouched. |
86
89
  | `holding` | issue | A gate — closes on human judgement, never picked up. |
87
90
 
@@ -147,12 +150,13 @@ grep -qxF '.claude/ai-loop-status' .gitignore || echo '.claude/ai-loop-status' >
147
150
 
148
151
  ```
149
152
  issue: ai-ready ─pickup─> ai-wip ─> PR opened, labelled ai-review
150
- PR: ai-review ─> ai-reviewing-* ─┬─> ai-ok-code + ai-ok-sec ─┬─ issue PR ─> merge-ready, assigned to you, ai-review dropped
153
+ PR: ai-review ─> ai-reviewing-* ─┬─> ai-ok-code + ai-ok-sec ─┬─ issue PR ─> merge-ready, assigned to you (ai-review + both ai-ok-* dropped)
151
154
  │ (± ai-notes) │ ─> YOU merge ─> worktree removed
152
155
  │ └─ dependabot ─┬─ no ai-notes ─> auto-merge ─> worktree removed
153
156
  │ └─ ai-notes ───> merge-ready, assigned to you
154
157
  └─> ai-changes (issue PRs only) ─> fix round (max 2) ─> ai-review
155
- └─ round 3 ─> ai-blocked
158
+ ▲ └─ round 3 ─> ai-blocked
159
+ └─ Pass 1 sends back: not CLEAN, or a required check FAILED
156
160
  ```
157
161
 
158
162
  `ai-reviewing-code` / `ai-reviewing-sec` are the *claim* step: Pass 3 applies one
@@ -263,9 +267,9 @@ immediately after a `worktree remove` and some with nothing removed at all. The
263
267
  is unidentified, so this is detection and repair only:
264
268
 
265
269
  ```bash
266
- if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
270
+ 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
271
  echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
268
- git -C "$ROOT" config core.bare false || {
272
+ env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
269
273
  echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
270
274
  fi
271
275
  ```
@@ -291,6 +295,14 @@ too, and nothing else separates the two — this skill ships to users' `~/.claud
291
295
  where "repairing" someone's real bare clone is the damage rather than the fix. The same
292
296
  check skips a linked worktree, whose `.git` is a file.
293
297
 
298
+ **`GIT_DIR` and `GIT_WORK_TREE` beat `-C`, so unset them.** When either is exported —
299
+ some tooling wrappers do — git ignores `-C "$ROOT"` and operates on whatever they
300
+ point at, so the probe would diagnose a *different* repo and the repair would write
301
+ that repo's `.git/config`. Both failures are silent, and both are worse than the bug
302
+ being guarded against. This skill installs into arbitrary users' `~/.claude/skills/`,
303
+ so the caller's environment is not ours to assume; `env -u` scopes the unset to the
304
+ one command rather than to the tick.
305
+
294
306
  **Fail loudly.** The repair writes `$ROOT/.git/config`, which a restrictive sandbox
295
307
  refuses with `error: could not lock config file .git/config: Operation not permitted` —
296
308
  observed. Aborting beats reporting a healthy repo while it stays broken.
@@ -348,9 +360,54 @@ and lacks either `ai-ok-*`, disarm it before labelling:
348
360
  gh pr merge <N> --disable-auto
349
361
  ```
350
362
 
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.
363
+ **Adopt agent-opened PRs the same way.** A PR an agent opens outside Pass 4 — one
364
+ with no `ai-ready` issue behind it — carries no `ai-*` label, so it matches no pass
365
+ and is therefore assigned by nothing: it never reaches *Assigned to you*, which is
366
+ the view where merges actually happen. Observed on #548, which passed all five
367
+ required checks and read *Able to merge* while its assignees read *No one—assign
368
+ yourself*. Label it `ai-review` and Pass 1 hands it over on the existing path once
369
+ both arms pass — no second assignment rule is needed:
370
+
371
+ ```bash
372
+ ME=$(gh api user --jq .login) # the identity every loop agent opens PRs as
373
+ gh pr list --state open --json number,author,labels,body \
374
+ | jq -r --arg me "$ME" \
375
+ '.[] | select(.author.login == $me)
376
+ | select([.labels[].name] | any(startswith("ai-")) | not)
377
+ | select((.body // "") | startswith("🤖 "))
378
+ | .number'
379
+ ```
380
+
381
+ **The `🤖` header is the discriminator, not the login.** Every agent authenticates as
382
+ the owner's own `gh`, so author login alone cannot tell a PR an agent opened from one
383
+ the owner wrote by hand — and a PR the owner wrote themselves must not be swept in,
384
+ which would put two reviewers on work nobody asked to have reviewed. The header is
385
+ wire format, the same as the `<!-- ai-issue-loop:* -->` markers: every PR body this
386
+ pipeline writes opens with `🤖 *Automated …*` or `🤖 *Opened by …*`, so match it and
387
+ do not redefine it. `(.body // "")` is load-bearing for the reason Pass 1's upsert
388
+ spells out — a null body throws and empties the whole filter, here adopting nothing
389
+ rather than everything.
390
+
391
+ If there are no open PRs carrying any `ai-*` label, no eligible `ai-ready` issues
392
+ (Pass 4's query), **and** no `ai-*` worktree left on disk, skip straight to Pass 5
393
+ with `SUMMARY=idle`. Skip the passes, never the report.
394
+
395
+ ```bash
396
+ find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null
397
+ ```
398
+
399
+ **The third condition is not implied by the other two.** Pass 2's cleanup is keyed
400
+ off worktrees *on disk*, never off open PRs, so the moment the last open PR is merged
401
+ by hand both of the other conditions go true while its worktree is still present and
402
+ its issue still carries `ai-wip` — the label only Pass 2 ever clears. Every later tick
403
+ meets the same two conditions, so the worktree and the label survive indefinitely
404
+ while the loop reports `idle`. The two leaks also protect each other: the
405
+ orphan-worktree rule that would otherwise reap it matches only a worktree *whose issue
406
+ is not `ai-wip`*, and the stale label is exactly what stops it. Observed 2026-08-26 —
407
+ #541 and #542 closed and their PRs merged, both worktrees still on disk, both issues
408
+ still `ai-wip`. No concurrency slot leaks (the cap counts *open* `ai-wip` issues); what
409
+ leaks is disk, an issue list that reads as though agents are still working, and Pass
410
+ 2's `node_modules` rebuild, which is gated on `REMOVED=1` and so never runs.
354
411
 
355
412
  ### Pass 1 — merge
356
413
 
@@ -372,7 +429,8 @@ gh api repos/$OWNER_REPO/environments \
372
429
  ```
373
430
 
374
431
  Non-zero → a non-Dependabot PR may auto-merge, but only carrying **all** of: both
375
- `ai-ok-code` and `ai-ok-sec`, no `ai-notes`, no `ai-changes`, and
432
+ `ai-ok-code` and `ai-ok-sec` — or `merge-ready`, which subsumes them once an
433
+ earlier tick handed the PR over — no `ai-notes`, no `ai-changes`, and
376
434
  `mergeStateStatus: CLEAN`. Zero, or the call errors, or `gh` lacks access to that
377
435
  endpoint → hand the PR over exactly as below.
378
436
 
@@ -452,47 +510,68 @@ leads with what to do.
452
510
 
453
511
  **Hand a ready PR over properly.** "Merge it yourself" is only actionable if the user
454
512
  can find it, and a PR sitting in a list of open PRs looks identical to one still being
455
- worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec` and
456
- not `ai-changes`, assign it, label it, and clear the stale review flag — **but only
513
+ worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec` —
514
+ or `merge-ready` already, from an earlier tick — and not `ai-changes`, assign it,
515
+ label it, and clear the labels the handoff supersedes — **but only
457
516
  after the `mergeStateStatus` probe below reports `CLEAN`**. That ordering is what
458
517
  makes `merge-ready` assert more than the `ai-ok-*` pair ever did: reviews passed
459
518
  *and* GitHub will accept the merge.
460
519
 
461
520
  ```bash
462
- gh pr edit <N> --add-assignee @me --add-label merge-ready --remove-label ai-review \
521
+ gh pr edit <N> --add-assignee @me --add-label merge-ready \
522
+ --remove-label ai-review --remove-label ai-ok-code --remove-label ai-ok-sec \
463
523
  ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
464
524
  ```
465
525
 
526
+ **`merge-ready` replaces the pass pair — it does not join it.** A handed-off PR
527
+ wearing `ai-ok-code`, `ai-ok-sec` *and* `merge-ready` says one thing three times,
528
+ and the reader has to know which of the three is the strongest before they can
529
+ act on any of them. `merge-ready` asserts strictly more than the pair (both
530
+ reviews passed **and** `CLEAN`), so the pair carries no information once it is
531
+ applied — a ready PR's whole vocabulary is the two-row table below.
532
+
533
+ Consequently **`merge-ready` satisfies every later test for the `ai-ok-*` pair** —
534
+ the gated-repo auto-merge arm above, the Dependabot arm below, and this pass's own
535
+ selector on the next tick. The pair stays the in-flight signal Pass 3 writes and
536
+ reads; it is only at the handoff that it stops being the thing anyone looks at.
537
+
466
538
  Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
467
539
  you says you both owe it something, which is the one thing never true here.
468
540
 
469
541
  It lands in the user's *Assigned to you* view, and the labels then read as state rather
470
542
  than noise — `merge-ready` means **waiting on you**, filterable at a glance where an
471
- absence never was. Both
472
- halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
473
- finished PR keeps wearing `ai-review` forever and looks mid-review. Idempotent, so
474
- re-running a tick is harmless.
475
-
476
- **`merge-ready` is derived state — reconcile it every tick.** The `ai-ok-*` pair
477
- plus `CLEAN` stays the source the loop computes from; the label only mirrors it.
478
- A PR carrying `merge-ready` while no longer `CLEAN`, or missing either pass
479
- label, gets it stripped (`gh pr edit <N> --remove-label merge-ready`). That is
543
+ absence never was. Every removal
544
+ matters: Pass 3 only ever *adds* its labels, so without them a finished PR keeps
545
+ wearing `ai-review` forever and looks mid-review while three green-ish labels
546
+ argue about who passed what. Idempotent, so re-running a tick is harmless.
547
+
548
+ **`merge-ready` is derived state — reconcile it every tick.** `CLEAN` stays the
549
+ source the loop computes from; the label only mirrors it. A PR carrying
550
+ `merge-ready` while no longer `CLEAN`, or carrying `ai-changes`, gets it stripped
551
+ (`gh pr edit <N> --remove-label merge-ready`) — and the two send-back blocks
552
+ below strip it as part of the same edit. That is
480
553
  what keeps a stateless 15-minute loop from letting the label lie after `main`
481
554
  moves. Take no other action — do not merge, and **post no
482
- comment on a clean handoff**: nothing is wrong, so those three labels are the
555
+ comment on a clean handoff**: nothing is wrong, so that one label is the
483
556
  whole message. A comment is how the loop records what a label cannot; a clean PR
484
557
  has nothing to record. An `ai-notes` handoff is the exception per the budget
485
558
  table — ≤10 lines through the marker upsert, linking the reviewer's
486
559
  `### Before merging` rather than restating it.
487
560
 
561
+ **Reconcile on `CLEAN` only — never on a missing `ai-ok-*`.** The handoff strips
562
+ that pair itself, so a rule that stripped `merge-ready` whenever a pass label was
563
+ absent would undo the tick before it on every handed-off PR, leaving it with no
564
+ labels at all, matching no selector in any pass, and assigned to a human with
565
+ nothing saying why it is theirs.
566
+
488
567
  **Never strip `ai-notes` here.** It is the whole point of the handoff: it has to
489
568
  survive to the moment of merging, which is the moment it is for. A ready PR reads
490
569
  one of two ways, and the difference must be legible without opening anything:
491
570
 
492
571
  | Labels | Means |
493
572
  |---|---|
494
- | `merge-ready` | Clean — merge freely. |
495
- | `merge-ready, ai-notes` | Passed, but open the comments first. |
573
+ | `merge-ready` | Merge freely. |
574
+ | `merge-ready`, `ai-notes` | Passed, but open the comments first. |
496
575
 
497
576
  **Check it can actually merge before calling it ready.** The `ai-ok-*` labels
498
577
  report the *agent review* verdict and nothing more — they say nothing about
@@ -539,8 +618,8 @@ gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
539
618
  Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
540
619
 
541
620
  So: every open PR **authored by `dependabot[bot]`**, labelled both `ai-ok-code`
542
- and `ai-ok-sec`, **not** `ai-changes`, **not** `ai-notes`, that has no
543
- `autoMergeRequest` yet:
621
+ and `ai-ok-sec` (or `merge-ready`), **not** `ai-changes`, **not** `ai-notes`, that
622
+ has no `autoMergeRequest` yet:
544
623
 
545
624
  ```bash
546
625
  gh pr merge <N> --auto --squash --delete-branch
@@ -556,10 +635,78 @@ no human picks it up. Merging
556
635
  unattended when a reviewer flagged something for a human writes the note into the
557
636
  void, which is the one way this label can be worse than useless.
558
637
 
559
- **Also flag CI red here** — it is the one stall the loop cannot resolve itself.
560
- Any PR that already has `autoMergeRequest != null` and a `FAILURE` in its
561
- `statusCheckRollup` will sit queued forever. Count these as `ci-red` for Pass 5;
562
- take no other action (a human decides whether to fix or close).
638
+ **CI red on an issue PR is a send-back, not a wait.** Reviewers are diff-scoped
639
+ and never see CI, so both arms happily pass a PR whose `build` failed two minutes
640
+ after it opened — and nothing else in the pipeline was ever going to dispatch a
641
+ fix. Observed on #543 (2026-08-26): the human found it via the red ✗ on the PR
642
+ page, which is precisely the noticing this loop exists to do. `ai-changes` **is**
643
+ the send-back label; Pass 3 dispatches the fix-round implementer off it, under
644
+ the same 2-round budget.
645
+
646
+ So for every open **non-Dependabot** PR carrying any `ai-*` label, with a
647
+ completed `FAILURE` on a **required** check:
648
+
649
+ ```bash
650
+ gh pr checks <N> --required --json name,state,link 2>/dev/null \
651
+ | jq -r '.[] | select(.state == "FAILURE") | "\(.name)\t\(.link)"'
652
+ ```
653
+
654
+ 1. `gh pr edit <N> --add-label ai-changes --remove-label ai-review --remove-label ai-ok-code --remove-label ai-ok-sec --remove-label ai-notes --remove-label merge-ready`
655
+ 2. **Comment through the marker upsert** — ≤10 lines, naming the failing check
656
+ and pasting the relevant excerpt from `gh run view <run-id> --log-failed`
657
+ (the run id is in that check's `link`). This step is not optional: the
658
+ fix-round prompt reads the PR's comments *as its instructions*, so without it
659
+ the implementer arrives at a PR marked `ai-changes` with nothing telling it
660
+ what changed or why.
661
+
662
+ **Write that excerpt to a file and pass `--body-file`; never interpolate the
663
+ log into the command.** A failing job prints whatever the branch told it to,
664
+ and on a public repo the branch is a stranger's — so the excerpt is untrusted
665
+ bytes that a contributor chooses. Inline `--body "$(gh run view …)"` puts
666
+ megabytes of it, control characters and all, through the shell and past
667
+ GitHub's comment size cap. The same rule already governs reviewer verdicts
668
+ further down; this is the one other place a body is assembled from output
669
+ nobody in this pipeline wrote. Trim to the failing lines before writing.
670
+ 3. Count it as `ci-red` for Pass 5, which carries the `⚠`.
671
+
672
+ **Say in the comment that the fix may not be code.** #543's failure was the
673
+ dogfood check finding a *bootstrap* gap — a label present in the canonical table
674
+ and not yet on the repo — where the fix was `gh label create` / `fix labels`, or
675
+ an `ACCEPTED` entry in `scripts/dogfood.mjs`, and never a branch edit. The
676
+ implementer has repo-write, so leave that path open; a comment that assumes the
677
+ branch is at fault steers it into editing code that is not wrong.
678
+
679
+ Two carve-outs, both so the loop does not fight itself:
680
+
681
+ - **An `ai-reviewing-code` / `ai-reviewing-sec` claim is active** — leave the PR
682
+ alone this tick. A reviewer is mid-run, and the fix round relabels `ai-review`
683
+ and re-spawns both arms anyway, so sending back now only throws away a review
684
+ in flight.
685
+ - **`ai-changes` is already on the PR** — leave it. Re-applying is not free:
686
+ Pass 3 counts `ai-changes` applications off the timeline and stops at three, so
687
+ a stateless 15-minute loop re-adding it while CI stays red would exhaust the
688
+ round budget within the hour and mark the issue `ai-blocked` before any agent
689
+ had done anything.
690
+
691
+ **`--required`, not the whole rollup.** `statusCheckRollup` also carries optional
692
+ and third-party contexts, and an advisory check going red is not a broken PR —
693
+ sending one back spends a fix round to change nothing. The required set is the
694
+ actual merge gate, and `gh` already resolves which checks are in it. Dropping
695
+ `ai-review` in step 1 is the mirror of what a `CHANGES` verdict does: leaving it
696
+ on would have Pass 3 spawn reviewers *and* a fix round against one PR, reviewing
697
+ a diff that is being rewritten underneath them. The implementer re-adds it when
698
+ it pushes.
699
+
700
+ No new label. `ai-changes` plus that comment already say "sent back, and why";
701
+ if telling a review-rejected PR from a CI-rejected one in the list view ever
702
+ matters, add a `ci-failing` rider on top of `ai-changes` then, not speculatively
703
+ now.
704
+
705
+ **A Dependabot PR is the exception — flag it, never send it back.** There is no
706
+ fix round for one (Pass 3 treats `ai-changes` on a bot PR as terminal), so a red
707
+ one that already armed auto-merge will sit queued forever and only a human can
708
+ choose between a fix and a close. Count these as `ci-red` too; take no other
709
+ action:
563
710
 
564
711
  ```bash
565
712
  gh pr list --state open --json number,autoMergeRequest,statusCheckRollup \
@@ -719,13 +866,16 @@ where `ai-blocked` would not be — nothing is lost, only the queue is honest.
719
866
  **Then re-check `core.bare`** — the same probe as Pass 0, against the same `ROOT`:
720
867
 
721
868
  ```bash
722
- if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
869
+ 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
870
  echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
724
- git -C "$ROOT" config core.bare false || {
871
+ env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
725
872
  echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
726
873
  fi
727
874
  ```
728
875
 
876
+ The `env -u` prefix carries the same weight here as in Pass 0, and for the same
877
+ reason — keep it on both lines.
878
+
729
879
  This is the last pass that *removes* worktrees, not the tick's last touch on the main
730
880
  checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is exactly
731
881
  why the re-check belongs here: it catches a flip after this pass's removals and before
@@ -1288,49 +1438,73 @@ mkdir -p "$WT_ROOT"
1288
1438
  git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
1289
1439
  ```
1290
1440
 
1291
- **Then give it dependencies — and the choice matters.** Symlinking is the cheap
1292
- path, but it is only correct for an issue confined to an app:
1441
+ **Then give it dependencies — from the repo's own symlink list.** `fix ai` writes
1442
+ `worktree.symlinkDirectories` into `.claude/settings.json`: the root
1443
+ `node_modules`, plus one entry per workspace package that has one, globbed from
1444
+ the repo's *own* `pnpm-workspace.yaml` / `package.json` `workspaces` (#406). That
1445
+ list is the single source of truth for what a worktree needs linked. Read it and
1446
+ do the linking here:
1293
1447
 
1294
1448
  ```bash
1295
- # Only for app/docs-only issues — nothing under a workspace package.
1296
- # Replaces worktree.symlinkDirectories. Link the workspace packages too, not just
1297
- # the root — with only the root linked, `pnpm verify` ENOENTs at the treeshake step
1298
- # because apps/*/node_modules is missing, and the agent cannot self-verify.
1299
- ln -s "$ROOT/node_modules" "$WT_ROOT/$SLUG/node_modules"
1300
- for app in "$ROOT"/apps/*/; do
1301
- [ -d "$app/node_modules" ] || continue
1302
- ln -s "$app/node_modules" "$WT_ROOT/$SLUG/apps/$(basename "$app")/node_modules"
1449
+ DIRS=$(jq -r '.worktree.symlinkDirectories[]? // empty' "$ROOT/.claude/settings.json" 2>/dev/null)
1450
+ for d in $DIRS; do
1451
+ [ -d "$ROOT/$d" ] || continue # an entry pointing at nothing links nothing
1452
+ mkdir -p "$(dirname "$WT_ROOT/$SLUG/$d")"
1453
+ ln -s "$ROOT/$d" "$WT_ROOT/$SLUG/$d"
1303
1454
  done
1304
1455
  ```
1305
1456
 
1306
- **For anything touching a workspace package, do not symlink — install for real:**
1457
+ **Read the setting, do not rely on it.** `worktree.symlinkDirectories` is a
1458
+ **Claude Code** setting, honoured by `EnterWorktree` — which this pipeline
1459
+ forbids outright (see below) and replaces with a raw `git worktree add`. So the
1460
+ setting is *inert for exactly the worktrees this loop creates*: `doctor` can
1461
+ report `Claude worktree settings: ok` while every agent worktree gets its
1462
+ dependencies by some other path, which is how #511/PR #526 ended up hand-installed.
1463
+ Taking the list as data and doing the `ln -s` here is what makes that check mean
1464
+ something for loop worktrees too, without either subsystem owning the other.
1465
+
1466
+ **No list, or no `.claude/settings.json` → install for real instead:**
1307
1467
 
1308
1468
  ```bash
1309
- (cd "$WT_ROOT/$SLUG" && pnpm install)
1469
+ [ -z "$DIRS" ] && (cd "$WT_ROOT/$SLUG" && pnpm install)
1310
1470
  ```
1311
1471
 
1312
- Those symlinks share the **root** and `apps/*`. pnpm workspaces keep the
1313
- resolution that matters in each `packages/<name>/node_modules`, which is not
1314
- symlinked and does not exist in a fresh worktree. Measured on `api-common`
1315
- 2026-08-20: the main checkout had per-package `node_modules` in **37 of 37**
1316
- packages, the worktree had **1**. So `pnpm --filter <pkg> typecheck` there fails
1317
- with `Cannot find module` rather than the real error — the agent cannot reproduce
1318
- the bug, and the environment looks like the issue's fault. Issue #201 was handed
1319
- back `ai-blocked` this way, well-diagnosed and untouched.
1472
+ That fallback is safe precisely because nothing was symlinked — the hazard below
1473
+ is `pnpm install` against a *symlinked* tree, not a real install in an isolated
1474
+ one. It costs a duplicate `node_modules` and about ten seconds, since pnpm
1475
+ hardlinks from the store. Run `npx @rtorcato/repo-tooling fix ai` in the repo to
1476
+ get the faster path back.
1477
+
1478
+ Why the list has to come from that file rather than a hand-rolled glob: pnpm
1479
+ workspaces keep the resolution that matters in each
1480
+ `packages/<name>/node_modules`, and an earlier version of this pass linked the
1481
+ root and `apps/*` only — so it reproduced that gap on every repo that nests its
1482
+ packages anywhere else. Measured on `api-common` 2026-08-20: the main checkout
1483
+ had per-package `node_modules` in **37 of 37** packages, the worktree had **1**.
1484
+ So `pnpm --filter <pkg> typecheck` there fails with `Cannot find module` rather
1485
+ than the real error — the agent cannot reproduce the bug, and the environment
1486
+ looks like the issue's fault. Issue #201 was handed back `ai-blocked` this way,
1487
+ well-diagnosed and untouched. `workspaceSymlinkDirs` already globs the consuming
1488
+ repo's own layout, so deriving the list is both shorter here and correct on repos
1489
+ this file has never seen.
1320
1490
 
1321
1491
  **Never force `pnpm install` against a symlinked tree.** It wants to purge and
1322
1492
  rebuild the modules dir (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), which
1323
1493
  mutates the **main checkout's** `node_modules` — shared by every other worktree
1324
1494
  and yanked out from under any agent mid-typecheck. `CI=true` and
1325
1495
  `--config.confirmModulesPurge=false` both silence that prompt; neither makes it
1326
- safe. A real install in an unsymlinked worktree costs a duplicate `node_modules`
1327
- and is the price of isolation. Pass 2's rebuild is the one sanctioned exception,
1328
- and only because it is gated on no worktree surviving.
1329
-
1330
- **Once per repo, exclude the symlink from git.** Repos ignore `node_modules/`
1331
- *with a trailing slash*, which does not match a symlink — so the link shows as
1332
- untracked in every worktree and a `git add -A` commits it. `.git/info/exclude`
1333
- is shared by all worktrees and never committed:
1496
+ safe. Now that the default path symlinks, this rule is **load-bearing rather than
1497
+ advisory** — the implementer prompt below states it, and `browser-common` #145 →
1498
+ PR #147 is what an implementer doing it anyway costs: the main checkout's `.bin`
1499
+ emptied, surfacing arbitrarily later in a human's `git push`. Pass 2's rebuild is
1500
+ the one sanctioned exception, and only because it is gated on no worktree
1501
+ surviving.
1502
+
1503
+ **Once per repo, exclude the symlinks from git.** Repos ignore `node_modules/`
1504
+ *with a trailing slash*, which does not match a symlink — so every link shows as
1505
+ untracked in every worktree and a `git add -A` commits it. The pattern below has
1506
+ no slash, so it matches at any depth and covers the nested workspace links too.
1507
+ `.git/info/exclude` is shared by all worktrees and never committed:
1334
1508
 
1335
1509
  ```bash
1336
1510
  grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$ROOT/.git/info/exclude"
@@ -1344,9 +1518,9 @@ directory — the two rules are incompatible, so the call can only ever be refus
1344
1518
  five-plus times in one tick, each producing *"this session is isolated in the worktree
1345
1519
  …"* refusals on unrelated orchestrator commands. Implementers work via
1346
1520
  `git -C <absolute worktree path>` instead, which is what the prompt below says.
1347
- Creating the worktree here also fixes the `worktree-` branch-prefix drift, and lets the
1348
- `node_modules` symlink be explicit rather than depending on
1349
- `worktree.symlinkDirectories` being configured.
1521
+ Creating the worktree here also fixes the `worktree-` branch-prefix drift, and it is
1522
+ why the symlinks above are created explicitly *from* `worktree.symlinkDirectories`
1523
+ rather than by `EnterWorktree` honouring it.
1350
1524
 
1351
1525
  **Spawn implementers one at a time — never two in the same message.** The worktree pin
1352
1526
  is a property of the session, not of an agent, so concurrent spawns cross-pin: the
@@ -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
@@ -127,10 +129,11 @@ done
127
129
  ```
128
130
 
129
131
  **Then give each worktree dependencies** — the loop skill's Pass 4 rules apply
130
- verbatim: symlink `node_modules` (root *and* `apps/*`) only for an issue confined
131
- to an app; run a real `pnpm install` in the worktree for anything touching a
132
- workspace package; never force an install against a symlinked tree; and add
133
- `node_modules` to `$ROOT/.git/info/exclude` once per repo.
132
+ verbatim: symlink every entry of `worktree.symlinkDirectories` from
133
+ `$ROOT/.claude/settings.json` (the root `node_modules` plus each workspace
134
+ package's, written by `fix ai`); run a real `pnpm install` in the worktree only
135
+ when that list is missing or empty; never force an install against a symlinked
136
+ tree; and add `node_modules` to `$ROOT/.git/info/exclude` once per repo.
134
137
 
135
138
  Stop here on `--label-only`. Report the picks and — briefly — what you skipped
136
139
  and why.
@@ -278,8 +281,8 @@ and still wearing a stale `ai-review`. Close that window here: once per PR
278
281
  whose two review arms both completed, apply the `ai-issue-loop` skill's Pass 1
279
282
  **by reference — execute what its text currently says, never a copy of it
280
283
  here**. A second copy of the handoff logic is drift with two files to keep
281
- honest; deferring means changes to Pass 1 (e.g. a future `merge-ready` label)
282
- take effect here without touching this file.
284
+ honest; deferring means changes to Pass 1 (its `merge-ready` handoff, its CI-red
285
+ send-back) take effect here without touching this file.
283
286
 
284
287
  - **Both arms passed** → run ai-issue-loop's Pass 1 handoff/send-back logic
285
288
  on this PR, per its current text — with one carve-out: `mergeStateStatus`