@rtorcato/repo-tooling 3.25.0 → 3.27.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
@@ -18,7 +18,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
18
18
  | `setup --config <path>` | ✅ | `--dry-run` only | Scaffold with a full `ProjectConfig` JSON file. See `setup --config-schema`. |
19
19
  | `setup --config-schema` | ✅ | ✅ (JSON Schema) | Print the JSON Schema for `ProjectConfig`. Use to validate configs before scaffolding. |
20
20
  | `setup --dry-run` | ✅ | ✅ | Print resolved config + file list without writing. Pair with `--preset` or `--config`. |
21
- | `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing`. |
21
+ | `doctor --json` | ✅ | ✅ | Audit a project. Returns `{ directory, results: [{ check, status, detail, hint? }] }`. Status: `ok` / `drift` / `missing` / `optional-missing` / `declared`. |
22
22
  | `fix --json --yes` | ✅ | ✅ | Walk every doctor finding, apply fixers. Returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`. |
23
23
  | `fix <target> --json --yes` | ✅ | ✅ | Apply one fixer. Targets from `list --json`. |
24
24
  | `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
@@ -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
@@ -51,15 +51,38 @@ const GIT_TIMEOUT_MS = 5_000;
51
51
  * `loop guard`, whose whole job is deciding whether one specific checkout has
52
52
  * gone bare (#519). Every caller here names its repo explicitly, so the
53
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.
54
66
  */
55
- const AMBIENT_REPO_VARS = [
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',
56
73
  'GIT_DIR',
57
74
  'GIT_WORK_TREE',
75
+ 'GIT_IMPLICIT_WORK_TREE',
76
+ 'GIT_GRAFT_FILE',
58
77
  'GIT_INDEX_FILE',
59
- 'GIT_COMMON_DIR',
60
- 'GIT_OBJECT_DIRECTORY',
61
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
78
+ 'GIT_NO_REPLACE_OBJECTS',
79
+ 'GIT_REPLACE_REF_BASE',
62
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.
63
86
  'GIT_NAMESPACE',
64
87
  ];
65
88
  function repoScopedEnv() {
@@ -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';
@@ -156,6 +156,40 @@ function demoteDeclined(results, lock) {
156
156
  };
157
157
  });
158
158
  }
159
+ // Declared exceptions (#558): a failing check the lock names is reported as
160
+ // `declared` with its reason — shown, never hidden, but no longer failing the
161
+ // run. An exception naming a check this run doesn't know is itself drift:
162
+ // otherwise a typo silently does nothing and a check rename silently
163
+ // un-suppresses a finding, and both are invisible.
164
+ function applyExceptions(results, lock) {
165
+ const exceptions = lock?.exceptions;
166
+ if (!exceptions)
167
+ return results;
168
+ const known = new Set(results.map((r) => r.check));
169
+ const overlaid = results.map((r) => {
170
+ const reason = exceptions[r.check];
171
+ if (!reason || r.status === 'ok')
172
+ return r;
173
+ // Hint deliberately dropped: the deviation is declared, so "how to fix it"
174
+ // is exactly the noise the exception exists to retire.
175
+ return {
176
+ check: r.check,
177
+ status: 'declared',
178
+ detail: `${r.detail} — declared exception: ${reason}`,
179
+ };
180
+ });
181
+ for (const name of Object.keys(exceptions)) {
182
+ if (known.has(name))
183
+ continue;
184
+ overlaid.push({
185
+ check: 'Declared exceptions',
186
+ status: 'drift',
187
+ detail: `.repo-tooling.json declares an exception for "${name}", which is not a check this run knows`,
188
+ hint: 'A typo, or a check that was renamed or removed — fix or delete the entry in `exceptions`',
189
+ });
190
+ }
191
+ return overlaid;
192
+ }
159
193
  // The language-agnostic checks (src/base): repo hygiene, git hooks, CI,
160
194
  // security, and GitHub repo-settings that apply to any repo regardless of
161
195
  // language. Declared once and run for every project — a language module layers
@@ -193,6 +227,16 @@ async function runBaseChecks(dir, lock, opts) {
193
227
  results.push(await checkAiSetup(dir));
194
228
  // User-global, not repo state — see checkClaudeSkills on why it never returns drift.
195
229
  results.push(await checkClaudeSkills(opts.skillsDir));
230
+ // #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
231
+ // signal, so a repo that doesn't gets no line at all rather than an empty one.
232
+ if (lock?.aiLoop && lock.requiredSkills?.length) {
233
+ results.push(await checkRequiredSkills(lock.requiredSkills, opts.skillsDir));
234
+ }
235
+ // #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
236
+ // about MCP, which is not a finding.
237
+ if (lock?.mcp?.recommended?.length) {
238
+ results.push(await checkRecommendedMcp(dir, lock.mcp.recommended));
239
+ }
196
240
  results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
197
241
  results.push(await checkCoverageUpload(dir));
198
242
  return results;
@@ -228,7 +272,7 @@ export async function runDoctor(dir, skillsDir) {
228
272
  skillsDir,
229
273
  })),
230
274
  ];
231
- return demoteDeclined(results, lock);
275
+ return applyExceptions(demoteDeclined(results, lock), lock);
232
276
  }
233
277
  // Swift suite (#286): base checks plus the module's own. Swift repos have no
234
278
  // package.json, so nothing JS-shaped runs.
@@ -252,7 +296,7 @@ export async function runDoctor(dir, skillsDir) {
252
296
  })),
253
297
  ...(await runSwiftChecks(targetDir)),
254
298
  ];
255
- return demoteDeclined(results, lock);
299
+ return applyExceptions(demoteDeclined(results, lock), lock);
256
300
  }
257
301
  // Python suite (#290): same shape as Swift — base checks plus the module's
258
302
  // own, and nothing JS-shaped, because a Python repo has no package.json.
@@ -276,7 +320,7 @@ export async function runDoctor(dir, skillsDir) {
276
320
  })),
277
321
  ...(await runPythonChecks(targetDir)),
278
322
  ];
279
- return demoteDeclined(results, lock);
323
+ return applyExceptions(demoteDeclined(results, lock), lock);
280
324
  }
281
325
  // Perl suite (#289): same shape as Swift and Python — base checks plus the
282
326
  // module's own, and nothing JS-shaped, because a distribution has no
@@ -302,7 +346,7 @@ export async function runDoctor(dir, skillsDir) {
302
346
  })),
303
347
  ...(await runPerlChecks(targetDir)),
304
348
  ];
305
- return demoteDeclined(results, lock);
349
+ return applyExceptions(demoteDeclined(results, lock), lock);
306
350
  }
307
351
  // JS suite: the module's own checks, then the shared base ones. Only the
308
352
  // JS-shaped checks are listed here — re-listing the base suite is what made
@@ -361,13 +405,14 @@ export async function runDoctor(dir, skillsDir) {
361
405
  codeqlLanguages: languageModule.codeqlLanguages,
362
406
  skillsDir,
363
407
  })));
364
- return demoteDeclined(results, lock);
408
+ return applyExceptions(demoteDeclined(results, lock), lock);
365
409
  }
366
410
  const STATUS_ICONS = {
367
411
  ok: chalk.green('✅'),
368
412
  drift: chalk.yellow('⚠️ '),
369
413
  missing: chalk.red('❌'),
370
414
  'optional-missing': chalk.gray('➖'),
415
+ declared: chalk.blue('📝'),
371
416
  };
372
417
  function statusLabel(status) {
373
418
  switch (status) {
@@ -379,6 +424,8 @@ function statusLabel(status) {
379
424
  return chalk.red('missing');
380
425
  case 'optional-missing':
381
426
  return chalk.gray('not configured');
427
+ case 'declared':
428
+ return chalk.blue('declared');
382
429
  }
383
430
  }
384
431
  const MAX_NEXT_STEP_SUGGESTIONS = 8;
@@ -411,6 +458,7 @@ export function summarize(results) {
411
458
  drift: results.filter((r) => r.status === 'drift').length,
412
459
  missing: results.filter((r) => r.status === 'missing').length,
413
460
  optionalMissing: results.filter((r) => r.status === 'optional-missing').length,
461
+ declared: results.filter((r) => r.status === 'declared').length,
414
462
  };
415
463
  }
416
464
  export async function doctorCommand(options = {}) {
@@ -430,7 +478,7 @@ export async function doctorCommand(options = {}) {
430
478
  }
431
479
  const summary = summarize(results);
432
480
  console.log();
433
- console.log(` Summary: ${chalk.green(`${summary.ok} ok`)}, ${chalk.yellow(`${summary.drift} drift`)}, ${chalk.red(`${summary.missing} missing`)}, ${chalk.gray(`${summary.optionalMissing} not configured`)}\n`);
481
+ console.log(` Summary: ${chalk.green(`${summary.ok} ok`)}, ${chalk.yellow(`${summary.drift} drift`)}, ${chalk.red(`${summary.missing} missing`)}, ${chalk.gray(`${summary.optionalMissing} not configured`)}, ${chalk.blue(`${summary.declared} declared`)}\n`);
434
482
  const suggestions = nextStepSuggestions(results, await detectLanguage(dir));
435
483
  if (suggestions.length > 0) {
436
484
  console.log(chalk.bold(' Next steps:'));
@@ -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
@@ -436,7 +436,10 @@ export async function fixCommand(target, options = {}) {
436
436
  console.log();
437
437
  return;
438
438
  }
439
- const fixable = results.filter((r) => r.status !== 'ok');
439
+ // `declared` is a deviation the lockfile records on purpose (#558) — a bulk
440
+ // fix must not "repair" it. A targeted `fix <target>` still can: naming the
441
+ // fixer is the same explicit override the declined-in-lock path gets.
442
+ const fixable = results.filter((r) => r.status !== 'ok' && r.status !== 'declared');
440
443
  if (fixable.length === 0) {
441
444
  if (json)
442
445
  return emitJson(null);
@@ -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,47 @@ 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
+ },
120
+ exceptions: {
121
+ type: 'object',
122
+ additionalProperties: { type: 'string', minLength: 1 },
123
+ description: 'Declared exceptions: doctor check name → the reason this repo deliberately deviates. The reason is mandatory and non-empty — doctor shows the check as `declared` with it (never hidden) and stops failing the run for it. An entry naming a check doctor does not run is itself reported as drift.',
124
+ },
76
125
  writtenBy: {
77
126
  type: 'string',
78
127
  description: 'Package name and version that last wrote this file.',
@@ -147,6 +196,9 @@ export async function writeLockfile(dir, config, assets) {
147
196
  config,
148
197
  ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
149
198
  ...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
199
+ ...(existing?.requiredSkills ? { requiredSkills: existing.requiredSkills } : {}),
200
+ ...(existing?.mcp ? { mcp: existing.mcp } : {}),
201
+ ...(existing?.exceptions ? { exceptions: existing.exceptions } : {}),
150
202
  writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
151
203
  writtenAt: new Date().toISOString(),
152
204
  };
@@ -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.25.0",
3
+ "version": "3.27.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": [
@@ -80,11 +80,11 @@ drift with a second copy to maintain.
80
80
  | `ai-review` | PR | Awaiting agent review. |
81
81
  | `ai-reviewing-code` | PR | `code-reviewer` claimed and running. Cleared with its verdict. |
82
82
  | `ai-reviewing-sec` | PR | `security-expert` claimed and running. Cleared with its verdict. |
83
- | `ai-ok-code` | PR | `code-reviewer` passed. |
84
- | `ai-ok-sec` | PR | `security-expert` passed. |
85
- | `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. |
86
86
  | `ai-notes` | PR | Passed, but a reviewer left something to read before merging. |
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. |
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. |
88
88
  | `ai-suggested` | issue | Follow-up a reviewer filed. A triage queue, never auto-picked. Pass 2 closes it after 30 days untouched. |
89
89
  | `holding` | issue | A gate — closes on human judgement, never picked up. |
90
90
 
@@ -150,12 +150,13 @@ grep -qxF '.claude/ai-loop-status' .gitignore || echo '.claude/ai-loop-status' >
150
150
 
151
151
  ```
152
152
  issue: ai-ready ─pickup─> ai-wip ─> PR opened, labelled ai-review
153
- 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)
154
154
  │ (± ai-notes) │ ─> YOU merge ─> worktree removed
155
155
  │ └─ dependabot ─┬─ no ai-notes ─> auto-merge ─> worktree removed
156
156
  │ └─ ai-notes ───> merge-ready, assigned to you
157
157
  └─> ai-changes (issue PRs only) ─> fix round (max 2) ─> ai-review
158
- └─ round 3 ─> ai-blocked
158
+ ▲ └─ round 3 ─> ai-blocked
159
+ └─ Pass 1 sends back: not CLEAN, or a required check FAILED
159
160
  ```
160
161
 
161
162
  `ai-reviewing-code` / `ai-reviewing-sec` are the *claim* step: Pass 3 applies one
@@ -428,7 +429,8 @@ gh api repos/$OWNER_REPO/environments \
428
429
  ```
429
430
 
430
431
  Non-zero → a non-Dependabot PR may auto-merge, but only carrying **all** of: both
431
- `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
432
434
  `mergeStateStatus: CLEAN`. Zero, or the call errors, or `gh` lacks access to that
433
435
  endpoint → hand the PR over exactly as below.
434
436
 
@@ -508,47 +510,68 @@ leads with what to do.
508
510
 
509
511
  **Hand a ready PR over properly.** "Merge it yourself" is only actionable if the user
510
512
  can find it, and a PR sitting in a list of open PRs looks identical to one still being
511
- worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec` and
512
- 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
513
516
  after the `mergeStateStatus` probe below reports `CLEAN`**. That ordering is what
514
517
  makes `merge-ready` assert more than the `ai-ok-*` pair ever did: reviews passed
515
518
  *and* GitHub will accept the merge.
516
519
 
517
520
  ```bash
518
- 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 \
519
523
  ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
520
524
  ```
521
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
+
522
538
  Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
523
539
  you says you both owe it something, which is the one thing never true here.
524
540
 
525
541
  It lands in the user's *Assigned to you* view, and the labels then read as state rather
526
542
  than noise — `merge-ready` means **waiting on you**, filterable at a glance where an
527
- absence never was. Both
528
- halves matter: Pass 3 only ever *adds* the `ai-ok-*` labels, so without the removal a
529
- finished PR keeps wearing `ai-review` forever and looks mid-review. Idempotent, so
530
- re-running a tick is harmless.
531
-
532
- **`merge-ready` is derived state — reconcile it every tick.** The `ai-ok-*` pair
533
- plus `CLEAN` stays the source the loop computes from; the label only mirrors it.
534
- A PR carrying `merge-ready` while no longer `CLEAN`, or missing either pass
535
- 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
536
553
  what keeps a stateless 15-minute loop from letting the label lie after `main`
537
554
  moves. Take no other action — do not merge, and **post no
538
- 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
539
556
  whole message. A comment is how the loop records what a label cannot; a clean PR
540
557
  has nothing to record. An `ai-notes` handoff is the exception per the budget
541
558
  table — ≤10 lines through the marker upsert, linking the reviewer's
542
559
  `### Before merging` rather than restating it.
543
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
+
544
567
  **Never strip `ai-notes` here.** It is the whole point of the handoff: it has to
545
568
  survive to the moment of merging, which is the moment it is for. A ready PR reads
546
569
  one of two ways, and the difference must be legible without opening anything:
547
570
 
548
571
  | Labels | Means |
549
572
  |---|---|
550
- | `merge-ready` | Clean — merge freely. |
551
- | `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. |
552
575
 
553
576
  **Check it can actually merge before calling it ready.** The `ai-ok-*` labels
554
577
  report the *agent review* verdict and nothing more — they say nothing about
@@ -595,8 +618,8 @@ gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
595
618
  Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
596
619
 
597
620
  So: every open PR **authored by `dependabot[bot]`**, labelled both `ai-ok-code`
598
- and `ai-ok-sec`, **not** `ai-changes`, **not** `ai-notes`, that has no
599
- `autoMergeRequest` yet:
621
+ and `ai-ok-sec` (or `merge-ready`), **not** `ai-changes`, **not** `ai-notes`, that
622
+ has no `autoMergeRequest` yet:
600
623
 
601
624
  ```bash
602
625
  gh pr merge <N> --auto --squash --delete-branch
@@ -612,10 +635,78 @@ no human picks it up. Merging
612
635
  unattended when a reviewer flagged something for a human writes the note into the
613
636
  void, which is the one way this label can be worse than useless.
614
637
 
615
- **Also flag CI red here** — it is the one stall the loop cannot resolve itself.
616
- Any PR that already has `autoMergeRequest != null` and a `FAILURE` in its
617
- `statusCheckRollup` will sit queued forever. Count these as `ci-red` for Pass 5;
618
- 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:
619
710
 
620
711
  ```bash
621
712
  gh pr list --state open --json number,autoMergeRequest,statusCheckRollup \
@@ -1347,49 +1438,73 @@ mkdir -p "$WT_ROOT"
1347
1438
  git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
1348
1439
  ```
1349
1440
 
1350
- **Then give it dependencies — and the choice matters.** Symlinking is the cheap
1351
- 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:
1352
1447
 
1353
1448
  ```bash
1354
- # Only for app/docs-only issues — nothing under a workspace package.
1355
- # Replaces worktree.symlinkDirectories. Link the workspace packages too, not just
1356
- # the root — with only the root linked, `pnpm verify` ENOENTs at the treeshake step
1357
- # because apps/*/node_modules is missing, and the agent cannot self-verify.
1358
- ln -s "$ROOT/node_modules" "$WT_ROOT/$SLUG/node_modules"
1359
- for app in "$ROOT"/apps/*/; do
1360
- [ -d "$app/node_modules" ] || continue
1361
- 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"
1362
1454
  done
1363
1455
  ```
1364
1456
 
1365
- **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:**
1366
1467
 
1367
1468
  ```bash
1368
- (cd "$WT_ROOT/$SLUG" && pnpm install)
1469
+ [ -z "$DIRS" ] && (cd "$WT_ROOT/$SLUG" && pnpm install)
1369
1470
  ```
1370
1471
 
1371
- Those symlinks share the **root** and `apps/*`. pnpm workspaces keep the
1372
- resolution that matters in each `packages/<name>/node_modules`, which is not
1373
- symlinked and does not exist in a fresh worktree. Measured on `api-common`
1374
- 2026-08-20: the main checkout had per-package `node_modules` in **37 of 37**
1375
- packages, the worktree had **1**. So `pnpm --filter <pkg> typecheck` there fails
1376
- with `Cannot find module` rather than the real error — the agent cannot reproduce
1377
- the bug, and the environment looks like the issue's fault. Issue #201 was handed
1378
- 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.
1379
1490
 
1380
1491
  **Never force `pnpm install` against a symlinked tree.** It wants to purge and
1381
1492
  rebuild the modules dir (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), which
1382
1493
  mutates the **main checkout's** `node_modules` — shared by every other worktree
1383
1494
  and yanked out from under any agent mid-typecheck. `CI=true` and
1384
1495
  `--config.confirmModulesPurge=false` both silence that prompt; neither makes it
1385
- safe. A real install in an unsymlinked worktree costs a duplicate `node_modules`
1386
- and is the price of isolation. Pass 2's rebuild is the one sanctioned exception,
1387
- and only because it is gated on no worktree surviving.
1388
-
1389
- **Once per repo, exclude the symlink from git.** Repos ignore `node_modules/`
1390
- *with a trailing slash*, which does not match a symlink — so the link shows as
1391
- untracked in every worktree and a `git add -A` commits it. `.git/info/exclude`
1392
- 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:
1393
1508
 
1394
1509
  ```bash
1395
1510
  grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$ROOT/.git/info/exclude"
@@ -1403,9 +1518,9 @@ directory — the two rules are incompatible, so the call can only ever be refus
1403
1518
  five-plus times in one tick, each producing *"this session is isolated in the worktree
1404
1519
  …"* refusals on unrelated orchestrator commands. Implementers work via
1405
1520
  `git -C <absolute worktree path>` instead, which is what the prompt below says.
1406
- Creating the worktree here also fixes the `worktree-` branch-prefix drift, and lets the
1407
- `node_modules` symlink be explicit rather than depending on
1408
- `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.
1409
1524
 
1410
1525
  **Spawn implementers one at a time — never two in the same message.** The worktree pin
1411
1526
  is a property of the session, not of an agent, so concurrent spawns cross-pin: the
@@ -129,10 +129,11 @@ done
129
129
  ```
130
130
 
131
131
  **Then give each worktree dependencies** — the loop skill's Pass 4 rules apply
132
- verbatim: symlink `node_modules` (root *and* `apps/*`) only for an issue confined
133
- to an app; run a real `pnpm install` in the worktree for anything touching a
134
- workspace package; never force an install against a symlinked tree; and add
135
- `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.
136
137
 
137
138
  Stop here on `--label-only`. Report the picks and — briefly — what you skipped
138
139
  and why.
@@ -280,8 +281,8 @@ and still wearing a stale `ai-review`. Close that window here: once per PR
280
281
  whose two review arms both completed, apply the `ai-issue-loop` skill's Pass 1
281
282
  **by reference — execute what its text currently says, never a copy of it
282
283
  here**. A second copy of the handoff logic is drift with two files to keep
283
- honest; deferring means changes to Pass 1 (e.g. a future `merge-ready` label)
284
- 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.
285
286
 
286
287
  - **Both arms passed** → run ai-issue-loop's Pass 1 handoff/send-back logic
287
288
  on this PR, per its current text — with one carve-out: `mergeStateStatus`
@@ -30,6 +30,8 @@ npx @rtorcato/repo-tooling doctor --json # confirm clean
30
30
  prompt to **No**; `--yes` is required to overwrite. Show `fix <target> --diff` first.
31
31
  - `missing` — required and absent → fix it.
32
32
  - `optional-missing` — opt-in tool not configured. Only fix if the user wants that tool.
33
+ - `declared` — a real deviation the repo's `.repo-tooling.json` `exceptions` records on
34
+ purpose, with its reason. Leave it alone; it doesn't fail the run.
33
35
 
34
36
  `fix` returns `FixActionRecord[]` with `status: applied | dry-run | skipped | already-ok | unsupported`.
35
37