@rtorcato/repo-tooling 3.42.0 → 3.44.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 +4 -0
- package/dist/cli/commands/loop-env.js +1 -1
- package/dist/cli/commands/loop-marker.js +169 -0
- package/dist/cli/commands/loop-reap.js +196 -0
- package/dist/cli/index.js +27 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -29,6 +29,8 @@ 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. |
|
|
33
|
+
| `loop comment <pr> --body-file <path>` / `loop verdict <pr> --arm <code\|sec>` | ✅ | ✅ | The loop's hidden-marker protocols. `comment` upserts the one `<!-- ai-issue-loop:decision -->` comment (body from a file or `-` stdin, never argv). `verdict` prints the arm's `PASS` / `PASS-NOTES` / `CHANGES` for the PR's current head, or empty. Both only trust markers posted by `gh`'s own login. Exit `1` when GitHub cannot be read. |
|
|
32
34
|
|
|
33
35
|
## Recommended workflows
|
|
34
36
|
|
|
@@ -121,6 +123,8 @@ A fixer may also **refuse** — the target file holds something the generator ca
|
|
|
121
123
|
- `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
124
|
- `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
|
|
123
125
|
- `src/cli/commands/loop-worktree.ts` — `loop worktree add`: Pass 4 worktree creation, dependency linking and the missing-link assertion (#616)
|
|
126
|
+
- `src/cli/commands/loop-reap.ts` — `loop reap`: the skill's Pass 2 stalled-agent table as verdicts (#618)
|
|
127
|
+
- `src/cli/commands/loop-marker.ts` — `loop comment` / `loop verdict`: the decision-comment upsert and verdict read-back, with the author and head-commit gates (#619)
|
|
124
128
|
- `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
|
|
125
129
|
- `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
|
|
126
130
|
|
|
@@ -3,7 +3,7 @@ import { LOGIN } from '../../base/agent-user.js';
|
|
|
3
3
|
import { realGitExec } from '../../base/git-identity.js';
|
|
4
4
|
import { realGhExec } from '../../base/github-settings.js';
|
|
5
5
|
import { configuredAgentUser, defaultWorktreeRoot } from './loop-guard.js';
|
|
6
|
-
async function ghOut(gh, args) {
|
|
6
|
+
export async function ghOut(gh, args) {
|
|
7
7
|
const r = await gh(args);
|
|
8
8
|
return r.ok ? r.stdout.trim() : '';
|
|
9
9
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { realGhExec } from '../../base/github-settings.js';
|
|
4
|
+
import { ghOut } from './loop-env.js';
|
|
5
|
+
/**
|
|
6
|
+
* `repo-tooling loop comment` and `loop verdict` — the ai-issue-loop skill's
|
|
7
|
+
* two hidden-marker protocols, moved out of prose-with-jq (#619):
|
|
8
|
+
*
|
|
9
|
+
* - **comment** upserts the one `<!-- ai-issue-loop:decision -->` comment on a
|
|
10
|
+
* PR (Pass 1), so a PR left over a weekend gets one edited comment, not one
|
|
11
|
+
* per tick.
|
|
12
|
+
* - **verdict** reads a reviewer arm's `<!-- ai-issue-loop:verdict:… -->`
|
|
13
|
+
* marker back off the PR's reviews (Pass 3), so a reviewer that posted and
|
|
14
|
+
* died before labelling is adopted rather than re-spawned.
|
|
15
|
+
*
|
|
16
|
+
* Both gate on the loop's own login — anyone can comment on or review a public
|
|
17
|
+
* PR, and a stranger's marker would otherwise own the slot or override a
|
|
18
|
+
* genuine `CHANGES`. Login, not `author_association`, which wobbles with repo
|
|
19
|
+
* ownership. Bodies travel to gh as JSON on stdin, never through a shell.
|
|
20
|
+
*/
|
|
21
|
+
export const DECISION_MARKER = '<!-- ai-issue-loop:decision -->';
|
|
22
|
+
async function resolveTarget(dir, gh) {
|
|
23
|
+
const cwd = path.resolve(dir ?? process.cwd());
|
|
24
|
+
const exec = gh ?? ((args, stdin) => realGhExec(args, stdin, cwd));
|
|
25
|
+
const ownerRepo = await ghOut(exec, [
|
|
26
|
+
'repo',
|
|
27
|
+
'view',
|
|
28
|
+
'--json',
|
|
29
|
+
'nameWithOwner',
|
|
30
|
+
'--jq',
|
|
31
|
+
'.nameWithOwner',
|
|
32
|
+
]);
|
|
33
|
+
if (!ownerRepo)
|
|
34
|
+
return 'could not resolve the GitHub repo from the working directory';
|
|
35
|
+
const me = await ghOut(exec, ['api', 'user', '--jq', '.login']);
|
|
36
|
+
if (!me)
|
|
37
|
+
return 'could not resolve the gh login';
|
|
38
|
+
return { ownerRepo, me, gh: exec };
|
|
39
|
+
}
|
|
40
|
+
/** The PR number goes into an API path — digits only. */
|
|
41
|
+
function prNumber(pr) {
|
|
42
|
+
const s = String(pr);
|
|
43
|
+
return /^[1-9]\d*$/.test(s) ? Number(s) : null;
|
|
44
|
+
}
|
|
45
|
+
/** `--paginate --slurp` yields one array per page; `--jq` would run per page. */
|
|
46
|
+
async function slurp(gh, route) {
|
|
47
|
+
const r = await gh(['api', route, '--paginate', '--slurp']);
|
|
48
|
+
if (!r.ok)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(r.stdout).flat();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function runLoopComment(pr, options) {
|
|
58
|
+
const n = prNumber(pr);
|
|
59
|
+
const fail = (error) => ({
|
|
60
|
+
pr: n,
|
|
61
|
+
action: 'failed',
|
|
62
|
+
commentId: null,
|
|
63
|
+
error,
|
|
64
|
+
});
|
|
65
|
+
if (n === null)
|
|
66
|
+
return fail(`not a PR number: ${pr}`);
|
|
67
|
+
const text = options.text.trim();
|
|
68
|
+
if (!text)
|
|
69
|
+
return fail('empty comment body');
|
|
70
|
+
const target = await resolveTarget(options.dir, options.gh);
|
|
71
|
+
if (typeof target === 'string')
|
|
72
|
+
return fail(target);
|
|
73
|
+
const { ownerRepo, me, gh } = target;
|
|
74
|
+
const comments = await slurp(gh, `repos/${ownerRepo}/issues/${n}/comments`);
|
|
75
|
+
// A failed read must not fall through to "create" — that is the duplicate.
|
|
76
|
+
if (comments === null)
|
|
77
|
+
return fail('could not list PR comments');
|
|
78
|
+
const existing = comments.find((c) => c.user?.login === me && (c.body ?? '').startsWith(DECISION_MARKER));
|
|
79
|
+
const input = JSON.stringify({ body: `${DECISION_MARKER}\n${text}` });
|
|
80
|
+
const r = existing
|
|
81
|
+
? await gh(['api', '-X', 'PATCH', `repos/${ownerRepo}/issues/comments/${existing.id}`, '--input', '-'], input)
|
|
82
|
+
: await gh(['api', `repos/${ownerRepo}/issues/${n}/comments`, '--input', '-'], input);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
return fail(r.stderr.trim() || 'gh api failed');
|
|
85
|
+
let id = existing?.id ?? null;
|
|
86
|
+
try {
|
|
87
|
+
id = JSON.parse(r.stdout).id ?? id;
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
return { pr: n, action: existing ? 'updated' : 'created', commentId: id };
|
|
91
|
+
}
|
|
92
|
+
export async function runLoopVerdict(pr, options) {
|
|
93
|
+
const n = prNumber(pr);
|
|
94
|
+
const { arm } = options;
|
|
95
|
+
const fail = (error) => ({
|
|
96
|
+
pr: n,
|
|
97
|
+
arm,
|
|
98
|
+
head: null,
|
|
99
|
+
verdict: null,
|
|
100
|
+
error,
|
|
101
|
+
});
|
|
102
|
+
if (n === null)
|
|
103
|
+
return fail(`not a PR number: ${pr}`);
|
|
104
|
+
if (arm !== 'code' && arm !== 'sec')
|
|
105
|
+
return fail(`--arm must be code or sec, got ${arm}`);
|
|
106
|
+
const target = await resolveTarget(options.dir, options.gh);
|
|
107
|
+
if (typeof target === 'string')
|
|
108
|
+
return fail(target);
|
|
109
|
+
const { ownerRepo, me, gh } = target;
|
|
110
|
+
const head = await ghOut(gh, [
|
|
111
|
+
'pr',
|
|
112
|
+
'view',
|
|
113
|
+
String(n),
|
|
114
|
+
'-R',
|
|
115
|
+
ownerRepo,
|
|
116
|
+
'--json',
|
|
117
|
+
'headRefOid',
|
|
118
|
+
'--jq',
|
|
119
|
+
'.headRefOid',
|
|
120
|
+
]);
|
|
121
|
+
if (!head)
|
|
122
|
+
return fail('could not resolve the PR head commit');
|
|
123
|
+
// `issues/<N>/comments` would never see these — `gh pr review --comment`
|
|
124
|
+
// creates a review.
|
|
125
|
+
const reviews = await slurp(gh, `repos/${ownerRepo}/pulls/${n}/reviews`);
|
|
126
|
+
if (reviews === null)
|
|
127
|
+
return { ...fail('could not list PR reviews'), head };
|
|
128
|
+
const marker = new RegExp(`<!-- ai-issue-loop:verdict:${arm}:(PASS-NOTES|PASS|CHANGES) -->`);
|
|
129
|
+
let verdict = null;
|
|
130
|
+
for (const r of reviews) {
|
|
131
|
+
// The head gate: a verdict expires with the diff it read.
|
|
132
|
+
if (r.user?.login !== me || r.commit_id !== head)
|
|
133
|
+
continue;
|
|
134
|
+
const m = (r.body ?? '').match(marker);
|
|
135
|
+
if (m)
|
|
136
|
+
verdict = m[1];
|
|
137
|
+
}
|
|
138
|
+
return { pr: n, arm, head, verdict };
|
|
139
|
+
}
|
|
140
|
+
export async function loopCommentCommand(pr, options) {
|
|
141
|
+
let text;
|
|
142
|
+
try {
|
|
143
|
+
text = fs.readFileSync(options.bodyFile === '-' ? 0 : options.bodyFile, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error(`✖ cannot read ${options.bodyFile}: ${err.message}`);
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const result = await runLoopComment(pr, { dir: options.dir, text });
|
|
151
|
+
if (options.json)
|
|
152
|
+
console.log(JSON.stringify(result, null, 2));
|
|
153
|
+
else if (result.error)
|
|
154
|
+
console.error(`✖ ${result.error}`);
|
|
155
|
+
else
|
|
156
|
+
console.log(`${result.action} comment ${result.commentId} on #${result.pr}`);
|
|
157
|
+
process.exitCode = result.action === 'failed' ? 1 : 0;
|
|
158
|
+
}
|
|
159
|
+
export async function loopVerdictCommand(pr, options) {
|
|
160
|
+
const result = await runLoopVerdict(pr, options);
|
|
161
|
+
if (options.json)
|
|
162
|
+
console.log(JSON.stringify(result, null, 2));
|
|
163
|
+
else if (result.error)
|
|
164
|
+
console.error(`✖ ${result.error}`);
|
|
165
|
+
// Empty line for "none", so `VERDICT=$(… loop verdict …)` needs no null check.
|
|
166
|
+
else
|
|
167
|
+
console.log(result.verdict ?? '');
|
|
168
|
+
process.exitCode = result.error ? 1 : 0;
|
|
169
|
+
}
|
|
@@ -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,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 { loopCommentCommand, loopVerdictCommand } from './commands/loop-marker.js';
|
|
12
|
+
import { loopReapCommand } from './commands/loop-reap.js';
|
|
11
13
|
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
12
14
|
import { setupProject } from './commands/setup.js';
|
|
13
15
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
@@ -402,6 +404,31 @@ loop
|
|
|
402
404
|
.addHelpText('after', '\nExit 1 when the worktree was not created or a symlinkDirectories entry is left\n' +
|
|
403
405
|
'unlinked — do not spawn an implementer. needsInstall means no list was declared.\n')
|
|
404
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);
|
|
405
432
|
program.hook('preAction', async (_, actionCommand) => {
|
|
406
433
|
const name = actionCommand.name();
|
|
407
434
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED