@rtorcato/repo-tooling 3.41.0 → 3.43.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 CHANGED
@@ -28,6 +28,8 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
28
28
  | `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
29
29
  | `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`), and assert `gh` authenticates as the declared `rules.aiLoop.agentUser`. Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout or the agent identity is wrong — both non-zero halt the tick. |
30
30
  | `loop env` | ✅ | ✅ | Resolve an `ai-issue-loop` tick's variables once: `root` (main checkout, correct from inside a worktree), `worktreeRoot`, `ownerRepo`, `agentUser` (empty unless an assignable collaborator), `humanUser` (empty for org repos), `me`. Without `--json`, prints `KEY='value'` lines for `eval`. Exit `1` when the checkout or its GitHub repo cannot be resolved. |
31
+ | `loop worktree add <ai-N-slug>` | ✅ | ✅ | Create an `ai-issue-loop` worktree at `<root>-worktrees/<slug>` on a new branch off `origin/main` (`--base`), symlink every `worktree.symlinkDirectories` entry from `.claude/settings.json`, and add `node_modules` to `.git/info/exclude`. `needsInstall: true` when no list is declared. Exit `1` when the worktree was not created or an entry is left unlinked — do not spawn an implementer. |
32
+ | `loop reap --json` | ✅ | ✅ | Report the `ai-issue-loop` Pass 2 stalls: a label that sat ≥45 minutes past its last application (per the issue timeline). Each entry is `{ kind, issue, pr, label, minutes, applications, action, worktree, reason }` — `kind` is `implementer` / `reviewer` / `fixer` / `orphan`, `action` is `block` / `drop-label` / `remove-worktree` (`block` once a claim has been applied ≥3 times). Read-only; exit `1` when a `gh` query failed. |
31
33
 
32
34
  ## Recommended workflows
33
35
 
@@ -119,6 +121,8 @@ A fixer may also **refuse** — the target file holds something the generator ca
119
121
  - `src/cli/commands/fix-targets.ts` — shared check → fix target map (used by both doctor's footer and fix's lookup)
120
122
  - `src/cli/commands/loop-guard.ts` — `loop guard`: the `--is-inside-work-tree` / `.git` invariant table and the `node_modules` rebuild gate, drained out of the ai-issue-loop skill's prose (#519)
121
123
  - `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
124
+ - `src/cli/commands/loop-worktree.ts` — `loop worktree add`: Pass 4 worktree creation, dependency linking and the missing-link assertion (#616)
125
+ - `src/cli/commands/loop-reap.ts` — `loop reap`: the skill's Pass 2 stalled-agent table as verdicts (#618)
122
126
  - `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
123
127
  - `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
124
128
 
@@ -0,0 +1,196 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import { realGhExec } from '../../base/github-settings.js';
4
+ import { defaultWorktreeRoot, findLive } from './loop-guard.js';
5
+ /**
6
+ * `repo-tooling loop reap` — the ai-issue-loop skill's Pass 2 stalled-agent
7
+ * table, moved out of prose (#618). Nothing can time out an agent, so a label
8
+ * that sat `STALE_MINUTES` past its last application (per the timeline) marks
9
+ * a dead one:
10
+ *
11
+ * - **implementer** — issue `ai-wip`, no PR for `ai-<N>-*` → `block`.
12
+ * - **reviewer** — PR `ai-reviewing-code|sec` with no matching `ai-ok-*` and
13
+ * no `ai-changes` → `drop-label` that claim, or `block` at ≥3 applications.
14
+ * - **fixer** — PR `ai-fixing` still `ai-changes` → `drop-label`, or `block`.
15
+ * - **orphan** — `ai-<N>-*` worktree whose issue is not `ai-wip` and has no
16
+ * open PR → `remove-worktree`.
17
+ *
18
+ * Read-only: it reports verdicts, the skill applies them — the label, assign
19
+ * and comment of an `ai-blocked`, and the call on whether a stall was benign.
20
+ * Exit `1` when a gh query failed, so the report is incomplete.
21
+ */
22
+ /** Three ticks. Generous: a live agent must never be reaped out from under itself. */
23
+ export const STALE_MINUTES = 45;
24
+ /** A claim applied this many times is not one more spawn away from working. */
25
+ export const MAX_APPLICATIONS = 3;
26
+ // ponytail: newest 200 PRs; an ai-wip issue's PR older than that reads as "no PR".
27
+ const PR_WINDOW = 200;
28
+ /** `ai-<N>-slug`, or the legacy `worktree-ai-<N>-slug`. */
29
+ const issueOf = (name) => Number(name.match(/^(?:worktree-)?ai-(\d+)-/)?.[1]) || null;
30
+ const REVIEW_CLAIMS = {
31
+ 'ai-reviewing-code': 'ai-ok-code',
32
+ 'ai-reviewing-sec': 'ai-ok-sec',
33
+ };
34
+ export async function runLoopReap(options = {}) {
35
+ const root = path.resolve(options.root ?? process.cwd());
36
+ const worktreeRoot = options.worktreeRoot
37
+ ? path.resolve(options.worktreeRoot)
38
+ : defaultWorktreeRoot(root);
39
+ const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
40
+ const now = (options.now ?? new Date()).getTime();
41
+ const errors = [];
42
+ const stalled = [];
43
+ const list = async (args) => {
44
+ const r = await gh(args);
45
+ if (!r.ok) {
46
+ errors.push(`gh ${args.slice(0, 2).join(' ')} failed: ${r.stderr.trim()}`);
47
+ return null;
48
+ }
49
+ return JSON.parse(r.stdout || '[]');
50
+ };
51
+ /** Every application of `label` on issue/PR `n`, oldest first. */
52
+ const applications = async (n, label) => {
53
+ const r = await gh([
54
+ 'api',
55
+ `repos/{owner}/{repo}/issues/${n}/timeline`,
56
+ '--paginate',
57
+ '--jq',
58
+ '.[] | select(.event=="labeled") | [.label.name, .created_at] | @tsv',
59
+ ]);
60
+ if (!r.ok) {
61
+ errors.push(`timeline for #${n} failed: ${r.stderr.trim()}`);
62
+ return null;
63
+ }
64
+ return r.stdout
65
+ .split('\n')
66
+ .map((line) => line.split('\t'))
67
+ .filter(([name]) => name === label)
68
+ .map(([, at]) => Date.parse(at))
69
+ .sort((a, b) => a - b);
70
+ };
71
+ /** Age and count of `label` on `n`, or null when it is not stale yet (or unknown). */
72
+ const stale = async (n, label) => {
73
+ const times = await applications(n, label);
74
+ const last = times?.at(-1);
75
+ if (last === undefined)
76
+ return null;
77
+ const minutes = Math.floor((now - last) / 60_000);
78
+ return minutes >= STALE_MINUTES ? { minutes, applications: times?.length ?? 0 } : null;
79
+ };
80
+ const wip = await list([
81
+ 'issue',
82
+ 'list',
83
+ '--label',
84
+ 'ai-wip',
85
+ '--state',
86
+ 'open',
87
+ '--limit',
88
+ '100',
89
+ '--json',
90
+ 'number',
91
+ ]);
92
+ const prs = await list([
93
+ 'pr',
94
+ 'list',
95
+ '--state',
96
+ 'all',
97
+ '--limit',
98
+ String(PR_WINDOW),
99
+ '--json',
100
+ 'number,state,headRefName,labels',
101
+ ]);
102
+ const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
103
+ const worktreeOf = (issue) => dirs.find((d) => issueOf(path.basename(d)) === issue) ?? null;
104
+ // Without both lists a "no PR" or "not ai-wip" verdict would be a guess.
105
+ if (wip && prs) {
106
+ const wipSet = new Set(wip.map((i) => i.number));
107
+ const prIssues = (state) => new Set(prs.filter((p) => !state || p.state === state).map((p) => issueOf(p.headRefName)));
108
+ const anyPr = prIssues();
109
+ const openPr = prIssues('OPEN');
110
+ for (const issue of wipSet) {
111
+ if (anyPr.has(issue))
112
+ continue;
113
+ const s = await stale(issue, 'ai-wip');
114
+ if (!s)
115
+ continue;
116
+ stalled.push({
117
+ kind: 'implementer',
118
+ issue,
119
+ pr: null,
120
+ label: 'ai-wip',
121
+ ...s,
122
+ action: 'block',
123
+ worktree: worktreeOf(issue),
124
+ reason: `ai-wip for ${s.minutes}min and no PR`,
125
+ });
126
+ }
127
+ for (const pr of prs.filter((p) => p.state === 'OPEN')) {
128
+ const labels = new Set(pr.labels.map((l) => l.name));
129
+ const issue = issueOf(pr.headRefName);
130
+ const claims = Object.entries(REVIEW_CLAIMS)
131
+ .filter(([claim, ok]) => labels.has(claim) && !labels.has(ok) && !labels.has('ai-changes'))
132
+ .map(([claim]) => ({ kind: 'reviewer', claim }));
133
+ if (labels.has('ai-fixing') && labels.has('ai-changes'))
134
+ claims.push({ kind: 'fixer', claim: 'ai-fixing' });
135
+ for (const { kind, claim } of claims) {
136
+ const s = await stale(pr.number, claim);
137
+ if (!s)
138
+ continue;
139
+ const block = s.applications >= MAX_APPLICATIONS;
140
+ stalled.push({
141
+ kind,
142
+ issue,
143
+ pr: pr.number,
144
+ label: claim,
145
+ ...s,
146
+ action: block ? 'block' : 'drop-label',
147
+ worktree: null,
148
+ reason: `${claim} for ${s.minutes}min, applied ${s.applications}×`,
149
+ });
150
+ }
151
+ }
152
+ for (const dir of dirs) {
153
+ const issue = issueOf(path.basename(dir));
154
+ if (issue === null || wipSet.has(issue) || openPr.has(issue))
155
+ continue;
156
+ stalled.push({
157
+ kind: 'orphan',
158
+ issue,
159
+ pr: null,
160
+ label: null,
161
+ minutes: null,
162
+ applications: null,
163
+ action: 'remove-worktree',
164
+ worktree: dir,
165
+ reason: 'issue not ai-wip and no open PR',
166
+ });
167
+ }
168
+ }
169
+ return {
170
+ root,
171
+ worktreeRoot,
172
+ staleMinutes: STALE_MINUTES,
173
+ stalled,
174
+ errors,
175
+ exitCode: errors.length > 0 ? 1 : 0,
176
+ };
177
+ }
178
+ export async function loopReapCommand(options) {
179
+ const result = await runLoopReap(options);
180
+ if (options.json) {
181
+ console.log(JSON.stringify(result, null, 2));
182
+ }
183
+ else {
184
+ console.log();
185
+ if (result.stalled.length === 0)
186
+ console.log(chalk.gray(' nothing stalled'));
187
+ for (const s of result.stalled) {
188
+ const target = s.pr ? `PR #${s.pr}` : `#${s.issue}`;
189
+ console.log(` ${chalk.yellow(s.action.padEnd(15))} ${target} (${s.kind}) — ${s.reason}`);
190
+ }
191
+ for (const e of result.errors)
192
+ console.log(` ${chalk.red(e)}`);
193
+ console.log();
194
+ }
195
+ process.exitCode = result.exitCode;
196
+ }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import { realGitExec } from '../../base/git-identity.js';
5
+ import { defaultWorktreeRoot } from './loop-guard.js';
6
+ /**
7
+ * `repo-tooling loop worktree add` — the ai-issue-loop skill's Pass 4
8
+ * worktree creation, moved into code (#616): `git worktree add`, linking every
9
+ * `worktree.symlinkDirectories` entry, the `.git/info/exclude` line, and the
10
+ * assertion that nothing was left unlinked.
11
+ *
12
+ * The prose version iterated `for d in $DIRS`, which zsh does not word-split,
13
+ * so it silently linked nothing (#585). An array has no such failure mode.
14
+ *
15
+ * Exit `0` means the worktree is ready for an implementer. Exit `1` means do
16
+ * not spawn one — either the worktree was not created, or an entry that exists
17
+ * in the main checkout has no link in the worktree. `needsInstall` (exit `0`)
18
+ * means the repo declares no symlink list, so nothing was linked and a real
19
+ * install in the worktree is safe.
20
+ */
21
+ /** `ai-<issue>-<kebab words>` — it becomes a branch name and a path segment. */
22
+ export const SLUG = /^ai-\d+-[a-z0-9]+(?:-[a-z0-9]+)*$/;
23
+ /**
24
+ * Relative and inside the checkout, or dropped: the list comes from a
25
+ * committed file, and `../x` would otherwise link outside both trees.
26
+ */
27
+ function safeEntry(entry) {
28
+ if (typeof entry !== 'string' || entry.trim() === '')
29
+ return false;
30
+ const n = path.normalize(entry);
31
+ return !path.isAbsolute(n) && n !== '..' && !n.startsWith(`..${path.sep}`);
32
+ }
33
+ export async function symlinkDirectories(root) {
34
+ const settings = await fs.readJson(path.join(root, '.claude', 'settings.json')).catch(() => null);
35
+ const dirs = settings?.worktree?.symlinkDirectories;
36
+ return Array.isArray(dirs) ? dirs.filter(safeEntry) : [];
37
+ }
38
+ /**
39
+ * `node_modules/` with a trailing slash does not match a symlink, so a
40
+ * `git add -A` would commit every link. No slash matches at any depth. The
41
+ * exclude file is shared by every worktree and never committed.
42
+ */
43
+ async function ensureExcluded(root) {
44
+ const file = path.join(root, '.git', 'info', 'exclude');
45
+ const current = await fs.readFile(file, 'utf8').catch(() => '');
46
+ if (current.split('\n').includes('node_modules'))
47
+ return false;
48
+ await fs.outputFile(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}node_modules\n`);
49
+ return true;
50
+ }
51
+ async function isSymlink(p) {
52
+ return fs
53
+ .lstat(p)
54
+ .then((s) => s.isSymbolicLink())
55
+ .catch(() => false);
56
+ }
57
+ export async function runLoopWorktreeAdd(slug, options = {}) {
58
+ const root = path.resolve(options.root ?? process.cwd());
59
+ const worktreeRoot = options.worktreeRoot
60
+ ? path.resolve(options.worktreeRoot)
61
+ : defaultWorktreeRoot(root);
62
+ const worktree = path.join(worktreeRoot, slug);
63
+ // A checkout of a real repo takes longer than realGitExec's 5s default.
64
+ const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
65
+ const result = {
66
+ root,
67
+ worktree,
68
+ branch: slug,
69
+ linked: [],
70
+ missing: [],
71
+ needsInstall: false,
72
+ exitCode: 1,
73
+ messages: [],
74
+ };
75
+ if (!SLUG.test(slug)) {
76
+ result.messages.push(`⚠ '${slug}' is not ai-<issue>-<kebab-slug>`);
77
+ return result;
78
+ }
79
+ const base = options.base ?? 'origin/main';
80
+ // Goes to git as an argument; a leading dash would be read as an option.
81
+ if (base.startsWith('-')) {
82
+ result.messages.push(`⚠ '${base}' is not a ref`);
83
+ return result;
84
+ }
85
+ await fs.ensureDir(worktreeRoot);
86
+ if ((await git(['worktree', 'add', worktree, '-b', slug, base])) === null) {
87
+ result.messages.push(`⚠ git worktree add failed — does branch ${slug} or ${worktree} already exist?`);
88
+ return result;
89
+ }
90
+ result.messages.push(`created ${worktree} on ${slug}`);
91
+ // Before linking, so the links are never momentarily untracked-and-addable.
92
+ if (await ensureExcluded(root))
93
+ result.messages.push('added node_modules to .git/info/exclude');
94
+ const dirs = await symlinkDirectories(root);
95
+ if (dirs.length === 0) {
96
+ result.needsInstall = true;
97
+ result.messages.push('no worktree.symlinkDirectories — run a real install in the worktree (`fix ai` restores the fast path)');
98
+ }
99
+ for (const d of dirs) {
100
+ const source = path.join(root, d);
101
+ // An entry pointing at nothing links nothing, and is not missing.
102
+ if (!(await fs.pathExists(source)))
103
+ continue;
104
+ const target = path.join(worktree, d);
105
+ await fs.ensureDir(path.dirname(target));
106
+ await fs.symlink(source, target).catch(() => { });
107
+ if (await isSymlink(target))
108
+ result.linked.push(d);
109
+ else
110
+ result.missing.push(d);
111
+ }
112
+ if (result.missing.length > 0) {
113
+ result.messages.push(`⚠ FATAL: no symlink for ${result.missing.join(', ')} — do not spawn an implementer`);
114
+ return result;
115
+ }
116
+ if (result.linked.length > 0)
117
+ result.messages.push(`linked ${result.linked.join(', ')}`);
118
+ result.exitCode = 0;
119
+ return result;
120
+ }
121
+ export async function loopWorktreeAddCommand(slug, options) {
122
+ const result = await runLoopWorktreeAdd(slug, options);
123
+ if (options.json) {
124
+ console.log(JSON.stringify(result, null, 2));
125
+ }
126
+ else {
127
+ for (const line of result.messages) {
128
+ console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
129
+ }
130
+ }
131
+ process.exitCode = result.exitCode;
132
+ }
package/dist/cli/index.js CHANGED
@@ -8,6 +8,8 @@ import { fixCommand } from './commands/fix.js';
8
8
  import { loopCleanupCommand } from './commands/loop-cleanup.js';
9
9
  import { loopEnvCommand } from './commands/loop-env.js';
10
10
  import { loopGuardCommand } from './commands/loop-guard.js';
11
+ import { loopReapCommand } from './commands/loop-reap.js';
12
+ import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
11
13
  import { setupProject } from './commands/setup.js';
12
14
  import { copyPreset, PRESETS } from './utils/copy-preset.js';
13
15
  import { getToolVersion } from './utils/version.js';
@@ -389,6 +391,27 @@ loop
389
391
  .addHelpText('after', '\nPass `removed` from --json to `loop guard --removed`.\n' +
390
392
  'Exits 1 when a worktree removal failed.\n')
391
393
  .action(loopCleanupCommand);
394
+ loop
395
+ .command('worktree')
396
+ .description('🌳 Loop worktree mechanics')
397
+ .command('add <slug>')
398
+ .description('🌱 Create ai-<issue>-<slug> off origin/main, link its deps, assert none are missing')
399
+ .option('--root <path>', 'Main checkout to branch the worktree from', process.cwd())
400
+ .option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
401
+ .option('--base <ref>', 'Ref to branch from', 'origin/main')
402
+ .option('--json', 'Emit machine-readable JSON output')
403
+ .addHelpText('after', '\nExit 1 when the worktree was not created or a symlinkDirectories entry is left\n' +
404
+ 'unlinked — do not spawn an implementer. needsInstall means no list was declared.\n')
405
+ .action(loopWorktreeAddCommand);
406
+ loop
407
+ .command('reap')
408
+ .description('🪦 Report agents stalled past 45 minutes and what to do about each')
409
+ .option('--root <path>', 'Main checkout the loop branches worktrees from', process.cwd())
410
+ .option('--worktree-root <path>', 'Where ai-* worktrees live (default: <root>-worktrees)')
411
+ .option('--json', 'Emit machine-readable JSON output')
412
+ .addHelpText('after', '\nRead-only: prints a verdict per stall (block / drop-label / remove-worktree).\n' +
413
+ 'Exits 1 when a gh query failed and the report is incomplete.\n')
414
+ .action(loopReapCommand);
392
415
  program.hook('preAction', async (_, actionCommand) => {
393
416
  const name = actionCommand.name();
394
417
  if (name === 'setup' || name === 'doctor' || name === 'fix') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.41.0",
3
+ "version": "3.43.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [