bullswarm 0.1.4
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/LICENSE +21 -0
- package/README.md +67 -0
- package/bin/bullswarm.js +12 -0
- package/connectors/_schema.json +31 -0
- package/connectors/claude-code.json +17 -0
- package/connectors/codex.json +41 -0
- package/connectors/command-code.json +39 -0
- package/connectors/echo-worker.mjs +38 -0
- package/connectors/echo.json +16 -0
- package/connectors/grok.json +38 -0
- package/connectors/opencode2.json +18 -0
- package/mcp/server.mjs +138 -0
- package/package.json +48 -0
- package/skill/SKILL.md +53 -0
- package/src/cli.js +307 -0
- package/src/lib/config.js +107 -0
- package/src/lib/release.js +55 -0
- package/src/lib/route.js +133 -0
- package/src/lib/state.js +105 -0
- package/src/lib/verify.js +126 -0
- package/src/lib/version.js +17 -0
- package/src/lib/watch.js +167 -0
- package/src/meters/claude.js +126 -0
- package/src/meters/codex.js +264 -0
- package/src/meters/command-code.js +218 -0
- package/src/meters/framework.js +128 -0
- package/src/meters/grok.js +200 -0
- package/src/meters/registry.js +85 -0
- package/src/setup.js +272 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// bullswarm CLI — verbs: setup (wizard), run, health, pools.
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
import { pickPool } from './lib/route.js';
|
|
7
|
+
import { watchOnce } from './lib/watch.js';
|
|
8
|
+
import {
|
|
9
|
+
loadState, saveState, quarantinePool, sweepQuarantines,
|
|
10
|
+
assertDepthAllowed, childDepthEnv,
|
|
11
|
+
} from './lib/state.js';
|
|
12
|
+
import { buildPools, buildPoolsLive } from './lib/config.js';
|
|
13
|
+
import { getAllMeterReadings } from './meters/registry.js';
|
|
14
|
+
import { judgeContent } from './lib/verify.js';
|
|
15
|
+
import { getVersion } from './lib/version.js';
|
|
16
|
+
import { release } from './lib/release.js';
|
|
17
|
+
|
|
18
|
+
export const BULLSWARM_DIR = join(homedir(), '.bullswarm');
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const args = {};
|
|
22
|
+
const rest = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
if (argv[i].startsWith('--')) {
|
|
25
|
+
const key = argv[i].slice(2);
|
|
26
|
+
if (key === 'json') args.json = true;
|
|
27
|
+
else if (i + 1 < argv.length) args[key] = argv[++i];
|
|
28
|
+
else args[key] = true;
|
|
29
|
+
} else rest.push(argv[i]);
|
|
30
|
+
}
|
|
31
|
+
return { ...args, rest };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- pools ----------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
async function cmdPools(opts) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const { state, pools } = await buildPoolsLive(BULLSWARM_DIR, now, {
|
|
39
|
+
force: opts.force === true,
|
|
40
|
+
getReadings: getAllMeterReadings,
|
|
41
|
+
});
|
|
42
|
+
const released = sweepQuarantines(state, now);
|
|
43
|
+
if (released.length && !opts.json) {
|
|
44
|
+
console.error(`quarantine expired, returned to service: ${released.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
saveState(BULLSWARM_DIR, state);
|
|
47
|
+
if (opts.json) {
|
|
48
|
+
console.log(JSON.stringify({ pools }, null, 2));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
for (const p of pools) {
|
|
52
|
+
const src = p.meterSource;
|
|
53
|
+
const meter = src === 'none'
|
|
54
|
+
? 'unmetered'
|
|
55
|
+
: `used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${src}]`;
|
|
56
|
+
const burst = p.burstGate ? ' BURST-GATED' : '';
|
|
57
|
+
const status = !p.enabled
|
|
58
|
+
? 'disabled'
|
|
59
|
+
: p.quarantine
|
|
60
|
+
? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
|
|
61
|
+
: `ready${burst}`;
|
|
62
|
+
console.log(
|
|
63
|
+
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} ${status}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- run --------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
async function cmdRun(opts) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const lane = opts.lane;
|
|
74
|
+
const targetDir = resolve(opts['add-dir'] ?? process.cwd());
|
|
75
|
+
|
|
76
|
+
// Recursion guard FIRST — core-owned, env handshake.
|
|
77
|
+
let state = loadState(BULLSWARM_DIR);
|
|
78
|
+
try {
|
|
79
|
+
assertDepthAllowed(state);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const verdict = { ok: false, keepOnClaude: true, why: err.message };
|
|
82
|
+
console.log(JSON.stringify(verdict, null, 2));
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
sweepQuarantines(state, now);
|
|
87
|
+
|
|
88
|
+
const { pools } = await buildPoolsLive(BULLSWARM_DIR, now, {
|
|
89
|
+
getReadings: getAllMeterReadings,
|
|
90
|
+
});
|
|
91
|
+
for (const p of pools) {
|
|
92
|
+
p.incumbent = state.incumbents?.[lane] === p.name;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Burst gate (M3): a pool whose 5h window is >=90% used is excluded from
|
|
96
|
+
// dispatch entirely this run — it paces nothing, it's just out of burst room.
|
|
97
|
+
const gated = pools.filter((p) => p.burstGate);
|
|
98
|
+
const eligiblePools = gated.length ? pools.filter((p) => !p.burstGate) : pools;
|
|
99
|
+
|
|
100
|
+
const route = pickPool(lane, eligiblePools, {
|
|
101
|
+
callerEligible: opts['no-caller'] !== true,
|
|
102
|
+
callerName: state.config.callerName ?? 'claude',
|
|
103
|
+
now,
|
|
104
|
+
});
|
|
105
|
+
if (gated.length && route.pick) {
|
|
106
|
+
route.why += ` (burst-gated: ${gated.map((g) => g.name).join(', ')})`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!route.pick && route.keepOnClaude) {
|
|
110
|
+
logDecision(state, { lane, picked: null, keepOnClaude: true, ok: null, why: route.why });
|
|
111
|
+
saveState(BULLSWARM_DIR, state);
|
|
112
|
+
emit({ ok: true, keepOnClaude: true, why: route.why, pick: { pool: null, command: null } }, opts);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (!route.pick) {
|
|
116
|
+
emit({ ok: false, keepOnClaude: false, why: route.why }, opts);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// pick.connector is the pool VIEW (config.js buildPools entry); the real
|
|
121
|
+
// connector spec lives one level down.
|
|
122
|
+
const poolView = route.pick.connector ?? { name: route.pick.pool };
|
|
123
|
+
const connector = poolView.connector ?? poolView;
|
|
124
|
+
|
|
125
|
+
// Task text: --task-file content or stdin string.
|
|
126
|
+
const taskText = opts['task-file']
|
|
127
|
+
? readFileSync(opts['task-file'], 'utf8')
|
|
128
|
+
: opts.rest.join(' ');
|
|
129
|
+
if (!taskText.trim()) {
|
|
130
|
+
console.error('empty task: pass --task-file or the task as arguments');
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
135
|
+
const runDir = join(BULLSWARM_DIR, 'runs');
|
|
136
|
+
mkdirSync(runDir, { recursive: true });
|
|
137
|
+
const paths = {
|
|
138
|
+
taskFile: join(runDir, `task-${stamp}.md`),
|
|
139
|
+
outFile: join(runDir, `out-${stamp}.md`),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const verdict = await watchOnce(connector, taskText, targetDir, paths, {
|
|
143
|
+
timeoutSec: Number(opts.timeout ?? connector.timeoutSec ?? 900),
|
|
144
|
+
env: childDepthEnv(process.env),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Persist incumbency on success; quarantine hint on auth failure.
|
|
148
|
+
if (verdict.ok) {
|
|
149
|
+
state.incumbents ??= {};
|
|
150
|
+
state.incumbents[lane] = connector.name;
|
|
151
|
+
} else if (verdict.quarantineHint) {
|
|
152
|
+
quarantinePool(state, connector.name, verdict.why, now);
|
|
153
|
+
verdict.quarantinedUntil = state.pools[connector.name]?.quarantine?.until;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
logDecision(state, {
|
|
157
|
+
lane,
|
|
158
|
+
picked: connector.name,
|
|
159
|
+
keepOnClaude: false,
|
|
160
|
+
ok: verdict.ok,
|
|
161
|
+
why: verdict.why,
|
|
162
|
+
wallSec: verdict.meta?.wallSec,
|
|
163
|
+
outFile: paths.outFile,
|
|
164
|
+
});
|
|
165
|
+
saveState(BULLSWARM_DIR, state);
|
|
166
|
+
|
|
167
|
+
emit(verdict, opts);
|
|
168
|
+
return verdict.ok ? 0 : 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function logDecision(state, d) {
|
|
172
|
+
state.decisionLog ??= [];
|
|
173
|
+
state.decisionLog.push({ ts: new Date().toISOString(), ...d });
|
|
174
|
+
if (state.decisionLog.length > 500) {
|
|
175
|
+
state.decisionLog = state.decisionLog.slice(-500);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function emit(verdict, opts) {
|
|
180
|
+
if (opts.json) console.log(JSON.stringify(verdict, null, 2));
|
|
181
|
+
else {
|
|
182
|
+
const line = [
|
|
183
|
+
verdict.ok ? 'OK' : 'FAIL',
|
|
184
|
+
verdict.keepOnClaude ? '(keep-on-caller)' : '',
|
|
185
|
+
verdict.pick?.pool ? `[${verdict.pick.pool}]` : '',
|
|
186
|
+
verdict.why ?? '',
|
|
187
|
+
].filter(Boolean).join(' ');
|
|
188
|
+
console.log(line);
|
|
189
|
+
if (verdict.outFile) console.log(`output: ${verdict.outFile}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- health -----------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function cmdHealth(opts) {
|
|
196
|
+
const state = loadState(BULLSWARM_DIR);
|
|
197
|
+
const runsDir = join(BULLSWARM_DIR, 'runs');
|
|
198
|
+
const findings = [];
|
|
199
|
+
|
|
200
|
+
// Correlate each logged decision with its saved output: the doctrine
|
|
201
|
+
// signal is "verdict said FAIL but the file re-judges OK" — that means
|
|
202
|
+
// the verify gate ate real work. Verdict-FAIL files that still re-judge
|
|
203
|
+
// pass are exactly the planted case; verdict-OK files are expected passes.
|
|
204
|
+
if (existsSync(runsDir)) {
|
|
205
|
+
const byOut = new Map(
|
|
206
|
+
(state.decisionLog ?? [])
|
|
207
|
+
.filter((d) => d.outFile)
|
|
208
|
+
.map((d) => [d.outFile, d]),
|
|
209
|
+
);
|
|
210
|
+
for (const f of readdirSync(runsDir)) {
|
|
211
|
+
if (!f.startsWith('out-')) continue;
|
|
212
|
+
const outPath = join(runsDir, f);
|
|
213
|
+
const out = readFileSync(outPath, 'utf8');
|
|
214
|
+
if (!out.trim()) continue;
|
|
215
|
+
const j = judgeContent(out);
|
|
216
|
+
const decision = byOut.get(outPath);
|
|
217
|
+
findings.push({
|
|
218
|
+
file: f,
|
|
219
|
+
savedVerdict: decision ? (decision.ok ? 'OK' : 'FAIL') : 'unlogged',
|
|
220
|
+
rejudge: j.verdict,
|
|
221
|
+
gateAteWork:
|
|
222
|
+
decision != null && decision.ok === false && j.verdict === 'pass',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const quarantined = Object.entries(state.pools ?? {})
|
|
228
|
+
.filter(([, v]) => v.quarantine)
|
|
229
|
+
.map(([k, v]) => ({ pool: k, until: v.quarantine.until, reason: v.quarantine.reason }));
|
|
230
|
+
|
|
231
|
+
const report = {
|
|
232
|
+
healthy:
|
|
233
|
+
findings.every((f) => !f.gateAteWork) &&
|
|
234
|
+
quarantined.length < 2 &&
|
|
235
|
+
(state.decisionLog?.length ?? 0) > 0,
|
|
236
|
+
gateFailures: findings.filter((f) => f.gateAteWork),
|
|
237
|
+
quarantineCluster: quarantined.length >= 2 ? quarantined : [],
|
|
238
|
+
quarantined,
|
|
239
|
+
decisionLogSize: state.decisionLog?.length ?? 0,
|
|
240
|
+
};
|
|
241
|
+
console.log(JSON.stringify(report, null, 2));
|
|
242
|
+
return report.healthy ? 0 : 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- setup ------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
async function cmdSetup(opts) {
|
|
248
|
+
const { runWizard } = await import('./setup.js');
|
|
249
|
+
return runWizard(BULLSWARM_DIR, opts);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- main ---------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export async function main(argv) {
|
|
255
|
+
const [verb, ...rest] = argv;
|
|
256
|
+
const opts = parseArgs(rest);
|
|
257
|
+
|
|
258
|
+
if (!verb || verb === 'setup') {
|
|
259
|
+
if (!existsSync(join(BULLSWARM_DIR, 'state.json')) || verb === 'setup') {
|
|
260
|
+
return cmdSetup(opts);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
switch (verb) {
|
|
264
|
+
case undefined:
|
|
265
|
+
return cmdSetup(opts); // bare bullswarm with config present still guides
|
|
266
|
+
case 'setup':
|
|
267
|
+
return cmdSetup(opts);
|
|
268
|
+
case 'run':
|
|
269
|
+
return cmdRun(opts);
|
|
270
|
+
case 'health':
|
|
271
|
+
return cmdHealth(opts);
|
|
272
|
+
case 'pools':
|
|
273
|
+
return cmdPools(opts);
|
|
274
|
+
case 'version':
|
|
275
|
+
console.log(getVersion());
|
|
276
|
+
return 0;
|
|
277
|
+
case 'release':
|
|
278
|
+
return cmdRelease(opts);
|
|
279
|
+
default:
|
|
280
|
+
console.error(
|
|
281
|
+
`unknown verb "${verb}". try: setup | run | health | pools | version | release`,
|
|
282
|
+
);
|
|
283
|
+
return 2;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- release -----------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function cmdRelease(opts) {
|
|
290
|
+
const kind = opts.rest[0];
|
|
291
|
+
if (!['patch', 'minor', 'major'].includes(kind)) {
|
|
292
|
+
console.error('usage: bullswarm release patch|minor|major [--dry-run]');
|
|
293
|
+
return 2;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const r = release(kind, { dryRun: opts['dry-run'] === true });
|
|
297
|
+
const label = r.dryRun ? 'would release' : 'released';
|
|
298
|
+
console.log(`${label}: ${r.from} → ${r.to} (tag ${r.tag})`);
|
|
299
|
+
if (!r.dryRun) {
|
|
300
|
+
console.log('next: npm publish (or: npm publish --access public)');
|
|
301
|
+
}
|
|
302
|
+
return 0;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(err.message);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// bullswarm config — merge connectors + state into runtime pool views.
|
|
2
|
+
//
|
|
3
|
+
// Meter precedence (doctrine M1):
|
|
4
|
+
// 1. live/cached provider reading (meter reader exists)
|
|
5
|
+
// 2. declared meter from state.json (labeled "declared")
|
|
6
|
+
// 3. unmetered (pace 0, neutral)
|
|
7
|
+
//
|
|
8
|
+
// Pace source (doctrine M2): the pacing object carries elapsed% computed
|
|
9
|
+
// from the provider's resets_at. Declared meters fall back to the local
|
|
10
|
+
// elapsed estimate and are visibly labeled.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { loadState } from './state.js';
|
|
15
|
+
import { paceScore, isQuarantined } from './route.js';
|
|
16
|
+
|
|
17
|
+
export function loadConnectors(bullswarmDir) {
|
|
18
|
+
const dir = join(bullswarmDir, 'connectors');
|
|
19
|
+
if (!existsSync(dir)) return {};
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const f of readdirSync(dir).sort()) {
|
|
22
|
+
if (!f.endsWith('.json') || f.startsWith('_')) continue;
|
|
23
|
+
try {
|
|
24
|
+
const c = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
25
|
+
out[c.name] = c;
|
|
26
|
+
} catch {
|
|
27
|
+
// broken connector files surface in `bullswarm setup` repair,
|
|
28
|
+
// never crash a run
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the runtime pool list: connector + state + meter reading.
|
|
36
|
+
* Meter readings are injected by the caller (async — readers poll the
|
|
37
|
+
* network); this function stays sync so tests can build pools without I/O.
|
|
38
|
+
*/
|
|
39
|
+
export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
|
|
40
|
+
const state = loadState(bullswarmDir);
|
|
41
|
+
const connectors = loadConnectors(bullswarmDir);
|
|
42
|
+
const pools = [];
|
|
43
|
+
for (const [name, conn] of Object.entries(connectors)) {
|
|
44
|
+
const ps = state.pools[name] ?? {};
|
|
45
|
+
const pool = {
|
|
46
|
+
name,
|
|
47
|
+
connector: conn,
|
|
48
|
+
enabled: ps.enabled !== false,
|
|
49
|
+
costRank: conn.costRank ?? 5,
|
|
50
|
+
lanes: conn.lanes,
|
|
51
|
+
quarantine: ps.quarantine ?? null,
|
|
52
|
+
incumbentLane: Object.entries(state.incumbents ?? {})
|
|
53
|
+
.filter(([, v]) => v === name)
|
|
54
|
+
.map(([k]) => k),
|
|
55
|
+
// meter fields filled below
|
|
56
|
+
meterSource: 'none',
|
|
57
|
+
usedPct: null,
|
|
58
|
+
elapsedPct: null,
|
|
59
|
+
pace: null,
|
|
60
|
+
burstGate: false,
|
|
61
|
+
};
|
|
62
|
+
pools.push(pool);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const p of pools) {
|
|
66
|
+
if (!p.enabled || isQuarantined(p, now)) continue;
|
|
67
|
+
const ps = state.pools[p.name] ?? {};
|
|
68
|
+
|
|
69
|
+
const reading = readings[p.name];
|
|
70
|
+
if (reading?.pacing) {
|
|
71
|
+
// Provider-truth path (M1/M2)
|
|
72
|
+
p.meterSource = reading.source; // live | cache | stale
|
|
73
|
+
p.usedPct = reading.pacing.usedPct;
|
|
74
|
+
p.elapsedPct = reading.pacing.elapsedPct;
|
|
75
|
+
p.pace = reading.pacing.surplus; // surplus = elapsed − used
|
|
76
|
+
p.paceResetsAt = reading.pacing.resetsAt;
|
|
77
|
+
p.burstGate = reading.burstGate === true;
|
|
78
|
+
} else {
|
|
79
|
+
// Declared / unmetered fallback
|
|
80
|
+
const meter = { ...(p.connector.meter ?? {}), ...(ps.meter ?? {}) };
|
|
81
|
+
if (meter.type !== 'none' && meter.usedPct != null) {
|
|
82
|
+
p.meterSource = 'declared';
|
|
83
|
+
p.usedPct = meter.usedPct;
|
|
84
|
+
// Without resets_at, elapsed is unknown → surplus is just −used,
|
|
85
|
+
// which still ranks pools by remaining headroom honestly.
|
|
86
|
+
p.pace = -meter.usedPct;
|
|
87
|
+
} else {
|
|
88
|
+
p.meterSource = 'none';
|
|
89
|
+
p.pace = 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { state, connectors, pools };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Async variant that fetches live readings for pools with readers.
|
|
98
|
+
*/
|
|
99
|
+
export async function buildPoolsLive(bullswarmDir, now = Date.now(), { force = false, getReadings } = {}) {
|
|
100
|
+
const state = loadState(bullswarmDir);
|
|
101
|
+
const connectors = loadConnectors(bullswarmDir);
|
|
102
|
+
const names = Object.keys(connectors);
|
|
103
|
+
const readings = getReadings
|
|
104
|
+
? await getReadings(names, { force, nowMs: now })
|
|
105
|
+
: {};
|
|
106
|
+
return buildPools(bullswarmDir, now, readings);
|
|
107
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// bullswarm release — version bumping + git tagging.
|
|
2
|
+
// Usage: bullswarm release patch|minor|major [--dry-run]
|
|
3
|
+
//
|
|
4
|
+
// Semver discipline:
|
|
5
|
+
// patch — connector flag fixes, verify-gate fixes, meter corrections
|
|
6
|
+
// minor — new verbs, new connectors, new meters, behavior additions
|
|
7
|
+
// major — verdict-contract or config-format breaking changes
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import { join, dirname, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
15
|
+
const PKG_PATH = join(REPO_ROOT, 'package.json');
|
|
16
|
+
|
|
17
|
+
export function bumpVersion(version, kind) {
|
|
18
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
19
|
+
if (!m) throw new Error(`current version "${version}" is not strict semver`);
|
|
20
|
+
let [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
21
|
+
if (kind === 'major') { maj += 1; min = 0; pat = 0; }
|
|
22
|
+
else if (kind === 'minor') { min += 1; pat = 0; }
|
|
23
|
+
else if (kind === 'patch') { pat += 1; }
|
|
24
|
+
else throw new Error(`unknown bump kind "${kind}" (use patch|minor|major)`);
|
|
25
|
+
return `${maj}.${min}.${pat}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function git(args) {
|
|
29
|
+
return execSync(`git ${args}`, { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function release(kind, { dryRun = false } = {}) {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'));
|
|
34
|
+
const prev = pkg.version;
|
|
35
|
+
const next = bumpVersion(prev, kind);
|
|
36
|
+
|
|
37
|
+
// Dirty-tree guard: a release commit must contain exactly the version change.
|
|
38
|
+
const status = git('status --porcelain');
|
|
39
|
+
if (status.trim()) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`working tree is dirty — commit first before releasing:\n${status}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (dryRun) {
|
|
46
|
+
return { from: prev, to: next, tag: `v${next}`, dryRun: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pkg.version = next;
|
|
50
|
+
writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
51
|
+
git('add package.json');
|
|
52
|
+
git(`commit -m "release v${next}"`);
|
|
53
|
+
git(`tag -a v${next} -m "v${next}"`);
|
|
54
|
+
return { from: prev, to: next, tag: `v${next}`, dryRun: false };
|
|
55
|
+
}
|
package/src/lib/route.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// bullswarm route brain — pick a pool for a lane at runtime.
|
|
2
|
+
//
|
|
3
|
+
// Doctrine:
|
|
4
|
+
// R1. Lanes are WORK NATURE: analyze | build | chore. Never a hard-coded
|
|
5
|
+
// lane→pool map; pools declare capability, runtime selects.
|
|
6
|
+
// R2. Selection is by time-adjusted pace: surplus = elapsed% − used%.
|
|
7
|
+
// Most-behind (HIGHEST surplus) wins — quota piling up unspent is
|
|
8
|
+
// expiring money.
|
|
9
|
+
// R3. Incumbency margin: an incumbent pool keeps the lane unless a
|
|
10
|
+
// challenger beats its surplus by MARGIN points — no flapping.
|
|
11
|
+
// R4. Cost guard (incumbency path only): pace may promote a challenger
|
|
12
|
+
// over an incumbent only if the challenger is CHEAPER.
|
|
13
|
+
// R5. The caller wins its lane only when no eligible delegate remains —
|
|
14
|
+
// it has to WIN, not be protected.
|
|
15
|
+
// R6. A pool at 100% used is exhausted; quarantined pools are ineligible
|
|
16
|
+
// until their quarantine expires (the re-probe path).
|
|
17
|
+
|
|
18
|
+
export const LANES = ['analyze', 'build', 'chore'];
|
|
19
|
+
|
|
20
|
+
export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
|
|
21
|
+
|
|
22
|
+
export function elapsedPct(meter, now = Date.now()) {
|
|
23
|
+
if (!meter || meter.type === 'none') return 0;
|
|
24
|
+
const start = meter.windowStart ?? 0;
|
|
25
|
+
const ms =
|
|
26
|
+
meter.type === '5h' ? 5 * 3600_000 :
|
|
27
|
+
meter.type === 'weekly' ? 7 * 24 * 3600_000 :
|
|
28
|
+
0;
|
|
29
|
+
if (!ms || !start) return 0;
|
|
30
|
+
return Math.min(100, ((now - start) / ms) * 100);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** surplus = elapsed% − used%; higher = more quota about to expire. */
|
|
34
|
+
export function paceScore(pool, now = Date.now()) {
|
|
35
|
+
const meter = pool.meter;
|
|
36
|
+
if (!meter || meter.type === 'none' || meter.usedPct == null) return 0;
|
|
37
|
+
return elapsedPct(meter, now) - meter.usedPct;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isQuarantined(pool, now = Date.now()) {
|
|
41
|
+
if (!pool.quarantine) return false;
|
|
42
|
+
if (pool.quarantine.until == null) return true;
|
|
43
|
+
return now < pool.quarantine.until;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isExhausted(pool) {
|
|
47
|
+
return pool.meter?.usedPct != null && pool.meter.usedPct >= 100;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pick a pool for a lane.
|
|
52
|
+
* @param {string} lane analyze | build | chore
|
|
53
|
+
* @param {Array} pools enabled pools: {name, costRank, lanes[], meter?,
|
|
54
|
+
* quarantine?, incumbent?}
|
|
55
|
+
* @param {object} [opts] { callerEligible=true, callerName='claude', now }
|
|
56
|
+
* @returns {{pick: object|null, keepOnClaude: boolean, why: string,
|
|
57
|
+
* candidates: Array}}
|
|
58
|
+
*/
|
|
59
|
+
export function pickPool(lane, pools, opts = {}) {
|
|
60
|
+
const {
|
|
61
|
+
callerEligible = true,
|
|
62
|
+
callerName = 'claude',
|
|
63
|
+
now = Date.now(),
|
|
64
|
+
} = opts;
|
|
65
|
+
|
|
66
|
+
if (!LANES.includes(lane)) {
|
|
67
|
+
return {
|
|
68
|
+
pick: null,
|
|
69
|
+
keepOnClaude: false,
|
|
70
|
+
why: `unknown lane ${lane}`,
|
|
71
|
+
candidates: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const eligible = pools.filter(
|
|
76
|
+
(p) =>
|
|
77
|
+
p.enabled !== false &&
|
|
78
|
+
(p.lanes ?? LANES).includes(lane) &&
|
|
79
|
+
!isQuarantined(p, now) &&
|
|
80
|
+
!isExhausted(p),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const scored = eligible.map((p) => ({
|
|
84
|
+
pool: p,
|
|
85
|
+
pace: paceScore(p, now),
|
|
86
|
+
}));
|
|
87
|
+
scored.sort((a, b) => b.pace - a.pace); // most-behind first
|
|
88
|
+
|
|
89
|
+
const candidates = scored.map((e) => ({
|
|
90
|
+
pool: e.pool.name,
|
|
91
|
+
pace: Math.round(e.pace * 10) / 10,
|
|
92
|
+
costRank: e.pool.costRank ?? null,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
if (scored.length === 0) {
|
|
96
|
+
return callerEligible
|
|
97
|
+
? {
|
|
98
|
+
pick: null,
|
|
99
|
+
keepOnClaude: true,
|
|
100
|
+
why: 'no eligible delegate pool; caller takes the lane',
|
|
101
|
+
candidates,
|
|
102
|
+
}
|
|
103
|
+
: {
|
|
104
|
+
pick: null,
|
|
105
|
+
keepOnClaude: false,
|
|
106
|
+
why: 'no eligible pool',
|
|
107
|
+
candidates,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const incumbentEntry = scored.find((e) => e.pool.incumbent === true);
|
|
112
|
+
|
|
113
|
+
let winnerEntry;
|
|
114
|
+
if (incumbentEntry) {
|
|
115
|
+
// R3+R4: challenger needs margin AND strictly lower costRank.
|
|
116
|
+
const challenger = scored.find(
|
|
117
|
+
(e) =>
|
|
118
|
+
e !== incumbentEntry &&
|
|
119
|
+
e.pace >= incumbentEntry.pace + INCUMBENCY_MARGIN &&
|
|
120
|
+
(e.pool.costRank ?? 99) < (incumbentEntry.pool.costRank ?? 99),
|
|
121
|
+
);
|
|
122
|
+
winnerEntry = challenger ?? incumbentEntry;
|
|
123
|
+
} else {
|
|
124
|
+
winnerEntry = scored[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
pick: { pool: winnerEntry.pool.name, connector: winnerEntry.pool },
|
|
129
|
+
keepOnClaude: false,
|
|
130
|
+
why: `most-behind capable pool (surplus ${Math.round(winnerEntry.pace * 10) / 10})`,
|
|
131
|
+
candidates,
|
|
132
|
+
};
|
|
133
|
+
}
|