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.
@@ -0,0 +1,105 @@
1
+ // bullswarm state — one JSON file at ~/.bullswarm/state.json.
2
+ //
3
+ // Doctrine:
4
+ // S1. Quarantine always carries a re-probe deadline; a recovered pool
5
+ // returns to service AUTOMATICALLY (fixes the /offload gap where a
6
+ // pool benched 30 minutes stayed benched while it had recovered).
7
+ // S2. Incumbency per lane persists so picks don't flap between runs.
8
+ // S3. Every run appends to the decision log — routing telemetry is the
9
+ // substrate for burn-rate learning later.
10
+ // S4. Recursion depth is owned by the CORE: the guard counter lives in
11
+ // state, incremented by env var handshake, never trusted from args.
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ export const DEFAULT_STATE = {
17
+ version: 1,
18
+ pools: {}, // name -> {enabled, meter:{type,windowStart?,usedPct?,declaredBy}, quarantine:{until,reason}|null}
19
+ incumbents: {}, // lane -> poolName
20
+ decisionLog: [], // {ts, lane, picked, keepOnClaude, ok, why, wallSec}
21
+ config: {
22
+ depthLimit: 2,
23
+ callerName: 'claude',
24
+ },
25
+ };
26
+
27
+ export function loadState(bullswarmDir) {
28
+ const p = join(bullswarmDir, 'state.json');
29
+ if (!existsSync(p)) return structuredClone(DEFAULT_STATE);
30
+ try {
31
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
32
+ return {
33
+ ...structuredClone(DEFAULT_STATE),
34
+ ...raw,
35
+ config: { ...DEFAULT_STATE.config, ...(raw.config ?? {}) },
36
+ };
37
+ } catch {
38
+ return structuredClone(DEFAULT_STATE);
39
+ }
40
+ }
41
+
42
+ export function saveState(bullswarmDir, state) {
43
+ mkdirSync(bullswarmDir, { recursive: true });
44
+ writeFileSync(
45
+ join(bullswarmDir, 'state.json'),
46
+ `${JSON.stringify(state, null, 2)}\n`,
47
+ );
48
+ }
49
+
50
+ // --- quarantine -----------------------------------------------------------
51
+
52
+ export function quarantinePool(state, poolName, reason, now = Date.now()) {
53
+ // Re-probe window: 10 minutes by default (not 30) with automatic release.
54
+ const until = now + 10 * 60_000;
55
+ state.pools[poolName] ??= {};
56
+ state.pools[poolName].quarantine = { until, reason };
57
+ return until;
58
+ }
59
+
60
+ export function releaseIfProbeDue(state, poolName, now = Date.now()) {
61
+ const q = state.pools[poolName]?.quarantine;
62
+ if (!q) return true;
63
+ if (now >= q.until) {
64
+ delete state.pools[poolName].quarantine;
65
+ return true; // automatic return to service
66
+ }
67
+ return false;
68
+ }
69
+
70
+ export function sweepQuarantines(state, now = Date.now()) {
71
+ const released = [];
72
+ for (const name of Object.keys(state.pools)) {
73
+ if (state.pools[name].quarantine && releaseIfProbeDue(state, name, now)) {
74
+ released.push(name);
75
+ }
76
+ }
77
+ return released;
78
+ }
79
+
80
+ // --- recursion ------------------------------------------------------------
81
+
82
+ export const DEPTH_ENV = 'BULLSWARM_DEPTH';
83
+
84
+ export function currentDepth(env = process.env) {
85
+ const n = Number.parseInt(env[DEPTH_ENV] ?? '0', 10);
86
+ return Number.isFinite(n) && n >= 0 ? n : 0;
87
+ }
88
+
89
+ /**
90
+ * Throws when a delegate would exceed the configured depth limit.
91
+ * The limit lives in core config — callers cannot widen it via args.
92
+ */
93
+ export function assertDepthAllowed(state, env = process.env) {
94
+ const depth = currentDepth(env);
95
+ if (depth >= (state.config.depthLimit ?? 2)) {
96
+ throw new Error(
97
+ `recursion guard: delegate chain already at depth ${depth} ` +
98
+ `(limit ${state.config.depthLimit}); offload refused`,
99
+ );
100
+ }
101
+ }
102
+
103
+ export function childDepthEnv(env = process.env) {
104
+ return { ...env, [DEPTH_ENV]: String(currentDepth(env) + 1) };
105
+ }
@@ -0,0 +1,126 @@
1
+ // bullswarm verify gate — judge by CONTENT, not exit code.
2
+ //
3
+ // Doctrine encoded here (each rule earned the hard way):
4
+ // V1. Exit code alone decides nothing in either direction.
5
+ // V2. A confident statement of INTENT with no work behind it is a no-op,
6
+ // not a result. An output that merely OPENS with an announcement is
7
+ // judged on its remainder; an output made ONLY of announcements is a
8
+ // no-op regardless of its byte length.
9
+ // V3. Failure patterns (rate limits, auth errors) only count near the
10
+ // START of an output. A delegate WRITING ABOUT rate limits is not
11
+ // rate-limited. Outputs shorter than SHORT_OUTPUT_MAX are judged
12
+ // whole: they are short enough to be nothing but an error.
13
+ // V4. Sentence splitting breaks ONLY before a capital letter, digit, or
14
+ // markdown starter after terminal punctuation + whitespace. Tokens
15
+ // like ".d.ts", "Node.js", "log.ts" never shred.
16
+
17
+ export const FAILURE_SCAN_HEAD = 400; // chars scanned for failure patterns
18
+ export const SHORT_OUTPUT_MAX = 600; // below this, whole output is the head
19
+ export const MIN_SUBSTANCE_CHARS = 80;
20
+
21
+ // Patterns indicating the DELEGATE ITSELF failed (not that it discusses
22
+ // failure). Case-insensitive against the head slice.
23
+ export const FAILURE_PATTERNS = [
24
+ /^rate limit/i,
25
+ /^exceeded.*quota/i,
26
+ /^\s*(error|fatal|exception)\b/i,
27
+ /unauthorized/i,
28
+ /authentication failed/i,
29
+ /invalid api key/i,
30
+ /api key expired/i,
31
+ /permission denied/i,
32
+ /command not found/,
33
+ /no such file or directory/,
34
+ /econnrefused/i,
35
+ /etimedout/i,
36
+ /socket hang up/i,
37
+ /too many requests/i,
38
+ /service unavailable/i,
39
+ /bad gateway/i,
40
+ ];
41
+
42
+ // First-person future = intent, not work.
43
+ const INTENT_RE =
44
+ /\b(?:i'?ll|i will|i'?m going to|i am going to|i plan to|i intend to|let me)\b/i;
45
+
46
+ // After terminal punctuation, a new sentence starts only at a capital,
47
+ // digit, quote, bracket, or markdown starter — nothing else.
48
+ const NEXT_SENTENCE_START = /[A-Z0-9"'`(#[*\->]/;
49
+
50
+ /** Split text into sentences using the V4 boundary rule. */
51
+ export function splitSentences(text) {
52
+ const sentences = [];
53
+ let start = 0;
54
+ let i = 0;
55
+ const push = (end) => {
56
+ const s = text.slice(start, end).trim();
57
+ if (s.length > 0) sentences.push(s);
58
+ };
59
+ while (i < text.length) {
60
+ const ch = text[i];
61
+ if (ch === '.' || ch === '!' || ch === '?') {
62
+ let j = i;
63
+ while (j + 1 < text.length && /[.!?]/.test(text[j + 1])) j++;
64
+ const k = j + 1;
65
+ if (k >= text.length) {
66
+ push(text.length);
67
+ return sentences;
68
+ }
69
+ if (/\s/.test(text[k])) {
70
+ let m = k;
71
+ while (m < text.length && /\s/.test(text[m])) m++;
72
+ if (
73
+ m >= text.length ||
74
+ NEXT_SENTENCE_START.test(text[m])
75
+ ) {
76
+ push(m);
77
+ start = m;
78
+ i = m;
79
+ continue;
80
+ }
81
+ }
82
+ i = j + 1;
83
+ continue;
84
+ }
85
+ i++;
86
+ }
87
+ push(text.length);
88
+ return sentences;
89
+ }
90
+
91
+ function scanForFailure(text) {
92
+ const slice =
93
+ text.length < SHORT_OUTPUT_MAX ? text : text.slice(0, FAILURE_SCAN_HEAD);
94
+ return FAILURE_PATTERNS.some((re) => re.test(slice));
95
+ }
96
+
97
+ /**
98
+ * Work = non-intent sentences carrying real substance after announcements
99
+ * are stripped. Byte length alone proves nothing (a 477-byte pure
100
+ * announcement is still a no-op).
101
+ */
102
+ export function looksLikeWork(text) {
103
+ const sentences = splitSentences(text);
104
+ const substance = sentences.filter((s) => !INTENT_RE.test(s)).join(' ');
105
+ return substance.trim().length >= MIN_SUBSTANCE_CHARS;
106
+ }
107
+
108
+ /**
109
+ * Judge delegate output content.
110
+ * @param {string} text full delegate output
111
+ * @param {object} opts { exitCode, expectWork=true }
112
+ * @returns {{verdict:'pass'|'fail'|'intent_only', why:string}}
113
+ */
114
+ export function judgeContent(text, { exitCode, expectWork = true } = {}) {
115
+ void exitCode; // content-only judgment; exit handled by the caller
116
+ if (typeof text !== 'string' || text.trim().length === 0) {
117
+ return { verdict: 'fail', why: 'empty output' };
118
+ }
119
+ if (scanForFailure(text)) {
120
+ return { verdict: 'fail', why: 'failure pattern at output head' };
121
+ }
122
+ if (expectWork && !looksLikeWork(text)) {
123
+ return { verdict: 'intent_only', why: 'announcement without substance' };
124
+ }
125
+ return { verdict: 'pass', why: 'content passed all gates' };
126
+ }
@@ -0,0 +1,17 @@
1
+ // bullswarm version — single source of truth is package.json.
2
+ // Resolved relative to this module so it works identically from a repo
3
+ // checkout and a global npm install.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const PKG_PATH = join(resolve(dirname(fileURLToPath(import.meta.url)), '../..'), 'package.json');
10
+
11
+ let cached;
12
+
13
+ export function getVersion() {
14
+ if (cached) return cached;
15
+ cached = JSON.parse(readFileSync(PKG_PATH, 'utf8')).version;
16
+ return cached;
17
+ }
@@ -0,0 +1,167 @@
1
+ // bullswarm watch — spawn a delegate directly, capture everything, judge.
2
+ //
3
+ // Doctrine:
4
+ // W1. Spawn the binary DIRECTLY (no shell) so no pipeline can swallow a
5
+ // real non-zero exit.
6
+ // W2. PWD quirk: connectors declaring cwdMode "pwd" get env.PWD set to
7
+ // the target dir AND are spawned with cwd = target dir. Otherwise
8
+ // they silently analyse the WRONG repository and exit 0.
9
+ // W3. Timeout kills the process tree; partial output is still judged.
10
+ // W4. A non-zero exit is never a success — but when content verification
11
+ // passes anyway, report contentUsableDespiteExit instead of
12
+ // discarding completed work.
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { writeFileSync, readFileSync, realpathSync } from 'node:fs';
16
+ import { dirname, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { judgeContent } from './verify.js';
19
+
20
+ const BULLSWARM_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
21
+
22
+ export function substituteArgv(cmdTemplate, { taskFile, cwd }) {
23
+ return cmdTemplate.map((a) =>
24
+ a
25
+ .replaceAll('{taskFile}', taskFile)
26
+ .replaceAll('{bullswarmDir}', BULLSWARM_DIR)
27
+ .replaceAll('{cwd}', cwd),
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Run one delegate and return the raw observation.
33
+ * @returns Promise<{exitCode, signal, stdout, stderr, timedOut}>
34
+ */
35
+ export function runDelegate(connector, taskFile, targetDir, opts = {}) {
36
+ const timeoutMs = (opts.timeoutSec ?? connector.timeoutSec ?? 900) * 1000;
37
+ const argv = substituteArgv(connector.spawn.cmd, {
38
+ taskFile,
39
+ cwd: resolve(targetDir),
40
+ });
41
+ const usePwdMode = connector.spawn.cwdMode === 'pwd';
42
+ // realpath: getcwd() resolves symlinks (macOS /var -> /private/var), so an
43
+ // unresolved PWD would disagree with cwd and defeat wrong-repo detection.
44
+ const resolvedDir = realpathSync(resolve(targetDir));
45
+
46
+ return new Promise((resolvePromise) => {
47
+ const child = spawn(argv[0], argv.slice(1), {
48
+ cwd: resolvedDir,
49
+ // ALWAYS sync PWD to the spawned cwd (not only for declared pwd-mode
50
+ // connectors): an inherited stale PWD is the wrong-repo hazard.
51
+ env: { ...process.env, PWD: resolvedDir },
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ });
54
+
55
+ let stdout = '';
56
+ let stderr = '';
57
+ let timedOut = false;
58
+ const timer = setTimeout(() => {
59
+ timedOut = true;
60
+ child.kill('SIGTERM');
61
+ setTimeout(() => child.kill('SIGKILL'), 2000);
62
+ }, timeoutMs);
63
+
64
+ child.stdout.on('data', (d) => (stdout += d));
65
+ child.stderr.on('data', (d) => (stderr += d));
66
+ child.on('error', (err) => {
67
+ clearTimeout(timer);
68
+ resolvePromise({
69
+ exitCode: null,
70
+ signal: null,
71
+ stdout,
72
+ stderr: `${stderr}\n${err.message}`,
73
+ timedOut,
74
+ spawnError: true,
75
+ });
76
+ });
77
+ child.on('close', (code, signal) => {
78
+ clearTimeout(timer);
79
+ resolvePromise({ exitCode: code, signal, stdout, stderr, timedOut });
80
+ });
81
+ });
82
+ }
83
+
84
+ function extractOutput(connector, obs) {
85
+ switch (connector.outputExtraction?.strategy ?? 'stdout') {
86
+ case 'stdout':
87
+ return obs.stdout || obs.stderr || '';
88
+ case 'stdout-tail':
89
+ return (obs.stdout || '').split('\n').slice(-80).join('\n') || obs.stderr;
90
+ default:
91
+ return obs.stdout || obs.stderr || '';
92
+ }
93
+ }
94
+
95
+ function matchAuthSignature(connector, text) {
96
+ const sigs = connector.authSignatures ?? [];
97
+ return sigs.find((s) => text.toLowerCase().includes(s.toLowerCase())) ?? null;
98
+ }
99
+
100
+ /**
101
+ * Watch one delegation end-to-end. Returns the standard verdict.
102
+ */
103
+ export async function watchOnce(connector, taskText, targetDir, paths, opts = {}) {
104
+ writeFileSync(paths.taskFile, taskText);
105
+ const startedAt = Date.now();
106
+ const obs = await runDelegate(connector, paths.taskFile, targetDir, opts);
107
+ const wallSec = Math.round((Date.now() - startedAt) / 100) / 10;
108
+
109
+ const output = extractOutput(connector, obs);
110
+ writeFileSync(paths.outFile, output);
111
+
112
+ // Gate order matters:
113
+ // timeout / spawn failure -> fail (nothing to trust)
114
+ // auth signature anywhere in the first 2000 chars -> fail + quarantine
115
+ // hint (checked BEFORE generic failure patterns so the specific cause
116
+ // wins; codex-style CLIs log auth errors after banner noise, so a
117
+ // fixed 400-char head misses them).
118
+ // else content judge decides; exit code only modulates flags.
119
+ const authHit = matchAuthSignature(connector, output.slice(0, 2000));
120
+
121
+ let verdict;
122
+ if (obs.timedOut) {
123
+ verdict = { ok: false, why: `timeout after ${opts.timeoutSec ?? connector.timeoutSec}s` };
124
+ } else if (obs.spawnError) {
125
+ verdict = { ok: false, why: `spawn failed: ${obs.stderr.trim().split('\n')[0]}` };
126
+ } else if (authHit) {
127
+ verdict = { ok: false, why: `auth/throttle signature: "${authHit}"`, quarantineHint: true };
128
+ } else {
129
+ const j = judgeContent(output, { exitCode: obs.exitCode });
130
+ if (j.verdict === 'pass') {
131
+ verdict = {
132
+ ok: obs.exitCode === 0,
133
+ why: obs.exitCode === 0
134
+ ? 'verified'
135
+ : 'verified content but non-zero exit',
136
+ };
137
+ } else {
138
+ verdict = { ok: false, why: j.why };
139
+ }
140
+ }
141
+
142
+ const usableDespite =
143
+ !verdict.ok &&
144
+ !obs.spawnError &&
145
+ !obs.timedOut &&
146
+ !authHit &&
147
+ obs.exitCode !== 0 &&
148
+ judgeContent(output, { expectWork: true }).verdict === 'pass';
149
+
150
+ return {
151
+ ...verdict,
152
+ ok: verdict.ok,
153
+ keepOnClaude: false,
154
+ pick: { pool: connector.name, command: connector.spawn.cmd },
155
+ contentUsableDespiteExit: usableDespite,
156
+ meta: {
157
+ pool: connector.name,
158
+ exitCode: obs.exitCode,
159
+ signal: obs.signal,
160
+ timedOut: obs.timedOut,
161
+ wallSec,
162
+ outBytes: output.length,
163
+ },
164
+ outFile: paths.outFile,
165
+ taskFile: paths.taskFile,
166
+ };
167
+ }
@@ -0,0 +1,126 @@
1
+ // bullswarm claude meter — Anthropic OAuth usage endpoint (same data the
2
+ // /usage slash command shows). Token lives in the macOS Keychain or
3
+ // ~/.claude/.credentials.json; no refresh flow — Claude Code rotates it.
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+ import { platform } from 'node:os';
7
+ import { readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
12
+ const BETA_HEADER = 'oauth-2025-04-20';
13
+ const EXPIRY_SKEW_MS = 60_000;
14
+
15
+ export class ClaudeMeterError extends Error {
16
+ constructor(message, code) {
17
+ super(message);
18
+ this.code = code; // no_token | expired | http | parse | network
19
+ }
20
+ }
21
+
22
+ export function readOAuthCredentials() {
23
+ if (platform() === 'darwin') {
24
+ return readFromMacKeychain() ?? readFromCredentialsFile();
25
+ }
26
+ return readFromCredentialsFile();
27
+ }
28
+
29
+ function readFromMacKeychain() {
30
+ try {
31
+ const blob = execFileSync(
32
+ 'security',
33
+ ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
34
+ { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' },
35
+ );
36
+ return extractCredentials(blob);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function readFromCredentialsFile() {
43
+ for (const p of [
44
+ join(homedir(), '.claude', '.credentials.json'),
45
+ join(homedir(), '.config', 'claude', 'credentials.json'),
46
+ ]) {
47
+ try {
48
+ return extractCredentials(readFileSync(p, 'utf8'));
49
+ } catch {
50
+ /* next candidate */
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export function extractCredentials(blob) {
57
+ try {
58
+ const oauth = JSON.parse(blob)?.claudeAiOauth;
59
+ const accessToken = typeof oauth?.accessToken === 'string' ? oauth.accessToken : null;
60
+ const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : null;
61
+ if (!accessToken || expiresAt === null) return null;
62
+ return { accessToken, expiresAt };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function normalizeWindow(raw) {
69
+ if (!raw || typeof raw !== 'object') return null;
70
+ return {
71
+ utilization: typeof raw.utilization === 'number' ? raw.utilization : null,
72
+ resets_at: typeof raw.resets_at === 'string' ? raw.resets_at : null,
73
+ };
74
+ }
75
+
76
+ export function parseClaudeUsage(body) {
77
+ if (!body || typeof body !== 'object') {
78
+ throw new ClaudeMeterError('Claude usage response missing body', 'parse');
79
+ }
80
+ return {
81
+ captured_at: new Date().toISOString(),
82
+ pool: 'claude-code',
83
+ five_hour: normalizeWindow(body.five_hour) ?? { utilization: null, resets_at: null },
84
+ seven_day: normalizeWindow(body.seven_day) ?? { utilization: null, resets_at: null },
85
+ monthly: null,
86
+ seven_day_opus: normalizeWindow(body.seven_day_opus),
87
+ seven_day_sonnet: normalizeWindow(body.seven_day_sonnet),
88
+ plan_type: null,
89
+ };
90
+ }
91
+
92
+ export async function fetchClaudeUsage() {
93
+ const creds = readOAuthCredentials();
94
+ if (!creds) {
95
+ throw new ClaudeMeterError('No Claude Code OAuth token. Run `claude` to log in.', 'no_token');
96
+ }
97
+ if (creds.expiresAt - EXPIRY_SKEW_MS <= Date.now()) {
98
+ throw new ClaudeMeterError(
99
+ 'Claude OAuth token expired; open Claude Code to refresh it.',
100
+ 'expired',
101
+ );
102
+ }
103
+
104
+ let res;
105
+ try {
106
+ res = await fetch(USAGE_ENDPOINT, {
107
+ headers: {
108
+ Authorization: `Bearer ${creds.accessToken}`,
109
+ 'anthropic-beta': BETA_HEADER,
110
+ 'User-Agent': 'bullswarm',
111
+ },
112
+ });
113
+ } catch (err) {
114
+ throw new ClaudeMeterError(`Network error reaching Anthropic: ${err.message}`, 'network');
115
+ }
116
+ if (!res.ok) {
117
+ throw new ClaudeMeterError(`Usage endpoint returned ${res.status}`, 'http');
118
+ }
119
+ let body;
120
+ try {
121
+ body = await res.json();
122
+ } catch (err) {
123
+ throw new ClaudeMeterError(`Failed to parse usage response: ${err.message}`, 'parse');
124
+ }
125
+ return parseClaudeUsage(body);
126
+ }