@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +49 -4
  3. package/README.tr.md +50 -4
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/install/templates/multi-agent-autopilot.plist.template +79 -0
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
  9. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
  10. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
  11. package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
  12. package/pipeline/commands/multi-agent/help/SKILL.md +41 -35
  13. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/sync/SKILL.md +10 -9
  15. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  16. package/pipeline/lib/autopilot-activation.sh +117 -0
  17. package/pipeline/lib/autopilot-state.sh +150 -0
  18. package/pipeline/lib/issue-fetcher.sh +18 -1
  19. package/pipeline/lib/plan-todos.sh +18 -0
  20. package/pipeline/multi-agent-refs/channels/jira.md +80 -20
  21. package/pipeline/multi-agent-refs/channels/pr.md +65 -19
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
  23. package/pipeline/multi-agent-refs/features/visual-evidence.md +19 -7
  24. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  25. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
  26. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +1 -1
  27. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
  28. package/pipeline/multi-agent-refs/readiness-review.md +7 -1
  29. package/pipeline/multi-agent-refs/rules.md +3 -11
  30. package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
  31. package/pipeline/schemas/autopilot-config.schema.json +149 -0
  32. package/pipeline/schemas/token-budget.json +2 -2
  33. package/pipeline/scripts/autopilot-arming.mjs +147 -0
  34. package/pipeline/scripts/autopilot-intake.mjs +383 -0
  35. package/pipeline/scripts/autopilot-menubar.swift +361 -0
  36. package/pipeline/scripts/autopilot-runner.mjs +349 -0
  37. package/pipeline/scripts/autopilot-status.sh +212 -0
  38. package/pipeline/scripts/jira-search.sh +70 -0
  39. package/pipeline/scripts/phase-tracker.sh +134 -12
  40. package/pipeline/scripts/probe-evidence-capability.sh +27 -3
  41. package/pipeline/scripts/run-ui-tests.sh +113 -4
  42. package/pipeline/skills/.skill-manifest.json +16 -4
  43. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
  44. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
  45. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
  46. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
  47. 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
+ }