bullswarm 0.17.0 → 0.18.1

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,6 +1,85 @@
1
1
  # bullswarm changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.18.1 — original-goal verification and denser timeline
4
+
5
+ - Goal workflows now derive a durable requirement ledger from the original
6
+ user goal. Planner verify actions declare which requirements they cover, and
7
+ neither explicit nor program-level completion is accepted until every
8
+ requirement has a successful verifier with specific evidence. The verifier
9
+ receives the original goal from the runtime, so a reduced planner scope can
10
+ no longer silently omit requested APIs, events, tests, or documentation.
11
+ - Successful verifier concerns are preserved in a verified
12
+ `completed_with_concerns` result instead of being discarded or triggering
13
+ unnecessary follow-up spending.
14
+ - The human timeline calls its first accepted planner decision `plan created`,
15
+ later decisions `plan updated`, and the final one `completion confirmed`.
16
+ Execution milestones are rendered as one dense block without blank rows.
17
+ Finished workflows now say `No agents running · workflow finished` and
18
+ `Workflow finished · result ready` instead of control-plane terminology.
19
+
20
+ ## 0.18.0 — exact routes, cheaper plans, clearer results
21
+
22
+ - `workflow goal` can now guarantee an exact planner model and a separate exact
23
+ worker route with `--orchestrator-model`, `--worker-pool`, and
24
+ `--worker-model`. The worker lock is runtime-owned and propagates through the
25
+ scout, ordinary actions, fan-out items, verification repairs, reverification,
26
+ and extraction helpers; unsupported or excluded models fail closed instead
27
+ of silently substituting another model.
28
+ - An action-bearing planner `proceed` is normalized to the schema-equivalent
29
+ `needs_more_work` program before validation. This removes a redundant
30
+ correction turn without changing the proposed graph or weakening any safety
31
+ check (the prior real run spent four frontier planner turns correcting this
32
+ exact representation mismatch).
33
+ - `workflow runs result` now selects the latest successful verifier that
34
+ transitively covers the delivery, so a final suite verifier depending on
35
+ unit verifiers is surfaced ahead of a narrower direct unit check.
36
+ - The same result envelope now adds a backward-compatible `deliveries[]`
37
+ frontier for parallel multi-worker outcomes while preserving the singular
38
+ `delivery` field for existing callers.
39
+ - The autonomous planner now batches cheap homogeneous edits instead of paying
40
+ for a worker and unit verifier per tiny file; substantial independent units
41
+ still fan out and retain focused verification before the final suite.
42
+ - Narrow SSH and phone terminals now open on a full-width workflow timeline;
43
+ `t` toggles between that overview and the phase browser without affecting the
44
+ existing Enter/Esc agent drill-down.
45
+ - Goal-level `--orchestrator <pool>` is now a preference with immediate
46
+ fallback when that pool is quota-gated, ineligible, or unavailable. Exact
47
+ provider testing moves to `--strict-orchestrator <pool>`. Quota waits now
48
+ refresh the durable runner heartbeat at least every 30 seconds, preventing a
49
+ live waiting run from being falsely reconciled as interrupted and making
50
+ cooperative cancellation responsive during long meter-poll intervals.
51
+
52
+ - The runtime now owns the acceptance bar for every verify and re-verify. Each
53
+ verifier's instructions end with a fixed "Acceptance standard (runtime-owned;
54
+ it overrides any stricter rule in the instructions above)": `ok:false` means
55
+ the work is unusable — its acceptance command fails, a required deliverable
56
+ is missing, or the answer is nonsense — and everything else (style, scope,
57
+ cosmetic mismatches, process rules the goal never stated such as append-only,
58
+ files changed by other actions in the shared tree) goes in `concerns` under
59
+ `ok:true`. A re-verify rejects only when the work is still unusable or the
60
+ repair broke the acceptance checks. Direction from the user after `8ebi8a`:
61
+ "unless it is completely nonsense or unable to finish I don't see a reason to
62
+ reject so easily".
63
+ - Repair prompts carry a runtime-owned shared-tree rule: edit only the files
64
+ the reviewed work owns; a concern about other files is not the repair's to
65
+ resolve; never revert, checkout or delete other actions' changes. Earned on
66
+ `8ebi8a`: `verify-docs` rejected on a repo-wide `git diff --stat` scope check
67
+ while siblings were writing, and its repair reverted five `src/` files it did
68
+ not own to satisfy the concern.
69
+ - Planner contract: rule 2 requires exactly one owner per file, including any
70
+ existing test the change breaks (the `workflow-adaptive.test.js:206` gap for
71
+ the fourth time); rule 7 restates the lenient bar above; the validator line
72
+ now says ids are unique across the whole run, finished and failed actions
73
+ included (turn 2 of `8ebi8a` re-proposed the blocked id `verify-suite` and
74
+ spent a 97 s correction turn on it).
75
+ - Goal-4 rerun on `7724da1` (`8ebi8a`, rule 7 + PR #5): 42 min 03 s, three
76
+ planner turns (775 s, 31 %), parallelism 1.34, 23 dispatches (20 on
77
+ `kaihk/gpt-5.6-luna`), three repair rounds each rejected on re-verify for
78
+ reasons the prompts caused, tail of five actions blocked, recovery program
79
+ auto-completed, 315/315, existing tests +179/−1. Goal-4 line:
80
+ 44 → 72 → 37 → 25 → 36 → 42 min.
81
+
82
+ ## 0.17.0 — the timeline tells the execution story
4
83
 
5
84
  - Workflow timeline (PR #5) hardened after a 16-agent adversarial review against
6
85
  real run state (23 findings, 21 confirmed): worker rows now name their phase
package/README.md CHANGED
@@ -188,8 +188,23 @@ Resume a process-interrupted autonomous run from its persisted workflow:
188
188
  bullswarm workflow goal --resume <shortId> --json
189
189
  ```
190
190
 
191
- `--orchestrator <pool>` exists for controlled testing; ordinary use should
192
- leave selection on `auto`. `--max-agents` and `--max-workflow-seconds` are
191
+ `--orchestrator <pool>` expresses a preference and immediately falls back to
192
+ another eligible pool if that provider is quota-gated or unavailable. Ordinary
193
+ use can leave selection on `auto`. For controlled provider QA only,
194
+ `--strict-orchestrator <pool>` requires that exact pool and may wait for its
195
+ quota window. Controlled comparisons can additionally pin the exact planner
196
+ and worker routes without changing global strategy:
197
+
198
+ ```bash
199
+ bullswarm workflow goal "Implement and verify the change" --cwd . \
200
+ --strict-orchestrator codex --orchestrator-model gpt-5.6-sol \
201
+ --worker-pool opencode2 --worker-model kaihk/gpt-5.6-luna
202
+ ```
203
+
204
+ The worker lock covers the scout, ordinary runs, fan-out items, repairs,
205
+ re-verification, and runtime extraction helpers. A pool that cannot guarantee
206
+ the requested model is ineligible rather than silently substituting another
207
+ model. `--max-agents` and `--max-workflow-seconds` are
193
208
  advisory planning targets; `--max-expansion-rounds` is also an advisory
194
209
  convergence target. Hard structural safeguards are adjusted with
195
210
  `--max-actions` and `--max-items-per-expansion`.
@@ -258,8 +273,9 @@ Values accept ISO timestamps, local `YYYY-MM-DD` dates, `today`, `yesterday`,
258
273
  After a workflow reaches a terminal state, agents should consume
259
274
  `workflow runs result <id> --json` instead of probing `state.json`, task files,
260
275
  or provider-specific output. The versioned `bullswarm.workflow.result.v1`
261
- envelope identifies the final delivery artifact and its matching verification
262
- verdict, and includes progress, step logs, tokens, and an explicitly
276
+ envelope retains the primary `delivery`, adds a `deliveries[]` frontier when
277
+ parallel workers jointly form the outcome, and identifies their strongest
278
+ matching verification verdict. It also includes progress, step logs, tokens, and an explicitly
263
279
  complete-or-partial tool-call total. `runs show` remains the low-level debugging
264
280
  surface.
265
281
  Goal launch output includes an `instructions` handoff with four named paths:
@@ -308,7 +324,8 @@ width of `⌛` across terminal fonts. It watches ongoing runs from disk and supp
308
324
  details, Esc to go back, `c` to request a confirmed cooperative stop, `r` to
309
325
  refresh, and `q` to detach. Its responsive drill-down fits both desktop and
310
326
  mobile SSH terminals without squeezing phase, agent, and activity into three
311
- narrow columns.
327
+ narrow columns. Below 100 columns it opens on a full-width timeline; press `t`
328
+ to toggle Timeline and Phases, then use Enter/Esc for agents and activity.
312
329
 
313
330
  ```bash
314
331
  bullswarm workflow tui
@@ -436,7 +453,7 @@ appending anything. It executes ready actions, observes their durable results,
436
453
  and calls the planner again. `events.jsonl`, `state.json`, the TUI, and JSON
437
454
  inspection expose the same plan, actions, attempts, decisions, budgets, and
438
455
  artifacts. See `workflows/adaptive-code-review.json` for a complete example.
439
- Planner actions cannot set `pool`, `addDir`, or `taskFile`. If those need to be
456
+ Planner actions cannot set `pool`, `model`, `addDir`, or `taskFile`. If those need to be
440
457
  fixed by the initiator, declare them under the `decide` step's `actionDefaults`;
441
458
  otherwise eligible capable pools are ranked by live quota surplus.
442
459
 
@@ -351,3 +351,41 @@ Cost of the false rejection: the 266 s planner turn plus the serialised tail ≈
351
351
  Fix committed after the run, unreleased (`71960ae`): rule 7 — "A verify checks the goal's own acceptance criteria …
352
352
  never add a process rule the goal does not state (append-only, tests untouched); when the implementation changes what
353
353
  an existing assertion pins, a worker must own updating it." Proof pending a rerun on that commit.
354
+
355
+ ## Run `8ebi8a` — runtime `7724da1` (rule 7 `71960ae` + PR #5 merge), luna pinned, fixture g4-bs-v6
356
+
357
+ Launched 2026-08-29 16:42 Z as the live proof of rule 7. Result: **42 min 03 s**, worse than `euh622` (36 min) and
358
+ `bizp4s` (25 min). Measured (`bs-g4-v6-metrics.json`): 3 planner turns / 775 s (31 % of wall; turn 2 430 s, correction
359
+ turn 97 s, turn 1 ≈ 248 s derived), parallelism 1.34, max 3 concurrent, 23 dispatches (20 workers on
360
+ `kaihk/gpt-5.6-luna`, 3 planner turns on claude-code/opus), 3 repair rounds — every re-verify rejected — 1 validator
361
+ correction, auto-completed by `program-completion`, `npm test` 315/315, existing tests +179/−1 (the mandated `:206`
362
+ extension, finally done by a named action `fix-pinned-test`).
363
+
364
+ The three rejections were each legitimate under the re-verify rule of `9af8fdf`; the defect was in the prompts the
365
+ planner wrote, and rule 7 did not stop it:
366
+ - `verify-impl` r1: real concern. Repair 1 removed `outputSchema` from `programFeatures` so the OLD assertion at
367
+ `workflow-adaptive.test.js:206` would pass — because `impl` was told "do NOT modify existing tests", `tests-runtime`
368
+ (owner of that file) was never told to extend `:206`, and `verify-impl` expected `impl` to have done it. Re-verify
369
+ rejected (a regression: item 5 mandates the entry). r2 re-added it; the old assertion failed again; rejected.
370
+ Turn 2's reason names it: "my round-1 prompt asked for it". Nobody owned the assertion — the fourth run with this gap.
371
+ - `verify-docs` r1: its prompt said "only those three doc files were changed by this worker (`git diff --stat`)"; the
372
+ repo-wide diff showed `impl`'s files, so it rejected on scope. The repair, told "Do not touch src/", still reverted
373
+ five `src/` files to make `git diff --name-only` show three files (its report: "git diff --name-only reports exactly
374
+ the three requested documentation files… 299 tests"). Re-verify rejected on the missing implementation.
375
+ - Tail blocked: `verify-tests-schema`, `verify-tests-runtime`, `verify-suite`, `report`, `verify-report` depended on
376
+ `verify-impl` (a verdict, chosen so repairs would not edit the same files) → `failed_terminal` → planner turn 2,
377
+ which re-proposed the blocked id `verify-suite` → validator rejection → 97 s correction → recovery program
378
+ `restore-src` → `fix-pinned-test` → `verify-src` / `verify-tests` → `verify-full-suite` → `final-report` →
379
+ `verify-final-report`, all ok.
380
+
381
+ Where the extra time went (vs `bizp4s`): ≈ 17 min in the two failed verify loops, the blocked tail, turn 2 and the
382
+ correction; `impl` 493 s vs 377 s is variance.
383
+
384
+ Conclusion and fix (unreleased, committed after the run): three runs in a row failed on a different planner-authored
385
+ constraint the goal never stated (append-only → contradictory ownership → repo-wide scope check), so contract text
386
+ alone is whack-a-mole. The runtime now owns the bar: every verify/re-verify instruction ends with a fixed acceptance
387
+ standard (ok:false = unusable; everything else is a concern under ok:true; other actions' files are never this unit's
388
+ defect), every repair prompt says to edit only the reviewed work's files and never revert others' changes, rule 2
389
+ requires one owner per file including an existing test the change breaks, and the validator line states run-wide id
390
+ uniqueness. Direction from the user: "unless it is completely nonsense or unable to finish I don't see a reason to
391
+ reject so easily". Claim to test on the next rerun: none of the three `8ebi8a` rejection reasons can produce ok:false.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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
@@ -130,8 +130,15 @@ terminal-owned execution.
130
130
  The detached runner does not depend on the initiating CLI remaining alive.
131
131
  Resume a process-interrupted run from its persisted definition with
132
132
  `bullswarm workflow goal --resume <shortId> --json`. Leave orchestrator
133
- selection automatic in normal use; `--orchestrator=<pool>` is for controlled
134
- provider QA. `SIGTERM`/`SIGINT` cooperatively terminate the active delegate and
133
+ selection automatic in normal use. `--orchestrator=<pool>` is a preference
134
+ that falls back when the pool is quota-gated or unavailable;
135
+ `--strict-orchestrator=<pool>` is the exact-provider control for QA and may
136
+ wait for that pool's quota window. For a controlled model comparison, add
137
+ `--orchestrator-model=<model>`, `--worker-pool=<pool>`, and
138
+ `--worker-model=<model>`. The worker constraints cover scout, runs, fan-out
139
+ items, repairs, re-verification, and extraction helpers; a pool that cannot
140
+ guarantee the model is excluded rather than silently substituting it.
141
+ `SIGTERM`/`SIGINT` cooperatively terminate the active delegate and
135
142
  persist `interrupted`; later workflow commands also reconcile dead or stale
136
143
  owners into that explicit resumable state.
137
144
 
@@ -223,8 +230,9 @@ ISO timestamps, local dates, today/yesterday/tomorrow/now, or durations such as
223
230
  normal ongoing-only scope.
224
231
 
225
232
  When the run is terminal, use `workflow runs result <id> --json` as the
226
- handoff contract. Its versioned result envelope points to the selected delivery
227
- artifact, the dependent verification verdict, progress, and usage. Do not guess
233
+ handoff contract. Its versioned result envelope keeps one primary `delivery`,
234
+ adds every jointly delivered parallel artifact under `deliveries[]`, and points
235
+ to the strongest dependent verification verdict, progress, and usage. Do not guess
228
236
  the output schema by scraping task files or assume the last provider response is
229
237
  the deliverable; `runs show` is for low-level debugging.
230
238
 
@@ -287,6 +295,8 @@ Select Workflow Planner and press Enter, or press
287
295
  reason, next action, progress, and recent semantic activity. Press `v` for the
288
296
  durable provider session, checkpoint prompts and turns, usage, and artifact
289
297
  paths; `v` returns to the overview and Esc returns to phases.
298
+ Below 100 columns the TUI opens on a full-width timeline; `t` toggles Timeline
299
+ and Phases, and Enter/Esc continues through agents and activity.
290
300
  The shared state marks are `○` not started, animated Braille spinner active,
291
301
  `⧖` waiting, `✓` finished, and `✗` failed or interrupted.
292
302
 
@@ -404,7 +414,7 @@ orchestrator for the rest of the run and tries one other eligible pool
404
414
  `completed_with_concerns` with a ready best-effort artifact when useful work
405
415
  exists, or `blocked` when it does not; neither hides failed verification.
406
416
  Use `workflows/adaptive-code-review.json` as the starting template.
407
- Planner proposals cannot choose `pool`, `addDir`, or `taskFile`. Those fields
417
+ Planner proposals cannot choose `pool`, `model`, `addDir`, or `taskFile`. Those fields
408
418
  are runtime-owned. An initiator may constrain them with a decide step's
409
419
  `actionDefaults`; absent a pinned default, normal capability and quota routing
410
420
  selects the worker.
package/src/help.js CHANGED
@@ -581,7 +581,11 @@ const workflowGoalText = rich({
581
581
  { flag: '--watch', desc: 'immediately follow low-noise progress until terminal; only valid for a new human-readable independent launch — cannot combine with --detach, --foreground, --json, --resume, or --request', default: 'off' },
582
582
  { flag: '--foreground', desc: 'keep execution attached to this terminal instead of detaching', default: 'off (detaches into a background process)' },
583
583
  { flag: '--json', desc: 'print the launch/report document as JSON', default: 'human-readable launch instructions' },
584
- { flag: '--orchestrator <pool|auto>', desc: 'pin the orchestrator pool for a new goal, or override it when combined with --resume', default: 'auto (capability- and quota-based selection)' },
584
+ { flag: '--orchestrator <pool|auto>', desc: 'prefer this orchestrator pool for a new goal or resumed run, falling back immediately when it is quota-gated, ineligible, or unavailable', default: 'auto (capability- and quota-based selection)' },
585
+ { flag: '--strict-orchestrator <pool>', desc: 'require exactly this orchestrator pool for controlled provider QA; waits when that pool is quota-gated instead of falling back; mutually exclusive with --orchestrator', default: 'off' },
586
+ { flag: '--orchestrator-model <model|auto>', desc: 'pin the exact model used by the autonomous planner; only pools that can guarantee this model remain eligible', default: 'auto (effort-tier strategy or connector default)' },
587
+ { flag: '--worker-pool <pool|auto>', desc: 'pin every non-planner dispatch, including scout, fan-out items, repairs, and verifiers, to one pool', default: 'auto (normal routing)' },
588
+ { flag: '--worker-model <model|auto>', desc: 'pin the exact model for every non-planner dispatch; only pools that can guarantee it remain eligible', default: 'auto (effort-tier strategy or connector default)' },
585
589
  { flag: '--max-agents <n>', desc: 'planning target for total dispatched agents (soft, not a hard stop)', default: '30 (max 500)' },
586
590
  { flag: '--max-expansion-rounds <n>', desc: 'planning target for planner replanning rounds', default: '8 (max 50)' },
587
591
  { flag: '--max-actions <n>', desc: 'planning target for total dispatched actions', default: '40 (max 1000)' },
@@ -605,6 +609,7 @@ const workflowGoalText = rich({
605
609
  ],
606
610
  examples: [
607
611
  { cmd: 'bullswarm workflow goal "Audit this repo for TODOs and file a one-page summary" --cwd .' },
612
+ { cmd: 'bullswarm workflow goal "Implement and verify the change" --cwd . --strict-orchestrator codex --orchestrator-model gpt-5.6-sol --worker-pool opencode2 --worker-model kaihk/gpt-5.6-luna', note: 'controlled Sol-planner/Luna-worker run' },
608
613
  ],
609
614
  next: 'bullswarm workflow watch <shortId> to follow progress, or bullswarm workflow tui for the interactive browser.',
610
615
  });
@@ -660,7 +665,7 @@ const workflowCapabilitiesText = rich({
660
665
  options: [{ flag: '--json', desc: 'accepted for consistency with other commands, but has no effect', default: 'output is always JSON regardless of this flag' }],
661
666
  safety: ['read-only — performs live pool discovery to populate pool/meter state; nothing is written'],
662
667
  examples: [{ cmd: 'bullswarm workflow capabilities' }],
663
- next: 'bullswarm workflow goal "<goal>" --orchestrator <pool> to pin one of the reported pools, or bullswarm strategy show to review model tier assignments.',
668
+ next: 'bullswarm workflow goal "<goal>" --orchestrator <pool> to prefer one of the reported pools with fallback, or bullswarm strategy show to review model tier assignments.',
664
669
  });
665
670
 
666
671
  const workflowInspectText = rich({
@@ -691,6 +696,7 @@ const workflowTuiText = rich({
691
696
  '--cancel writes state.json (cancelRequested=true, status=cancelling) — cooperative, not a force-kill: the workflow stops at its next safe checkpoint',
692
697
  'inside the interactive browser, q detaches without stopping the underlying workflow; c requests the same cancellation with a confirmation prompt',
693
698
  'the default timeline is derived from durable state and events; press v for raw action-ledger and event evidence',
699
+ 'below 100 columns the timeline remains full-width; press t to toggle Timeline and Phases, then Enter/Esc to drill into agents and activity',
694
700
  ],
695
701
  examples: [
696
702
  { cmd: 'bullswarm workflow tui', note: 'interactive run picker' },
@@ -869,8 +875,8 @@ const workflowRunsShowText = rich({
869
875
 
870
876
  const workflowRunsResultText = rich({
871
877
  usage: 'bullswarm workflow runs result <shortId|runId> [--json]',
872
- purpose: 'Print the stable, caller-facing delivery content, verification verdict, progress, '
873
- + 'and usage envelope for one run — the intended integration point for scripts and agents.',
878
+ purpose: 'Print the stable caller envelope: primary delivery, parallel deliveries[] frontier, '
879
+ + 'strongest verification verdict, progress, and usage for one run — the intended integration point for scripts and agents.',
874
880
  args: [{ name: '<shortId|runId>', desc: 'run identifier' }],
875
881
  options: [{ flag: '--json', desc: 'print the full result document as JSON', default: 'human-readable summary (delivery preview truncated to 64KB)' }],
876
882
  safety: ['read-only'],
@@ -295,21 +295,83 @@ export function shouldAutoWatchGoal(opts) {
295
295
  opts.json !== true && opts.resume == null && opts.request == null;
296
296
  }
297
297
 
298
- export function applyResumeOrchestratorOverride(doc, requested) {
299
- if (!requested) return doc;
300
- const pool = requested === 'auto' ? null : requested;
298
+ export function applyResumeOrchestratorOverride(doc, requested, strictRequested = null) {
299
+ if (!requested && !strictRequested) return doc;
300
+ if (requested && strictRequested) {
301
+ throw new Error('--orchestrator and --strict-orchestrator are mutually exclusive');
302
+ }
303
+ const strict = Boolean(strictRequested);
304
+ const selected = strictRequested ?? requested;
305
+ const pool = selected === 'auto' ? null : selected;
301
306
  doc.intent ??= {};
302
307
  doc.orchestration ??= {};
303
308
  doc.intent.requestedOrchestrator = pool ?? 'auto';
304
309
  doc.orchestration.requestedPool = pool;
310
+ doc.orchestration.strictPool = strict ? pool : null;
305
311
  doc.orchestration.selection = pool
306
- ? 'user-pinned-for-testing'
312
+ ? (strict ? 'user-strict-for-testing' : 'user-preferred-with-fallback')
307
313
  : 'capability-strategy-and-quota';
308
314
  for (const phase of doc.phases ?? []) {
309
315
  for (const step of phase.steps ?? []) {
310
316
  if (step.type !== 'decide') continue;
311
- if (pool) step.pool = pool;
312
- else delete step.pool;
317
+ delete step.pool;
318
+ delete step.preferredPool;
319
+ if (pool) step[strict ? 'pool' : 'preferredPool'] = pool;
320
+ }
321
+ }
322
+ return doc;
323
+ }
324
+
325
+ export function applyResumeModelOverrides(doc, {
326
+ orchestratorModel = null,
327
+ workerPool = null,
328
+ workerModel = null,
329
+ } = {}) {
330
+ const normalize = (value) => value === 'auto' ? null : value;
331
+ const plannerModel = normalize(orchestratorModel);
332
+ const workers = normalize(workerPool);
333
+ const workerModelLock = normalize(workerModel);
334
+ if (orchestratorModel == null && workerPool == null && workerModel == null) return doc;
335
+ doc.intent ??= {};
336
+ doc.orchestration ??= {};
337
+ if (orchestratorModel != null) {
338
+ doc.intent.requestedOrchestratorModel = plannerModel ?? 'auto';
339
+ doc.orchestration.requestedModel = plannerModel;
340
+ }
341
+ if (workerPool != null) {
342
+ doc.intent.requestedWorkerPool = workers ?? 'auto';
343
+ doc.orchestration.workerPool = workers;
344
+ }
345
+ if (workerModel != null) {
346
+ doc.intent.requestedWorkerModel = workerModelLock ?? 'auto';
347
+ doc.orchestration.workerModel = workerModelLock;
348
+ }
349
+ for (const phase of doc.phases ?? []) {
350
+ for (const step of phase.steps ?? []) {
351
+ if (step.type === 'decide') {
352
+ step.actionDefaults ??= {};
353
+ if (orchestratorModel != null) {
354
+ if (plannerModel) step.model = plannerModel;
355
+ else delete step.model;
356
+ }
357
+ if (workerPool != null) {
358
+ if (workers) step.actionDefaults.pool = workers;
359
+ else delete step.actionDefaults.pool;
360
+ }
361
+ if (workerModel != null) {
362
+ if (workerModelLock) step.actionDefaults.model = workerModelLock;
363
+ else delete step.actionDefaults.model;
364
+ }
365
+ continue;
366
+ }
367
+ if (workerPool != null) {
368
+ if (workers) step.pool = workers;
369
+ else delete step.pool;
370
+ }
371
+ if (workerModel != null) {
372
+ if (workerModelLock) step.model = workerModelLock;
373
+ else delete step.model;
374
+ }
313
375
  }
314
376
  }
315
377
  return doc;
@@ -324,6 +386,10 @@ async function wfGoal(opts) {
324
386
  console.error('✗ --watch is only valid for a new human-readable independent launch; do not combine it with --detach, --foreground, --json, --resume, or --request');
325
387
  return 2;
326
388
  }
389
+ if (opts.orchestrator && opts['strict-orchestrator']) {
390
+ console.error('✗ --orchestrator and --strict-orchestrator are mutually exclusive');
391
+ return 2;
392
+ }
327
393
  const { names, pools } = await livePoolNames();
328
394
  let doc;
329
395
  let resumeRunId = null;
@@ -345,7 +411,17 @@ async function wfGoal(opts) {
345
411
  console.error(`✗ cannot load durable workflow for ${resumeRunId}: ${err.message}`);
346
412
  return 1;
347
413
  }
348
- applyResumeOrchestratorOverride(doc, opts.orchestrator);
414
+ try {
415
+ applyResumeOrchestratorOverride(doc, opts.orchestrator, opts['strict-orchestrator']);
416
+ applyResumeModelOverrides(doc, {
417
+ orchestratorModel: opts['orchestrator-model'],
418
+ workerPool: opts['worker-pool'],
419
+ workerModel: opts['worker-model'],
420
+ });
421
+ } catch (err) {
422
+ console.error(`✗ invalid goal options: ${err.message}`);
423
+ return 2;
424
+ }
349
425
  } else if (opts.request) {
350
426
  try {
351
427
  const request = JSON.parse(readFileSync(resolve(opts.request), 'utf8'));
@@ -364,13 +440,24 @@ async function wfGoal(opts) {
364
440
  console.error(goalUsage());
365
441
  return 2;
366
442
  }
367
- const orchestrator = opts.orchestrator && opts.orchestrator !== 'auto'
368
- ? opts.orchestrator : null;
443
+ const requestedOrchestrator = opts['strict-orchestrator'] ?? opts.orchestrator;
444
+ const orchestrator = requestedOrchestrator && requestedOrchestrator !== 'auto'
445
+ ? requestedOrchestrator : null;
446
+ const workerPool = opts['worker-pool'] && opts['worker-pool'] !== 'auto'
447
+ ? opts['worker-pool'] : null;
448
+ const workerModel = opts['worker-model'] && opts['worker-model'] !== 'auto'
449
+ ? opts['worker-model'] : null;
450
+ const orchestratorModel = opts['orchestrator-model'] && opts['orchestrator-model'] !== 'auto'
451
+ ? opts['orchestrator-model'] : null;
369
452
  try {
370
453
  doc = buildGoalWorkflow({
371
454
  goal,
372
455
  cwd: opts.cwd ?? process.cwd(),
373
456
  orchestrator,
457
+ strictOrchestrator: Boolean(opts['strict-orchestrator']),
458
+ orchestratorModel,
459
+ workerPool,
460
+ workerModel,
374
461
  settings: goalSettings(opts),
375
462
  scout: !opts.noScout,
376
463
  worktreeIsolation: loadState(BULLSWARM_DIR()).config?.worktreeIsolation ?? 'agent-decides',
@@ -616,7 +703,8 @@ async function wfInspect(opts) {
616
703
  function parseFlags(argv) {
617
704
  const out = { inputs: {}, rest: [] };
618
705
  const valueFlags = new Set([
619
- 'resume', 'after', 'cwd', 'orchestrator', 'request', 'run-id',
706
+ 'resume', 'after', 'cwd', 'orchestrator', 'strict-orchestrator', 'orchestrator-model',
707
+ 'worker-pool', 'worker-model', 'request', 'run-id',
620
708
  'max-agents', 'max-expansion-rounds', 'max-actions',
621
709
  'max-items-per-expansion', 'max-workflow-seconds', 'concurrency',
622
710
  'retry-attempts', 'interval', 'heartbeat', 'message',
@@ -363,7 +363,7 @@ export function renderWorkflowTui(row, {
363
363
  width = 120, height = 36, focus = 0, phaseIndex = null, agentIndex = null,
364
364
  detailScroll = 0, message = null, confirmCancel = false,
365
365
  controlSelected = false, orchestratorDetail = false, orchestratorVerbose = false,
366
- workflowVerbose = false,
366
+ workflowVerbose = false, mobileTimeline = true,
367
367
  spinnerFrame = 0,
368
368
  } = {}) {
369
369
  width = Math.max(20, Number(width) || 120);
@@ -392,11 +392,13 @@ export function renderWorkflowTui(row, {
392
392
  : workflowVerbose
393
393
  ? ' ↑/↓ scroll · v overview · Esc back · c stop · q detach'
394
394
  : narrow
395
- ? ' ↑/↓ select · Enter inspect · Esc back · o planner · v technical · c stop · q detach'
395
+ ? mobileTimeline && focus === 0
396
+ ? ' ↑/↓ timeline · t phases · Enter agents · o planner · v technical · q detach'
397
+ : ' ↑/↓ select · t timeline · Enter inspect · Esc back · o planner · v technical · q detach'
396
398
  : ' ↑/↓ select · PgUp/PgDn timeline · Enter inspect · ←/→ switch · v technical · q detach';
397
399
  const rawMessageLine = message
398
400
  ? ` ${truncate(message, width - 2)}`
399
- : ` ${orchestratorDetail ? `Workflow Planner ${orchestratorVerbose ? 'technical details' : 'overview'}` : workflowVerbose ? 'Workflow technical details' : focus === 0 ? (narrow ? 'Phases' : 'Timeline · auto-following newest event') : focus === 1 ? 'Agents' : 'Agent activity'} · r refresh · workflow continues after detach`;
401
+ : ` ${orchestratorDetail ? `Workflow Planner ${orchestratorVerbose ? 'technical details' : 'overview'}` : workflowVerbose ? 'Workflow technical details' : focus === 0 ? (narrow && !mobileTimeline ? 'Phases' : 'Timeline · auto-following newest event') : focus === 1 ? 'Agents' : 'Agent activity'} · r refresh · workflow continues after detach`;
400
402
  const messageLine = truncate(rawMessageLine, width);
401
403
  const bodyHeight = Math.max(10, height - header.length - 3);
402
404
 
@@ -487,7 +489,9 @@ export function renderWorkflowTui(row, {
487
489
  body = joinPanels(left, renderPanel('Workflow technical details', visibleTechnical, rightWidth, bodyHeight));
488
490
  }
489
491
  } else if (narrow) {
490
- const mobile = focus === 0
492
+ const mobile = focus === 0 && mobileTimeline
493
+ ? null
494
+ : focus === 0
491
495
  ? {
492
496
  title: model.orchestrator.autonomous
493
497
  ? `Workflow · ${model.phases.length} phase${model.phases.length === 1 ? '' : 's'}`
@@ -497,7 +501,9 @@ export function renderWorkflowTui(row, {
497
501
  : focus === 1
498
502
  ? { title: agentTitle, lines: visibleAgents }
499
503
  : { title: detailTitle, lines: visibleDetail };
500
- body = renderPanel(mobile.title, mobile.lines, width, bodyHeight);
504
+ body = mobile
505
+ ? renderPanel(mobile.title, mobile.lines, width, bodyHeight)
506
+ : renderWorkflowOverviewPanel(model, width, bodyHeight, spinnerFrame, detailScroll);
501
507
  } else if (focus < 2) {
502
508
  const left = model.orchestrator.autonomous
503
509
  ? [
@@ -582,9 +588,9 @@ function workflowTimelineLines(model, width) {
582
588
  const { state, orchestrator } = model;
583
589
  const ledger = state.actionLedger ?? [];
584
590
  const events = [];
585
- const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER) => {
591
+ const add = (at, lines, sequence = Number.MAX_SAFE_INTEGER, group = null) => {
586
592
  if (!at) return;
587
- events.push({ at, sequence, lines: Array.isArray(lines) ? lines : [lines] });
593
+ events.push({ at, sequence, group, lines: Array.isArray(lines) ? lines : [lines] });
588
594
  };
589
595
  const scout = ledger.find((action) => action.id === 'scout');
590
596
  add(state.startedAt, [
@@ -614,10 +620,17 @@ function workflowTimelineLines(model, width) {
614
620
  const decision = decisionForPlannerAttempt(state, attempt, index, orchestrator.attempts);
615
621
  const summary = decision?.reason ? sentencePreview(decision.reason, Math.max(30, width - 10))
616
622
  : decision ? decisionLabel(decision.decision) : 'No accepted decision; correction or retry turn';
623
+ const acceptedBefore = orchestrator.attempts.slice(0, index).filter((entry, priorIndex) =>
624
+ decisionForPlannerAttempt(state, entry, priorIndex, orchestrator.attempts)).length;
625
+ const plannerLabel = !decision
626
+ ? `planning retry #${index + 1}`
627
+ : decision.decision === 'complete'
628
+ ? 'completion confirmed'
629
+ : acceptedBefore === 0 ? 'plan created' : `plan updated #${acceptedBefore + 1}`;
617
630
  add(attempt.finishedAt, [
618
- timelineRow(attempt.finishedAt, `◆ [Workflow Planner] checkpoint #${index + 1}`, durationText(attempt.startedAt, attempt.finishedAt), width),
631
+ timelineRow(attempt.finishedAt, `◆ [Workflow Planner] ${plannerLabel}`, durationText(attempt.startedAt, attempt.finishedAt), width),
619
632
  timelineDetail(summary, width),
620
- ]);
633
+ ], Number.MAX_SAFE_INTEGER, 'execution');
621
634
  });
622
635
 
623
636
  const phases = new Map();
@@ -632,7 +645,7 @@ function workflowTimelineLines(model, width) {
632
645
  const startedAt = realStart ?? earliestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
633
646
  if (!startedAt) continue;
634
647
  const label = phaseLabel(name, orchestrator);
635
- add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width));
648
+ add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width), Number.MAX_SAFE_INTEGER, 'execution');
636
649
  const finished = actions
637
650
  .filter((action) => actionFinishedAt(state, action) && TERMINAL_ACTIONS.has(effectiveActionStatus(action, state)))
638
651
  .sort((a, b) => Date.parse(actionFinishedAt(state, a)) - Date.parse(actionFinishedAt(state, b)));
@@ -646,24 +659,24 @@ function workflowTimelineLines(model, width) {
646
659
  `${branch}${statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
647
660
  actionStarted ? durationText(actionStarted, actionFinished) : '',
648
661
  width,
649
- ));
662
+ ), Number.MAX_SAFE_INTEGER, 'execution');
650
663
  });
651
664
  if (finished.length === actions.length && actions.length) {
652
665
  const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
653
666
  const failed = actions.some((action) => String(effectiveActionStatus(action, state)).startsWith('failed'));
654
- add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : '✓'} [Phase: ${label}] completed`, `${finished.length}/${actions.length}`, width));
667
+ add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : '✓'} [Phase: ${label}] completed`, `${finished.length}/${actions.length}`, width), Number.MAX_SAFE_INTEGER, 'execution');
655
668
  }
656
669
  }
657
670
 
658
671
  for (const event of model.events) {
659
672
  const detail = timelineControlEvent(event, width);
660
- if (detail) add(event.committedAt, detail, Number(event.sequence));
673
+ if (detail) add(event.committedAt, detail, Number(event.sequence), event.type.startsWith('decision.') ? 'execution' : null);
661
674
  }
662
675
 
663
676
  events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || a.sequence - b.sequence);
664
677
  const lines = [];
665
678
  events.forEach((event, index) => {
666
- if (index) lines.push('');
679
+ if (index && (!event.group || event.group !== events[index - 1].group)) lines.push('');
667
680
  lines.push(...event.lines);
668
681
  });
669
682
  return { lines: lines.length ? lines : ['Waiting for the first durable workflow milestone'], milestoneCount: events.length };
@@ -751,13 +764,35 @@ function workflowLiveLines(model, width, spinnerFrame) {
751
764
  if (stream) lines.push(` ${stream}`);
752
765
  lines.push('');
753
766
  }
754
- if (!lines.length) lines.push(state.finishedAt ? '✓ No live agents · workflow is terminal' : '⧖ Waiting for the next dispatch');
767
+ if (!lines.length) lines.push(state.finishedAt
768
+ ? `${statusIcon(state.status)} No agents running · ${terminalWorkflowLabel(state.status)}`
769
+ : '⧖ Waiting for the next dispatch');
755
770
  return { lines, running, waiting };
756
771
  }
757
772
 
773
+ function terminalWorkflowLabel(status) {
774
+ if (status === 'completed') return 'workflow finished';
775
+ if (status === 'completed_with_concerns') return 'workflow finished with concerns';
776
+ if (status === 'blocked') return 'workflow stopped with blockers';
777
+ if (status === 'failed') return 'workflow failed';
778
+ if (status === 'cancelled') return 'workflow cancelled';
779
+ if (status === 'interrupted') return 'workflow interrupted';
780
+ return 'workflow stopped';
781
+ }
782
+
758
783
  function workflowNextLines(model, width) {
759
784
  const { state, orchestrator } = model;
760
- if (state.finishedAt) return [truncate(`${statusIcon(state.status)} Workflow terminal · obtain the stable result envelope`, width)];
785
+ if (state.finishedAt) {
786
+ const next = state.status === 'completed' || state.status === 'completed_with_concerns'
787
+ ? 'result ready'
788
+ : state.status === 'blocked' ? 'review blockers and partial work'
789
+ : state.status === 'failed' ? 'inspect the failure before using partial work'
790
+ : state.status === 'cancelled' ? 'review any partial work'
791
+ : state.status === 'interrupted' ? 'resume the workflow or inspect partial work'
792
+ : 'inspect the workflow result';
793
+ const label = terminalWorkflowLabel(state.status);
794
+ return [truncate(`${statusIcon(state.status)} ${label[0].toUpperCase()}${label.slice(1)} · ${next}`, width)];
795
+ }
761
796
  const ledger = state.actionLedger ?? [];
762
797
  const pending = ledger.find((action) => action.id !== 'scout'
763
798
  && action.id !== orchestrator.actionId
@@ -1228,6 +1263,7 @@ export async function runDashboard(bullswarmDir, {
1228
1263
  orchestratorDetail: false,
1229
1264
  orchestratorVerbose: false,
1230
1265
  workflowVerbose: false,
1266
+ mobileTimeline: true,
1231
1267
  spinnerFrame: 0,
1232
1268
  };
1233
1269
  const paintUnsafe = () => {
@@ -1298,8 +1334,10 @@ export async function runDashboard(bullswarmDir, {
1298
1334
  }
1299
1335
  const row = detailRow(bullswarmDir, selectedRunId);
1300
1336
  const model = workflowPanelModel(row, { phaseIndex: ui.phaseIndex, agentIndex: ui.agentIndex });
1301
- if (ui.orchestratorDetail || ui.workflowVerbose) {
1302
- ui.detailScroll = Math.max(0, ui.detailScroll + delta);
1337
+ const narrowTimeline = output.columns < 100 && ui.mobileTimeline && ui.focus === 0;
1338
+ if (ui.orchestratorDetail || ui.workflowVerbose || narrowTimeline) {
1339
+ if (narrowTimeline) ui.detailScroll = Math.max(0, ui.detailScroll - delta);
1340
+ else ui.detailScroll = Math.max(0, ui.detailScroll + delta);
1303
1341
  return paint();
1304
1342
  }
1305
1343
  if (ui.focus === 0) {
@@ -1396,6 +1434,14 @@ export async function runDashboard(bullswarmDir, {
1396
1434
  } else message = 'This workflow has no autonomous orchestrator thread.';
1397
1435
  return paint();
1398
1436
  }
1437
+ if (key === 't' && detail && output.columns < 100 && !ui.orchestratorDetail && !ui.workflowVerbose) {
1438
+ ui.mobileTimeline = !ui.mobileTimeline;
1439
+ ui.focus = 0;
1440
+ ui.controlSelected = false;
1441
+ ui.detailScroll = 0;
1442
+ message = null;
1443
+ return paint();
1444
+ }
1399
1445
  if (key === '1' && detail) { ui.focus = 0; return paint(); }
1400
1446
  if (key === '2' && detail) { ui.focus = 1; return paint(); }
1401
1447
  if (key === '3' && detail) { ui.focus = 2; return paint(); }