@rtorcato/repo-tooling 3.42.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 +2 -0
- package/dist/cli/commands/loop-reap.js +196 -0
- package/dist/cli/index.js +10 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -29,6 +29,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
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
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. |
|
|
32
33
|
|
|
33
34
|
## Recommended workflows
|
|
34
35
|
|
|
@@ -121,6 +122,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
|
|
|
121
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)
|
|
122
123
|
- `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
|
|
123
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)
|
|
124
126
|
- `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
|
|
125
127
|
- `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
|
|
126
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
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@ 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';
|
|
11
12
|
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
12
13
|
import { setupProject } from './commands/setup.js';
|
|
13
14
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
@@ -402,6 +403,15 @@ loop
|
|
|
402
403
|
.addHelpText('after', '\nExit 1 when the worktree was not created or a symlinkDirectories entry is left\n' +
|
|
403
404
|
'unlinked — do not spawn an implementer. needsInstall means no list was declared.\n')
|
|
404
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);
|
|
405
415
|
program.hook('preAction', async (_, actionCommand) => {
|
|
406
416
|
const name = actionCommand.name();
|
|
407
417
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED