@rtorcato/repo-tooling 3.45.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 -25
- package/README.md +13 -16
- package/dist/base/checks.js +0 -132
- package/dist/base/fixers.js +0 -123
- 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/index.js +14 -102
- 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 -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
|
@@ -1,202 +0,0 @@
|
|
|
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
|
-
/** Every application of `label` on issue/PR `n`, oldest first — or why it could not be read. */
|
|
35
|
-
export async function labelApplications(gh, n, label) {
|
|
36
|
-
const r = await gh([
|
|
37
|
-
'api',
|
|
38
|
-
`repos/{owner}/{repo}/issues/${n}/timeline`,
|
|
39
|
-
'--paginate',
|
|
40
|
-
'--jq',
|
|
41
|
-
'.[] | select(.event=="labeled") | [.label.name, .created_at] | @tsv',
|
|
42
|
-
]);
|
|
43
|
-
if (!r.ok)
|
|
44
|
-
return `timeline for #${n} failed: ${r.stderr.trim()}`;
|
|
45
|
-
return r.stdout
|
|
46
|
-
.split('\n')
|
|
47
|
-
.map((line) => line.split('\t'))
|
|
48
|
-
.filter(([name]) => name === label)
|
|
49
|
-
.map(([, at]) => Date.parse(at))
|
|
50
|
-
.sort((a, b) => a - b);
|
|
51
|
-
}
|
|
52
|
-
export async function runLoopReap(options = {}) {
|
|
53
|
-
const root = path.resolve(options.root ?? process.cwd());
|
|
54
|
-
const worktreeRoot = options.worktreeRoot
|
|
55
|
-
? path.resolve(options.worktreeRoot)
|
|
56
|
-
: defaultWorktreeRoot(root);
|
|
57
|
-
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
|
|
58
|
-
const now = (options.now ?? new Date()).getTime();
|
|
59
|
-
const errors = [];
|
|
60
|
-
const stalled = [];
|
|
61
|
-
const list = async (args) => {
|
|
62
|
-
const r = await gh(args);
|
|
63
|
-
if (!r.ok) {
|
|
64
|
-
errors.push(`gh ${args.slice(0, 2).join(' ')} failed: ${r.stderr.trim()}`);
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
return JSON.parse(r.stdout || '[]');
|
|
68
|
-
};
|
|
69
|
-
const applications = async (n, label) => {
|
|
70
|
-
const times = await labelApplications(gh, n, label);
|
|
71
|
-
if (typeof times === 'string') {
|
|
72
|
-
errors.push(times);
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
return times;
|
|
76
|
-
};
|
|
77
|
-
/** Age and count of `label` on `n`, or null when it is not stale yet (or unknown). */
|
|
78
|
-
const stale = async (n, label) => {
|
|
79
|
-
const times = await applications(n, label);
|
|
80
|
-
const last = times?.at(-1);
|
|
81
|
-
if (last === undefined)
|
|
82
|
-
return null;
|
|
83
|
-
const minutes = Math.floor((now - last) / 60_000);
|
|
84
|
-
return minutes >= STALE_MINUTES ? { minutes, applications: times?.length ?? 0 } : null;
|
|
85
|
-
};
|
|
86
|
-
const wip = await list([
|
|
87
|
-
'issue',
|
|
88
|
-
'list',
|
|
89
|
-
'--label',
|
|
90
|
-
'ai-wip',
|
|
91
|
-
'--state',
|
|
92
|
-
'open',
|
|
93
|
-
'--limit',
|
|
94
|
-
'100',
|
|
95
|
-
'--json',
|
|
96
|
-
'number',
|
|
97
|
-
]);
|
|
98
|
-
const prs = await list([
|
|
99
|
-
'pr',
|
|
100
|
-
'list',
|
|
101
|
-
'--state',
|
|
102
|
-
'all',
|
|
103
|
-
'--limit',
|
|
104
|
-
String(PR_WINDOW),
|
|
105
|
-
'--json',
|
|
106
|
-
'number,state,headRefName,labels',
|
|
107
|
-
]);
|
|
108
|
-
const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
|
|
109
|
-
const worktreeOf = (issue) => dirs.find((d) => issueOf(path.basename(d)) === issue) ?? null;
|
|
110
|
-
// Without both lists a "no PR" or "not ai-wip" verdict would be a guess.
|
|
111
|
-
if (wip && prs) {
|
|
112
|
-
const wipSet = new Set(wip.map((i) => i.number));
|
|
113
|
-
const prIssues = (state) => new Set(prs.filter((p) => !state || p.state === state).map((p) => issueOf(p.headRefName)));
|
|
114
|
-
const anyPr = prIssues();
|
|
115
|
-
const openPr = prIssues('OPEN');
|
|
116
|
-
for (const issue of wipSet) {
|
|
117
|
-
if (anyPr.has(issue))
|
|
118
|
-
continue;
|
|
119
|
-
const s = await stale(issue, 'ai-wip');
|
|
120
|
-
if (!s)
|
|
121
|
-
continue;
|
|
122
|
-
stalled.push({
|
|
123
|
-
kind: 'implementer',
|
|
124
|
-
issue,
|
|
125
|
-
pr: null,
|
|
126
|
-
label: 'ai-wip',
|
|
127
|
-
...s,
|
|
128
|
-
action: 'block',
|
|
129
|
-
worktree: worktreeOf(issue),
|
|
130
|
-
reason: `ai-wip for ${s.minutes}min and no PR`,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
for (const pr of prs.filter((p) => p.state === 'OPEN')) {
|
|
134
|
-
const labels = new Set(pr.labels.map((l) => l.name));
|
|
135
|
-
const issue = issueOf(pr.headRefName);
|
|
136
|
-
const claims = Object.entries(REVIEW_CLAIMS)
|
|
137
|
-
.filter(([claim, ok]) => labels.has(claim) && !labels.has(ok) && !labels.has('ai-changes'))
|
|
138
|
-
.map(([claim]) => ({ kind: 'reviewer', claim }));
|
|
139
|
-
if (labels.has('ai-fixing') && labels.has('ai-changes'))
|
|
140
|
-
claims.push({ kind: 'fixer', claim: 'ai-fixing' });
|
|
141
|
-
for (const { kind, claim } of claims) {
|
|
142
|
-
const s = await stale(pr.number, claim);
|
|
143
|
-
if (!s)
|
|
144
|
-
continue;
|
|
145
|
-
const block = s.applications >= MAX_APPLICATIONS;
|
|
146
|
-
stalled.push({
|
|
147
|
-
kind,
|
|
148
|
-
issue,
|
|
149
|
-
pr: pr.number,
|
|
150
|
-
label: claim,
|
|
151
|
-
...s,
|
|
152
|
-
action: block ? 'block' : 'drop-label',
|
|
153
|
-
worktree: null,
|
|
154
|
-
reason: `${claim} for ${s.minutes}min, applied ${s.applications}×`,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
for (const dir of dirs) {
|
|
159
|
-
const issue = issueOf(path.basename(dir));
|
|
160
|
-
if (issue === null || wipSet.has(issue) || openPr.has(issue))
|
|
161
|
-
continue;
|
|
162
|
-
stalled.push({
|
|
163
|
-
kind: 'orphan',
|
|
164
|
-
issue,
|
|
165
|
-
pr: null,
|
|
166
|
-
label: null,
|
|
167
|
-
minutes: null,
|
|
168
|
-
applications: null,
|
|
169
|
-
action: 'remove-worktree',
|
|
170
|
-
worktree: dir,
|
|
171
|
-
reason: 'issue not ai-wip and no open PR',
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return {
|
|
176
|
-
root,
|
|
177
|
-
worktreeRoot,
|
|
178
|
-
staleMinutes: STALE_MINUTES,
|
|
179
|
-
stalled,
|
|
180
|
-
errors,
|
|
181
|
-
exitCode: errors.length > 0 ? 1 : 0,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
export async function loopReapCommand(options) {
|
|
185
|
-
const result = await runLoopReap(options);
|
|
186
|
-
if (options.json) {
|
|
187
|
-
console.log(JSON.stringify(result, null, 2));
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
console.log();
|
|
191
|
-
if (result.stalled.length === 0)
|
|
192
|
-
console.log(chalk.gray(' nothing stalled'));
|
|
193
|
-
for (const s of result.stalled) {
|
|
194
|
-
const target = s.pr ? `PR #${s.pr}` : `#${s.issue}`;
|
|
195
|
-
console.log(` ${chalk.yellow(s.action.padEnd(15))} ${target} (${s.kind}) — ${s.reason}`);
|
|
196
|
-
}
|
|
197
|
-
for (const e of result.errors)
|
|
198
|
-
console.log(` ${chalk.red(e)}`);
|
|
199
|
-
console.log();
|
|
200
|
-
}
|
|
201
|
-
process.exitCode = result.exitCode;
|
|
202
|
-
}
|
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import { realGitExec } from '../../base/git-identity.js';
|
|
4
|
-
import { realGhExec, releaseGated } from '../../base/github-settings.js';
|
|
5
|
-
import { runLoopCleanup } from './loop-cleanup.js';
|
|
6
|
-
import { resolveLoopEnv } from './loop-env.js';
|
|
7
|
-
import { runLoopGuard } from './loop-guard.js';
|
|
8
|
-
import { runLoopVerdict } from './loop-marker.js';
|
|
9
|
-
import { labelApplications, MAX_APPLICATIONS, runLoopReap } from './loop-reap.js';
|
|
10
|
-
/**
|
|
11
|
-
* `repo-tooling loop tick` — one ai-issue-loop tick's mechanics as one work
|
|
12
|
-
* list (#620). It composes `loop env`, `loop guard`, `loop cleanup`,
|
|
13
|
-
* `loop reap` and `loop verdict` with the Pass 1 merge-state and CI reads, the
|
|
14
|
-
* Pass 0 PR-adoption query, the Pass 2 `ai-suggested` decay and the Pass 4
|
|
15
|
-
* eligibility query, and says what to do. The skill applies it: every label,
|
|
16
|
-
* assignee, comment, merge and agent spawn stays the agent's call.
|
|
17
|
-
*
|
|
18
|
-
* It writes no GitHub state. Its only local writes are the ones the helpers it
|
|
19
|
-
* composes already make — `loop guard`'s bare repair and gated `node_modules`
|
|
20
|
-
* rebuild, and `loop cleanup`'s removal of worktrees whose PR landed or closed.
|
|
21
|
-
*
|
|
22
|
-
* Exit non-zero only to halt the tick: `loop guard`'s own code (`1`/`2`), or `1`
|
|
23
|
-
* when the checkout or its GitHub repo cannot be resolved. A failed gh read is
|
|
24
|
-
* not a halt — the lists it feeds are left empty, it lands in `errors`, and the
|
|
25
|
-
* summary leads with `⚠error`.
|
|
26
|
-
*/
|
|
27
|
-
/** Issues in flight at once. */
|
|
28
|
-
export const MAX_IN_FLIGHT = 6;
|
|
29
|
-
/** `ai-suggested` issues untouched this long are closed. */
|
|
30
|
-
export const DECAY_DAYS = 30;
|
|
31
|
-
const TRUSTED = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
32
|
-
/** Merge states that name something to fix; any other non-CLEAN state waits. */
|
|
33
|
-
const SEND_BACK_STATES = new Set(['BEHIND', 'DIRTY', 'BLOCKED']);
|
|
34
|
-
const ARMS = ['code', 'sec'];
|
|
35
|
-
const issueOf = (head) => Number(head.match(/^(?:worktree-)?ai-(\d+)-/)?.[1]) || null;
|
|
36
|
-
function empty(env) {
|
|
37
|
-
return {
|
|
38
|
-
env,
|
|
39
|
-
halt: null,
|
|
40
|
-
idle: false,
|
|
41
|
-
releaseGated: false,
|
|
42
|
-
adopt: [],
|
|
43
|
-
disarm: [],
|
|
44
|
-
handoffs: [],
|
|
45
|
-
sendBacks: [],
|
|
46
|
-
stripMergeReady: [],
|
|
47
|
-
dependabotCiRed: [],
|
|
48
|
-
dependabotChanges: [],
|
|
49
|
-
cleaned: [],
|
|
50
|
-
stalled: [],
|
|
51
|
-
decay: [],
|
|
52
|
-
verdicts: [],
|
|
53
|
-
reviewsToSpawn: [],
|
|
54
|
-
fixRounds: [],
|
|
55
|
-
slots: 0,
|
|
56
|
-
pickups: [],
|
|
57
|
-
rebuild: 'not-requested',
|
|
58
|
-
summary: '',
|
|
59
|
-
errors: [],
|
|
60
|
-
exitCode: 0,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
export async function runLoopTick(options = {}) {
|
|
64
|
-
const dir = path.resolve(options.root ?? process.cwd());
|
|
65
|
-
const env = await resolveLoopEnv({ dir, git: options.git, gh: options.gh, env: options.env });
|
|
66
|
-
const result = empty(env);
|
|
67
|
-
if (!env.root || !env.ownerRepo) {
|
|
68
|
-
result.halt = env.warnings.join('; ') || 'could not resolve the checkout';
|
|
69
|
-
result.exitCode = 1;
|
|
70
|
-
return result;
|
|
71
|
-
}
|
|
72
|
-
const { root, ownerRepo } = env;
|
|
73
|
-
const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
|
|
74
|
-
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
|
|
75
|
-
const now = (options.now ?? new Date()).getTime();
|
|
76
|
-
const errors = result.errors;
|
|
77
|
-
const seams = { root, git: options.git, gh: options.gh };
|
|
78
|
-
const guard = await runLoopGuard({ ...seams, install: options.install });
|
|
79
|
-
if (guard.exitCode !== 0) {
|
|
80
|
-
result.halt = guard.messages.filter((m) => m.startsWith('⚠')).join('; ') || 'loop guard failed';
|
|
81
|
-
result.exitCode = guard.exitCode;
|
|
82
|
-
return result;
|
|
83
|
-
}
|
|
84
|
-
// Best-effort: Pass 4 branches worktrees off origin/main.
|
|
85
|
-
await git(['fetch', '--prune', 'origin']);
|
|
86
|
-
const cleanup = await runLoopCleanup(seams);
|
|
87
|
-
result.cleaned = cleanup.worktrees.filter((w) => w.action === 'removed');
|
|
88
|
-
for (const w of cleanup.worktrees.filter((w) => w.action === 'remove-failed'))
|
|
89
|
-
errors.push(`could not remove ${w.path}`);
|
|
90
|
-
let live = guard.live;
|
|
91
|
-
if (cleanup.removed) {
|
|
92
|
-
const again = await runLoopGuard({ ...seams, install: options.install, removed: true });
|
|
93
|
-
if (again.exitCode !== 0) {
|
|
94
|
-
result.halt =
|
|
95
|
-
again.messages.filter((m) => m.startsWith('⚠')).join('; ') || 'loop guard failed';
|
|
96
|
-
result.exitCode = again.exitCode;
|
|
97
|
-
return result;
|
|
98
|
-
}
|
|
99
|
-
result.rebuild = again.rebuild;
|
|
100
|
-
live = again.live;
|
|
101
|
-
}
|
|
102
|
-
const reap = await runLoopReap({ root, gh: options.gh, now: options.now });
|
|
103
|
-
result.stalled = reap.stalled;
|
|
104
|
-
errors.push(...reap.errors);
|
|
105
|
-
const json = async (args) => {
|
|
106
|
-
const r = await gh(args);
|
|
107
|
-
if (r.ok) {
|
|
108
|
-
try {
|
|
109
|
-
return JSON.parse(r.stdout || 'null');
|
|
110
|
-
}
|
|
111
|
-
catch { }
|
|
112
|
-
}
|
|
113
|
-
errors.push(`gh ${args.slice(0, 3).join(' ')} failed: ${r.stderr.trim()}`);
|
|
114
|
-
return null;
|
|
115
|
-
};
|
|
116
|
-
const prs = await json([
|
|
117
|
-
'pr',
|
|
118
|
-
'list',
|
|
119
|
-
'--state',
|
|
120
|
-
'open',
|
|
121
|
-
'--limit',
|
|
122
|
-
'100',
|
|
123
|
-
'--json',
|
|
124
|
-
'number,headRefName,labels,autoMergeRequest,author,body,statusCheckRollup',
|
|
125
|
-
]);
|
|
126
|
-
const wip = await json([
|
|
127
|
-
'issue',
|
|
128
|
-
'list',
|
|
129
|
-
'--label',
|
|
130
|
-
'ai-wip',
|
|
131
|
-
'--state',
|
|
132
|
-
'open',
|
|
133
|
-
'--limit',
|
|
134
|
-
'100',
|
|
135
|
-
'--json',
|
|
136
|
-
'number',
|
|
137
|
-
]);
|
|
138
|
-
result.releaseGated = await releaseGated(gh, ownerRepo, root);
|
|
139
|
-
for (const pr of prs ?? []) {
|
|
140
|
-
const labels = new Set(pr.labels.map((l) => l.name));
|
|
141
|
-
const has = (l) => labels.has(l);
|
|
142
|
-
const issue = issueOf(pr.headRefName);
|
|
143
|
-
if (pr.headRefName.startsWith('dependabot/')) {
|
|
144
|
-
const red = (pr.statusCheckRollup ?? []).some((c) => c.conclusion === 'FAILURE');
|
|
145
|
-
if (pr.autoMergeRequest && red)
|
|
146
|
-
result.dependabotCiRed.push(pr.number);
|
|
147
|
-
if (has('ai-changes'))
|
|
148
|
-
result.dependabotChanges.push(pr.number);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
const loopPr = has('merge-ready') || [...labels].some((l) => l.startsWith('ai-'));
|
|
152
|
-
if (!loopPr) {
|
|
153
|
-
// The 🤖 header is the discriminator: every agent is the owner's login.
|
|
154
|
-
const mine = pr.author?.login.toLowerCase() === env.me.toLowerCase();
|
|
155
|
-
if (env.me && mine && (pr.body ?? '').startsWith('🤖 '))
|
|
156
|
-
result.adopt.push(pr.number);
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
const passed = (has('ai-ok-code') && has('ai-ok-sec')) || has('merge-ready');
|
|
160
|
-
if (pr.autoMergeRequest && !passed)
|
|
161
|
-
result.disarm.push(pr.number);
|
|
162
|
-
const claimed = has('ai-reviewing-code') || has('ai-reviewing-sec');
|
|
163
|
-
// CI red is a send-back, except mid-review or when one is already out.
|
|
164
|
-
if (!claimed && !has('ai-changes')) {
|
|
165
|
-
const r = await gh([
|
|
166
|
-
'pr',
|
|
167
|
-
'checks',
|
|
168
|
-
String(pr.number),
|
|
169
|
-
'--required',
|
|
170
|
-
'--json',
|
|
171
|
-
'name,state,link',
|
|
172
|
-
]);
|
|
173
|
-
// Exits non-zero whenever a check fails or is pending; stdout is still the answer.
|
|
174
|
-
let checks = [];
|
|
175
|
-
try {
|
|
176
|
-
checks = JSON.parse(r.stdout || '[]');
|
|
177
|
-
}
|
|
178
|
-
catch { }
|
|
179
|
-
const failing = checks
|
|
180
|
-
.filter((c) => c.state === 'FAILURE')
|
|
181
|
-
.map(({ name, link }) => ({ name, link }));
|
|
182
|
-
if (failing.length > 0) {
|
|
183
|
-
result.sendBacks.push({ pr: pr.number, issue, reason: 'ci-red', failing });
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (has('ai-changes')) {
|
|
188
|
-
if (has('merge-ready'))
|
|
189
|
-
result.stripMergeReady.push(pr.number);
|
|
190
|
-
}
|
|
191
|
-
else if (passed) {
|
|
192
|
-
const view = await json([
|
|
193
|
-
'pr',
|
|
194
|
-
'view',
|
|
195
|
-
String(pr.number),
|
|
196
|
-
'--json',
|
|
197
|
-
'mergeStateStatus',
|
|
198
|
-
]);
|
|
199
|
-
const s = view?.mergeStateStatus ?? 'UNKNOWN';
|
|
200
|
-
if (s === 'CLEAN') {
|
|
201
|
-
const notes = has('ai-notes');
|
|
202
|
-
result.handoffs.push({
|
|
203
|
-
pr: pr.number,
|
|
204
|
-
issue,
|
|
205
|
-
notes,
|
|
206
|
-
autoMerge: result.releaseGated && !notes,
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
else if (SEND_BACK_STATES.has(s)) {
|
|
210
|
-
result.sendBacks.push({
|
|
211
|
-
pr: pr.number,
|
|
212
|
-
issue,
|
|
213
|
-
reason: s,
|
|
214
|
-
failing: [],
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
else if (s !== 'UNKNOWN' && has('merge-ready')) {
|
|
218
|
-
result.stripMergeReady.push(pr.number);
|
|
219
|
-
}
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
if (has('ai-review') && !has('ai-changes')) {
|
|
223
|
-
const spawn = [];
|
|
224
|
-
let changes = false;
|
|
225
|
-
for (const arm of ARMS) {
|
|
226
|
-
if (has(`ai-ok-${arm}`) || has(`ai-reviewing-${arm}`))
|
|
227
|
-
continue;
|
|
228
|
-
const v = await runLoopVerdict(pr.number, { dir: root, arm, gh: options.gh });
|
|
229
|
-
if (v.error)
|
|
230
|
-
errors.push(`verdict ${arm} on #${pr.number}: ${v.error}`);
|
|
231
|
-
else if (v.verdict) {
|
|
232
|
-
result.verdicts.push({ pr: pr.number, arm, verdict: v.verdict });
|
|
233
|
-
changes ||= v.verdict === 'CHANGES';
|
|
234
|
-
}
|
|
235
|
-
else
|
|
236
|
-
spawn.push(arm);
|
|
237
|
-
}
|
|
238
|
-
// A CHANGES about to be applied sends the PR back; a review now is wasted.
|
|
239
|
-
if (!changes)
|
|
240
|
-
for (const arm of spawn)
|
|
241
|
-
result.reviewsToSpawn.push({ pr: pr.number, issue, arm });
|
|
242
|
-
}
|
|
243
|
-
if (has('ai-changes') && !has('ai-fixing')) {
|
|
244
|
-
const times = await labelApplications(gh, pr.number, 'ai-changes');
|
|
245
|
-
if (typeof times === 'string') {
|
|
246
|
-
errors.push(times);
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
const slug = pr.headRefName.replace(/^worktree-/, '');
|
|
250
|
-
const worktree = live.find((d) => path.basename(d) === slug) ?? null;
|
|
251
|
-
const atCap = times.length >= MAX_APPLICATIONS;
|
|
252
|
-
result.fixRounds.push({
|
|
253
|
-
pr: pr.number,
|
|
254
|
-
issue,
|
|
255
|
-
worktree,
|
|
256
|
-
applications: times.length,
|
|
257
|
-
action: atCap || !worktree ? 'block' : 'spawn',
|
|
258
|
-
reason: atCap
|
|
259
|
-
? `ai-changes applied ${times.length}× — round cap reached`
|
|
260
|
-
: worktree
|
|
261
|
-
? `round ${times.length}`
|
|
262
|
-
: 'no worktree to fix in',
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
const suggested = await json([
|
|
267
|
-
'issue',
|
|
268
|
-
'list',
|
|
269
|
-
'--label',
|
|
270
|
-
'ai-suggested',
|
|
271
|
-
'--state',
|
|
272
|
-
'open',
|
|
273
|
-
'--limit',
|
|
274
|
-
'100',
|
|
275
|
-
'--json',
|
|
276
|
-
'number,updatedAt,labels',
|
|
277
|
-
]);
|
|
278
|
-
const cutoff = now - DECAY_DAYS * 86_400_000;
|
|
279
|
-
result.decay = (suggested ?? [])
|
|
280
|
-
// A promoted suggestion keeps its label; closing a queued one is unrecoverable.
|
|
281
|
-
.filter((i) => !i.labels.some((l) => ['ai-ready', 'ai-wip', 'holding'].includes(l.name)))
|
|
282
|
-
.filter((i) => Date.parse(i.updatedAt) < cutoff)
|
|
283
|
-
.map((i) => i.number);
|
|
284
|
-
// ponytail: first 100 ai-ready issues; paginate if a queue ever outgrows that.
|
|
285
|
-
const queue = await json([
|
|
286
|
-
'api',
|
|
287
|
-
`repos/${ownerRepo}/issues?labels=ai-ready&state=open&per_page=100`,
|
|
288
|
-
]);
|
|
289
|
-
result.pickups = (queue ?? [])
|
|
290
|
-
.filter((i) => !i.pull_request)
|
|
291
|
-
.filter((i) => !i.labels.some((l) => ['ai-wip', 'ai-blocked', 'holding'].includes(l.name)))
|
|
292
|
-
// The label is the hard gate; association is the backstop.
|
|
293
|
-
.filter((i) => TRUSTED.has(i.author_association))
|
|
294
|
-
.map(({ number, title, body }) => ({ number, title, body: body ?? '' }));
|
|
295
|
-
// Slots count what is still in flight once this tick's cleanup and reaping land.
|
|
296
|
-
const freed = new Set([
|
|
297
|
-
...result.cleaned.map((w) => w.issue),
|
|
298
|
-
...result.stalled.filter((s) => s.kind === 'implementer').map((s) => s.issue),
|
|
299
|
-
]);
|
|
300
|
-
const inFlight = (wip ?? []).filter((i) => !freed.has(i.number)).length;
|
|
301
|
-
result.slots = wip ? Math.max(0, MAX_IN_FLIGHT - inFlight) : 0;
|
|
302
|
-
const loopPrs = (prs ?? []).filter((p) => !p.headRefName.startsWith('dependabot/') &&
|
|
303
|
-
p.labels.some((l) => l.name === 'merge-ready' || l.name.startsWith('ai-')));
|
|
304
|
-
result.idle =
|
|
305
|
-
errors.length === 0 &&
|
|
306
|
-
loopPrs.length === 0 &&
|
|
307
|
-
live.length === 0 &&
|
|
308
|
-
[result.adopt, result.pickups, result.stalled, result.decay, result.dependabotCiRed].every((l) => l.length === 0);
|
|
309
|
-
result.summary = summarize(result, inFlight, loopPrs.length);
|
|
310
|
-
return result;
|
|
311
|
-
}
|
|
312
|
-
/** `⚠` segments first, so a truncated phone banner still leads with the stall. */
|
|
313
|
-
export function summarize(r, inFlight, loopPrs) {
|
|
314
|
-
if (r.idle)
|
|
315
|
-
return 'idle';
|
|
316
|
-
const blocked = r.stalled.filter((s) => s.action === 'block').length +
|
|
317
|
-
r.fixRounds.filter((f) => f.action === 'block').length;
|
|
318
|
-
const ciRed = r.sendBacks.filter((s) => s.reason === 'ci-red').length + r.dependabotCiRed.length;
|
|
319
|
-
const wip = inFlight + Math.min(r.slots, r.pickups.length);
|
|
320
|
-
const ready = r.handoffs.length;
|
|
321
|
-
const segments = [
|
|
322
|
-
[r.errors.length > 0, '⚠error'],
|
|
323
|
-
[blocked, `⚠${blocked}blocked`],
|
|
324
|
-
[ciRed, `⚠${ciRed}ci-red`],
|
|
325
|
-
[r.rebuild === 'deferred' || r.rebuild === 'rebuild-failed', '⚠rebuild'],
|
|
326
|
-
[wip, `${wip}wip`],
|
|
327
|
-
[loopPrs - ready, `${loopPrs - ready}rev`],
|
|
328
|
-
[ready, `${ready}ready`],
|
|
329
|
-
];
|
|
330
|
-
return (segments
|
|
331
|
-
.filter(([n]) => n)
|
|
332
|
-
.map(([, s]) => s)
|
|
333
|
-
.join('·') || 'idle');
|
|
334
|
-
}
|
|
335
|
-
export async function loopTickCommand(options) {
|
|
336
|
-
const result = await runLoopTick(options);
|
|
337
|
-
if (options.json) {
|
|
338
|
-
console.log(JSON.stringify(result, null, 2));
|
|
339
|
-
}
|
|
340
|
-
else if (result.halt) {
|
|
341
|
-
console.error(chalk.red(`✖ halt: ${result.halt}`));
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
console.log(result.summary);
|
|
345
|
-
for (const e of result.errors)
|
|
346
|
-
console.log(` ${chalk.yellow(e)}`);
|
|
347
|
-
}
|
|
348
|
-
process.exitCode = result.exitCode;
|
|
349
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
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
|
-
}
|