@rtorcato/repo-tooling 3.24.0 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/README.md +6 -4
- package/dist/base/checks.js +94 -0
- package/dist/base/git-identity.js +52 -1
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/fix-targets.js +6 -0
- package/dist/cli/commands/loop-guard.js +186 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/utils/lockfile.js +48 -2
- package/dist/cli/utils/shell.js +8 -0
- package/dist/languages/js/checks.js +6 -0
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +245 -71
- package/skills/ai-workflow/SKILL.md +14 -11
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
24
24
|
| `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
|
|
25
25
|
| `list --json` | ✅ | ✅ | Enumerate the library's surface area. Each entry has `{ name, description, exports, fixTarget }`. |
|
|
26
26
|
| `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
|
|
27
|
+
| `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`). Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout — both non-zero halt the tick. |
|
|
27
28
|
|
|
28
29
|
## Recommended workflows
|
|
29
30
|
|
|
@@ -104,6 +105,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
|
|
|
104
105
|
- `src/cli/commands/doctor.ts` — all checks and the public `runDoctor(dir)` / `evaluateNodeVersion(version)` / `nextStepSuggestions(results)`
|
|
105
106
|
- `src/cli/commands/fix.ts` — `Fixer` interface, fixer registry, `fixCommand`
|
|
106
107
|
- `src/cli/commands/fix-targets.ts` — shared check → fix target map (used by both doctor's footer and fix's lookup)
|
|
108
|
+
- `src/cli/commands/loop-guard.ts` — `loop guard`: the `--is-inside-work-tree` / `.git` invariant table and the `node_modules` rebuild gate, drained out of the ai-issue-loop skill's prose (#519)
|
|
107
109
|
- `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
|
|
108
110
|
- `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
|
|
109
111
|
|
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ See the [Getting Started guide](https://rtorcato.github.io/repo-tooling/guides/g
|
|
|
91
91
|
| `copy <config>` | Copy a single config file into the current project. | `npx @rtorcato/repo-tooling copy biome` |
|
|
92
92
|
| `doctor` | Diagnose an existing project for missing or drifted tooling. | `npx @rtorcato/repo-tooling doctor` |
|
|
93
93
|
| `fix [target]` | Apply scaffolders for what `doctor` flagged (`--yes`, `--dry-run`, `--diff`). | `npx @rtorcato/repo-tooling fix` |
|
|
94
|
+
| `loop guard` | Repair a main checkout that has gone `core.bare = true`, and gate the `node_modules` rebuild after a worktree removal. Exits `1` if the repair failed and `2` if the root is not a repairable checkout — see `--help`. | `npx @rtorcato/repo-tooling loop guard --root .` |
|
|
94
95
|
|
|
95
96
|
Prefer to run the audit in CI? `doctor` also ships as a GitHub Action:
|
|
96
97
|
|
|
@@ -181,10 +182,11 @@ ln -sf ../../node_modules/@rtorcato/repo-tooling/tooling/claude/repo-tooling.md
|
|
|
181
182
|
|
|
182
183
|
This repo is also a self-hosted Claude Code marketplace. Install the plugin to
|
|
183
184
|
get six skills — `repo-tooling` (adopt/audit the presets via the CLI),
|
|
184
|
-
`npm-publish` (never hand-cut a release), `ai-
|
|
185
|
-
issue → PR pipeline
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
`npm-publish` (never hand-cut a release), `ai-workflow` (**the entry point** to
|
|
186
|
+
the `ai-ready` issue → PR pipeline: bursts the queue in parallel worktrees, then
|
|
187
|
+
schedules the engine below), `ai-issue-loop` (that engine — one stateless tick;
|
|
188
|
+
it runs on a loop rather than being typed), `ai-issue` (file agent-executable
|
|
189
|
+
issues) and `ai-loop-status` (read-only pipeline status) — in any session:
|
|
188
190
|
|
|
189
191
|
```
|
|
190
192
|
/plugin marketplace add rtorcato/repo-tooling
|
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
|
|
@@ -44,6 +44,53 @@ const DETAIL = {
|
|
|
44
44
|
const HINT = 'Commits made with this identity will not link to your forge account, and the address cannot be added as a verified secondary email to fix them retroactively (#327). Set a real one: `git config --global user.email you@yourdomain.com` — or per-repo, drop `--global`.';
|
|
45
45
|
const CHECK = 'Git identity';
|
|
46
46
|
const GIT_TIMEOUT_MS = 5_000;
|
|
47
|
+
/**
|
|
48
|
+
* Git exports these to everything a hook runs, and they outrank `cwd`/`-C`: a
|
|
49
|
+
* child spawned from a `pre-push` answers about the *hook's* repository, not
|
|
50
|
+
* the directory it was handed. Harmless for a config read; not harmless for
|
|
51
|
+
* `loop guard`, whose whole job is deciding whether one specific checkout has
|
|
52
|
+
* gone bare (#519). Every caller here names its repo explicitly, so the
|
|
53
|
+
* ambient one is never what was meant.
|
|
54
|
+
*
|
|
55
|
+
* This is `git rev-parse --local-env-vars` verbatim — git's own answer, and what
|
|
56
|
+
* githooks(1) says to clear before touching a different repository. Do not
|
|
57
|
+
* curate it by hand: the first version of this list was assembled from the vars
|
|
58
|
+
* that looked repository-ish and missed the `GIT_CONFIG*` family, which
|
|
59
|
+
* redirects where `git config` reads *and writes* — the exact operation this
|
|
60
|
+
* module's callers perform.
|
|
61
|
+
*
|
|
62
|
+
* Kept byte-identical with `AMBIENT_GIT_REPO_VARS` in `scripts/lib/git-env.mjs`;
|
|
63
|
+
* a test asserts both cover what the installed git reports. Two copies because
|
|
64
|
+
* this one compiles into `dist/` for consumers and that one is loaded raw by
|
|
65
|
+
* `.mjs` scripts that run before any build.
|
|
66
|
+
*/
|
|
67
|
+
export const AMBIENT_REPO_VARS = [
|
|
68
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
69
|
+
'GIT_CONFIG',
|
|
70
|
+
'GIT_CONFIG_PARAMETERS',
|
|
71
|
+
'GIT_CONFIG_COUNT',
|
|
72
|
+
'GIT_OBJECT_DIRECTORY',
|
|
73
|
+
'GIT_DIR',
|
|
74
|
+
'GIT_WORK_TREE',
|
|
75
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
76
|
+
'GIT_GRAFT_FILE',
|
|
77
|
+
'GIT_INDEX_FILE',
|
|
78
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
79
|
+
'GIT_REPLACE_REF_BASE',
|
|
80
|
+
'GIT_PREFIX',
|
|
81
|
+
'GIT_SHALLOW_FILE',
|
|
82
|
+
'GIT_COMMON_DIR',
|
|
83
|
+
// Not in git's local-env list: it scopes which refs are visible rather than
|
|
84
|
+
// which repository is used. Cleared anyway — a namespace inherited from a
|
|
85
|
+
// hook would hide refs from a command that meant to see all of them.
|
|
86
|
+
'GIT_NAMESPACE',
|
|
87
|
+
];
|
|
88
|
+
function repoScopedEnv() {
|
|
89
|
+
const env = { ...process.env };
|
|
90
|
+
for (const key of AMBIENT_REPO_VARS)
|
|
91
|
+
delete env[key];
|
|
92
|
+
return env;
|
|
93
|
+
}
|
|
47
94
|
/** Never rejects; a missing or failing git resolves to null. */
|
|
48
95
|
export const realGitExec = (args, cwd) => new Promise((resolve) => {
|
|
49
96
|
let settled = false;
|
|
@@ -56,7 +103,11 @@ export const realGitExec = (args, cwd) => new Promise((resolve) => {
|
|
|
56
103
|
};
|
|
57
104
|
// Args are internal constants, never user free-text — shell:false keeps
|
|
58
105
|
// this injection-safe.
|
|
59
|
-
const child = spawn('git', args, {
|
|
106
|
+
const child = spawn('git', args, {
|
|
107
|
+
cwd,
|
|
108
|
+
env: repoScopedEnv(),
|
|
109
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
110
|
+
});
|
|
60
111
|
let stdout = '';
|
|
61
112
|
const timer = setTimeout(() => {
|
|
62
113
|
child.kill();
|
|
@@ -20,7 +20,7 @@ import { checkGitIdentity } from '../../base/git-identity.js';
|
|
|
20
20
|
import { checkCopiedAssets } from '../utils/copied-assets.js';
|
|
21
21
|
import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
22
22
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
23
|
-
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
23
|
+
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
24
24
|
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
25
25
|
export { evaluateNodeVersion };
|
|
26
26
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
@@ -193,6 +193,16 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
193
193
|
results.push(await checkAiSetup(dir));
|
|
194
194
|
// User-global, not repo state — see checkClaudeSkills on why it never returns drift.
|
|
195
195
|
results.push(await checkClaudeSkills(opts.skillsDir));
|
|
196
|
+
// #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
|
|
197
|
+
// signal, so a repo that doesn't gets no line at all rather than an empty one.
|
|
198
|
+
if (lock?.aiLoop && lock.requiredSkills?.length) {
|
|
199
|
+
results.push(await checkRequiredSkills(lock.requiredSkills, opts.skillsDir));
|
|
200
|
+
}
|
|
201
|
+
// #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
|
|
202
|
+
// about MCP, which is not a finding.
|
|
203
|
+
if (lock?.mcp?.recommended?.length) {
|
|
204
|
+
results.push(await checkRecommendedMcp(dir, lock.mcp.recommended));
|
|
205
|
+
}
|
|
196
206
|
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
|
|
197
207
|
results.push(await checkCoverageUpload(dir));
|
|
198
208
|
return results;
|
|
@@ -49,6 +49,12 @@ export const FIX_TARGETS = {
|
|
|
49
49
|
'Claude worktree settings': 'ai',
|
|
50
50
|
'Claude skills': 'claude-skills',
|
|
51
51
|
'Copied assets': 'copied-assets',
|
|
52
|
+
// `Required skills` (#533) and `Recommended MCP` (#534) are deliberately
|
|
53
|
+
// absent. Both are driven by committed repo config and both would act outside
|
|
54
|
+
// the repo — installing into `~/.claude`, enabling a code-executing MCP
|
|
55
|
+
// server. Staying out of this map keeps them out of `fix`'s footer suggestions
|
|
56
|
+
// and out of every lookup a fixer path makes; their own hints name the command
|
|
57
|
+
// a human runs by hand.
|
|
52
58
|
};
|
|
53
59
|
/**
|
|
54
60
|
* Where the Swift module's fixers shadow (or extend) the JS-named defaults
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
6
|
+
/**
|
|
7
|
+
* The invariant table from the skill, verified on git 2.55.0:
|
|
8
|
+
*
|
|
9
|
+
* | repo state | `--is-inside-work-tree` | `.git` |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | healthy checkout | `true`, exit 0 | directory |
|
|
12
|
+
* | **wrongly bare** | `false`, **exit 0** | directory |
|
|
13
|
+
* | genuinely bare | `false`, exit 0 | absent |
|
|
14
|
+
* | linked worktree | `true`, exit 0 | file |
|
|
15
|
+
*
|
|
16
|
+
* Two things it encodes. **Stdout, not the exit code** — `rev-parse
|
|
17
|
+
* --is-inside-work-tree` exits `0` either way and only *prints* the answer, so
|
|
18
|
+
* an exit-code probe is dead code (`insideWorkTree` is `null` only when git
|
|
19
|
+
* itself failed, i.e. there is no repo here). And **`.git` must be a directory
|
|
20
|
+
* before repairing** — a genuinely bare repo prints `false` too, and nothing
|
|
21
|
+
* else separates the two. This ships to consumers' machines, where "repairing"
|
|
22
|
+
* someone's real bare clone is the damage rather than the fix.
|
|
23
|
+
*/
|
|
24
|
+
export function classifyRoot(insideWorkTree, gitEntry) {
|
|
25
|
+
if (insideWorkTree === null)
|
|
26
|
+
return 'not-a-repo';
|
|
27
|
+
if (insideWorkTree.trim() === 'true')
|
|
28
|
+
return 'work-tree';
|
|
29
|
+
if (gitEntry === 'directory')
|
|
30
|
+
return 'wrongly-bare';
|
|
31
|
+
// A linked worktree's `.git` is a file; its main checkout is where a repair
|
|
32
|
+
// belongs, and that is not the path we were handed.
|
|
33
|
+
if (gitEntry === 'file')
|
|
34
|
+
return 'linked-worktree';
|
|
35
|
+
return 'genuinely-bare';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
|
|
39
|
+
* `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup.
|
|
40
|
+
* `--config.confirmModulesPurge=false` gets past
|
|
41
|
+
* `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare `pnpm
|
|
42
|
+
* install` cannot repair this.
|
|
43
|
+
*/
|
|
44
|
+
export const REBUILD_ARGS = [
|
|
45
|
+
'install',
|
|
46
|
+
'--frozen-lockfile',
|
|
47
|
+
'--config.confirmModulesPurge=false',
|
|
48
|
+
];
|
|
49
|
+
const realInstall = (cwd) => new Promise((resolve) => {
|
|
50
|
+
// stdout stays clean for the `--json` payload; pnpm's diagnostics are
|
|
51
|
+
// still visible on stderr.
|
|
52
|
+
const child = spawn('pnpm', [...REBUILD_ARGS], {
|
|
53
|
+
cwd,
|
|
54
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
55
|
+
});
|
|
56
|
+
child.on('close', (code) => resolve(code === 0));
|
|
57
|
+
child.on('error', () => resolve(false));
|
|
58
|
+
});
|
|
59
|
+
async function gitEntryKind(root) {
|
|
60
|
+
// lstat, not stat: the file/directory distinction is the whole discriminator.
|
|
61
|
+
try {
|
|
62
|
+
const stat = await fs.lstat(path.join(root, '.git'));
|
|
63
|
+
return stat.isDirectory() ? 'directory' : 'file';
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'absent';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** `ai-*` directories one level down, in either place worktrees are kept. */
|
|
70
|
+
async function findLive(dirs) {
|
|
71
|
+
const live = [];
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
// A missing worktree root is the normal case, not an error.
|
|
74
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry.isDirectory() && entry.name.startsWith('ai-'))
|
|
77
|
+
live.push(path.join(dir, entry.name));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return live;
|
|
81
|
+
}
|
|
82
|
+
/** The sibling layout the skill uses: `<parent>/<name>-worktrees`. */
|
|
83
|
+
export function defaultWorktreeRoot(root) {
|
|
84
|
+
return path.join(path.dirname(root), `${path.basename(root)}-worktrees`);
|
|
85
|
+
}
|
|
86
|
+
const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
87
|
+
const UNUSABLE = {
|
|
88
|
+
'genuinely-bare': 'main checkout is a genuinely bare repository (.git absent) — refusing to flip core.bare on a real bare clone',
|
|
89
|
+
'linked-worktree': '--root points at a linked worktree (.git is a file), not the main checkout — repair belongs on the main checkout',
|
|
90
|
+
'not-a-repo': 'not a git repository',
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* One tick's worth of guarding, as data. The command wrapper prints it and
|
|
94
|
+
* sets the exit code; tests call this directly.
|
|
95
|
+
*/
|
|
96
|
+
export async function runLoopGuard(options = {}) {
|
|
97
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
98
|
+
const worktreeRoot = options.worktreeRoot
|
|
99
|
+
? path.resolve(options.worktreeRoot)
|
|
100
|
+
: defaultWorktreeRoot(root);
|
|
101
|
+
const git = options.git ?? ((args) => realGitExec(args, root));
|
|
102
|
+
const install = options.install ?? realInstall;
|
|
103
|
+
const messages = [];
|
|
104
|
+
const state = classifyRoot(await git(['rev-parse', '--is-inside-work-tree']), await gitEntryKind(root));
|
|
105
|
+
let bare = 'healthy';
|
|
106
|
+
let exitCode = 0;
|
|
107
|
+
if (state === 'wrongly-bare') {
|
|
108
|
+
messages.push(`⚠ main checkout bare at ${stamp()} — repairing`);
|
|
109
|
+
// Writes $ROOT/.git/config, which a restrictive sandbox refuses with
|
|
110
|
+
// `error: could not lock config file .git/config`. Aborting beats
|
|
111
|
+
// reporting a healthy repo while it stays broken.
|
|
112
|
+
if ((await git(['config', 'core.bare', 'false'])) === null) {
|
|
113
|
+
bare = 'repair-failed';
|
|
114
|
+
exitCode = 1;
|
|
115
|
+
messages.push('⚠ repair FAILED — main checkout still bare');
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
bare = 'repaired';
|
|
119
|
+
messages.push('main checkout repaired — core.bare is false');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (state !== 'work-tree') {
|
|
123
|
+
bare = 'unrepairable';
|
|
124
|
+
exitCode = 2;
|
|
125
|
+
messages.push(`⚠ ${UNUSABLE[state]}`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
messages.push('main checkout is a work tree');
|
|
129
|
+
}
|
|
130
|
+
const live = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
|
|
131
|
+
const rebuild = await decideRebuild({ root, removed: options.removed === true, exitCode, live });
|
|
132
|
+
let outcome = rebuild;
|
|
133
|
+
if (rebuild === 'deferred') {
|
|
134
|
+
messages.push(`rebuild deferred — ${live.length} worktree(s) still live`);
|
|
135
|
+
}
|
|
136
|
+
else if (rebuild === 'rebuilt') {
|
|
137
|
+
messages.push('rebuilding the main checkout’s node_modules');
|
|
138
|
+
if (!(await install(root))) {
|
|
139
|
+
outcome = 'rebuild-failed';
|
|
140
|
+
// Not fatal: a broken .bin cannot corrupt a commit the way a bare
|
|
141
|
+
// checkout does. Loud, though — a silent skip is the breakage this
|
|
142
|
+
// guard exists to end.
|
|
143
|
+
messages.push('⚠ node_modules rebuild FAILED — main checkout may be unbuildable');
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
messages.push('node_modules rebuilt');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { root, worktreeRoot, state, bare, rebuild: outcome, live, exitCode, messages };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The three load-bearing conditions, kept separate from the run so a test can
|
|
153
|
+
* assert them without a pnpm install:
|
|
154
|
+
*
|
|
155
|
+
* - **`removed`** — set by every removal path, merged-PR cleanup *and* stall
|
|
156
|
+
* reaping. A reaped worktree needs this most: its agent died mid-command.
|
|
157
|
+
* - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
|
|
158
|
+
* - **no live worktrees** — the rebuild *purges* the shared modules dir, which
|
|
159
|
+
* would be yanked out from under any agent still running in a surviving
|
|
160
|
+
* worktree. Deferring costs a broken main checkout until the last worktree
|
|
161
|
+
* clears; not deferring costs a live implementer run.
|
|
162
|
+
*/
|
|
163
|
+
export async function decideRebuild(input) {
|
|
164
|
+
if (!input.removed)
|
|
165
|
+
return 'not-requested';
|
|
166
|
+
// Nothing runs against a root we are about to halt the tick over.
|
|
167
|
+
if (input.exitCode !== 0)
|
|
168
|
+
return 'skipped-root-unusable';
|
|
169
|
+
if (!(await fs.pathExists(path.join(input.root, 'pnpm-lock.yaml'))))
|
|
170
|
+
return 'skipped-no-lockfile';
|
|
171
|
+
return input.live.length > 0 ? 'deferred' : 'rebuilt';
|
|
172
|
+
}
|
|
173
|
+
export async function loopGuardCommand(options) {
|
|
174
|
+
const result = await runLoopGuard(options);
|
|
175
|
+
if (options.json) {
|
|
176
|
+
console.log(JSON.stringify(result, null, 2));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.log();
|
|
180
|
+
for (const line of result.messages) {
|
|
181
|
+
console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
|
|
182
|
+
}
|
|
183
|
+
console.log();
|
|
184
|
+
}
|
|
185
|
+
process.exitCode = result.exitCode;
|
|
186
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import fs from 'fs-extra';
|
|
|
6
6
|
import packageJson from '../../package.json' with { type: 'json' };
|
|
7
7
|
import { doctorCommand } from './commands/doctor.js';
|
|
8
8
|
import { fixCommand } from './commands/fix.js';
|
|
9
|
+
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
9
10
|
import { setupProject } from './commands/setup.js';
|
|
10
11
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
11
12
|
async function isSelfRepo(dir) {
|
|
@@ -346,6 +347,22 @@ program
|
|
|
346
347
|
skillsDir: options.skillsDir,
|
|
347
348
|
forceSkills: options.forceSkills,
|
|
348
349
|
}));
|
|
350
|
+
// Mechanics the ai-issue-loop skill used to describe in prose-with-shell (#519).
|
|
351
|
+
// Deliberately outside the isSelfRepo hook below: the loop runs against this
|
|
352
|
+
// repo like any other, and a guard that refuses here would guard nothing.
|
|
353
|
+
const loop = program.command('loop').description('🔁 ai-issue-loop mechanics as tested commands');
|
|
354
|
+
loop
|
|
355
|
+
.command('guard')
|
|
356
|
+
.description('🛡️ Repair a wrongly-bare main checkout and gate the node_modules rebuild')
|
|
357
|
+
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
358
|
+
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
359
|
+
.option('--removed', 'A worktree was removed this tick — consider rebuilding node_modules')
|
|
360
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
361
|
+
.addHelpText('after', '\nExit codes:\n' +
|
|
362
|
+
' 0 root is a usable work tree (healthy, or repaired in place) — continue the tick\n' +
|
|
363
|
+
' 1 repair was attempted and failed; the root is still bare — halt the tick\n' +
|
|
364
|
+
' 2 root is not a repairable main checkout (bare clone, linked worktree, or not a repo) — halt the tick\n')
|
|
365
|
+
.action(loopGuardCommand);
|
|
349
366
|
program.hook('preAction', async (_, actionCommand) => {
|
|
350
367
|
const name = actionCommand.name();
|
|
351
368
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import packageJson from '../../../package.json' with { type: 'json' };
|
|
4
4
|
import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
|
|
5
|
+
import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
|
|
5
6
|
export const LOCKFILE_NAME = '.repo-tooling.json';
|
|
6
7
|
// Package and bin name used before the js-tooling→repo-tooling rename (#272).
|
|
7
8
|
// The bin no longer exists and the package is 404 on the registry, so any
|
|
@@ -17,6 +18,12 @@ export const LEGACY_LOCKFILE_NAME = `.${LEGACY_TOOL_NAME}.json`;
|
|
|
17
18
|
// files carry no hashes, which reads as "not tracked", never as drift.
|
|
18
19
|
export const LOCKFILE_VERSION = 3;
|
|
19
20
|
const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/lockfile.json';
|
|
21
|
+
/**
|
|
22
|
+
* How much of the repo's workflow assumes a recommended MCP server (#534).
|
|
23
|
+
* Signals priority to a human reading the file, and nothing more — no code
|
|
24
|
+
* branches on it beyond printing it.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_IMPORTANCE = ['nice-to-have', 'important', 'critical'];
|
|
20
27
|
/**
|
|
21
28
|
* JSON Schema for the lockfile, published with the docs site at the exact URL
|
|
22
29
|
* every written lockfile's `$schema` points to (#529). The `satisfies` clauses
|
|
@@ -26,8 +33,9 @@ const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/loc
|
|
|
26
33
|
* `pnpm schema:generate` and gated by tests/cli/utils/lockfile-schema.test.ts.
|
|
27
34
|
*
|
|
28
35
|
* A function, not a const: lockfile.ts sits in an import cycle with
|
|
29
|
-
* setup-presets.ts (via the swift scaffolder)
|
|
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,42 @@ export function lockfileSchema() {
|
|
|
73
81
|
},
|
|
74
82
|
},
|
|
75
83
|
},
|
|
84
|
+
requiredSkills: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
items: { type: 'string', enum: SHIPPED_SKILLS },
|
|
87
|
+
description: 'Agent skills this repo\'s workflows depend on. doctor compares each installed copy\'s stamped hash against the shipped one and reports a missing or stale skill — always as "not configured", never drift, because it probes the machine rather than the repo. It never runs the fixer for you.',
|
|
88
|
+
},
|
|
89
|
+
mcp: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
description: "Advisory MCP metadata: names, importance and reasons only, never an install directive. Executable server config belongs in the native .mcp.json, which carries Claude Code's own first-use consent prompt.",
|
|
93
|
+
properties: {
|
|
94
|
+
recommended: {
|
|
95
|
+
type: 'array',
|
|
96
|
+
description: "MCP servers this repo's workflow assumes. doctor reports which of them .mcp.json does not declare, informationally — it never installs or enables one.",
|
|
97
|
+
items: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
required: ['name', 'importance', 'why'],
|
|
101
|
+
properties: {
|
|
102
|
+
name: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: 'The server name as it would appear in .mcp.json.',
|
|
105
|
+
},
|
|
106
|
+
importance: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
enum: MCP_IMPORTANCE,
|
|
109
|
+
description: "How much of the repo's workflow assumes the server.",
|
|
110
|
+
},
|
|
111
|
+
why: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
description: 'One line on what the server is for — the thing .mcp.json structurally cannot say.',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
76
120
|
writtenBy: {
|
|
77
121
|
type: 'string',
|
|
78
122
|
description: 'Package name and version that last wrote this file.',
|
|
@@ -147,6 +191,8 @@ export async function writeLockfile(dir, config, assets) {
|
|
|
147
191
|
config,
|
|
148
192
|
...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
|
|
149
193
|
...(existing?.aiLoop ? { aiLoop: existing.aiLoop } : {}),
|
|
194
|
+
...(existing?.requiredSkills ? { requiredSkills: existing.requiredSkills } : {}),
|
|
195
|
+
...(existing?.mcp ? { mcp: existing.mcp } : {}),
|
|
150
196
|
writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
|
|
151
197
|
writtenAt: new Date().toISOString(),
|
|
152
198
|
};
|
package/dist/cli/utils/shell.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Quote a value as one literal argument **for a POSIX `sh` command line, and
|
|
3
|
+
* nothing else**. The result is wrong in `cmd.exe`, which does not treat `'` as
|
|
4
|
+
* a quote at all, and wrong in PowerShell, which does — but escapes an embedded
|
|
5
|
+
* `'` by doubling it rather than POSIX's `'\''`, so this output still mangles
|
|
6
|
+
* any value containing one. It is also wrong anywhere the value lands inside
|
|
7
|
+
* double quotes or an existing quoted string — it is a whole argument, not a
|
|
8
|
+
* fragment.
|
|
9
|
+
*
|
|
2
10
|
* POSIX single-quote escaping — the only form correct for arbitrary bytes.
|
|
3
11
|
* Inside single quotes every character is literal, so `'` is the sole one
|
|
4
12
|
* needing care: end the quote, escape it, start a new one. Double quotes are
|
|
@@ -1002,6 +1002,12 @@ export async function checkTreeshakeSetup(dir, pkg) {
|
|
|
1002
1002
|
* one pays a full install before it can typecheck, lint or test — unless
|
|
1003
1003
|
* `.claude/settings.json` tells Claude to symlink the directory from the main
|
|
1004
1004
|
* checkout (#396). JS-only: the other language modules have nothing to symlink.
|
|
1005
|
+
*
|
|
1006
|
+
* Two consumers, one list (#527). Claude Code honours the setting only for
|
|
1007
|
+
* worktrees it creates itself (`EnterWorktree`); the shipped `ai-issue-loop`
|
|
1008
|
+
* skill creates its own with `git worktree add`, so it reads this same list and
|
|
1009
|
+
* makes the symlinks itself. That is why the check is worth passing on a repo
|
|
1010
|
+
* running the loop, where the setting alone would govern nothing.
|
|
1005
1011
|
*/
|
|
1006
1012
|
export async function checkClaudeWorktreeSettings(dir) {
|
|
1007
1013
|
const check = 'Claude worktree settings';
|
package/package.json
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
name: ai-issue-loop
|
|
3
3
|
model: sonnet
|
|
4
4
|
description: |
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
**The engine behind `/ai-workflow` — normally you do not invoke this
|
|
6
|
+
directly.** One stateless tick over the GitHub label state: answer
|
|
7
|
+
`ai-changes` with a fix round, merge Dependabot PRs, hand passed issue PRs to
|
|
8
|
+
the human, clean up merged worktrees, reap stalled agents, and pick up any
|
|
9
|
+
remaining `ai-ready` issues. `/ai-workflow` is the entry point and schedules
|
|
10
|
+
this itself via `/loop 15m /ai-issue-loop`; reach for it directly only to
|
|
11
|
+
force a tick early — "run one tick", "babysit the AI PRs" — or when the user
|
|
12
|
+
invokes `/ai-issue-loop`. Only Dependabot PRs ever merge unattended.
|
|
10
13
|
GitHub only (`gh`) — not GitLab.
|
|
11
14
|
---
|
|
12
15
|
|
|
@@ -77,11 +80,11 @@ drift with a second copy to maintain.
|
|
|
77
80
|
| `ai-review` | PR | Awaiting agent review. |
|
|
78
81
|
| `ai-reviewing-code` | PR | `code-reviewer` claimed and running. Cleared with its verdict. |
|
|
79
82
|
| `ai-reviewing-sec` | PR | `security-expert` claimed and running. Cleared with its verdict. |
|
|
80
|
-
| `ai-ok-code` | PR | `code-reviewer` passed. |
|
|
81
|
-
| `ai-ok-sec` | PR | `security-expert` passed. |
|
|
82
|
-
| `ai-changes` | PR | A reviewer requested changes. Reviewers never apply it to a Dependabot PR. |
|
|
83
|
+
| `ai-ok-code` | PR | `code-reviewer` passed. In-flight only — Pass 1 strips it at handoff. |
|
|
84
|
+
| `ai-ok-sec` | PR | `security-expert` passed. In-flight only — Pass 1 strips it at handoff. |
|
|
85
|
+
| `ai-changes` | PR | A reviewer requested changes, **or** Pass 1 sent the PR back over CI. Reviewers never apply it to a Dependabot PR. |
|
|
83
86
|
| `ai-notes` | PR | Passed, but a reviewer left something to read before merging. |
|
|
84
|
-
| `merge-ready` | PR | Both agent reviews passed and the PR is mergeable — waiting on a human. Derived state; Pass 1 applies and strips it. |
|
|
87
|
+
| `merge-ready` | PR | Both agent reviews passed and the PR is mergeable — waiting on a human. Derived state; Pass 1 applies and strips it, and it **supersedes** the `ai-ok-*` pair rather than joining it. |
|
|
85
88
|
| `ai-suggested` | issue | Follow-up a reviewer filed. A triage queue, never auto-picked. Pass 2 closes it after 30 days untouched. |
|
|
86
89
|
| `holding` | issue | A gate — closes on human judgement, never picked up. |
|
|
87
90
|
|
|
@@ -147,12 +150,13 @@ grep -qxF '.claude/ai-loop-status' .gitignore || echo '.claude/ai-loop-status' >
|
|
|
147
150
|
|
|
148
151
|
```
|
|
149
152
|
issue: ai-ready ─pickup─> ai-wip ─> PR opened, labelled ai-review
|
|
150
|
-
PR: ai-review ─> ai-reviewing-* ─┬─> ai-ok-code + ai-ok-sec ─┬─ issue PR ─> merge-ready, assigned to you
|
|
153
|
+
PR: ai-review ─> ai-reviewing-* ─┬─> ai-ok-code + ai-ok-sec ─┬─ issue PR ─> merge-ready, assigned to you (ai-review + both ai-ok-* dropped)
|
|
151
154
|
│ (± ai-notes) │ ─> YOU merge ─> worktree removed
|
|
152
155
|
│ └─ dependabot ─┬─ no ai-notes ─> auto-merge ─> worktree removed
|
|
153
156
|
│ └─ ai-notes ───> merge-ready, assigned to you
|
|
154
157
|
└─> ai-changes (issue PRs only) ─> fix round (max 2) ─> ai-review
|
|
155
|
-
|
|
158
|
+
▲ └─ round 3 ─> ai-blocked
|
|
159
|
+
└─ Pass 1 sends back: not CLEAN, or a required check FAILED
|
|
156
160
|
```
|
|
157
161
|
|
|
158
162
|
`ai-reviewing-code` / `ai-reviewing-sec` are the *claim* step: Pass 3 applies one
|
|
@@ -263,9 +267,9 @@ immediately after a `worktree remove` and some with nothing removed at all. The
|
|
|
263
267
|
is unidentified, so this is detection and repair only:
|
|
264
268
|
|
|
265
269
|
```bash
|
|
266
|
-
if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
|
|
270
|
+
if [ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
|
|
267
271
|
echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
|
|
268
|
-
git -C "$ROOT" config core.bare false || {
|
|
272
|
+
env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
|
|
269
273
|
echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
|
|
270
274
|
fi
|
|
271
275
|
```
|
|
@@ -291,6 +295,14 @@ too, and nothing else separates the two — this skill ships to users' `~/.claud
|
|
|
291
295
|
where "repairing" someone's real bare clone is the damage rather than the fix. The same
|
|
292
296
|
check skips a linked worktree, whose `.git` is a file.
|
|
293
297
|
|
|
298
|
+
**`GIT_DIR` and `GIT_WORK_TREE` beat `-C`, so unset them.** When either is exported —
|
|
299
|
+
some tooling wrappers do — git ignores `-C "$ROOT"` and operates on whatever they
|
|
300
|
+
point at, so the probe would diagnose a *different* repo and the repair would write
|
|
301
|
+
that repo's `.git/config`. Both failures are silent, and both are worse than the bug
|
|
302
|
+
being guarded against. This skill installs into arbitrary users' `~/.claude/skills/`,
|
|
303
|
+
so the caller's environment is not ours to assume; `env -u` scopes the unset to the
|
|
304
|
+
one command rather than to the tick.
|
|
305
|
+
|
|
294
306
|
**Fail loudly.** The repair writes `$ROOT/.git/config`, which a restrictive sandbox
|
|
295
307
|
refuses with `error: could not lock config file .git/config: Operation not permitted` —
|
|
296
308
|
observed. Aborting beats reporting a healthy repo while it stays broken.
|
|
@@ -348,9 +360,54 @@ and lacks either `ai-ok-*`, disarm it before labelling:
|
|
|
348
360
|
gh pr merge <N> --disable-auto
|
|
349
361
|
```
|
|
350
362
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
363
|
+
**Adopt agent-opened PRs the same way.** A PR an agent opens outside Pass 4 — one
|
|
364
|
+
with no `ai-ready` issue behind it — carries no `ai-*` label, so it matches no pass
|
|
365
|
+
and is therefore assigned by nothing: it never reaches *Assigned to you*, which is
|
|
366
|
+
the view where merges actually happen. Observed on #548, which passed all five
|
|
367
|
+
required checks and read *Able to merge* while its assignees read *No one—assign
|
|
368
|
+
yourself*. Label it `ai-review` and Pass 1 hands it over on the existing path once
|
|
369
|
+
both arms pass — no second assignment rule is needed:
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
ME=$(gh api user --jq .login) # the identity every loop agent opens PRs as
|
|
373
|
+
gh pr list --state open --json number,author,labels,body \
|
|
374
|
+
| jq -r --arg me "$ME" \
|
|
375
|
+
'.[] | select(.author.login == $me)
|
|
376
|
+
| select([.labels[].name] | any(startswith("ai-")) | not)
|
|
377
|
+
| select((.body // "") | startswith("🤖 "))
|
|
378
|
+
| .number'
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**The `🤖` header is the discriminator, not the login.** Every agent authenticates as
|
|
382
|
+
the owner's own `gh`, so author login alone cannot tell a PR an agent opened from one
|
|
383
|
+
the owner wrote by hand — and a PR the owner wrote themselves must not be swept in,
|
|
384
|
+
which would put two reviewers on work nobody asked to have reviewed. The header is
|
|
385
|
+
wire format, the same as the `<!-- ai-issue-loop:* -->` markers: every PR body this
|
|
386
|
+
pipeline writes opens with `🤖 *Automated …*` or `🤖 *Opened by …*`, so match it and
|
|
387
|
+
do not redefine it. `(.body // "")` is load-bearing for the reason Pass 1's upsert
|
|
388
|
+
spells out — a null body throws and empties the whole filter, here adopting nothing
|
|
389
|
+
rather than everything.
|
|
390
|
+
|
|
391
|
+
If there are no open PRs carrying any `ai-*` label, no eligible `ai-ready` issues
|
|
392
|
+
(Pass 4's query), **and** no `ai-*` worktree left on disk, skip straight to Pass 5
|
|
393
|
+
with `SUMMARY=idle`. Skip the passes, never the report.
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
find "$WT_ROOT" "$ROOT/.claude/worktrees" -maxdepth 1 -name 'ai-*' -type d 2>/dev/null
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**The third condition is not implied by the other two.** Pass 2's cleanup is keyed
|
|
400
|
+
off worktrees *on disk*, never off open PRs, so the moment the last open PR is merged
|
|
401
|
+
by hand both of the other conditions go true while its worktree is still present and
|
|
402
|
+
its issue still carries `ai-wip` — the label only Pass 2 ever clears. Every later tick
|
|
403
|
+
meets the same two conditions, so the worktree and the label survive indefinitely
|
|
404
|
+
while the loop reports `idle`. The two leaks also protect each other: the
|
|
405
|
+
orphan-worktree rule that would otherwise reap it matches only a worktree *whose issue
|
|
406
|
+
is not `ai-wip`*, and the stale label is exactly what stops it. Observed 2026-08-26 —
|
|
407
|
+
#541 and #542 closed and their PRs merged, both worktrees still on disk, both issues
|
|
408
|
+
still `ai-wip`. No concurrency slot leaks (the cap counts *open* `ai-wip` issues); what
|
|
409
|
+
leaks is disk, an issue list that reads as though agents are still working, and Pass
|
|
410
|
+
2's `node_modules` rebuild, which is gated on `REMOVED=1` and so never runs.
|
|
354
411
|
|
|
355
412
|
### Pass 1 — merge
|
|
356
413
|
|
|
@@ -372,7 +429,8 @@ gh api repos/$OWNER_REPO/environments \
|
|
|
372
429
|
```
|
|
373
430
|
|
|
374
431
|
Non-zero → a non-Dependabot PR may auto-merge, but only carrying **all** of: both
|
|
375
|
-
`ai-ok-code` and `ai-ok-sec
|
|
432
|
+
`ai-ok-code` and `ai-ok-sec` — or `merge-ready`, which subsumes them once an
|
|
433
|
+
earlier tick handed the PR over — no `ai-notes`, no `ai-changes`, and
|
|
376
434
|
`mergeStateStatus: CLEAN`. Zero, or the call errors, or `gh` lacks access to that
|
|
377
435
|
endpoint → hand the PR over exactly as below.
|
|
378
436
|
|
|
@@ -452,47 +510,68 @@ leads with what to do.
|
|
|
452
510
|
|
|
453
511
|
**Hand a ready PR over properly.** "Merge it yourself" is only actionable if the user
|
|
454
512
|
can find it, and a PR sitting in a list of open PRs looks identical to one still being
|
|
455
|
-
worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec`
|
|
456
|
-
|
|
513
|
+
worked. So for every non-Dependabot PR carrying both `ai-ok-code` and `ai-ok-sec` —
|
|
514
|
+
or `merge-ready` already, from an earlier tick — and not `ai-changes`, assign it,
|
|
515
|
+
label it, and clear the labels the handoff supersedes — **but only
|
|
457
516
|
after the `mergeStateStatus` probe below reports `CLEAN`**. That ordering is what
|
|
458
517
|
makes `merge-ready` assert more than the `ai-ok-*` pair ever did: reviews passed
|
|
459
518
|
*and* GitHub will accept the merge.
|
|
460
519
|
|
|
461
520
|
```bash
|
|
462
|
-
gh pr edit <N> --add-assignee @me --add-label merge-ready
|
|
521
|
+
gh pr edit <N> --add-assignee @me --add-label merge-ready \
|
|
522
|
+
--remove-label ai-review --remove-label ai-ok-code --remove-label ai-ok-sec \
|
|
463
523
|
${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
464
524
|
```
|
|
465
525
|
|
|
526
|
+
**`merge-ready` replaces the pass pair — it does not join it.** A handed-off PR
|
|
527
|
+
wearing `ai-ok-code`, `ai-ok-sec` *and* `merge-ready` says one thing three times,
|
|
528
|
+
and the reader has to know which of the three is the strongest before they can
|
|
529
|
+
act on any of them. `merge-ready` asserts strictly more than the pair (both
|
|
530
|
+
reviews passed **and** `CLEAN`), so the pair carries no information once it is
|
|
531
|
+
applied — a ready PR's whole vocabulary is the two-row table below.
|
|
532
|
+
|
|
533
|
+
Consequently **`merge-ready` satisfies every later test for the `ai-ok-*` pair** —
|
|
534
|
+
the gated-repo auto-merge arm above, the Dependabot arm below, and this pass's own
|
|
535
|
+
selector on the next tick. The pair stays the in-flight signal Pass 3 writes and
|
|
536
|
+
reads; it is only at the handoff that it stops being the thing anyone looks at.
|
|
537
|
+
|
|
466
538
|
Dropping `AGENT_USER` is half the signal: leaving the agent assigned alongside
|
|
467
539
|
you says you both owe it something, which is the one thing never true here.
|
|
468
540
|
|
|
469
541
|
It lands in the user's *Assigned to you* view, and the labels then read as state rather
|
|
470
542
|
than noise — `merge-ready` means **waiting on you**, filterable at a glance where an
|
|
471
|
-
absence never was.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
re-running a tick is harmless.
|
|
475
|
-
|
|
476
|
-
**`merge-ready` is derived state — reconcile it every tick.**
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
543
|
+
absence never was. Every removal
|
|
544
|
+
matters: Pass 3 only ever *adds* its labels, so without them a finished PR keeps
|
|
545
|
+
wearing `ai-review` forever and looks mid-review while three green-ish labels
|
|
546
|
+
argue about who passed what. Idempotent, so re-running a tick is harmless.
|
|
547
|
+
|
|
548
|
+
**`merge-ready` is derived state — reconcile it every tick.** `CLEAN` stays the
|
|
549
|
+
source the loop computes from; the label only mirrors it. A PR carrying
|
|
550
|
+
`merge-ready` while no longer `CLEAN`, or carrying `ai-changes`, gets it stripped
|
|
551
|
+
(`gh pr edit <N> --remove-label merge-ready`) — and the two send-back blocks
|
|
552
|
+
below strip it as part of the same edit. That is
|
|
480
553
|
what keeps a stateless 15-minute loop from letting the label lie after `main`
|
|
481
554
|
moves. Take no other action — do not merge, and **post no
|
|
482
|
-
comment on a clean handoff**: nothing is wrong, so
|
|
555
|
+
comment on a clean handoff**: nothing is wrong, so that one label is the
|
|
483
556
|
whole message. A comment is how the loop records what a label cannot; a clean PR
|
|
484
557
|
has nothing to record. An `ai-notes` handoff is the exception per the budget
|
|
485
558
|
table — ≤10 lines through the marker upsert, linking the reviewer's
|
|
486
559
|
`### Before merging` rather than restating it.
|
|
487
560
|
|
|
561
|
+
**Reconcile on `CLEAN` only — never on a missing `ai-ok-*`.** The handoff strips
|
|
562
|
+
that pair itself, so a rule that stripped `merge-ready` whenever a pass label was
|
|
563
|
+
absent would undo the tick before it on every handed-off PR, leaving it with no
|
|
564
|
+
labels at all, matching no selector in any pass, and assigned to a human with
|
|
565
|
+
nothing saying why it is theirs.
|
|
566
|
+
|
|
488
567
|
**Never strip `ai-notes` here.** It is the whole point of the handoff: it has to
|
|
489
568
|
survive to the moment of merging, which is the moment it is for. A ready PR reads
|
|
490
569
|
one of two ways, and the difference must be legible without opening anything:
|
|
491
570
|
|
|
492
571
|
| Labels | Means |
|
|
493
572
|
|---|---|
|
|
494
|
-
| `merge-ready` |
|
|
495
|
-
| `merge-ready
|
|
573
|
+
| `merge-ready` | Merge freely. |
|
|
574
|
+
| `merge-ready`, `ai-notes` | Passed, but open the comments first. |
|
|
496
575
|
|
|
497
576
|
**Check it can actually merge before calling it ready.** The `ai-ok-*` labels
|
|
498
577
|
report the *agent review* verdict and nothing more — they say nothing about
|
|
@@ -539,8 +618,8 @@ gh pr edit <N> --add-assignee @me ${AGENT_USER:+--remove-assignee "$AGENT_USER"}
|
|
|
539
618
|
Count it as `rev`. Idempotent, so it also picks up ones an earlier tick stranded.
|
|
540
619
|
|
|
541
620
|
So: every open PR **authored by `dependabot[bot]`**, labelled both `ai-ok-code`
|
|
542
|
-
and `ai-ok-sec
|
|
543
|
-
`autoMergeRequest` yet:
|
|
621
|
+
and `ai-ok-sec` (or `merge-ready`), **not** `ai-changes`, **not** `ai-notes`, that
|
|
622
|
+
has no `autoMergeRequest` yet:
|
|
544
623
|
|
|
545
624
|
```bash
|
|
546
625
|
gh pr merge <N> --auto --squash --delete-branch
|
|
@@ -556,10 +635,78 @@ no human picks it up. Merging
|
|
|
556
635
|
unattended when a reviewer flagged something for a human writes the note into the
|
|
557
636
|
void, which is the one way this label can be worse than useless.
|
|
558
637
|
|
|
559
|
-
**
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
638
|
+
**CI red on an issue PR is a send-back, not a wait.** Reviewers are diff-scoped
|
|
639
|
+
and never see CI, so both arms happily pass a PR whose `build` failed two minutes
|
|
640
|
+
after it opened — and nothing else in the pipeline was ever going to dispatch a
|
|
641
|
+
fix. Observed on #543 (2026-08-26): the human found it via the red ✗ on the PR
|
|
642
|
+
page, which is precisely the noticing this loop exists to do. `ai-changes` **is**
|
|
643
|
+
the send-back label; Pass 3 dispatches the fix-round implementer off it, under
|
|
644
|
+
the same 2-round budget.
|
|
645
|
+
|
|
646
|
+
So for every open **non-Dependabot** PR carrying any `ai-*` label, with a
|
|
647
|
+
completed `FAILURE` on a **required** check:
|
|
648
|
+
|
|
649
|
+
```bash
|
|
650
|
+
gh pr checks <N> --required --json name,state,link 2>/dev/null \
|
|
651
|
+
| jq -r '.[] | select(.state == "FAILURE") | "\(.name)\t\(.link)"'
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
1. `gh pr edit <N> --add-label ai-changes --remove-label ai-review --remove-label ai-ok-code --remove-label ai-ok-sec --remove-label ai-notes --remove-label merge-ready`
|
|
655
|
+
2. **Comment through the marker upsert** — ≤10 lines, naming the failing check
|
|
656
|
+
and pasting the relevant excerpt from `gh run view <run-id> --log-failed`
|
|
657
|
+
(the run id is in that check's `link`). This step is not optional: the
|
|
658
|
+
fix-round prompt reads the PR's comments *as its instructions*, so without it
|
|
659
|
+
the implementer arrives at a PR marked `ai-changes` with nothing telling it
|
|
660
|
+
what changed or why.
|
|
661
|
+
|
|
662
|
+
**Write that excerpt to a file and pass `--body-file`; never interpolate the
|
|
663
|
+
log into the command.** A failing job prints whatever the branch told it to,
|
|
664
|
+
and on a public repo the branch is a stranger's — so the excerpt is untrusted
|
|
665
|
+
bytes that a contributor chooses. Inline `--body "$(gh run view …)"` puts
|
|
666
|
+
megabytes of it, control characters and all, through the shell and past
|
|
667
|
+
GitHub's comment size cap. The same rule already governs reviewer verdicts
|
|
668
|
+
further down; this is the one other place a body is assembled from output
|
|
669
|
+
nobody in this pipeline wrote. Trim to the failing lines before writing.
|
|
670
|
+
3. Count it as `ci-red` for Pass 5, which carries the `⚠`.
|
|
671
|
+
|
|
672
|
+
**Say in the comment that the fix may not be code.** #543's failure was the
|
|
673
|
+
dogfood check finding a *bootstrap* gap — a label present in the canonical table
|
|
674
|
+
and not yet on the repo — where the fix was `gh label create` / `fix labels`, or
|
|
675
|
+
an `ACCEPTED` entry in `scripts/dogfood.mjs`, and never a branch edit. The
|
|
676
|
+
implementer has repo-write, so leave that path open; a comment that assumes the
|
|
677
|
+
branch is at fault steers it into editing code that is not wrong.
|
|
678
|
+
|
|
679
|
+
Two carve-outs, both so the loop does not fight itself:
|
|
680
|
+
|
|
681
|
+
- **An `ai-reviewing-code` / `ai-reviewing-sec` claim is active** — leave the PR
|
|
682
|
+
alone this tick. A reviewer is mid-run, and the fix round relabels `ai-review`
|
|
683
|
+
and re-spawns both arms anyway, so sending back now only throws away a review
|
|
684
|
+
in flight.
|
|
685
|
+
- **`ai-changes` is already on the PR** — leave it. Re-applying is not free:
|
|
686
|
+
Pass 3 counts `ai-changes` applications off the timeline and stops at three, so
|
|
687
|
+
a stateless 15-minute loop re-adding it while CI stays red would exhaust the
|
|
688
|
+
round budget within the hour and mark the issue `ai-blocked` before any agent
|
|
689
|
+
had done anything.
|
|
690
|
+
|
|
691
|
+
**`--required`, not the whole rollup.** `statusCheckRollup` also carries optional
|
|
692
|
+
and third-party contexts, and an advisory check going red is not a broken PR —
|
|
693
|
+
sending one back spends a fix round to change nothing. The required set is the
|
|
694
|
+
actual merge gate, and `gh` already resolves which checks are in it. Dropping
|
|
695
|
+
`ai-review` in step 1 is the mirror of what a `CHANGES` verdict does: leaving it
|
|
696
|
+
on would have Pass 3 spawn reviewers *and* a fix round against one PR, reviewing
|
|
697
|
+
a diff that is being rewritten underneath them. The implementer re-adds it when
|
|
698
|
+
it pushes.
|
|
699
|
+
|
|
700
|
+
No new label. `ai-changes` plus that comment already say "sent back, and why";
|
|
701
|
+
if telling a review-rejected PR from a CI-rejected one in the list view ever
|
|
702
|
+
matters, add a `ci-failing` rider on top of `ai-changes` then, not speculatively
|
|
703
|
+
now.
|
|
704
|
+
|
|
705
|
+
**A Dependabot PR is the exception — flag it, never send it back.** There is no
|
|
706
|
+
fix round for one (Pass 3 treats `ai-changes` on a bot PR as terminal), so a red
|
|
707
|
+
one that already armed auto-merge will sit queued forever and only a human can
|
|
708
|
+
choose between a fix and a close. Count these as `ci-red` too; take no other
|
|
709
|
+
action:
|
|
563
710
|
|
|
564
711
|
```bash
|
|
565
712
|
gh pr list --state open --json number,autoMergeRequest,statusCheckRollup \
|
|
@@ -719,13 +866,16 @@ where `ai-blocked` would not be — nothing is lost, only the queue is honest.
|
|
|
719
866
|
**Then re-check `core.bare`** — the same probe as Pass 0, against the same `ROOT`:
|
|
720
867
|
|
|
721
868
|
```bash
|
|
722
|
-
if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
|
|
869
|
+
if [ "$(env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
|
|
723
870
|
echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
|
|
724
|
-
git -C "$ROOT" config core.bare false || {
|
|
871
|
+
env -u GIT_DIR -u GIT_WORK_TREE git -C "$ROOT" config core.bare false || {
|
|
725
872
|
echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
|
|
726
873
|
fi
|
|
727
874
|
```
|
|
728
875
|
|
|
876
|
+
The `env -u` prefix carries the same weight here as in Pass 0, and for the same
|
|
877
|
+
reason — keep it on both lines.
|
|
878
|
+
|
|
729
879
|
This is the last pass that *removes* worktrees, not the tick's last touch on the main
|
|
730
880
|
checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is exactly
|
|
731
881
|
why the re-check belongs here: it catches a flip after this pass's removals and before
|
|
@@ -1288,49 +1438,73 @@ mkdir -p "$WT_ROOT"
|
|
|
1288
1438
|
git -C "$ROOT" worktree add "$WT_ROOT/$SLUG" -b "$SLUG" origin/main
|
|
1289
1439
|
```
|
|
1290
1440
|
|
|
1291
|
-
**Then give it dependencies —
|
|
1292
|
-
|
|
1441
|
+
**Then give it dependencies — from the repo's own symlink list.** `fix ai` writes
|
|
1442
|
+
`worktree.symlinkDirectories` into `.claude/settings.json`: the root
|
|
1443
|
+
`node_modules`, plus one entry per workspace package that has one, globbed from
|
|
1444
|
+
the repo's *own* `pnpm-workspace.yaml` / `package.json` `workspaces` (#406). That
|
|
1445
|
+
list is the single source of truth for what a worktree needs linked. Read it and
|
|
1446
|
+
do the linking here:
|
|
1293
1447
|
|
|
1294
1448
|
```bash
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
ln -s "$ROOT
|
|
1300
|
-
for app in "$ROOT"/apps/*/; do
|
|
1301
|
-
[ -d "$app/node_modules" ] || continue
|
|
1302
|
-
ln -s "$app/node_modules" "$WT_ROOT/$SLUG/apps/$(basename "$app")/node_modules"
|
|
1449
|
+
DIRS=$(jq -r '.worktree.symlinkDirectories[]? // empty' "$ROOT/.claude/settings.json" 2>/dev/null)
|
|
1450
|
+
for d in $DIRS; do
|
|
1451
|
+
[ -d "$ROOT/$d" ] || continue # an entry pointing at nothing links nothing
|
|
1452
|
+
mkdir -p "$(dirname "$WT_ROOT/$SLUG/$d")"
|
|
1453
|
+
ln -s "$ROOT/$d" "$WT_ROOT/$SLUG/$d"
|
|
1303
1454
|
done
|
|
1304
1455
|
```
|
|
1305
1456
|
|
|
1306
|
-
**
|
|
1457
|
+
**Read the setting, do not rely on it.** `worktree.symlinkDirectories` is a
|
|
1458
|
+
**Claude Code** setting, honoured by `EnterWorktree` — which this pipeline
|
|
1459
|
+
forbids outright (see below) and replaces with a raw `git worktree add`. So the
|
|
1460
|
+
setting is *inert for exactly the worktrees this loop creates*: `doctor` can
|
|
1461
|
+
report `Claude worktree settings: ok` while every agent worktree gets its
|
|
1462
|
+
dependencies by some other path, which is how #511/PR #526 ended up hand-installed.
|
|
1463
|
+
Taking the list as data and doing the `ln -s` here is what makes that check mean
|
|
1464
|
+
something for loop worktrees too, without either subsystem owning the other.
|
|
1465
|
+
|
|
1466
|
+
**No list, or no `.claude/settings.json` → install for real instead:**
|
|
1307
1467
|
|
|
1308
1468
|
```bash
|
|
1309
|
-
(cd "$WT_ROOT/$SLUG" && pnpm install)
|
|
1469
|
+
[ -z "$DIRS" ] && (cd "$WT_ROOT/$SLUG" && pnpm install)
|
|
1310
1470
|
```
|
|
1311
1471
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
the
|
|
1319
|
-
|
|
1472
|
+
That fallback is safe precisely because nothing was symlinked — the hazard below
|
|
1473
|
+
is `pnpm install` against a *symlinked* tree, not a real install in an isolated
|
|
1474
|
+
one. It costs a duplicate `node_modules` and about ten seconds, since pnpm
|
|
1475
|
+
hardlinks from the store. Run `npx @rtorcato/repo-tooling fix ai` in the repo to
|
|
1476
|
+
get the faster path back.
|
|
1477
|
+
|
|
1478
|
+
Why the list has to come from that file rather than a hand-rolled glob: pnpm
|
|
1479
|
+
workspaces keep the resolution that matters in each
|
|
1480
|
+
`packages/<name>/node_modules`, and an earlier version of this pass linked the
|
|
1481
|
+
root and `apps/*` only — so it reproduced that gap on every repo that nests its
|
|
1482
|
+
packages anywhere else. Measured on `api-common` 2026-08-20: the main checkout
|
|
1483
|
+
had per-package `node_modules` in **37 of 37** packages, the worktree had **1**.
|
|
1484
|
+
So `pnpm --filter <pkg> typecheck` there fails with `Cannot find module` rather
|
|
1485
|
+
than the real error — the agent cannot reproduce the bug, and the environment
|
|
1486
|
+
looks like the issue's fault. Issue #201 was handed back `ai-blocked` this way,
|
|
1487
|
+
well-diagnosed and untouched. `workspaceSymlinkDirs` already globs the consuming
|
|
1488
|
+
repo's own layout, so deriving the list is both shorter here and correct on repos
|
|
1489
|
+
this file has never seen.
|
|
1320
1490
|
|
|
1321
1491
|
**Never force `pnpm install` against a symlinked tree.** It wants to purge and
|
|
1322
1492
|
rebuild the modules dir (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`), which
|
|
1323
1493
|
mutates the **main checkout's** `node_modules` — shared by every other worktree
|
|
1324
1494
|
and yanked out from under any agent mid-typecheck. `CI=true` and
|
|
1325
1495
|
`--config.confirmModulesPurge=false` both silence that prompt; neither makes it
|
|
1326
|
-
safe.
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1496
|
+
safe. Now that the default path symlinks, this rule is **load-bearing rather than
|
|
1497
|
+
advisory** — the implementer prompt below states it, and `browser-common` #145 →
|
|
1498
|
+
PR #147 is what an implementer doing it anyway costs: the main checkout's `.bin`
|
|
1499
|
+
emptied, surfacing arbitrarily later in a human's `git push`. Pass 2's rebuild is
|
|
1500
|
+
the one sanctioned exception, and only because it is gated on no worktree
|
|
1501
|
+
surviving.
|
|
1502
|
+
|
|
1503
|
+
**Once per repo, exclude the symlinks from git.** Repos ignore `node_modules/`
|
|
1504
|
+
*with a trailing slash*, which does not match a symlink — so every link shows as
|
|
1505
|
+
untracked in every worktree and a `git add -A` commits it. The pattern below has
|
|
1506
|
+
no slash, so it matches at any depth and covers the nested workspace links too.
|
|
1507
|
+
`.git/info/exclude` is shared by all worktrees and never committed:
|
|
1334
1508
|
|
|
1335
1509
|
```bash
|
|
1336
1510
|
grep -qxF 'node_modules' "$ROOT/.git/info/exclude" || echo 'node_modules' >> "$ROOT/.git/info/exclude"
|
|
@@ -1344,9 +1518,9 @@ directory — the two rules are incompatible, so the call can only ever be refus
|
|
|
1344
1518
|
five-plus times in one tick, each producing *"this session is isolated in the worktree
|
|
1345
1519
|
…"* refusals on unrelated orchestrator commands. Implementers work via
|
|
1346
1520
|
`git -C <absolute worktree path>` instead, which is what the prompt below says.
|
|
1347
|
-
Creating the worktree here also fixes the `worktree-` branch-prefix drift, and
|
|
1348
|
-
|
|
1349
|
-
`
|
|
1521
|
+
Creating the worktree here also fixes the `worktree-` branch-prefix drift, and it is
|
|
1522
|
+
why the symlinks above are created explicitly *from* `worktree.symlinkDirectories`
|
|
1523
|
+
rather than by `EnterWorktree` honouring it.
|
|
1350
1524
|
|
|
1351
1525
|
**Spawn implementers one at a time — never two in the same message.** The worktree pin
|
|
1352
1526
|
is a property of the session, not of an agent, so concurrent spawns cross-pin: the
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ai-workflow
|
|
3
3
|
description: |
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
**The entry point for the `ai-ready` issue pipeline — start here.** Implements
|
|
5
|
+
the queue in parallel, one agent per issue, each in its own git worktree,
|
|
6
|
+
ending at open PRs reviewed by two agents; then registers the ai-issue-loop
|
|
7
|
+
engine on a 15-minute loop to carry those PRs through fix rounds and cleanup.
|
|
8
|
+
Use when the user says "burst the queue", "run the AI pipeline", "work the
|
|
9
|
+
ai-ready issues", or invokes `/ai-workflow`. Never merges. GitHub only
|
|
10
|
+
(`gh`) — not GitLab.
|
|
9
11
|
---
|
|
10
12
|
|
|
11
13
|
# ai-workflow
|
|
@@ -127,10 +129,11 @@ done
|
|
|
127
129
|
```
|
|
128
130
|
|
|
129
131
|
**Then give each worktree dependencies** — the loop skill's Pass 4 rules apply
|
|
130
|
-
verbatim: symlink
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
verbatim: symlink every entry of `worktree.symlinkDirectories` from
|
|
133
|
+
`$ROOT/.claude/settings.json` (the root `node_modules` plus each workspace
|
|
134
|
+
package's, written by `fix ai`); run a real `pnpm install` in the worktree only
|
|
135
|
+
when that list is missing or empty; never force an install against a symlinked
|
|
136
|
+
tree; and add `node_modules` to `$ROOT/.git/info/exclude` once per repo.
|
|
134
137
|
|
|
135
138
|
Stop here on `--label-only`. Report the picks and — briefly — what you skipped
|
|
136
139
|
and why.
|
|
@@ -278,8 +281,8 @@ and still wearing a stale `ai-review`. Close that window here: once per PR
|
|
|
278
281
|
whose two review arms both completed, apply the `ai-issue-loop` skill's Pass 1
|
|
279
282
|
**by reference — execute what its text currently says, never a copy of it
|
|
280
283
|
here**. A second copy of the handoff logic is drift with two files to keep
|
|
281
|
-
honest; deferring means changes to Pass 1 (
|
|
282
|
-
take effect here without touching this file.
|
|
284
|
+
honest; deferring means changes to Pass 1 (its `merge-ready` handoff, its CI-red
|
|
285
|
+
send-back) take effect here without touching this file.
|
|
283
286
|
|
|
284
287
|
- **Both arms passed** → run ai-issue-loop's Pass 1 handoff/send-back logic
|
|
285
288
|
on this PR, per its current text — with one carve-out: `mergeStateStatus`
|