@rtorcato/repo-tooling 3.45.0 → 4.1.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.
@@ -1,220 +0,0 @@
1
- import path from 'node:path';
2
- import chalk from 'chalk';
3
- import fs from 'fs-extra';
4
- import { realGhExec } from './github-settings.js';
5
- /**
6
- * `ai-issue-loop` label hygiene (#446). Same class of GitHub-side drift as
7
- * github-settings.ts and milestones.ts, on the same `gh` seam.
8
- *
9
- * The bug this exists for: the skill's bootstrap uses `gh label create`, which
10
- * *errors as a no-op* when the label already exists. It can add a missing label
11
- * but can never repair an existing one, so a hand-created `ai-ready` keeps
12
- * whatever colour the web picker gave it forever. Measured across eight repos,
13
- * six had `ai-ready` at `B60205` — byte-identical to `ai-blocked`, so "an agent
14
- * should pick this up" and "an agent gave up" rendered the same. Repair here
15
- * goes through `gh label edit`, which is the whole point of the fixer.
16
- *
17
- * This table is the single source of truth for the label set. The bootstrap
18
- * block in skills/ai-issue-loop/SKILL.md is asserted against it in
19
- * tests/base/labels.test.ts, so the two cannot drift apart.
20
- */
21
- const CHECK = 'AI loop labels';
22
- export const LOOP_LABELS = [
23
- {
24
- name: 'holding',
25
- color: '5319e7',
26
- description: 'Gate/holding issue — human judgement, never auto-picked',
27
- },
28
- { name: 'ai-ready', color: '0e8a16', description: 'Eligible for an AI agent to implement' },
29
- { name: 'ai-wip', color: 'fbca04', description: 'Claimed by an agent; worktree exists' },
30
- { name: 'ai-blocked', color: 'b60205', description: 'Agent gave up; needs a human' },
31
- { name: 'ai-review', color: '1d76db', description: 'PR awaiting agent review' },
32
- { name: 'ai-reviewing-code', color: 'c5def5', description: 'code-reviewer claimed and running' },
33
- { name: 'ai-reviewing-sec', color: 'c5def5', description: 'security-expert claimed and running' },
34
- { name: 'ai-ok-code', color: '0e8a16', description: 'code-reviewer passed' },
35
- { name: 'ai-ok-sec', color: '0e8a16', description: 'security-expert passed' },
36
- { name: 'ai-changes', color: 'd93f0b', description: 'Reviewer requested changes' },
37
- {
38
- name: 'ai-fixing',
39
- color: '006b75',
40
- description: 'Fix-round implementer claimed and running',
41
- },
42
- {
43
- name: 'ai-notes',
44
- color: 'fbca04',
45
- description: 'Passed, but a reviewer left something to read before merging',
46
- },
47
- {
48
- name: 'merge-ready',
49
- color: '8250df',
50
- description: 'Both agent reviews passed and the PR is mergeable — waiting on a human',
51
- },
52
- {
53
- name: 'ai-suggested',
54
- color: 'c2e0c6',
55
- description: 'Follow-up surfaced by an agent review — triage queue, never auto-picked',
56
- },
57
- ];
58
- /**
59
- * How many of the set have to exist before this repo counts as running the
60
- * loop. A repo with none has opted out, not drifted — creating fourteen labels it
61
- * will never use is the nag this threshold exists to prevent. One alone is the
62
- * observed half-state (`cf-common` has only `ai-ready`, applied by hand), which
63
- * is likewise not evidence the pipeline runs there.
64
- */
65
- const IN_USE_THRESHOLD = 2;
66
- const skip = (reason) => ({
67
- check: CHECK,
68
- status: 'ok',
69
- detail: `skipped — ${reason}`,
70
- });
71
- /**
72
- * Case-insensitive, `#`-insensitive. The drift that started this was uppercase
73
- * `B60205` against lowercase `b60205` — GitHub's colour picker writes uppercase,
74
- * `gh` writes lowercase, and the two render identically. Comparing raw would
75
- * report every hand-created label as drift forever and have the fixer PATCH a
76
- * colour that was already correct.
77
- */
78
- const normalizeColor = (c) => c.trim().replace(/^#/, '').toLowerCase();
79
- async function readLabels(gh) {
80
- const r = await gh(['label', 'list', '--json', 'name,color,description', '--limit', '200']);
81
- if (!r.ok)
82
- return null;
83
- try {
84
- const parsed = JSON.parse(r.stdout);
85
- if (!Array.isArray(parsed))
86
- return null;
87
- const byName = new Map();
88
- for (const l of parsed) {
89
- if (typeof l?.name !== 'string')
90
- continue;
91
- byName.set(l.name, {
92
- name: l.name,
93
- color: typeof l.color === 'string' ? l.color : '',
94
- description: typeof l.description === 'string' ? l.description : '',
95
- });
96
- }
97
- return byName;
98
- }
99
- catch {
100
- return null;
101
- }
102
- }
103
- export function classifyLabels(existing) {
104
- const deltas = { present: [], missing: [], wrongColor: [], wrongDescription: [] };
105
- for (const spec of LOOP_LABELS) {
106
- const actual = existing.get(spec.name);
107
- if (!actual) {
108
- deltas.missing.push(spec);
109
- continue;
110
- }
111
- deltas.present.push(spec);
112
- if (normalizeColor(actual.color) !== normalizeColor(spec.color))
113
- deltas.wrongColor.push({ spec, actual: normalizeColor(actual.color) });
114
- if (actual.description.trim() !== spec.description)
115
- deltas.wrongDescription.push(spec);
116
- }
117
- return deltas;
118
- }
119
- const names = (specs) => specs.map((s) => `\`${s.name}\``).join(', ');
120
- export async function checkLoopLabels(dir, exec) {
121
- // Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
122
- if (!(await fs.pathExists(path.join(dir, '.git'))))
123
- return skip('not a git repository');
124
- const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
125
- const existing = await readLabels(gh);
126
- if (!existing)
127
- return skip('could not read labels');
128
- const { present, missing, wrongColor, wrongDescription } = classifyLabels(existing);
129
- if (present.length < IN_USE_THRESHOLD)
130
- return {
131
- check: CHECK,
132
- status: 'ok',
133
- detail: 'not applicable — repo does not use the ai-issue-loop labels',
134
- };
135
- const deltas = [];
136
- for (const { spec, actual } of wrongColor)
137
- deltas.push(`\`${spec.name}\` is #${actual}, should be #${spec.color}`);
138
- if (wrongDescription.length)
139
- deltas.push(`wrong description: ${names(wrongDescription)}`);
140
- if (missing.length)
141
- deltas.push(`missing: ${names(missing)}`);
142
- if (deltas.length)
143
- return {
144
- check: CHECK,
145
- status: 'drift',
146
- detail: deltas.join('; '),
147
- hint: 'Run `npx @rtorcato/repo-tooling fix labels` to repair them with `gh label edit` — `gh label create` cannot change an existing label, which is how this drifted',
148
- };
149
- return {
150
- check: CHECK,
151
- status: 'ok',
152
- detail: `${present.length} ai-issue-loop label(s) match spec`,
153
- };
154
- }
155
- /**
156
- * Repairs colour and description with `gh label edit`, and creates the labels
157
- * the set is missing. Only on a repo already running the loop (the same
158
- * `IN_USE_THRESHOLD` gate the check uses) — otherwise a plain `fix --yes` would
159
- * push fourteen labels into every repo it touches.
160
- *
161
- * Idempotent: an aligned repo is a no-op, and a label whose only difference is
162
- * the hex case is not touched at all.
163
- *
164
- * Advisories go to `console.error`; stdout carries the `--json` payload (#357).
165
- */
166
- export async function applyLoopLabels(dir, exec) {
167
- if (!(await fs.pathExists(path.join(dir, '.git')))) {
168
- console.error(chalk.gray(' skipped — not a git repository'));
169
- return [];
170
- }
171
- const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
172
- const existing = await readLabels(gh);
173
- if (!existing) {
174
- console.error(chalk.gray(' skipped — could not read labels'));
175
- return [];
176
- }
177
- const { present, missing, wrongColor, wrongDescription } = classifyLabels(existing);
178
- if (present.length < IN_USE_THRESHOLD) {
179
- console.error(chalk.gray(' skipped — repo does not use the ai-issue-loop labels'));
180
- return [];
181
- }
182
- // One edit per drifted label, whichever field drifted — the API takes both.
183
- const toEdit = new Set([...wrongColor.map((w) => w.spec), ...wrongDescription]);
184
- const applied = [];
185
- for (const spec of toEdit) {
186
- // `edit`, not `create`: create errors as a no-op on an existing label, so a
187
- // fixer built on it would silently repair nothing (#446).
188
- const r = await gh([
189
- 'label',
190
- 'edit',
191
- spec.name,
192
- '--color',
193
- spec.color,
194
- '--description',
195
- spec.description,
196
- ]);
197
- if (r.ok)
198
- applied.push(`repaired label "${spec.name}"`);
199
- else
200
- console.error(chalk.yellow(` could not repair "${spec.name}": ${r.stderr.trim() || 'gh error'}`));
201
- }
202
- for (const spec of missing) {
203
- const r = await gh([
204
- 'label',
205
- 'create',
206
- spec.name,
207
- '--color',
208
- spec.color,
209
- '--description',
210
- spec.description,
211
- ]);
212
- if (r.ok)
213
- applied.push(`created label "${spec.name}"`);
214
- else
215
- console.error(chalk.yellow(` could not create "${spec.name}": ${r.stderr.trim() || 'gh error'}`));
216
- }
217
- if (applied.length === 0)
218
- console.error(chalk.gray(' labels already match spec'));
219
- return applied;
220
- }
@@ -1,100 +0,0 @@
1
- import path from 'node:path';
2
- import chalk from 'chalk';
3
- import { realGitExec } from '../../base/git-identity.js';
4
- import { realGhExec } from '../../base/github-settings.js';
5
- import { defaultWorktreeRoot, findLive } from './loop-guard.js';
6
- /** How far back to look for the squash subject. */
7
- const SQUASH_WINDOW = 200;
8
- async function findPr(gh, heads) {
9
- for (const head of heads) {
10
- const r = await gh(['pr', 'list', '--head', head, '--state', 'all', '--json', 'number,state']);
11
- if (!r.ok)
12
- continue;
13
- const [pr] = JSON.parse(r.stdout || '[]');
14
- if (pr)
15
- return pr;
16
- }
17
- return null;
18
- }
19
- export async function runLoopCleanup(options = {}) {
20
- const root = path.resolve(options.root ?? process.cwd());
21
- const worktreeRoot = options.worktreeRoot
22
- ? path.resolve(options.worktreeRoot)
23
- : defaultWorktreeRoot(root);
24
- // Fetch and `worktree remove` outlast the 5s probe timeout on a real repo.
25
- const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
26
- const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
27
- const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
28
- let subjects = null;
29
- const worktrees = [];
30
- for (const dir of dirs) {
31
- const slug = path.basename(dir);
32
- const issue = Number(slug.match(/^ai-(\d+)-/)?.[1]) || null;
33
- // `worktree-` is the prefix EnterWorktree once gave branches — legacy.
34
- const heads = [slug, `worktree-${slug}`];
35
- const branch = (await git(['branch', '--list', ...heads, '--format=%(refname:short)']))
36
- ?.split('\n')[0]
37
- ?.trim() || null;
38
- const pr = await findPr(gh, heads);
39
- const entry = {
40
- path: dir,
41
- issue,
42
- branch,
43
- pr: pr?.number ?? null,
44
- prState: pr?.state ?? null,
45
- action: 'kept',
46
- reason: '',
47
- };
48
- worktrees.push(entry);
49
- if (!pr || pr.state === 'OPEN') {
50
- entry.reason = pr ? 'PR still open' : 'no PR';
51
- continue;
52
- }
53
- if (pr.state === 'MERGED') {
54
- if (subjects === null) {
55
- // Best-effort: a failed fetch leaves origin/main stale, which can
56
- // only keep a worktree, never remove one wrongly.
57
- await git(['fetch', '--prune', 'origin']);
58
- const log = await git(['log', 'origin/main', `-n${SQUASH_WINDOW}`, '--format=%s']);
59
- subjects = log ? log.split('\n') : [];
60
- }
61
- if (!subjects.some((s) => s.trimEnd().endsWith(`(#${pr.number})`))) {
62
- entry.reason = `squash for #${pr.number} not on origin/main`;
63
- continue;
64
- }
65
- }
66
- if ((await git(['worktree', 'remove', '--force', dir])) === null) {
67
- entry.action = 'remove-failed';
68
- entry.reason = 'git worktree remove failed';
69
- continue;
70
- }
71
- if (branch)
72
- await git(['branch', '-D', branch]);
73
- entry.action = 'removed';
74
- entry.reason = pr.state === 'MERGED' ? `#${pr.number} landed on main` : `#${pr.number} closed`;
75
- }
76
- return {
77
- root,
78
- worktreeRoot,
79
- removed: worktrees.some((w) => w.action === 'removed'),
80
- worktrees,
81
- exitCode: worktrees.some((w) => w.action === 'remove-failed') ? 1 : 0,
82
- };
83
- }
84
- export async function loopCleanupCommand(options) {
85
- const result = await runLoopCleanup(options);
86
- if (options.json) {
87
- console.log(JSON.stringify(result, null, 2));
88
- }
89
- else {
90
- console.log();
91
- if (result.worktrees.length === 0)
92
- console.log(chalk.gray(' no ai-* worktrees'));
93
- for (const w of result.worktrees) {
94
- const line = `${w.action.padEnd(13)} ${path.basename(w.path)} — ${w.reason}`;
95
- console.log(` ${w.action === 'remove-failed' ? chalk.yellow(line) : chalk.gray(line)}`);
96
- }
97
- console.log();
98
- }
99
- process.exitCode = result.exitCode;
100
- }
@@ -1,76 +0,0 @@
1
- import path from 'node:path';
2
- import { LOGIN } from '../../base/agent-user.js';
3
- import { realGitExec } from '../../base/git-identity.js';
4
- import { realGhExec } from '../../base/github-settings.js';
5
- import { configuredAgentUser, defaultWorktreeRoot } from './loop-guard.js';
6
- export async function ghOut(gh, args) {
7
- const r = await gh(args);
8
- return r.ok ? r.stdout.trim() : '';
9
- }
10
- export async function resolveLoopEnv(options = {}) {
11
- const dir = path.resolve(options.dir ?? process.cwd());
12
- const git = options.git ?? ((args) => realGitExec(args, dir));
13
- const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, dir));
14
- const env = options.env ?? process.env;
15
- const warnings = [];
16
- // Absolute, so the common dir is `<main checkout>/.git` from anywhere.
17
- const common = (await git(['rev-parse', '--path-format=absolute', '--git-common-dir']))?.trim();
18
- const root = common ? path.resolve(common, '..') : '';
19
- if (!root)
20
- warnings.push('not a git repository');
21
- const worktreeRoot = root ? defaultWorktreeRoot(root) : '';
22
- const ownerRepo = await ghOut(gh, [
23
- 'repo',
24
- 'view',
25
- '--json',
26
- 'nameWithOwner',
27
- '--jq',
28
- '.nameWithOwner',
29
- ]);
30
- if (!ownerRepo)
31
- warnings.push('could not resolve the GitHub repo from the working directory');
32
- let agentUser = env.AI_LOOP_AGENT?.trim() || (root ? await configuredAgentUser(root) : '') || '';
33
- // LOGIN is the injection boundary — the login goes into an API path.
34
- if (agentUser &&
35
- (!ownerRepo ||
36
- !LOGIN.test(agentUser) ||
37
- !(await gh(['api', `repos/${ownerRepo}/assignees/${agentUser}`, '--silent'])).ok)) {
38
- warnings.push(`agentUser '${agentUser}' is not an assignable collaborator — assigning nothing`);
39
- agentUser = '';
40
- }
41
- const humanUser = ownerRepo
42
- ? await ghOut(gh, [
43
- 'api',
44
- `repos/${ownerRepo}`,
45
- '--jq',
46
- 'if .owner.type == "User" then .owner.login else "" end',
47
- ])
48
- : '';
49
- const me = await ghOut(gh, ['api', 'user', '--jq', '.login']);
50
- return { root, worktreeRoot, ownerRepo, agentUser, humanUser, me, warnings };
51
- }
52
- const VARS = [
53
- ['ROOT', 'root'],
54
- ['WT_ROOT', 'worktreeRoot'],
55
- ['OWNER_REPO', 'ownerRepo'],
56
- ['AGENT_USER', 'agentUser'],
57
- ['HUMAN_USER', 'humanUser'],
58
- ['ME', 'me'],
59
- ];
60
- /** `KEY='value'` lines, single-quoted so `eval "$(… loop env)"` is safe. */
61
- export function toShell(env) {
62
- return VARS.map(([name, key]) => `${name}='${env[key].replaceAll("'", `'\\''`)}'`).join('\n');
63
- }
64
- export async function loopEnvCommand(options) {
65
- const env = await resolveLoopEnv(options);
66
- if (options.json) {
67
- console.log(JSON.stringify(env, null, 2));
68
- }
69
- else {
70
- for (const w of env.warnings)
71
- console.error(`⚠ ${w}`);
72
- console.log(toShell(env));
73
- }
74
- // Nothing downstream works without both — the skill bails on either.
75
- process.exitCode = env.root && env.ownerRepo ? 0 : 1;
76
- }
@@ -1,232 +0,0 @@
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
- import { realGhExec } from '../../base/github-settings.js';
7
- /**
8
- * The invariant table from the skill, verified on git 2.55.0:
9
- *
10
- * | repo state | `--is-inside-work-tree` | `.git` |
11
- * |---|---|---|
12
- * | healthy checkout | `true`, exit 0 | directory |
13
- * | **wrongly bare** | `false`, **exit 0** | directory |
14
- * | genuinely bare | `false`, exit 0 | absent |
15
- * | linked worktree | `true`, exit 0 | file |
16
- *
17
- * Two things it encodes. **Stdout, not the exit code** — `rev-parse
18
- * --is-inside-work-tree` exits `0` either way and only *prints* the answer, so
19
- * an exit-code probe is dead code (`insideWorkTree` is `null` only when git
20
- * itself failed, i.e. there is no repo here). And **`.git` must be a directory
21
- * before repairing** — a genuinely bare repo prints `false` too, and nothing
22
- * else separates the two. This ships to consumers' machines, where "repairing"
23
- * someone's real bare clone is the damage rather than the fix.
24
- */
25
- export function classifyRoot(insideWorkTree, gitEntry) {
26
- if (insideWorkTree === null)
27
- return 'not-a-repo';
28
- if (insideWorkTree.trim() === 'true')
29
- return 'work-tree';
30
- if (gitEntry === 'directory')
31
- return 'wrongly-bare';
32
- // A linked worktree's `.git` is a file; its main checkout is where a repair
33
- // belongs, and that is not the path we were handed.
34
- if (gitEntry === 'file')
35
- return 'linked-worktree';
36
- return 'genuinely-bare';
37
- }
38
- const LOCKFILE = '.repo-tooling.json';
39
- /**
40
- * `rules.aiLoop.agentUser`, with the flat pre-v4 fallback — the same pair the
41
- * skill's `jq` reads. Read raw rather than through `readLockfile`, whose parser
42
- * requires a `record.config`: a hand-written rules-only lockfile is exactly the
43
- * file this has to see.
44
- */
45
- export async function configuredAgentUser(root) {
46
- const raw = await fs.readJson(path.join(root, LOCKFILE)).catch(() => null);
47
- const user = raw?.rules?.aiLoop?.agentUser ?? raw?.aiLoop?.agentUser;
48
- return typeof user === 'string' && user.trim() !== '' ? user.trim() : undefined;
49
- }
50
- /**
51
- * Declared intent that is not met is a misconfiguration, not a degraded mode —
52
- * so this halts, unlike the assignability check in `base/agent-user.ts`, which
53
- * warns and carries on. That check can never catch this: `agentUser` is
54
- * assignable regardless of who is calling.
55
- */
56
- export async function checkAgentIdentity(configured, gh) {
57
- if (!configured) {
58
- return {
59
- verdict: 'not-configured',
60
- message: `no aiLoop.agentUser in ${LOCKFILE} — identity check skipped`,
61
- };
62
- }
63
- const r = await gh(['api', 'user', '--jq', '.login']);
64
- const effective = r.ok ? r.stdout.trim() : '';
65
- // GitHub logins are case-insensitive, so a case difference is one account.
66
- if (effective !== '' && effective.toLowerCase() === configured.toLowerCase()) {
67
- return {
68
- verdict: 'match',
69
- message: `gh is authenticated as ${effective} — the configured agent account`,
70
- };
71
- }
72
- return {
73
- verdict: 'mismatch',
74
- message: `⚠ agentUser is ${configured} but gh authenticates as ${effective || '(gh could not say — unauthenticated or missing)'} — the tick would commit, push and review as the wrong account. Run \`npx @rtorcato/repo-tooling fix ai-loop-identity\` in this checkout, then relaunch the Claude session`,
75
- };
76
- }
77
- /**
78
- * `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
79
- * `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup.
80
- * `--config.confirmModulesPurge=false` gets past
81
- * `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare `pnpm
82
- * install` cannot repair this.
83
- */
84
- export const REBUILD_ARGS = [
85
- 'install',
86
- '--frozen-lockfile',
87
- '--config.confirmModulesPurge=false',
88
- ];
89
- const realInstall = (cwd) => new Promise((resolve) => {
90
- // stdout stays clean for the `--json` payload; pnpm's diagnostics are
91
- // still visible on stderr.
92
- const child = spawn('pnpm', [...REBUILD_ARGS], {
93
- cwd,
94
- stdio: ['ignore', 'ignore', 'inherit'],
95
- });
96
- child.on('close', (code) => resolve(code === 0));
97
- child.on('error', () => resolve(false));
98
- });
99
- async function gitEntryKind(root) {
100
- // lstat, not stat: the file/directory distinction is the whole discriminator.
101
- try {
102
- const stat = await fs.lstat(path.join(root, '.git'));
103
- return stat.isDirectory() ? 'directory' : 'file';
104
- }
105
- catch {
106
- return 'absent';
107
- }
108
- }
109
- /** `ai-*` directories one level down, in either place worktrees are kept. */
110
- export async function findLive(dirs) {
111
- const live = [];
112
- for (const dir of dirs) {
113
- // A missing worktree root is the normal case, not an error.
114
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
115
- for (const entry of entries) {
116
- if (entry.isDirectory() && entry.name.startsWith('ai-'))
117
- live.push(path.join(dir, entry.name));
118
- }
119
- }
120
- return live;
121
- }
122
- /** The sibling layout the skill uses: `<parent>/<name>-worktrees`. */
123
- export function defaultWorktreeRoot(root) {
124
- return path.join(path.dirname(root), `${path.basename(root)}-worktrees`);
125
- }
126
- const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
127
- const UNUSABLE = {
128
- 'genuinely-bare': 'main checkout is a genuinely bare repository (.git absent) — refusing to flip core.bare on a real bare clone',
129
- 'linked-worktree': '--root points at a linked worktree (.git is a file), not the main checkout — repair belongs on the main checkout',
130
- 'not-a-repo': 'not a git repository',
131
- };
132
- /**
133
- * One tick's worth of guarding, as data. The command wrapper prints it and
134
- * sets the exit code; tests call this directly.
135
- */
136
- export async function runLoopGuard(options = {}) {
137
- const root = path.resolve(options.root ?? process.cwd());
138
- const worktreeRoot = options.worktreeRoot
139
- ? path.resolve(options.worktreeRoot)
140
- : defaultWorktreeRoot(root);
141
- const git = options.git ?? ((args) => realGitExec(args, root));
142
- const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
143
- const install = options.install ?? realInstall;
144
- const messages = [];
145
- const state = classifyRoot(await git(['rev-parse', '--is-inside-work-tree']), await gitEntryKind(root));
146
- let bare = 'healthy';
147
- let exitCode = 0;
148
- if (state === 'wrongly-bare') {
149
- messages.push(`⚠ main checkout bare at ${stamp()} — repairing`);
150
- // Writes $ROOT/.git/config, which a restrictive sandbox refuses with
151
- // `error: could not lock config file .git/config`. Aborting beats
152
- // reporting a healthy repo while it stays broken.
153
- if ((await git(['config', 'core.bare', 'false'])) === null) {
154
- bare = 'repair-failed';
155
- exitCode = 1;
156
- messages.push('⚠ repair FAILED — main checkout still bare');
157
- }
158
- else {
159
- bare = 'repaired';
160
- messages.push('main checkout repaired — core.bare is false');
161
- }
162
- }
163
- else if (state !== 'work-tree') {
164
- bare = 'unrepairable';
165
- exitCode = 2;
166
- messages.push(`⚠ ${UNUSABLE[state]}`);
167
- }
168
- else {
169
- messages.push('main checkout is a work tree');
170
- }
171
- const { verdict: identity, message: identityMessage } = await checkAgentIdentity(await configuredAgentUser(root), gh);
172
- messages.push(identityMessage);
173
- // A failed repair (1) is the more specific verdict, so it keeps the code.
174
- if (identity === 'mismatch' && exitCode === 0)
175
- exitCode = 2;
176
- const live = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
177
- const rebuild = await decideRebuild({ root, removed: options.removed === true, exitCode, live });
178
- let outcome = rebuild;
179
- if (rebuild === 'deferred') {
180
- messages.push(`rebuild deferred — ${live.length} worktree(s) still live`);
181
- }
182
- else if (rebuild === 'rebuilt') {
183
- messages.push('rebuilding the main checkout’s node_modules');
184
- if (!(await install(root))) {
185
- outcome = 'rebuild-failed';
186
- // Not fatal: a broken .bin cannot corrupt a commit the way a bare
187
- // checkout does. Loud, though — a silent skip is the breakage this
188
- // guard exists to end.
189
- messages.push('⚠ node_modules rebuild FAILED — main checkout may be unbuildable');
190
- }
191
- else {
192
- messages.push('node_modules rebuilt');
193
- }
194
- }
195
- return { root, worktreeRoot, state, bare, identity, rebuild: outcome, live, exitCode, messages };
196
- }
197
- /**
198
- * The three load-bearing conditions, kept separate from the run so a test can
199
- * assert them without a pnpm install:
200
- *
201
- * - **`removed`** — set by every removal path, merged-PR cleanup *and* stall
202
- * reaping. A reaped worktree needs this most: its agent died mid-command.
203
- * - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
204
- * - **no live worktrees** — the rebuild *purges* the shared modules dir, which
205
- * would be yanked out from under any agent still running in a surviving
206
- * worktree. Deferring costs a broken main checkout until the last worktree
207
- * clears; not deferring costs a live implementer run.
208
- */
209
- export async function decideRebuild(input) {
210
- if (!input.removed)
211
- return 'not-requested';
212
- // Nothing runs against a root we are about to halt the tick over.
213
- if (input.exitCode !== 0)
214
- return 'skipped-root-unusable';
215
- if (!(await fs.pathExists(path.join(input.root, 'pnpm-lock.yaml'))))
216
- return 'skipped-no-lockfile';
217
- return input.live.length > 0 ? 'deferred' : 'rebuilt';
218
- }
219
- export async function loopGuardCommand(options) {
220
- const result = await runLoopGuard(options);
221
- if (options.json) {
222
- console.log(JSON.stringify(result, null, 2));
223
- }
224
- else {
225
- console.log();
226
- for (const line of result.messages) {
227
- console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
228
- }
229
- console.log();
230
- }
231
- process.exitCode = result.exitCode;
232
- }