bullswarm 0.10.7 → 0.10.8

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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.10.8 — quieter monitoring and resilient orchestration
4
+
5
+ - Made `workflow watch` aggregate low-level activity into compact interval
6
+ heartbeats by default, with event/action deltas, quiet duration, prompt
7
+ semantic transitions, terminal result handoff, and `--verbose` drill-down.
8
+ - Implemented the documented top-level `run --prompt` form and standardized
9
+ usage errors as exit 2 across run, workflow drafts/runs, and strategy paths.
10
+ - Added explicit goal-resume orchestrator pin/unpin behavior and strengthened
11
+ the orchestrator as a control-plane-only decision thread.
12
+ - Recognize Claude's exact `Failed to authenticate` response as an auth failure
13
+ and migrate the connector signature additively without replacing local
14
+ connector customization.
15
+ - Expanded non-network CLI, health, release, watch, resume, auth, help, and
16
+ documentation coverage. The full suite now contains 271 tests.
17
+
3
18
  ## 0.10.7 — clearer orchestration overview
4
19
 
5
20
  - Replaced the orchestrator trace dump with a summary-first view showing what
package/README.md CHANGED
@@ -67,6 +67,7 @@ bullswarm setup # re-run or repair
67
67
  bullswarm pools # meter state, pace position, quarantine status
68
68
  bullswarm strategy refresh --apply --yes # approve capability-aware tier autopilot
69
69
  bullswarm run --lane analyze --add-dir ~/some-repo --task-file /tmp/t.md --json
70
+ bullswarm run --lane analyze --add-dir ~/some-repo --prompt "Inspect the parser" --json
70
71
  bullswarm workflow goal "Fix the failing tests and verify the change" --cwd ~/some-repo
71
72
  bullswarm health # re-judge saved outputs; catch gate failures
72
73
  ```
@@ -84,6 +85,17 @@ bullswarm health # re-judge saved outputs; catch gate failures
84
85
  | `doctor` | Machine-readable readiness report; self-heals on first call |
85
86
  | `workflow` | Start an autonomous goal, or run / validate / draft / inspect explicit workflows and their live instances. |
86
87
 
88
+ Discover and validate workflow definitions without executing them:
89
+
90
+ ```bash
91
+ bullswarm workflow list
92
+ bullswarm workflow list --json
93
+ bullswarm workflow validate workflows/my-workflow.json
94
+ ```
95
+
96
+ `workflow goal --request <path>` and `--run-id <id>` are internal detached-runner
97
+ resume plumbing. Normal callers should provide a goal or use `--resume <shortId|runId>`.
98
+
87
99
  ## Model strategy and invocation telemetry
88
100
 
89
101
  Bullswarm can inventory the models exposed by installed agent CLIs and combine
@@ -260,16 +272,20 @@ auditing completed runs.
260
272
 
261
273
  ### Live workflow dashboard
262
274
 
263
- For ordinary observation, use the non-interactive watcher. It prints only when
264
- the phase, step, agent action, routing, or status changes, plus a 60-second
265
- heartbeat while otherwise quiet. Terminal output includes every attempt's
266
- agent/model, elapsed time, outcome, and tokens, so a slow test is distinguishable
267
- from a stalled process without writing a polling script.
275
+ For ordinary observation, use the non-interactive watcher. Human output is one
276
+ compact aggregate line per semantic change and a 60-second heartbeat while
277
+ otherwise quiet. Each line reports status/location, events and agent actions
278
+ captured since the preceding sample, and quiet duration. It does not repeat
279
+ command or response excerpts. Use `--verbose` for the detailed per-agent and
280
+ last-action view. Compact terminal output reports the overall attempt count and
281
+ elapsed time; `--verbose` includes every attempt's agent/model, outcome, and
282
+ tokens. This keeps agent monitoring cheap while retaining a drill-down path.
268
283
 
269
284
  ```bash
270
285
  bullswarm workflow watch <shortId>
271
286
  bullswarm workflow watch <shortId> --jsonl # automation-friendly stream
272
287
  bullswarm workflow watch <shortId> --once # one current/terminal snapshot
288
+ bullswarm workflow watch <shortId> --verbose # detailed agent/action view
273
289
  ```
274
290
 
275
291
  `workflow tui` is the interactive, Claude-style `/workflows` view. For an
@@ -18,6 +18,7 @@
18
18
  "authSignatures": [
19
19
  "unauthorized",
20
20
  "authentication failed",
21
+ "failed to authenticate",
21
22
  "credit balance",
22
23
  "not logged in",
23
24
  "please run /login"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -59,6 +59,10 @@ bullswarm run --lane <analyze|build|chore> \
59
59
  --add-dir <abs/path/to/repo> \
60
60
  --task-file <abs/path/to/task.md> \
61
61
  --json
62
+
63
+ # Equivalent explicit prompt form (also accepts trailing task text)
64
+ bullswarm run --lane analyze --add-dir <abs/path/to/repo> \
65
+ --prompt "Inspect and verify the parser" --json
62
66
  ```
63
67
 
64
68
  The verdict shape:
@@ -202,8 +206,15 @@ bullswarm workflow runs --historical --since yesterday --until today
202
206
  bullswarm workflow runs show <shortId> # state + report + summary
203
207
  bullswarm workflow runs result <shortId> --json # stable delivery for the caller
204
208
  bullswarm workflow runs delete <shortId> --yes
209
+
210
+ bullswarm workflow list
211
+ bullswarm workflow validate workflows/demo.json
205
212
  ```
206
213
 
214
+ `workflow goal --request <path>` and `--run-id <id>` are internal detached-runner
215
+ plumbing. Ordinary callers should provide a goal or resume with
216
+ `--resume <shortId|runId>`.
217
+
207
218
  Historical time ranges filter the workflow's initiation time (`startedAt`),
208
219
  not completion time. `--since` is inclusive and `--until` is exclusive, with
209
220
  `--from`/`--to` and `--started-after`/`--started-before` aliases. Bounds accept
@@ -279,9 +290,13 @@ reason, and child-process termination evidence. `workflow tui` displays the
279
290
  same information interactively. `workflow events` supports replay after a
280
291
  monotonic sequence cursor.
281
292
 
282
- Prefer `workflow watch` for ordinary monitoring; it prints semantic changes and
283
- a periodic heartbeat, then a per-attempt timing/token breakdown at terminal
284
- status. `workflow steer` is an optional durable queue for autonomous workflows:
293
+ Prefer `workflow watch` for ordinary monitoring; default human text is a compact
294
+ aggregate heartbeat with status/location, interval event and agent-action counts,
295
+ and quiet duration. It does not repeat command or response excerpts. Add
296
+ `--verbose` for the detailed agent and last-action view. `--jsonl` keeps the
297
+ machine-readable snapshot stream. Compact terminal text includes overall timing
298
+ and the exact `workflow runs result <shortId> --json` next command; `--verbose`
299
+ adds per-attempt timing and token details. `workflow steer` is an optional durable queue for autonomous workflows:
285
300
  it never changes the active worker and is delivered only to the next
286
301
  not-yet-started decision gate. Steering cannot expand the original authority,
287
302
  weaken verification, or bypass proposal validation.
package/src/cli.js CHANGED
@@ -96,6 +96,34 @@ async function cmdRun(opts) {
96
96
  }
97
97
  const targetDir = resolve(opts['add-dir'] ?? process.cwd());
98
98
 
99
+ // Validate task input before routing so malformed invocations never fall
100
+ // through to the caller-session path.
101
+ if (opts['task-file'] === true || opts.prompt === true ||
102
+ (opts['task-file'] != null && typeof opts['task-file'] !== 'string') ||
103
+ (opts.prompt != null && typeof opts.prompt !== 'string')) {
104
+ console.error('usage: --prompt and --task-file require a value');
105
+ return 2;
106
+ }
107
+ if (opts['task-file'] && opts.prompt != null) {
108
+ console.error('usage: choose one of --prompt, --task-file, or trailing task text');
109
+ return 2;
110
+ }
111
+ if (opts['task-file'] && opts.rest.length) {
112
+ console.error('usage: choose one of --task-file or trailing task text');
113
+ return 2;
114
+ }
115
+ if (opts.prompt != null && opts.rest.length) {
116
+ console.error('usage: choose one of --prompt or trailing task text');
117
+ return 2;
118
+ }
119
+ const taskText = opts['task-file']
120
+ ? readFileSync(opts['task-file'], 'utf8')
121
+ : opts.prompt ?? opts.rest.join(' ');
122
+ if (!taskText.trim()) {
123
+ console.error('empty task: pass --task-file, --prompt, or the task as arguments');
124
+ return 2;
125
+ }
126
+
99
127
  // Recursion guard FIRST — core-owned, env handshake.
100
128
  let state = loadState(getBullswarmDir());
101
129
  try {
@@ -166,15 +194,6 @@ async function cmdRun(opts) {
166
194
  subscription: poolView.subscription ?? connector.subscription ?? null,
167
195
  };
168
196
 
169
- // Task text: --task-file content or stdin string.
170
- const taskText = opts['task-file']
171
- ? readFileSync(opts['task-file'], 'utf8')
172
- : opts.rest.join(' ');
173
- if (!taskText.trim()) {
174
- console.error('empty task: pass --task-file or the task as arguments');
175
- return 2;
176
- }
177
-
178
197
  const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
179
198
  const runDir = join(getBullswarmDir(), 'runs');
180
199
  mkdirSync(runDir, { recursive: true });
package/src/help.js CHANGED
@@ -35,7 +35,7 @@ const HELP = {
35
35
  'retire-legacy': { _text: leaf('bullswarm integrate retire-legacy --yes [--json]',
36
36
  'Recoverably archive the retired Claude offload skill.') },
37
37
  },
38
- run: { _text: leaf('bullswarm run --lane <analyze|build|chore> --add-dir <dir> (--task-file <file> | --prompt <text>) [options]',
38
+ run: { _text: leaf('bullswarm run --lane <analyze|build|chore> --add-dir <dir> (--task-file <file> | --prompt <text> | <task text...>) [options]',
39
39
  'Options: --effort <high|medium|low>, --timeout <seconds>, --json. Routes one task and verifies its content.') },
40
40
  health: { _text: leaf('bullswarm health [--json]', 'Re-judge saved outputs and report failed gates or quarantine clusters.') },
41
41
  pools: { _text: leaf('bullswarm pools [--force] [--json]', 'Show live meter, quota-surplus, burst-gate, and quarantine state.') },
@@ -100,7 +100,7 @@ Run bullswarm workflow <command> --help for complete command options.`),
100
100
  inspect: { _text: leaf('bullswarm workflow inspect <file-or-name>') },
101
101
  tui: { _text: leaf('bullswarm workflow tui [<runId>] [--json] [--all] [--show <runId>] [--cancel <runId>]',
102
102
  'Interactive mode opens a full-screen phase → agent → step/detail browser. Up/down selects, Enter drills in, Esc goes back, q detaches without stopping work, and c requests explicit cancellation confirmation.') },
103
- watch: { _text: leaf('bullswarm workflow watch <runId> [--interval <seconds>] [--heartbeat <seconds>] [--jsonl] [--once]') },
103
+ watch: { _text: leaf('bullswarm workflow watch <runId> [--interval <seconds>] [--heartbeat <seconds>] [--jsonl] [--once] [--verbose]', 'Human output is compact by default; --verbose preserves per-agent action details.') },
104
104
  events: { _text: leaf('bullswarm workflow events <runId> [--after <sequence>] [--json]') },
105
105
  steer: { _text: leaf('bullswarm workflow steer <runId> --message <guidance> [--json]') },
106
106
  action: {
@@ -25,17 +25,18 @@ export function bumpVersion(version, kind) {
25
25
  return `${maj}.${min}.${pat}`;
26
26
  }
27
27
 
28
- function git(args) {
29
- return execSync(`git ${args}`, { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
28
+ function git(args, repoRoot = REPO_ROOT) {
29
+ return execSync(`git ${args}`, { cwd: repoRoot, encoding: 'utf8' }).trim();
30
30
  }
31
31
 
32
- export function release(kind, { dryRun = false } = {}) {
33
- const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'));
32
+ export function release(kind, { dryRun = false, repoRoot = REPO_ROOT } = {}) {
33
+ const pkgPath = repoRoot === REPO_ROOT ? PKG_PATH : join(repoRoot, 'package.json');
34
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
34
35
  const prev = pkg.version;
35
36
  const next = bumpVersion(prev, kind);
36
37
 
37
38
  // Dirty-tree guard: a release commit must contain exactly the version change.
38
- const status = git('status --porcelain');
39
+ const status = git('status --porcelain', repoRoot);
39
40
  if (status.trim()) {
40
41
  throw new Error(
41
42
  `working tree is dirty — commit first before releasing:\n${status}`,
@@ -47,9 +48,9 @@ export function release(kind, { dryRun = false } = {}) {
47
48
  }
48
49
 
49
50
  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}"`);
51
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
52
+ git('add package.json', repoRoot);
53
+ git(`commit -m "release v${next}"`, repoRoot);
54
+ git(`tag -a v${next} -m "v${next}"`, repoRoot);
54
55
  return { from: prev, to: next, tag: `v${next}`, dryRun: false };
55
56
  }
package/src/lib/watch.js CHANGED
@@ -223,7 +223,7 @@ function matchLikelyAuthFailure(connector, text) {
223
223
  const line = lower.slice(lineStart, lineEnd < 0 ? lower.length : lineEnd).trim();
224
224
  // A semantic result may legitimately discuss auth handling. Require the
225
225
  // matched line to look like a provider failure instead of source/report text.
226
- const errorShaped = /^(?:error|fatal)(?:\b|:)|^(?:authentication failed|not authenticated|invalid api key|rate limit(?:ed| exceeded)?|cmd login)(?:[.!:]|$)|\b(?:http\s*(?:401|403|429)|status\s*(?:401|403|429)|login required|please login|access denied|quota exceeded)\b/i.test(line);
226
+ const errorShaped = /^(?:error|fatal)(?:\b|:)|^(?:authentication failed|failed to authenticate|not authenticated|invalid api key|rate limit(?:ed| exceeded)?|cmd login)(?:[.!:]|$)|\b(?:http\s*(?:401|403|429)|status\s*(?:401|403|429)|login required|please login|access denied|quota exceeded)\b/i.test(line);
227
227
  return errorShaped ? hit : null;
228
228
  }
229
229
 
package/src/setup.js CHANGED
@@ -184,6 +184,14 @@ export function upgradeConnectorMetadata(bullswarmDir) {
184
184
  changed = true;
185
185
  }
186
186
  }
187
+ if (Array.isArray(packaged.authSignatures)) {
188
+ const existing = Array.isArray(installed.authSignatures) ? installed.authSignatures : [];
189
+ const merged = [...new Set([...existing, ...packaged.authSignatures])];
190
+ if (JSON.stringify(merged) !== JSON.stringify(existing)) {
191
+ installed.authSignatures = merged;
192
+ changed = true;
193
+ }
194
+ }
187
195
  if (installed.model == null && packaged.model != null) {
188
196
  installed.model = packaged.model;
189
197
  changed = true;
@@ -262,6 +262,7 @@ export async function cmdStrategy(args, { bullswarmDir }) {
262
262
  throw new Error(strategyUsage());
263
263
  } catch (err) {
264
264
  console.error(`✗ ${err.message}`);
265
- return 1;
265
+ const usage = /^(usage:|missing |assignment needs |--apply changes|strategy (?:apply|auto off) changes|refresh-hours must be|.* must be a non-negative number|unknown phase|unknown command|unknown pool)/i.test(err.message);
266
+ return usage ? 2 : 1;
266
267
  }
267
268
  }
@@ -310,6 +310,26 @@ export function shouldAutoWatchGoal(opts) {
310
310
  opts.json !== true && opts.resume == null && opts.request == null;
311
311
  }
312
312
 
313
+ export function applyResumeOrchestratorOverride(doc, requested) {
314
+ if (!requested) return doc;
315
+ const pool = requested === 'auto' ? null : requested;
316
+ doc.intent ??= {};
317
+ doc.orchestration ??= {};
318
+ doc.intent.requestedOrchestrator = pool ?? 'auto';
319
+ doc.orchestration.requestedPool = pool;
320
+ doc.orchestration.selection = pool
321
+ ? 'user-pinned-for-testing'
322
+ : 'capability-strategy-and-quota';
323
+ for (const phase of doc.phases ?? []) {
324
+ for (const step of phase.steps ?? []) {
325
+ if (step.type !== 'decide') continue;
326
+ if (pool) step.pool = pool;
327
+ else delete step.pool;
328
+ }
329
+ }
330
+ return doc;
331
+ }
332
+
313
333
  async function wfGoal(opts) {
314
334
  if (opts.help) {
315
335
  console.log(goalUsage());
@@ -340,6 +360,7 @@ async function wfGoal(opts) {
340
360
  console.error(`✗ cannot load durable workflow for ${resumeRunId}: ${err.message}`);
341
361
  return 1;
342
362
  }
363
+ applyResumeOrchestratorOverride(doc, opts.orchestrator);
343
364
  } else if (opts.request) {
344
365
  try {
345
366
  const request = JSON.parse(readFileSync(resolve(opts.request), 'utf8'));
@@ -488,7 +509,7 @@ function wfEvents(opts) {
488
509
  async function wfWatch(opts) {
489
510
  const token = opts.rest[0];
490
511
  if (!token) {
491
- console.error('usage: bullswarm workflow watch <runId> [--interval <seconds>] [--heartbeat <seconds>] [--jsonl] [--once]');
512
+ console.error('usage: bullswarm workflow watch <runId> [--interval <seconds>] [--heartbeat <seconds>] [--jsonl] [--once] [--verbose]');
492
513
  return 2;
493
514
  }
494
515
  const intervalSec = Number(opts.interval ?? 2);
@@ -504,6 +525,7 @@ async function wfWatch(opts) {
504
525
  heartbeatMs: heartbeatSec * 1000,
505
526
  once: opts.once === true,
506
527
  jsonl: opts.jsonl === true,
528
+ verbose: opts.verbose === true,
507
529
  });
508
530
  } catch (err) {
509
531
  console.error(`✗ ${err.message}`);
@@ -107,7 +107,9 @@ function jsonOut(obj, opts) {
107
107
  if (opts.json) console.log(JSON.stringify(obj, null, 2));
108
108
  }
109
109
 
110
- function err(msg) { console.error(msg); return 1; }
110
+ function err(msg, code = 1) { console.error(msg); return code; }
111
+
112
+ function usageErr(msg) { return err(msg, 2); }
111
113
 
112
114
  // --- subcommand dispatch ---------------------------------------------------
113
115
 
@@ -173,7 +175,7 @@ async function livePoolNames() {
173
175
 
174
176
  function draftCreate(opts) {
175
177
  const [name] = opts._positional;
176
- if (!name) return err('usage: bullswarm workflow draft create <name>');
178
+ if (!name) return usageErr('usage: bullswarm workflow draft create <name>');
177
179
  if (draftExists(BULLSWARM_DIR(), name)) {
178
180
  return err(`draft "${name}" already exists (delete it first or use 'show')`);
179
181
  }
@@ -206,7 +208,7 @@ function draftCreate(opts) {
206
208
 
207
209
  function draftShow(opts) {
208
210
  const [name] = opts._positional;
209
- if (!name) return err('usage: bullswarm workflow draft show <name>');
211
+ if (!name) return usageErr('usage: bullswarm workflow draft show <name>');
210
212
  const { doc, meta } = loadDraft(BULLSWARM_DIR(), name);
211
213
  if (opts.json) {
212
214
  jsonOut({ doc, meta }, opts);
@@ -247,7 +249,7 @@ function draftList(opts) {
247
249
  function draftPhase(opts) {
248
250
  const [action, name, phaseName] = opts._positional;
249
251
  if (!action || !name || !phaseName) {
250
- return err('usage: bullswarm workflow draft phase <add|remove> <draft> <phase>');
252
+ return usageErr('usage: bullswarm workflow draft phase <add|remove> <draft> <phase>');
251
253
  }
252
254
  if (action === 'add') {
253
255
  if (!draftExists(BULLSWARM_DIR(), name)) return err(`draft "${name}" does not exist`);
@@ -266,13 +268,13 @@ function draftPhase(opts) {
266
268
  else { console.log(`✓ removed phase "${phaseName}" from draft "${name}"`); reportValidation(r.validation); }
267
269
  return r.validation?.ok === false ? 1 : 0;
268
270
  }
269
- return err(`unknown phase action "${action}"`);
271
+ return usageErr(`unknown phase action "${action}"`);
270
272
  }
271
273
 
272
274
  function draftStep(opts) {
273
275
  const [action, name, phaseName, stepId] = opts._positional;
274
276
  if (!action || !name || !phaseName || !stepId) {
275
- return err('usage: bullswarm workflow draft step <add|remove|set> <draft> <phase> <step-id> [options]');
277
+ return usageErr('usage: bullswarm workflow draft step <add|remove|set> <draft> <phase> <step-id> [options]');
276
278
  }
277
279
  if (action === 'add') {
278
280
  const f = opts._flags;
@@ -314,8 +316,8 @@ function draftStep(opts) {
314
316
  }
315
317
  if (action === 'set') {
316
318
  const field = opts._positional[4];
317
- if (!field) return err('usage: bullswarm workflow draft step set <draft> <phase> <step> <field> --value <text>');
318
- if (!f_has(opts._flags, 'value')) return err('missing --value');
319
+ if (!field) return usageErr('usage: bullswarm workflow draft step set <draft> <phase> <step> <field> --value <text>');
320
+ if (!f_has(opts._flags, 'value')) return usageErr('missing --value');
319
321
  const r = setStepField(BULLSWARM_DIR(), name, {
320
322
  phaseName, stepId, field, value: opts._flags.value,
321
323
  });
@@ -323,7 +325,7 @@ function draftStep(opts) {
323
325
  else { console.log(`✓ set ${field} on step "${stepId}" in draft "${name}"`); reportValidation(r.validation); }
324
326
  return r.validation?.ok === false ? 1 : 0;
325
327
  }
326
- return err(`unknown step action "${action}"`);
328
+ return usageErr(`unknown step action "${action}"`);
327
329
  }
328
330
 
329
331
  function f_has(flags, name) {
@@ -332,8 +334,8 @@ function f_has(flags, name) {
332
334
 
333
335
  function draftSet(opts) {
334
336
  const [name, field] = opts._positional;
335
- if (!name || !field) return err('usage: bullswarm workflow draft set <draft> <field> --value <text>');
336
- if (!f_has(opts._flags, 'value')) return err('missing --value');
337
+ if (!name || !field) return usageErr('usage: bullswarm workflow draft set <draft> <field> --value <text>');
338
+ if (!f_has(opts._flags, 'value')) return usageErr('missing --value');
337
339
  const r = setField(BULLSWARM_DIR(), name, { field, value: opts._flags.value });
338
340
  if (opts.json) jsonOut({ ok: true, action: 'set', name, field, validation: r.validation }, opts);
339
341
  else { console.log(`✓ set ${field} on draft "${name}"`); reportValidation(r.validation); }
@@ -342,7 +344,7 @@ function draftSet(opts) {
342
344
 
343
345
  async function draftValidate(opts) {
344
346
  const [name] = opts._positional;
345
- if (!name) return err('usage: bullswarm workflow draft validate <name>');
347
+ if (!name) return usageErr('usage: bullswarm workflow draft validate <name>');
346
348
  if (!draftExists(BULLSWARM_DIR(), name)) return err(`draft "${name}" does not exist`);
347
349
  const { doc } = loadDraft(BULLSWARM_DIR(), name);
348
350
  const { names } = await livePoolNames();
@@ -371,7 +373,7 @@ async function draftValidate(opts) {
371
373
 
372
374
  function draftExport(opts) {
373
375
  const [name, outFile] = opts._positional;
374
- if (!name || !outFile) return err('usage: bullswarm workflow draft export <name> <out-file>');
376
+ if (!name || !outFile) return usageErr('usage: bullswarm workflow draft export <name> <out-file>');
375
377
  if (!draftExists(BULLSWARM_DIR(), name)) return err(`draft "${name}" does not exist`);
376
378
  const { outPath } = exportDraft(BULLSWARM_DIR(), name, outFile);
377
379
  if (opts.json) jsonOut({ ok: true, name, outPath }, opts);
@@ -381,10 +383,10 @@ function draftExport(opts) {
381
383
 
382
384
  function draftDelete(opts) {
383
385
  const [name] = opts._positional;
384
- if (!name) return err('usage: bullswarm workflow draft delete <name>');
386
+ if (!name) return usageErr('usage: bullswarm workflow draft delete <name>');
385
387
  if (!f_has(opts._flags, 'yes') && !opts._flags.y) {
386
388
  // Refuse without --yes for safety.
387
- return err(`refusing to delete draft "${name}" without --yes`);
389
+ return usageErr(`refusing to delete draft "${name}" without --yes`);
388
390
  }
389
391
  const ok = deleteDraft(BULLSWARM_DIR(), name);
390
392
  if (opts.json) jsonOut({ ok, name }, opts);
@@ -394,7 +396,7 @@ function draftDelete(opts) {
394
396
 
395
397
  async function draftRun(opts) {
396
398
  const [name] = opts._positional;
397
- if (!name) return err('usage: bullswarm workflow draft run <name> [--input k=v]...');
399
+ if (!name) return usageErr('usage: bullswarm workflow draft run <name> [--input k=v]...');
398
400
  if (!draftExists(BULLSWARM_DIR(), name)) return err(`draft "${name}" does not exist`);
399
401
  // Build a synthetic path inside the drafts dir so the existing
400
402
  // loadWorkflow + runWorkflow paths work without modification.
@@ -11,6 +11,7 @@ export const AUTONOMOUS_ORCHESTRATOR_PROMPT = [
11
11
  'You are the autonomous orchestrator for the user goal in the durable workflow context.',
12
12
  'Own the workflow from initial decomposition through implementation and independent verification.',
13
13
  'The user did not author a graph and must not be asked to steer routine execution.',
14
+ 'This is a control-plane decision thread, not a worker assignment. Do not invoke Bullswarm, run shell commands, call tools, or modify repository files. Use only the durable context supplied below and return the requested decision JSON; delegate all inspection, implementation, and verification as bounded actions.',
14
15
  '',
15
16
  'At every checkpoint:',
16
17
  '1. Observe the intent, completed actions, artifacts, failures, verification results, available capabilities, and remaining budget.',
@@ -213,6 +213,13 @@ export async function runWorkflow(opts) {
213
213
  // Always keep a reference to the doc on the state so the runtime can
214
214
  // enforce inputs.<k>.required (R-run-time-required).
215
215
  state._doc = doc;
216
+ if (resuming) {
217
+ state.intent = doc.intent ?? state.intent;
218
+ state.orchestration = doc.orchestration
219
+ ? { ...(state.orchestration ?? {}), ...doc.orchestration }
220
+ : state.orchestration;
221
+ writeFileSync(join(runDir, 'workflow.json'), `${JSON.stringify(doc, null, 2)}\n`);
222
+ }
216
223
  if (opts.inputs && Object.keys(opts.inputs).length) {
217
224
  state.inputs = { ...state.inputs, ...opts.inputs };
218
225
  }
@@ -20,7 +20,7 @@ import { BULLSWARM_DIR } from './cli.js';
20
20
  import { buildWorkflowResult } from './result.js';
21
21
 
22
22
  function jsonOut(obj, opts) { if (opts.json) console.log(JSON.stringify(obj, null, 2)); }
23
- function err(msg) { console.error(msg); return 1; }
23
+ function err(msg, code = 1) { console.error(msg); return code; }
24
24
 
25
25
  export function cmdRuns(args) {
26
26
  const opts = parseRunsFlags(args);
@@ -89,7 +89,7 @@ function runsList(opts) {
89
89
  try {
90
90
  initiatedRange = resolveInitiatedRange(opts);
91
91
  } catch (error) {
92
- return err(error.message);
92
+ return err(error.message, 2);
93
93
  }
94
94
  const all = listRuns(BULLSWARM_DIR());
95
95
  let filtered = all;
@@ -165,7 +165,7 @@ function runsList(opts) {
165
165
  }
166
166
 
167
167
  function runsShow(idToken, opts) {
168
- if (!idToken) return err('usage: bullswarm workflow runs show <id>');
168
+ if (!idToken) return err('usage: bullswarm workflow runs show <id>', 2);
169
169
  const resolved = resolveRunId(BULLSWARM_DIR(), idToken);
170
170
  if (!resolved) return err(`no run found for "${idToken}"`);
171
171
 
@@ -195,7 +195,7 @@ function runsShow(idToken, opts) {
195
195
  }
196
196
 
197
197
  function runsResult(idToken, opts) {
198
- if (!idToken) return err('usage: bullswarm workflow runs result <id> [--json]');
198
+ if (!idToken) return err('usage: bullswarm workflow runs result <id> [--json]', 2);
199
199
  const resolved = resolveRunId(BULLSWARM_DIR(), idToken);
200
200
  if (!resolved) return err(`no run found for "${idToken}"`);
201
201
 
@@ -248,8 +248,8 @@ function runsResult(idToken, opts) {
248
248
  const MAX_HUMAN_RESULT_CHARS = 64 * 1024;
249
249
 
250
250
  function runsDelete(idToken, opts, rest) {
251
- if (!idToken) return err('usage: bullswarm workflow runs delete <id> --yes');
252
- if (!opts.yes) return err(`refusing to delete run "${idToken}" without --yes`);
251
+ if (!idToken) return err('usage: bullswarm workflow runs delete <id> --yes', 2);
252
+ if (!opts.yes) return err(`refusing to delete run "${idToken}" without --yes`, 2);
253
253
  const resolved = resolveRunId(BULLSWARM_DIR(), idToken);
254
254
  if (!resolved) return err(`no run found for "${idToken}"`);
255
255
 
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { resolveRunId } from './short-id.js';
9
9
  import { readSteering } from './steering.js';
10
+ import { readEvents } from './events.js';
10
11
  import { isDeliveredWorkflowStatus, isTerminalWorkflowStatus } from './status.js';
11
12
 
12
13
  function readJson(path) {
@@ -81,6 +82,7 @@ export function watchSnapshot(runDir, state, now = new Date()) {
81
82
  tokens: state.usage?.tokens?.totalKnown ?? null,
82
83
  pendingSteering: queuedSteering.length,
83
84
  deliveredSteering: state.steering?.length ?? 0,
85
+ quietForSec: 0,
84
86
  agents: Object.values(state.activeAgents ?? {}).map((agent) => ({
85
87
  stepId: agent.stepId,
86
88
  pool: agent.pool ?? null,
@@ -119,9 +121,37 @@ export function snapshotFingerprint(snapshot) {
119
121
  });
120
122
  }
121
123
 
122
- export function renderWatchSnapshot(snapshot, { heartbeat = false } = {}) {
124
+ function humanTransitionFingerprint(snapshot) {
125
+ return JSON.stringify({
126
+ status: snapshot.status,
127
+ stage: snapshot.stage,
128
+ phase: snapshot.phase,
129
+ step: snapshot.step,
130
+ dispatchesUsed: snapshot.dispatchesUsed,
131
+ pendingSteering: snapshot.pendingSteering,
132
+ agents: snapshot.agents.map((agent) => ({
133
+ stepId: agent.stepId,
134
+ pool: agent.pool,
135
+ model: agent.model,
136
+ status: agent.status,
137
+ stall: agent.stall,
138
+ })),
139
+ });
140
+ }
141
+
142
+ export function renderWatchSnapshot(snapshot, { heartbeat = false, verbose = false, events = [] } = {}) {
123
143
  const target = snapshot.dispatchTarget == null ? '∞' : snapshot.dispatchTarget;
124
144
  const location = [snapshot.phase, snapshot.step].filter(Boolean).join('/') || 'starting';
145
+ if (!verbose) {
146
+ const actions = events.filter((event) => event.type === 'attempt.agent_action').length;
147
+ const line = `${snapshot.terminal ? '■' : heartbeat ? '♡' : '●'} +${formatDuration(snapshot.elapsedSec)} ` +
148
+ `${snapshot.status}/${snapshot.stage ?? '?'} ${location} · ${events.length} events, ${actions} actions · ` +
149
+ `quiet ${formatDuration(snapshot.quietForSec)}`;
150
+ if (snapshot.terminal && snapshot.timing) {
151
+ return `${line}\n timing: ${snapshot.timing.attempts.length} attempts in ${formatDuration(snapshot.timing.workflowElapsedSec)}`;
152
+ }
153
+ return line;
154
+ }
125
155
  const marker = snapshot.terminal ? '■' : heartbeat ? '♡' : '●';
126
156
  const lines = [
127
157
  `${marker} +${formatDuration(snapshot.elapsedSec)} ${snapshot.status}/${snapshot.stage ?? '?'} ` +
@@ -152,6 +182,7 @@ export async function runWorkflowWatch(bullswarmDir, token, {
152
182
  heartbeatMs = 60000,
153
183
  once = false,
154
184
  jsonl = false,
185
+ verbose = false,
155
186
  output = process.stdout,
156
187
  } = {}) {
157
188
  const resolved = resolveRunId(bullswarmDir, token);
@@ -159,21 +190,71 @@ export async function runWorkflowWatch(bullswarmDir, token, {
159
190
  const statePath = join(resolved.runDir, 'state.json');
160
191
  if (!existsSync(statePath)) throw new Error(`run "${token}" has no state.json`);
161
192
  let priorFingerprint = null;
193
+ let priorHumanFingerprint = null;
162
194
  let lastPrintedAt = 0;
195
+ let priorSequence = null;
196
+ let pendingEvents = [];
197
+ let lastActivityAt = null;
163
198
  while (true) {
164
199
  const state = readJson(statePath);
165
200
  if (state) {
166
201
  const snapshot = watchSnapshot(resolved.runDir, state);
202
+ if (priorSequence == null) {
203
+ // A newly attached watcher has no preceding interval. Start at the
204
+ // durable high-water mark instead of replaying the run lifetime.
205
+ priorSequence = state.eventSequence ?? 0;
206
+ lastActivityAt = Math.max(
207
+ Date.parse(state.lastEvent?.committedAt ?? '') || 0,
208
+ Date.parse(state.finishedAt ?? '') || 0,
209
+ ...Object.values(state.attempts ?? []).map((attempt) => Math.max(
210
+ Date.parse(attempt.lastActivityAt ?? '') || 0,
211
+ Date.parse(attempt.lastHeartbeatAt ?? '') || 0,
212
+ Date.parse(attempt.finishedAt ?? '') || 0,
213
+ )),
214
+ ...Object.values(state.activeAgents ?? {}).map((agent) => Math.max(
215
+ Date.parse(agent.lastActivityAt ?? '') || 0,
216
+ Date.parse(agent.lastActionAt ?? '') || 0,
217
+ Date.parse(agent.startedAt ?? '') || 0,
218
+ )),
219
+ Date.parse(state.startedAt ?? '') || Date.now(),
220
+ );
221
+ }
222
+ const newEvents = readEvents(resolved.runDir, { after: priorSequence });
223
+ if (newEvents.length) {
224
+ pendingEvents.push(...newEvents);
225
+ lastActivityAt = Math.max(
226
+ lastActivityAt,
227
+ ...newEvents.map((event) => Date.parse(event.committedAt ?? '') || Date.now()),
228
+ );
229
+ priorSequence = newEvents.at(-1)?.sequence ?? state.eventSequence ?? priorSequence;
230
+ }
231
+ snapshot.quietForSec = Math.max(0, Math.floor((Date.now() - lastActivityAt) / 1000));
167
232
  const fingerprint = snapshotFingerprint(snapshot);
233
+ const humanFingerprint = humanTransitionFingerprint(snapshot);
168
234
  const heartbeat = Date.now() - lastPrintedAt >= heartbeatMs;
169
- if (fingerprint !== priorFingerprint || heartbeat || once) {
235
+ const changed = jsonl || verbose
236
+ ? fingerprint !== priorFingerprint
237
+ : humanFingerprint !== priorHumanFingerprint;
238
+ if (changed || heartbeat || once) {
170
239
  output.write(jsonl
171
240
  ? `${JSON.stringify({ type: heartbeat && fingerprint === priorFingerprint ? 'heartbeat' : 'progress', ...snapshot })}\n`
172
- : `${renderWatchSnapshot(snapshot, { heartbeat: heartbeat && fingerprint === priorFingerprint })}\n`);
241
+ : `${renderWatchSnapshot(snapshot, {
242
+ heartbeat: heartbeat && !changed,
243
+ verbose,
244
+ events: pendingEvents,
245
+ })}\n`);
173
246
  priorFingerprint = fingerprint;
247
+ priorHumanFingerprint = humanFingerprint;
174
248
  lastPrintedAt = Date.now();
249
+ pendingEvents = [];
250
+ }
251
+ if (snapshot.terminal || once) {
252
+ if (!jsonl && snapshot.terminal) {
253
+ output.write(`outcome: ${snapshot.status}\n`);
254
+ output.write(`next: bullswarm workflow runs result ${snapshot.shortId ?? snapshot.runId} --json\n`);
255
+ }
256
+ return isDeliveredWorkflowStatus(snapshot.status) || once ? 0 : 1;
175
257
  }
176
- if (snapshot.terminal || once) return isDeliveredWorkflowStatus(snapshot.status) || once ? 0 : 1;
177
258
  }
178
259
  await new Promise((resolve) => setTimeout(resolve, Math.max(100, intervalMs)));
179
260
  }