@mmerterden/multi-agent-pipeline 16.31.1 → 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 (69) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +148 -103
  3. package/README.tr.md +149 -103
  4. package/docs/adr/0011-dormant-ci.md +10 -1
  5. package/docs/adr/0012-macos-only.md +98 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/architecture.md +3 -3
  8. package/docs/ecosystem.md +5 -5
  9. package/docs/engineering.md +1 -1
  10. package/index.js +26 -0
  11. package/install/_dev-only-files.mjs +0 -1
  12. package/install/index.mjs +10 -0
  13. package/install/templates/multi-agent-autopilot.plist.template +79 -0
  14. package/package.json +5 -3
  15. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
  16. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
  17. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
  18. package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
  19. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +40 -3
  20. package/pipeline/commands/multi-agent/help/SKILL.md +43 -37
  21. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  22. package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
  23. package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
  24. package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +34 -28
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/autopilot-activation.sh +117 -0
  28. package/pipeline/lib/autopilot-state.sh +150 -0
  29. package/pipeline/lib/issue-fetcher.sh +18 -1
  30. package/pipeline/lib/plan-todos.sh +18 -0
  31. package/pipeline/lib/stack-detect.sh +200 -0
  32. package/pipeline/multi-agent-refs/channels/jira.md +80 -20
  33. package/pipeline/multi-agent-refs/channels/pr.md +65 -19
  34. package/pipeline/multi-agent-refs/cross-cli-contract.md +35 -15
  35. package/pipeline/multi-agent-refs/features/doctor.md +15 -3
  36. package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -1
  37. package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
  38. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
  39. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
  40. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +7 -5
  41. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
  42. package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
  43. package/pipeline/multi-agent-refs/readiness-review.md +7 -1
  44. package/pipeline/multi-agent-refs/rules.md +3 -11
  45. package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
  46. package/pipeline/schemas/agent-state.schema.json +99 -25
  47. package/pipeline/schemas/autopilot-config.schema.json +149 -0
  48. package/pipeline/schemas/token-budget.json +4 -4
  49. package/pipeline/scripts/_stack-routing.mjs +91 -0
  50. package/pipeline/scripts/autopilot-arming.mjs +147 -0
  51. package/pipeline/scripts/autopilot-intake.mjs +383 -0
  52. package/pipeline/scripts/autopilot-menubar.swift +361 -0
  53. package/pipeline/scripts/autopilot-runner.mjs +349 -0
  54. package/pipeline/scripts/autopilot-status.sh +212 -0
  55. package/pipeline/scripts/capture-resume.sh +76 -14
  56. package/pipeline/scripts/doctor.mjs +26 -3
  57. package/pipeline/scripts/gc-abandoned.sh +352 -0
  58. package/pipeline/scripts/jira-search.sh +70 -0
  59. package/pipeline/scripts/phase-tracker.sh +134 -12
  60. package/pipeline/scripts/probe-evidence-capability.sh +27 -3
  61. package/pipeline/scripts/run-ui-tests.sh +113 -4
  62. package/pipeline/scripts/usage-report.mjs +5 -5
  63. package/pipeline/skills/.skill-manifest.json +16 -4
  64. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
  65. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
  66. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
  67. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
  68. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
  69. package/pipeline/scripts/gate-linux.sh +0 -62
@@ -14,6 +14,8 @@
14
14
  // of them and --check-routing polices accidental multi-routing. routeSkill()
15
15
  // returns the full match list so callers can decide.
16
16
 
17
+ import { readFileSync } from "node:fs";
18
+
17
19
  export const COMMON_PLUGIN = "ai-common-toolkit";
18
20
 
19
21
  export const COMMON_SKILLS = [
@@ -120,3 +122,92 @@ export function routeSkill(name) {
120
122
  }
121
123
  return hits;
122
124
  }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Stack -> plugin. A different question from routeSkill() above, which answers
128
+ // "which plugin carries this SKILL". This half answers "which plugins does a
129
+ // repo built with these stacks want", and it lives here because the plugin-name
130
+ // vocabulary - including the one place the two vocabularies disagree - is
131
+ // already defined in this file. It was previously written out by hand in four
132
+ // places (`stack/SKILL.md` twice, `install/_plugin-skills.mjs`, and a shell
133
+ // helper), which is three opportunities for the frontend/web asymmetry below to
134
+ // be got wrong.
135
+
136
+ // The detector says `web`; the marketplace ships `ai-frontend-toolkit`. This is
137
+ // the only name that is not `ai-<stack>-toolkit`, and it is an alias rather than
138
+ // a rename on either side: `web` is what a repo IS, `frontend` is what the
139
+ // plugin COVERS, and both names are load-bearing in their own vocabulary.
140
+ export const STACK_PLUGIN_ALIAS = { web: "frontend" };
141
+
142
+ // Stack-independent, enabled everywhere: "does this dependency have a known bug"
143
+ // reads the same on iOS and on a backend. Also the honest answer when detection
144
+ // finds nothing - two toolkits plus a recorded reason, not six toolkits of paid
145
+ // context on a repo that is one Swift package.
146
+ export const ALWAYS_ON = [COMMON_PLUGIN, ANALYST_PLUGIN];
147
+
148
+ // What a user may type where a stack is expected. Kept beside the stacks it
149
+ // expands to so `/multi-agent:stack mobile` cannot mean one thing in the command
150
+ // and another in the installer.
151
+ export const STACK_ARG_ALIAS = {
152
+ frontend: ["web"],
153
+ mobile: ["ios", "android"],
154
+ fullstack: ["web", "backend"],
155
+ all: ["ios", "android", "web", "backend"],
156
+ };
157
+
158
+ export const KNOWN_STACKS = ["ios", "android", "web", "backend"];
159
+
160
+ /**
161
+ * Expand a user-typed stack argument into detector vocabulary.
162
+ * @param {string} arg
163
+ * @returns {string[]} zero or more of KNOWN_STACKS; empty means unrecognised
164
+ */
165
+ export function expandStackArg(arg) {
166
+ const a = String(arg || "")
167
+ .trim()
168
+ .toLowerCase();
169
+ if (STACK_ARG_ALIAS[a]) return [...STACK_ARG_ALIAS[a]];
170
+ return KNOWN_STACKS.includes(a) ? [a] : [];
171
+ }
172
+
173
+ /**
174
+ * Plugins for a detected stack set.
175
+ *
176
+ * Derived names are checked against the locally installed marketplace manifest,
177
+ * so a name that does not exist is caught HERE rather than at load time, where
178
+ * it presents as a toolkit silently missing from the session. `mobile-specs` had
179
+ * a toolkit name selected that the marketplace has never carried.
180
+ *
181
+ * Never fail-closed on the manifest: an unreadable or absent file means the
182
+ * convention is used unchecked and `manifestVerified` says so, because refusing
183
+ * to route at all would be worse than routing optimistically.
184
+ *
185
+ * @param {string[]} stacks - subset of KNOWN_STACKS
186
+ * @param {{manifestPath?: string}} [opts]
187
+ * @returns {{plugins: string[], dropped: string[], manifestVerified: boolean}}
188
+ */
189
+ export function pluginsForStacks(stacks, opts = {}) {
190
+ const wanted = [...ALWAYS_ON];
191
+ for (const s of stacks || []) {
192
+ if (!KNOWN_STACKS.includes(s)) continue;
193
+ const name = `ai-${STACK_PLUGIN_ALIAS[s] ?? s}-toolkit`;
194
+ if (!wanted.includes(name)) wanted.push(name);
195
+ }
196
+
197
+ let available = null;
198
+ const path =
199
+ opts.manifestPath ??
200
+ `${process.env.HOME}/.claude/plugins/marketplaces/multi-agent-plugins/.claude-plugin/marketplace.json`;
201
+ try {
202
+ const raw = JSON.parse(readFileSync(path, "utf8"));
203
+ const names = (raw.plugins || []).map((p) => p.name).filter(Boolean);
204
+ if (names.length) available = new Set(names);
205
+ } catch {
206
+ available = null;
207
+ }
208
+
209
+ if (!available) return { plugins: wanted, dropped: [], manifestVerified: false };
210
+ const plugins = wanted.filter((p) => available.has(p));
211
+ const dropped = wanted.filter((p) => !available.has(p));
212
+ return { plugins, dropped, manifestVerified: true };
213
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * autopilot-arming.mjs - the two questions asked before an item is taken.
4
+ *
5
+ * Both exist because an unattended queue fails DIFFERENTLY from an attended run.
6
+ * When you are at the keyboard, an expired token stops you and you replace it;
7
+ * a run that costs more than you expected is a thing you watch happen. Overnight
8
+ * neither is true, so both become preconditions:
9
+ *
10
+ * Spend. A rolling 24-hour total from the attempt log against
11
+ * `costCeilingUsd`. Past the ceiling, dispatch refuses and says so. Work
12
+ * already running is NEVER killed mid-item: stopping between a commit and a
13
+ * PR leaves a branch nobody asked for, and the money is already spent.
14
+ *
15
+ * Credentials. `prefs.global.serviceStatus.<service>` must not be a recorded
16
+ * failure for the service an item needs. Without this, an expired Jira token
17
+ * burns the whole queue one item at a time and the morning brings forty
18
+ * `died` records and no work. The gate is per source, so a dead Jira token
19
+ * does not stop GitHub items.
20
+ *
21
+ * A service that was never probed is NOT a failure: it means Phase 0 has not
22
+ * run yet on this machine, and refusing on absence would mean the queue never
23
+ * starts. Only a recorded `ok: false` blocks.
24
+ *
25
+ * Usage:
26
+ * autopilot-arming.mjs check [--source github|jira] KEY=VALUE lines, exit 0
27
+ * autopilot-arming.mjs check --json
28
+ *
29
+ * Exit: 0 whether or not it is armed - "not armed" is an answer. 2 on usage.
30
+ */
31
+
32
+ import { existsSync, readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { homedir } from "node:os";
35
+
36
+ const ROOT = process.env.MA_AUTOPILOT_ROOT || join(homedir(), ".claude", "autopilot");
37
+ const PREFS =
38
+ process.env.MA_PREFS_FILE || join(homedir(), ".claude", "multi-agent-preferences.json");
39
+
40
+ /** Which service an item from this source cannot proceed without. */
41
+ export const SOURCE_SERVICE = { github: "github", jira: "jira" };
42
+
43
+ /**
44
+ * Spend inside the trailing window. Reads the append-only attempt log rather
45
+ * than a running total in a file: a counter can be lost, double-counted, or
46
+ * left behind by a crash, and the log is the thing that already has to be
47
+ * written for retry to work at all.
48
+ */
49
+ export function spendWindow(lines, { now = Date.now() / 1000, windowSeconds = 86400 } = {}) {
50
+ const cutoff = now - windowSeconds;
51
+ let usd = 0;
52
+ let items = 0;
53
+ for (const raw of lines) {
54
+ const s = raw.trim();
55
+ if (!s) continue;
56
+ let rec;
57
+ try {
58
+ rec = JSON.parse(s);
59
+ } catch {
60
+ continue;
61
+ }
62
+ if ((rec.at || 0) < cutoff) continue;
63
+ usd += Number(rec.usd || 0);
64
+ items += 1;
65
+ }
66
+ return { usd: Math.round(usd * 100) / 100, items };
67
+ }
68
+
69
+ /**
70
+ * @returns {{ok: boolean, reason: string|null}} - `reason` is written for a
71
+ * human to read in the menu bar, so it says what to do, not what failed.
72
+ */
73
+ export function credentialGate(serviceStatus, source) {
74
+ const svc = SOURCE_SERVICE[source];
75
+ if (!svc) return { ok: true, reason: null };
76
+ const rec = serviceStatus?.[svc];
77
+ if (!rec) return { ok: true, reason: null }; // never probed is not a failure
78
+ if (rec.ok) return { ok: true, reason: null };
79
+ return {
80
+ ok: false,
81
+ reason: `${svc} kimlik bilgisi geçersiz (${rec.checkedAt || "?"}) - /multi-agent:setup ile yenile`,
82
+ };
83
+ }
84
+
85
+ export function arming({ lines, prefs, config, source, now }) {
86
+ const ceiling = Number(config?.costCeilingUsd ?? 25);
87
+ const spend = spendWindow(lines, { now });
88
+ const cred = credentialGate(prefs?.global?.serviceStatus, source);
89
+
90
+ if (!cred.ok)
91
+ return { armed: false, blockedBy: "credential", reason: cred.reason, spend, ceiling };
92
+ if (spend.usd >= ceiling) {
93
+ return {
94
+ armed: false,
95
+ blockedBy: "cost",
96
+ reason: `son 24 saatte $${spend.usd.toFixed(2)} harcandı, tavan $${ceiling.toFixed(2)} - koşan iş bitirilir, yeni madde alınmaz`,
97
+ spend,
98
+ ceiling,
99
+ };
100
+ }
101
+ return { armed: true, blockedBy: null, reason: null, spend, ceiling };
102
+ }
103
+
104
+ function readJson(path, fallback) {
105
+ if (!existsSync(path)) return fallback;
106
+ try {
107
+ return JSON.parse(readFileSync(path, "utf-8"));
108
+ } catch {
109
+ return fallback;
110
+ }
111
+ }
112
+
113
+ function main(argv) {
114
+ const args = argv.slice(2);
115
+ if (args[0] !== "check") {
116
+ process.stderr.write("usage: autopilot-arming.mjs check [--source github|jira] [--json]\n");
117
+ return 2;
118
+ }
119
+ const source = args.includes("--source") ? args[args.indexOf("--source") + 1] : null;
120
+ const attempts = join(ROOT, "attempted.jsonl");
121
+ const result = arming({
122
+ lines: existsSync(attempts) ? readFileSync(attempts, "utf-8").split("\n") : [],
123
+ prefs: readJson(PREFS, {}),
124
+ config: readJson(join(ROOT, "config.json"), {}),
125
+ source,
126
+ });
127
+
128
+ if (args.includes("--json")) {
129
+ process.stdout.write(JSON.stringify(result) + "\n");
130
+ return 0;
131
+ }
132
+ process.stdout.write(
133
+ [
134
+ `ARM_OK=${result.armed}`,
135
+ `ARM_BLOCKED_BY=${result.blockedBy ?? ""}`,
136
+ `ARM_REASON=${result.reason ?? ""}`,
137
+ `ARM_SPEND_USD=${result.spend.usd}`,
138
+ `ARM_SPEND_ITEMS=${result.spend.items}`,
139
+ `ARM_CEILING_USD=${result.ceiling}`,
140
+ ].join("\n") + "\n",
141
+ );
142
+ return 0;
143
+ }
144
+
145
+ if (import.meta.url === `file://${process.argv[1]}`) {
146
+ process.exit(main(process.argv));
147
+ }
@@ -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
+ }