@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.
- package/CHANGELOG.md +110 -0
- package/README.md +148 -103
- package/README.tr.md +149 -103
- package/docs/adr/0011-dormant-ci.md +10 -1
- package/docs/adr/0012-macos-only.md +98 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/engineering.md +1 -1
- package/index.js +26 -0
- package/install/_dev-only-files.mjs +0 -1
- package/install/index.mjs +10 -0
- package/install/templates/multi-agent-autopilot.plist.template +79 -0
- package/package.json +5 -3
- 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/garbage-collect/SKILL.md +40 -3
- package/pipeline/commands/multi-agent/help/SKILL.md +43 -37
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
- package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
- package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +34 -28
- 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/lib/stack-detect.sh +200 -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 +35 -15
- package/pipeline/multi-agent-refs/features/doctor.md +15 -3
- package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +7 -5
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
- 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/agent-state.schema.json +99 -25
- package/pipeline/schemas/autopilot-config.schema.json +149 -0
- package/pipeline/schemas/token-budget.json +4 -4
- package/pipeline/scripts/_stack-routing.mjs +91 -0
- 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/capture-resume.sh +76 -14
- package/pipeline/scripts/doctor.mjs +26 -3
- package/pipeline/scripts/gc-abandoned.sh +352 -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/scripts/usage-report.mjs +5 -5
- 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
- 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
|
+
}
|