@rtorcato/repo-tooling 3.44.0 โ 4.0.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 -23
- package/README.md +13 -16
- package/dist/base/checks.js +0 -132
- package/dist/base/fixers.js +0 -123
- 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/index.js +14 -93
- package/dist/cli/utils/lockfile.js +5 -7
- 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 -196
- 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 -1342
- package/skills/ai-loop-status/SKILL.md +0 -122
- package/skills/ai-workflow/SKILL.md +0 -337
package/dist/cli/index.js
CHANGED
|
@@ -5,12 +5,6 @@ import { Command } from 'commander';
|
|
|
5
5
|
import fs from 'fs-extra';
|
|
6
6
|
import { doctorCommand } from './commands/doctor.js';
|
|
7
7
|
import { fixCommand } from './commands/fix.js';
|
|
8
|
-
import { loopCleanupCommand } from './commands/loop-cleanup.js';
|
|
9
|
-
import { loopEnvCommand } from './commands/loop-env.js';
|
|
10
|
-
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
11
|
-
import { loopCommentCommand, loopVerdictCommand } from './commands/loop-marker.js';
|
|
12
|
-
import { loopReapCommand } from './commands/loop-reap.js';
|
|
13
|
-
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
14
8
|
import { setupProject } from './commands/setup.js';
|
|
15
9
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
16
10
|
import { getToolVersion } from './utils/version.js';
|
|
@@ -296,12 +290,6 @@ const TOOL_CATALOG = [
|
|
|
296
290
|
exports: [],
|
|
297
291
|
fixTarget: 'ai',
|
|
298
292
|
},
|
|
299
|
-
{
|
|
300
|
-
name: 'Claude skills',
|
|
301
|
-
description: 'Install the ai-issue-loop skill into ~/.claude/skills (user-global, opt-in โ see --skills-dir)',
|
|
302
|
-
exports: [],
|
|
303
|
-
fixTarget: 'claude-skills',
|
|
304
|
-
},
|
|
305
293
|
];
|
|
306
294
|
program
|
|
307
295
|
.command('list')
|
|
@@ -325,9 +313,6 @@ program
|
|
|
325
313
|
.description('๐ฉบ Diagnose project alignment with @rtorcato/repo-tooling presets')
|
|
326
314
|
.option('-d, --directory <path>', 'Target directory to diagnose', process.cwd())
|
|
327
315
|
.option('--json', 'Emit machine-readable JSON output')
|
|
328
|
-
// The read side of `fix --skills-dir` (#485). No --yes/--json requirement
|
|
329
|
-
// here: doctor never writes, so an unresolved directory is just reported.
|
|
330
|
-
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Pass the same path `fix` was given, or the skill reports as not installed')
|
|
331
316
|
// Rules are per-repo (#563): this compares them against another repo's, and
|
|
332
317
|
// only reports. Never drift, never a fixer, never part of the exit code.
|
|
333
318
|
.option('--rules-from <owner/repo>', "Report how this repo's config and rules differ from that repo's .repo-tooling.json (informational; read via `gh`)")
|
|
@@ -342,11 +327,6 @@ program
|
|
|
342
327
|
.option('--list', 'List all registered fix targets and exit')
|
|
343
328
|
.option('--resync', 'Re-scaffold every file recorded in .repo-tooling.json')
|
|
344
329
|
.option('--diff', 'Show a unified diff of each change before confirming')
|
|
345
|
-
.option('--skills-dir <path>', 'Where `fix claude-skills` installs user-global agent skills (default: ~/.claude/skills). Required with --yes/--json when that directory does not exist')
|
|
346
|
-
// Deliberately not folded into --yes: unattended runs pass --yes, and this is
|
|
347
|
-
// the one overwrite that destroys work living outside the repo (#480).
|
|
348
|
-
.option('--force-skills', 'Let `fix claude-skills` overwrite a locally modified skill instead of refusing')
|
|
349
|
-
.option('--gh-config-dir <path>', 'gh profile signed in as rules.aiLoop.agentUser, for `fix ai-loop-identity` (default: ~/.config/gh-<agentUser>)')
|
|
350
330
|
.action((target, options) => fixCommand(target, {
|
|
351
331
|
directory: options.directory,
|
|
352
332
|
yes: options.yes,
|
|
@@ -355,80 +335,21 @@ program
|
|
|
355
335
|
list: options.list,
|
|
356
336
|
resync: options.resync,
|
|
357
337
|
diff: options.diff,
|
|
358
|
-
skillsDir: options.skillsDir,
|
|
359
|
-
forceSkills: options.forceSkills,
|
|
360
|
-
ghConfigDir: options.ghConfigDir,
|
|
361
338
|
}));
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
loop
|
|
367
|
-
.
|
|
368
|
-
.
|
|
369
|
-
.
|
|
370
|
-
.
|
|
371
|
-
.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
' 2 root is not a repairable main checkout (bare clone, linked worktree, or not a repo) โ halt the tick\n')
|
|
377
|
-
.action(loopGuardCommand);
|
|
378
|
-
loop
|
|
379
|
-
.command('env')
|
|
380
|
-
.description('๐งญ Resolve ROOT, WT_ROOT, OWNER_REPO, AGENT_USER, HUMAN_USER and ME once')
|
|
381
|
-
.option('-d, --dir <path>', 'Directory to resolve from โ main checkout or any worktree', process.cwd())
|
|
382
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
383
|
-
.addHelpText('after', "\nWithout --json, prints KEY='value' lines for eval. Empty means none.\n" +
|
|
384
|
-
'Exits 1 when the checkout or its GitHub repo cannot be resolved.\n')
|
|
385
|
-
.action(loopEnvCommand);
|
|
386
|
-
loop
|
|
387
|
-
.command('cleanup')
|
|
388
|
-
.description('๐งน Remove ai-* worktrees whose PR landed on main or was closed')
|
|
389
|
-
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
390
|
-
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
391
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
392
|
-
.addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
|
|
393
|
-
'Exits 1 when a worktree removal failed.\n')
|
|
394
|
-
.action(loopCleanupCommand);
|
|
395
|
-
loop
|
|
396
|
-
.command('worktree')
|
|
397
|
-
.description('๐ณ Loop worktree mechanics')
|
|
398
|
-
.command('add <slug>')
|
|
399
|
-
.description('๐ฑ Create ai-<issue>-<slug> off origin/main, link its deps, assert none are missing')
|
|
400
|
-
.option('--root <path>', 'Main checkout to branch the worktree from', process.cwd())
|
|
401
|
-
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
402
|
-
.option('--base <ref>', 'Ref to branch from', 'origin/main')
|
|
403
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
404
|
-
.addHelpText('after', '\nExit 1 when the worktree was not created or a symlinkDirectories entry is left\n' +
|
|
405
|
-
'unlinked โ do not spawn an implementer. needsInstall means no list was declared.\n')
|
|
406
|
-
.action(loopWorktreeAddCommand);
|
|
407
|
-
loop
|
|
408
|
-
.command('reap')
|
|
409
|
-
.description('๐ชฆ Report agents stalled past 45 minutes and what to do about each')
|
|
410
|
-
.option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
|
|
411
|
-
.option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
|
|
412
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
413
|
-
.addHelpText('after', '\nRead-only: prints a verdict per stall (block / drop-label / remove-worktree).\n' +
|
|
414
|
-
'Exits 1 when a gh query failed and the report is incomplete.\n')
|
|
415
|
-
.action(loopReapCommand);
|
|
416
|
-
loop
|
|
417
|
-
.command('comment <pr>')
|
|
418
|
-
.description("๐ฌ Upsert the loop's one decision-marker comment on a PR")
|
|
419
|
-
.requiredOption('--body-file <path>', 'Comment text, without the marker (- for stdin)')
|
|
420
|
-
.option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
|
|
421
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
422
|
-
.action(loopCommentCommand);
|
|
423
|
-
loop
|
|
424
|
-
.command('verdict <pr>')
|
|
425
|
-
.description("โ๏ธ Read a reviewer arm's verdict marker for the PR's current head")
|
|
426
|
-
.requiredOption('--arm <arm>', 'Reviewer arm: code or sec')
|
|
427
|
-
.option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
|
|
428
|
-
.option('--json', 'Emit machine-readable JSON output')
|
|
429
|
-
.addHelpText('after', '\nPrints PASS, PASS-NOTES, CHANGES, or an empty line when none is posted.\n' +
|
|
430
|
-
'Exits 1 when the PR or its reviews cannot be read.\n')
|
|
431
|
-
.action(loopVerdictCommand);
|
|
339
|
+
// The ai-issue-loop pipeline moved to @rtorcato/repo-ai (#658). Kept as a
|
|
340
|
+
// pointer so a stale skill calling `repo-tooling loop โฆ` fails loudly with the
|
|
341
|
+
// fix, instead of commander's generic "unknown command".
|
|
342
|
+
program
|
|
343
|
+
.command('loop', { hidden: true })
|
|
344
|
+
.argument('[args...]')
|
|
345
|
+
.allowUnknownOption()
|
|
346
|
+
.helpOption(false)
|
|
347
|
+
.action(() => {
|
|
348
|
+
console.error(chalk.red('`repo-tooling loop` moved to @rtorcato/repo-ai.') +
|
|
349
|
+
'\n Run `npx @rtorcato/repo-ai loop โฆ`, and refresh the skills with' +
|
|
350
|
+
'\n `npx @rtorcato/repo-ai fix claude-skills --force-skills`.');
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
});
|
|
432
353
|
program.hook('preAction', async (_, actionCommand) => {
|
|
433
354
|
const name = actionCommand.name();
|
|
434
355
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
|
|
4
|
-
import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
|
|
5
4
|
import { getToolVersion } from './version.js';
|
|
6
5
|
export const LOCKFILE_NAME = '.repo-tooling.json';
|
|
7
6
|
// Package and bin name used before the js-toolingโrepo-tooling rename (#272).
|
|
@@ -51,9 +50,8 @@ export const DEFAULT_RULES = {
|
|
|
51
50
|
* `pnpm schema:generate` and gated by tests/cli/utils/lockfile-schema.test.ts.
|
|
52
51
|
*
|
|
53
52
|
* A function, not a const: lockfile.ts sits in an import cycle with
|
|
54
|
-
* setup-presets.ts (via the swift scaffolder)
|
|
55
|
-
*
|
|
56
|
-
* while this module evaluates. Reading them here, at call time, is safe.
|
|
53
|
+
* setup-presets.ts (via the swift scaffolder), so CONFIG_SCHEMA is in its TDZ
|
|
54
|
+
* while this module evaluates. Reading it here, at call time, is safe.
|
|
57
55
|
*/
|
|
58
56
|
// ponytail: key sets are compiler-checked against the type; a changed field
|
|
59
57
|
// *type* (string โ number) still needs both lines edited by hand.
|
|
@@ -113,7 +111,7 @@ export function lockfileSchema() {
|
|
|
113
111
|
aiLoop: {
|
|
114
112
|
type: 'object',
|
|
115
113
|
additionalProperties: false,
|
|
116
|
-
description: 'Settings for the ai-issue-loop skills. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
|
|
114
|
+
description: 'Settings for the ai-issue-loop skills from @rtorcato/repo-ai. Repo-scoped on purpose: committed here they travel with the repo and survive a new laptop.',
|
|
117
115
|
properties: {
|
|
118
116
|
agentUser: {
|
|
119
117
|
type: 'string',
|
|
@@ -123,8 +121,8 @@ export function lockfileSchema() {
|
|
|
123
121
|
},
|
|
124
122
|
requiredSkills: {
|
|
125
123
|
type: 'array',
|
|
126
|
-
items: { type: 'string'
|
|
127
|
-
description:
|
|
124
|
+
items: { type: 'string' },
|
|
125
|
+
description: "Agent skills this repo's workflows depend on. Audited by `npx @rtorcato/repo-ai doctor`, which reports a missing or stale installed copy; it never installs one for you.",
|
|
128
126
|
},
|
|
129
127
|
mcp: {
|
|
130
128
|
type: 'object',
|
package/package.json
CHANGED
package/dist/base/agent-user.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import { realGhExec } from './github-settings.js';
|
|
4
|
-
/**
|
|
5
|
-
* `aiLoop.agentUser` assignability (#530). The skills that consume the option
|
|
6
|
-
* (`ai-issue-loop`, `ai-workflow`) verify it at runtime and *silently assign
|
|
7
|
-
* nothing* on failure โ by design, so a deleted bot account or a bot never
|
|
8
|
-
* added as a collaborator degrades the loop with no visible symptom. This
|
|
9
|
-
* check is where that failure becomes visible.
|
|
10
|
-
*
|
|
11
|
-
* Read-only, on the same `gh` seam as labels.ts and milestones.ts. Everything
|
|
12
|
-
* derives from the consuming repo: the login from its lockfile, the repo from
|
|
13
|
-
* its own remote via gh's `{owner}/{repo}` placeholders.
|
|
14
|
-
*/
|
|
15
|
-
const CHECK = 'AI loop agent';
|
|
16
|
-
/**
|
|
17
|
-
* GitHub login shape: 1โ39 chars, alphanumerics and single hyphens, no leading
|
|
18
|
-
* or trailing hyphen. The injection boundary โ the login is interpolated into
|
|
19
|
-
* the API path below.
|
|
20
|
-
*/
|
|
21
|
-
export const LOGIN = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9]){0,38}$/;
|
|
22
|
-
const skip = (reason) => ({
|
|
23
|
-
check: CHECK,
|
|
24
|
-
status: 'ok',
|
|
25
|
-
detail: `skipped โ ${reason}`,
|
|
26
|
-
});
|
|
27
|
-
export async function checkAgentUser(dir, agentUser, exec) {
|
|
28
|
-
// Absent field โ not applicable โ the single-identity model is the default,
|
|
29
|
-
// not a requirement (#521).
|
|
30
|
-
if (!agentUser) {
|
|
31
|
-
return {
|
|
32
|
-
check: CHECK,
|
|
33
|
-
status: 'ok',
|
|
34
|
-
detail: 'not applicable โ no rules.aiLoop.agentUser in .repo-tooling.json',
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
if (!LOGIN.test(agentUser)) {
|
|
38
|
-
return {
|
|
39
|
-
check: CHECK,
|
|
40
|
-
status: 'drift',
|
|
41
|
-
detail: `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`,
|
|
42
|
-
hint: 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json',
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
// Cheap gate first: no .git โ never spawn (keeps tmp-dir doctor runs offline).
|
|
46
|
-
if (!(await fs.pathExists(path.join(dir, '.git'))))
|
|
47
|
-
return skip('not a git repository');
|
|
48
|
-
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
49
|
-
// 204 when the login can be assigned issues on this repo, 404 otherwise.
|
|
50
|
-
const r = await gh(['api', `repos/{owner}/{repo}/assignees/${agentUser}`]);
|
|
51
|
-
if (r.ok) {
|
|
52
|
-
return {
|
|
53
|
-
check: CHECK,
|
|
54
|
-
status: 'ok',
|
|
55
|
-
detail: `rules.aiLoop.agentUser "${agentUser}" is an assignable collaborator`,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
if (/HTTP 404/.test(r.stderr)) {
|
|
59
|
-
return {
|
|
60
|
-
check: CHECK,
|
|
61
|
-
status: 'drift',
|
|
62
|
-
detail: `rules.aiLoop.agentUser "${agentUser}" is not an assignable collaborator โ the loop skills will silently assign nothing`,
|
|
63
|
-
hint: `Add the account as a collaborator (\`gh api -X PUT repos/{owner}/{repo}/collaborators/${agentUser}\`) or remove rules.aiLoop.agentUser from .repo-tooling.json`,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
// Offline, unauthenticated, or gh missing โ not evidence of drift.
|
|
67
|
-
return skip('could not verify assignability');
|
|
68
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `fix ai-loop-identity` (#638): point this checkout's Claude sessions at a gh
|
|
3
|
-
* profile signed in as `rules.aiLoop.agentUser`, so `loop guard` stops halting
|
|
4
|
-
* on `identity: mismatch`. `.repo-tooling.json` (committed) says *who* the agent
|
|
5
|
-
* is; the profile path is machine-specific, so it lands in the gitignored,
|
|
6
|
-
* per-checkout `.claude/settings.local.json`.
|
|
7
|
-
*/
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import fs from 'fs-extra';
|
|
10
|
-
import { configuredAgentUser } from '../cli/commands/loop-guard.js';
|
|
11
|
-
import { LOGIN } from './agent-user.js';
|
|
12
|
-
import { FixerAbort } from './fixers.js';
|
|
13
|
-
import { realGhExec } from './github-settings.js';
|
|
14
|
-
export const SETTINGS_LOCAL = '.claude/settings.local.json';
|
|
15
|
-
/** The profile dir the fixer looks in when `--gh-config-dir` is not given. */
|
|
16
|
-
export function defaultGhConfigDir(agentUser, home) {
|
|
17
|
-
return path.join(home, '.config', `gh-${agentUser}`);
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Returns the files written. Writes nothing โ and aborts with the `gh auth
|
|
21
|
-
* login` command as the hint โ unless the profile really is the agent account.
|
|
22
|
-
*/
|
|
23
|
-
export async function setupAgentIdentity(targetDir, opts) {
|
|
24
|
-
const agentUser = await configuredAgentUser(targetDir);
|
|
25
|
-
if (!agentUser) {
|
|
26
|
-
console.error(' nothing to do โ no rules.aiLoop.agentUser in .repo-tooling.json');
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
// The login comes from a committed file and becomes a path segment below.
|
|
30
|
-
if (!LOGIN.test(agentUser)) {
|
|
31
|
-
throw new FixerAbort('invalid-agent-user', `rules.aiLoop.agentUser "${agentUser}" is not a valid GitHub login`, 'Fix or remove rules.aiLoop.agentUser in .repo-tooling.json');
|
|
32
|
-
}
|
|
33
|
-
const dir = path.resolve(opts.ghConfigDir ?? defaultGhConfigDir(agentUser, opts.home));
|
|
34
|
-
const gh = opts.gh ?? ((args, env) => realGhExec(args, undefined, targetDir, env));
|
|
35
|
-
const login = (await fs.pathExists(dir))
|
|
36
|
-
? await gh(['api', 'user', '--jq', '.login'], { GH_CONFIG_DIR: dir }).then((r) => r.ok ? r.stdout.trim() : '')
|
|
37
|
-
: '';
|
|
38
|
-
// GitHub logins are case-insensitive, so a case difference is one account.
|
|
39
|
-
if (login.toLowerCase() !== agentUser.toLowerCase()) {
|
|
40
|
-
throw new FixerAbort('agent-identity-unavailable', `${dir} is ${login ? `signed in as ${login}, not ${agentUser}` : `not signed in to gh as ${agentUser}`} โ nothing written`, `Sign in once, in a browser logged in as ${agentUser}, then re-run:\n GH_CONFIG_DIR=${dir} gh auth login --hostname github.com --web --scopes repo`);
|
|
41
|
-
}
|
|
42
|
-
const written = [];
|
|
43
|
-
const settingsPath = path.join(targetDir, SETTINGS_LOCAL);
|
|
44
|
-
let settings = {};
|
|
45
|
-
if (await fs.pathExists(settingsPath)) {
|
|
46
|
-
try {
|
|
47
|
-
settings = await fs.readJson(settingsPath);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
throw new FixerAbort('settings-local-unparseable', `${SETTINGS_LOCAL} is not valid JSON โ not overwriting it`, `Fix the file by hand, or add "env": {"GH_CONFIG_DIR": "${dir}"} to it yourself`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const env = (settings.env ?? {});
|
|
54
|
-
if (env.GH_CONFIG_DIR !== dir) {
|
|
55
|
-
await fs.outputJson(settingsPath, { ...settings, env: { ...env, GH_CONFIG_DIR: dir } }, { spaces: 2 });
|
|
56
|
-
written.push(SETTINGS_LOCAL);
|
|
57
|
-
}
|
|
58
|
-
if (await ensureIgnored(targetDir))
|
|
59
|
-
written.push('.gitignore');
|
|
60
|
-
return written;
|
|
61
|
-
}
|
|
62
|
-
// ponytail: exact-line match on the usual spellings; a glob that happens to
|
|
63
|
-
// cover the file gets a redundant (harmless) line. `git check-ignore` if that bites.
|
|
64
|
-
const IGNORE_LINES = new Set([
|
|
65
|
-
SETTINGS_LOCAL,
|
|
66
|
-
`/${SETTINGS_LOCAL}`,
|
|
67
|
-
'.claude',
|
|
68
|
-
'.claude/',
|
|
69
|
-
'/.claude',
|
|
70
|
-
'/.claude/',
|
|
71
|
-
]);
|
|
72
|
-
async function ensureIgnored(targetDir) {
|
|
73
|
-
const file = path.join(targetDir, '.gitignore');
|
|
74
|
-
const existing = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf-8') : '';
|
|
75
|
-
if (existing.split('\n').some((l) => IGNORE_LINES.has(l.trim())))
|
|
76
|
-
return false;
|
|
77
|
-
const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
78
|
-
await fs.writeFile(file, `${existing}${separator}${SETTINGS_LOCAL}\n`);
|
|
79
|
-
return true;
|
|
80
|
-
}
|
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
|
-
}
|