@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 +1 -1
- package/dist/base/checks.js +94 -0
- package/dist/base/git-identity.js +27 -4
- package/dist/cli/commands/doctor.js +55 -7
- package/dist/cli/commands/fix-targets.js +6 -0
- package/dist/cli/commands/fix.js +4 -1
- package/dist/cli/utils/lockfile.js +54 -2
- package/dist/languages/js/checks.js +6 -0
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +174 -59
- package/skills/ai-workflow/SKILL.md +7 -6
- package/skills/repo-tooling/SKILL.md +2 -0
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`. |
|
package/dist/base/checks.js
CHANGED
|
@@ -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
|
-
'
|
|
60
|
-
'
|
|
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
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -436,7 +436,10 @@ export async function fixCommand(target, options = {}) {
|
|
|
436
436
|
console.log();
|
|
437
437
|
return;
|
|
438
438
|
}
|
|
439
|
-
|
|
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)
|
|
30
|
-
*
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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`
|
|
512
|
-
|
|
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
|
|
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.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
re-running a tick is harmless.
|
|
531
|
-
|
|
532
|
-
**`merge-ready` is derived state — reconcile it every tick.**
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
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` |
|
|
551
|
-
| `merge-ready
|
|
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
|
|
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
|
-
**
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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 —
|
|
1351
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
ln -s "$ROOT
|
|
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
|
-
**
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
the
|
|
1378
|
-
|
|
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.
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
|
1407
|
-
|
|
1408
|
-
`
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
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
|
|