@mmerterden/multi-agent-pipeline 17.0.0 → 17.1.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/CHANGELOG.md +32 -0
- package/README.md +49 -4
- package/README.tr.md +50 -4
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/install/templates/multi-agent-autopilot.plist.template +79 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
- package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/autopilot-activation.sh +117 -0
- package/pipeline/lib/autopilot-state.sh +150 -0
- package/pipeline/lib/issue-fetcher.sh +18 -1
- package/pipeline/lib/plan-todos.sh +18 -0
- package/pipeline/multi-agent-refs/channels/jira.md +80 -20
- package/pipeline/multi-agent-refs/channels/pr.md +65 -19
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
- package/pipeline/multi-agent-refs/features/visual-evidence.md +19 -7
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
- package/pipeline/multi-agent-refs/readiness-review.md +7 -1
- package/pipeline/multi-agent-refs/rules.md +3 -11
- package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
- package/pipeline/schemas/autopilot-config.schema.json +149 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/autopilot-arming.mjs +147 -0
- package/pipeline/scripts/autopilot-intake.mjs +383 -0
- package/pipeline/scripts/autopilot-menubar.swift +361 -0
- package/pipeline/scripts/autopilot-runner.mjs +349 -0
- package/pipeline/scripts/autopilot-status.sh +212 -0
- package/pipeline/scripts/jira-search.sh +70 -0
- package/pipeline/scripts/phase-tracker.sh +134 -12
- package/pipeline/scripts/probe-evidence-capability.sh +27 -3
- package/pipeline/scripts/run-ui-tests.sh +113 -4
- package/pipeline/skills/.skill-manifest.json +16 -4
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* autopilot-intake.mjs - sources to one ordered queue. Read-only on the world.
|
|
4
|
+
*
|
|
5
|
+
* This file answers "what is next" and nothing else. It never dispatches, never
|
|
6
|
+
* writes to a ticket, never touches a worktree: the runner does that, and
|
|
7
|
+
* keeping the two apart is what makes the ordering testable without a network,
|
|
8
|
+
* a simulator, or a willingness to open a real PR while a gate runs.
|
|
9
|
+
*
|
|
10
|
+
* ORDER (descending precedence). Deterministic, no model call:
|
|
11
|
+
*
|
|
12
|
+
* 1. explicit rank from the label (`agent-queue:1` ... `agent-queue:9`)
|
|
13
|
+
* 2. repo grouping - once a repo is at the head its items keep going until it
|
|
14
|
+
* is empty or `maxConsecutivePerRepo` is reached. This is not cosmetic: the
|
|
15
|
+
* code graph, repo map and knowledge index are per-repo and are rebuilt per
|
|
16
|
+
* run today, so draining a repo is what lets items 2..N skip that setup.
|
|
17
|
+
* 3. priority - the Jira ladder from jira/SKILL.md, or a GitHub priority label
|
|
18
|
+
* 4. created ASCENDING - oldest first. Note this is the OPPOSITE of the `jira`
|
|
19
|
+
* picker, which shows newest first: a human picking from a list wants what
|
|
20
|
+
* just landed, an unattended queue must not starve what has been waiting.
|
|
21
|
+
* 5. `source:id` lexicographic, so equal inputs give one identical order and a
|
|
22
|
+
* re-scan does not reshuffle the queue under a running item.
|
|
23
|
+
*
|
|
24
|
+
* Grouping never moves an item across a rank boundary; rank is global, grouping
|
|
25
|
+
* is within a rank.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* autopilot-intake.mjs fetch, order, write queue.json
|
|
29
|
+
* autopilot-intake.mjs --dry-run fetch, order, print, write NOTHING
|
|
30
|
+
* autopilot-intake.mjs --from <file> candidates from a file, no fetching
|
|
31
|
+
* autopilot-intake.mjs --order-only candidates on stdin, ordered JSON out
|
|
32
|
+
*
|
|
33
|
+
* Exit: 0 always when it can read its own state. An empty queue is an answer,
|
|
34
|
+
* not an error - "no item carries the label yet" is the single most common
|
|
35
|
+
* reason nothing is happening and it is not a fault.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { execFileSync } from "node:child_process";
|
|
39
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync } from "node:fs";
|
|
40
|
+
import { join, dirname } from "node:path";
|
|
41
|
+
import { homedir } from "node:os";
|
|
42
|
+
|
|
43
|
+
const ROOT = process.env.MA_AUTOPILOT_ROOT || join(homedir(), ".claude", "autopilot");
|
|
44
|
+
|
|
45
|
+
/** Jira's ladder, copied in score form from jira/SKILL.md so both rank the same. */
|
|
46
|
+
const PRIORITY = {
|
|
47
|
+
blocker: 100,
|
|
48
|
+
critical: 90,
|
|
49
|
+
highest: 95,
|
|
50
|
+
high: 80,
|
|
51
|
+
medium: 50,
|
|
52
|
+
normal: 50,
|
|
53
|
+
low: 30,
|
|
54
|
+
lowest: 10,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An outcome that means the item is finished with us. Anything not listed is
|
|
59
|
+
* either retryable or a waiting state, and the difference matters: treating
|
|
60
|
+
* `died` as terminal drops an item because a laptop lid closed once.
|
|
61
|
+
*/
|
|
62
|
+
const TERMINAL = new Set(["pr-opened", "merged", "skipped"]);
|
|
63
|
+
/** Counted against `maxAttempts`. A credential block is NOT one of these - the
|
|
64
|
+
* token being expired is not the item's fault, and it becomes workable again
|
|
65
|
+
* the moment the token is replaced. */
|
|
66
|
+
const RETRYABLE = new Set(["died", "build-failed"]);
|
|
67
|
+
|
|
68
|
+
export function priorityScore(name) {
|
|
69
|
+
if (!name) return 0;
|
|
70
|
+
return PRIORITY[String(name).trim().toLowerCase()] ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `agent-queue:3` -> 3. Any other label shape -> null (ordered by the rules). */
|
|
74
|
+
export function rankFromLabels(labels, base) {
|
|
75
|
+
for (const l of labels || []) {
|
|
76
|
+
const name = typeof l === "string" ? l : l?.name;
|
|
77
|
+
if (!name) continue;
|
|
78
|
+
const m = new RegExp(`^${base}:([1-9])$`).exec(name);
|
|
79
|
+
if (m) return Number(m[1]);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function baseCompare(a, b) {
|
|
85
|
+
const pa = a.priority ?? 0;
|
|
86
|
+
const pb = b.priority ?? 0;
|
|
87
|
+
if (pa !== pb) return pb - pa; // higher score first
|
|
88
|
+
const ca = a.createdAt || "";
|
|
89
|
+
const cb = b.createdAt || "";
|
|
90
|
+
if (ca !== cb) return ca < cb ? -1 : 1; // oldest first
|
|
91
|
+
return `${a.source}:${a.id}` < `${b.source}:${b.id}` ? -1 : 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The whole ordering, as a pure function of its arguments. Everything above
|
|
96
|
+
* this line fetches; everything below is what the gate can exercise with a
|
|
97
|
+
* fixture and what a reader can reason about.
|
|
98
|
+
*/
|
|
99
|
+
export function orderQueue(items, { maxConsecutivePerRepo = 5 } = {}) {
|
|
100
|
+
const byRank = new Map();
|
|
101
|
+
for (const it of items) {
|
|
102
|
+
const r = it.rank ?? 10;
|
|
103
|
+
if (!byRank.has(r)) byRank.set(r, []);
|
|
104
|
+
byRank.get(r).push(it);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const rank of [...byRank.keys()].sort((x, y) => x - y)) {
|
|
109
|
+
const pool = byRank.get(rank).slice().sort(baseCompare);
|
|
110
|
+
let current = null;
|
|
111
|
+
let run = 0;
|
|
112
|
+
while (pool.length > 0) {
|
|
113
|
+
let idx = -1;
|
|
114
|
+
if (current !== null && run < maxConsecutivePerRepo) {
|
|
115
|
+
idx = pool.findIndex((i) => i.repo === current);
|
|
116
|
+
}
|
|
117
|
+
if (idx === -1 && current !== null && run >= maxConsecutivePerRepo) {
|
|
118
|
+
// The ceiling has to ACTIVELY hand the turn to another repo. The first
|
|
119
|
+
// version of this loop fell back to pool[0] here, which is still the
|
|
120
|
+
// busy repo's next item whenever that repo dominates the pool - so the
|
|
121
|
+
// ceiling was computed, compared, and then did nothing. Twelve items in
|
|
122
|
+
// one repo still shipped before a single item in another.
|
|
123
|
+
idx = pool.findIndex((i) => i.repo !== current);
|
|
124
|
+
}
|
|
125
|
+
if (idx === -1) idx = 0; // only this repo is left; the ceiling is moot
|
|
126
|
+
const item = pool[idx];
|
|
127
|
+
if (item.repo !== current) {
|
|
128
|
+
current = item.repo;
|
|
129
|
+
run = 0;
|
|
130
|
+
}
|
|
131
|
+
pool.splice(idx, 1);
|
|
132
|
+
out.push(item);
|
|
133
|
+
run += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Attempt history, folded into one record per item. */
|
|
140
|
+
export function foldAttempts(lines) {
|
|
141
|
+
const byKey = new Map();
|
|
142
|
+
for (const raw of lines) {
|
|
143
|
+
const s = raw.trim();
|
|
144
|
+
if (!s) continue;
|
|
145
|
+
let rec;
|
|
146
|
+
try {
|
|
147
|
+
rec = JSON.parse(s);
|
|
148
|
+
} catch {
|
|
149
|
+
continue; // a truncated last line is normal on an append-only log
|
|
150
|
+
}
|
|
151
|
+
const key = `${rec.source}:${rec.id}`;
|
|
152
|
+
const cur = byKey.get(key) || { attempts: 0, last: null, usd: 0, at: 0 };
|
|
153
|
+
if (RETRYABLE.has(rec.outcome)) cur.attempts += 1;
|
|
154
|
+
cur.usd += Number(rec.usd || 0);
|
|
155
|
+
if ((rec.at || 0) >= cur.at) {
|
|
156
|
+
cur.at = rec.at || 0;
|
|
157
|
+
cur.last = rec.outcome;
|
|
158
|
+
cur.prUrl = rec.prUrl || cur.prUrl;
|
|
159
|
+
cur.gapFingerprint = rec.gapFingerprint ?? cur.gapFingerprint;
|
|
160
|
+
}
|
|
161
|
+
byKey.set(key, cur);
|
|
162
|
+
}
|
|
163
|
+
return byKey;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which candidates may enter the queue, and WHY each excluded one was excluded.
|
|
168
|
+
* Every drop carries a reason: a queue that silently shortens is the thing that
|
|
169
|
+
* makes "why is nothing happening" unanswerable.
|
|
170
|
+
*/
|
|
171
|
+
export function selectable(candidates, history, { maxAttempts = 2 } = {}) {
|
|
172
|
+
const queued = [];
|
|
173
|
+
const awaiting = [];
|
|
174
|
+
const dropped = [];
|
|
175
|
+
for (const c of candidates) {
|
|
176
|
+
const h = history.get(`${c.source}:${c.id}`);
|
|
177
|
+
if (!h) {
|
|
178
|
+
queued.push(c);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (TERMINAL.has(h.last)) {
|
|
182
|
+
dropped.push({ ...c, reason: `already ${h.last}`, prUrl: h.prUrl });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (h.last === "awaiting-answer") {
|
|
186
|
+
awaiting.push({ ...c, askedAt: h.at, gapFingerprint: h.gapFingerprint });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (h.attempts >= maxAttempts) {
|
|
190
|
+
dropped.push({ ...c, reason: `${h.attempts} attempts, ceiling ${maxAttempts}` });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
queued.push({ ...c, attempts: h.attempts });
|
|
194
|
+
}
|
|
195
|
+
return { queued, awaiting, dropped };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Fetching. Everything below talks to the world.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
function sh(cmd, args, opts = {}) {
|
|
203
|
+
try {
|
|
204
|
+
return execFileSync(cmd, args, {
|
|
205
|
+
encoding: "utf-8",
|
|
206
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
207
|
+
...opts,
|
|
208
|
+
});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return e.stdout || "";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function fetchGitHub(repo) {
|
|
215
|
+
const label = repo.githubLabel || "agent-queue";
|
|
216
|
+
const out = sh("gh", [
|
|
217
|
+
"issue",
|
|
218
|
+
"list",
|
|
219
|
+
"--repo",
|
|
220
|
+
repo.nameWithOwner,
|
|
221
|
+
"--label",
|
|
222
|
+
label,
|
|
223
|
+
"--state",
|
|
224
|
+
"open",
|
|
225
|
+
"--json",
|
|
226
|
+
"number,title,labels,url,createdAt",
|
|
227
|
+
"--limit",
|
|
228
|
+
"100",
|
|
229
|
+
]);
|
|
230
|
+
let rows;
|
|
231
|
+
try {
|
|
232
|
+
rows = JSON.parse(out || "[]");
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
const short = repo.nameWithOwner.split("/").pop();
|
|
237
|
+
return rows.map((r) => ({
|
|
238
|
+
source: "github",
|
|
239
|
+
id: `${short}#${r.number}`,
|
|
240
|
+
ref: `${short}#${r.number}`,
|
|
241
|
+
repo: repo.nameWithOwner,
|
|
242
|
+
localPath: repo.localPath,
|
|
243
|
+
title: r.title,
|
|
244
|
+
url: r.url,
|
|
245
|
+
createdAt: r.createdAt,
|
|
246
|
+
rank: rankFromLabels(r.labels, label),
|
|
247
|
+
// GitHub has no priority field; a `priority:high` label is the convention
|
|
248
|
+
// and its absence is an honest 0 rather than a guess.
|
|
249
|
+
priority: priorityScore(
|
|
250
|
+
(r.labels || [])
|
|
251
|
+
.map((l) => l.name || "")
|
|
252
|
+
.find((n) => n.startsWith("priority:"))
|
|
253
|
+
?.split(":")[1],
|
|
254
|
+
),
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function fetchJira(repo) {
|
|
259
|
+
const label = repo.jiraLabel || "agent-queue";
|
|
260
|
+
const jql =
|
|
261
|
+
repo.jiraJql ||
|
|
262
|
+
`assignee = currentUser() AND labels = "${label}" AND resolution = EMPTY ` +
|
|
263
|
+
`AND status not in (Done, Closed, Cancelled) ORDER BY priority DESC, created ASC`;
|
|
264
|
+
// One helper owns Jira search: URL encoding and JQL injection are already
|
|
265
|
+
// solved there, and a second copy here would be a second place to get them
|
|
266
|
+
// wrong. It prints one JSON document on stdout.
|
|
267
|
+
const helper = join(homedir(), ".claude", "scripts", "jira-search.sh");
|
|
268
|
+
if (!existsSync(helper)) return [];
|
|
269
|
+
const out = sh("bash", [helper, "--jql", jql, "--fields", "key,summary,priority,created"]);
|
|
270
|
+
let rows;
|
|
271
|
+
try {
|
|
272
|
+
rows = JSON.parse(out || "{}").issues || [];
|
|
273
|
+
} catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
return rows.map((i) => ({
|
|
277
|
+
source: "jira",
|
|
278
|
+
id: i.key,
|
|
279
|
+
ref: i.key,
|
|
280
|
+
repo: repo.nameWithOwner,
|
|
281
|
+
localPath: repo.localPath,
|
|
282
|
+
title: i.fields?.summary,
|
|
283
|
+
url: null,
|
|
284
|
+
createdAt: i.fields?.created,
|
|
285
|
+
rank: null,
|
|
286
|
+
priority: priorityScore(i.fields?.priority?.name),
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function gather(config) {
|
|
291
|
+
const items = [];
|
|
292
|
+
for (const repo of config.repos || []) {
|
|
293
|
+
const sources = repo.sources || ["github"];
|
|
294
|
+
if (sources.includes("github")) items.push(...fetchGitHub(repo));
|
|
295
|
+
if (sources.includes("jira")) items.push(...fetchJira(repo));
|
|
296
|
+
}
|
|
297
|
+
return items;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
function atomicWrite(name, text) {
|
|
303
|
+
mkdirSync(ROOT, { recursive: true, mode: 0o700 });
|
|
304
|
+
const dst = join(ROOT, name);
|
|
305
|
+
const tmp = join(dirname(dst), `.${name}.${process.pid}`);
|
|
306
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
307
|
+
chmodSync(tmp, 0o600);
|
|
308
|
+
renameSync(tmp, dst);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function main(argv) {
|
|
312
|
+
const args = argv.slice(2);
|
|
313
|
+
const has = (f) => args.includes(f);
|
|
314
|
+
const val = (f) => (args.includes(f) ? args[args.indexOf(f) + 1] : null);
|
|
315
|
+
|
|
316
|
+
if (has("--order-only")) {
|
|
317
|
+
const raw = readFileSync(0, "utf-8");
|
|
318
|
+
const items = JSON.parse(raw || "[]");
|
|
319
|
+
process.stdout.write(JSON.stringify(orderQueue(items), null, 2) + "\n");
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const cfgPath = join(ROOT, "config.json");
|
|
324
|
+
const config = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf-8")) : { repos: [] };
|
|
325
|
+
|
|
326
|
+
const fromFile = val("--from");
|
|
327
|
+
const candidates = fromFile ? JSON.parse(readFileSync(fromFile, "utf-8")) : gather(config);
|
|
328
|
+
|
|
329
|
+
const attemptPath = join(ROOT, "attempted.jsonl");
|
|
330
|
+
const history = foldAttempts(
|
|
331
|
+
existsSync(attemptPath) ? readFileSync(attemptPath, "utf-8").split("\n") : [],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const { queued, awaiting, dropped } = selectable(candidates, history, {
|
|
335
|
+
maxAttempts: config.maxAttempts ?? 2,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const ordered = orderQueue(queued, {
|
|
339
|
+
maxConsecutivePerRepo: config.maxConsecutivePerRepo ?? 5,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// The existing queue's `running` list is preserved: intake decides what is
|
|
343
|
+
// NEXT and has no opinion about what is already in flight. Overwriting it here
|
|
344
|
+
// would make a scan look like a crash to the status reader.
|
|
345
|
+
let running = [];
|
|
346
|
+
const qPath = join(ROOT, "queue.json");
|
|
347
|
+
if (existsSync(qPath)) {
|
|
348
|
+
try {
|
|
349
|
+
running = JSON.parse(readFileSync(qPath, "utf-8")).running || [];
|
|
350
|
+
} catch {
|
|
351
|
+
running = [];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const queue = {
|
|
356
|
+
lastTickAt: Math.floor(Date.now() / 1000),
|
|
357
|
+
running,
|
|
358
|
+
queued: ordered,
|
|
359
|
+
awaiting,
|
|
360
|
+
dropped,
|
|
361
|
+
emptyReason:
|
|
362
|
+
ordered.length === 0 && awaiting.length === 0
|
|
363
|
+
? candidates.length === 0
|
|
364
|
+
? "no item carries the label yet"
|
|
365
|
+
: "every candidate is finished, waiting or past its attempt ceiling"
|
|
366
|
+
: null,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const text = JSON.stringify(queue, null, 2);
|
|
370
|
+
if (has("--dry-run")) {
|
|
371
|
+
process.stdout.write(text + "\n");
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
atomicWrite("queue.json", text);
|
|
375
|
+
process.stdout.write(
|
|
376
|
+
`autopilot-intake: ${ordered.length} queued, ${awaiting.length} awaiting, ${dropped.length} dropped\n`,
|
|
377
|
+
);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
382
|
+
process.exit(main(process.argv));
|
|
383
|
+
}
|