@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.
- package/AGENTS.md +3 -25
- package/README.md +13 -16
- package/dist/base/checks.js +0 -132
- package/dist/base/fixers.js +2 -125
- package/dist/base/github-settings.js +0 -13
- package/dist/cli/commands/doctor.js +3 -23
- package/dist/cli/commands/fix-targets.js +4 -7
- package/dist/cli/commands/fix.js +1 -9
- package/dist/cli/commands/setup-presets.js +1 -0
- package/dist/cli/generators/brand.js +26 -6
- package/dist/cli/generators/docs-site.js +27 -14
- package/dist/cli/generators/pnpm-workspace.js +26 -0
- package/dist/cli/index.js +14 -102
- package/dist/cli/utils/lockfile.js +16 -7
- package/dist/languages/js/ci.js +9 -2
- package/dist/languages/js/fixers.js +31 -3
- package/package.json +1 -1
- package/dist/base/agent-user.js +0 -68
- package/dist/base/ai-loop-identity.js +0 -80
- package/dist/base/labels.js +0 -220
- package/dist/cli/commands/loop-cleanup.js +0 -100
- package/dist/cli/commands/loop-env.js +0 -76
- package/dist/cli/commands/loop-guard.js +0 -232
- package/dist/cli/commands/loop-marker.js +0 -169
- package/dist/cli/commands/loop-reap.js +0 -202
- package/dist/cli/commands/loop-tick.js +0 -349
- package/dist/cli/commands/loop-worktree.js +0 -132
- package/dist/cli/generators/claude-skills.js +0 -240
- package/skills/ai-issue/SKILL.md +0 -74
- package/skills/ai-issue-loop/SKILL.md +0 -597
- package/skills/ai-loop-status/SKILL.md +0 -122
- package/skills/ai-workflow/SKILL.md +0 -335
package/dist/base/labels.js
DELETED
|
@@ -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
|
-
}
|