bullswarm 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,68 @@
1
1
  # bullswarm changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.18.0 — exact routes, cheaper plans, clearer results
4
+
5
+ - `workflow goal` can now guarantee an exact planner model and a separate exact
6
+ worker route with `--orchestrator-model`, `--worker-pool`, and
7
+ `--worker-model`. The worker lock is runtime-owned and propagates through the
8
+ scout, ordinary actions, fan-out items, verification repairs, reverification,
9
+ and extraction helpers; unsupported or excluded models fail closed instead
10
+ of silently substituting another model.
11
+ - An action-bearing planner `proceed` is normalized to the schema-equivalent
12
+ `needs_more_work` program before validation. This removes a redundant
13
+ correction turn without changing the proposed graph or weakening any safety
14
+ check (the prior real run spent four frontier planner turns correcting this
15
+ exact representation mismatch).
16
+ - `workflow runs result` now selects the latest successful verifier that
17
+ transitively covers the delivery, so a final suite verifier depending on
18
+ unit verifiers is surfaced ahead of a narrower direct unit check.
19
+ - The same result envelope now adds a backward-compatible `deliveries[]`
20
+ frontier for parallel multi-worker outcomes while preserving the singular
21
+ `delivery` field for existing callers.
22
+ - The autonomous planner now batches cheap homogeneous edits instead of paying
23
+ for a worker and unit verifier per tiny file; substantial independent units
24
+ still fan out and retain focused verification before the final suite.
25
+ - Narrow SSH and phone terminals now open on a full-width workflow timeline;
26
+ `t` toggles between that overview and the phase browser without affecting the
27
+ existing Enter/Esc agent drill-down.
28
+ - Goal-level `--orchestrator <pool>` is now a preference with immediate
29
+ fallback when that pool is quota-gated, ineligible, or unavailable. Exact
30
+ provider testing moves to `--strict-orchestrator <pool>`. Quota waits now
31
+ refresh the durable runner heartbeat at least every 30 seconds, preventing a
32
+ live waiting run from being falsely reconciled as interrupted and making
33
+ cooperative cancellation responsive during long meter-poll intervals.
34
+
35
+ - The runtime now owns the acceptance bar for every verify and re-verify. Each
36
+ verifier's instructions end with a fixed "Acceptance standard (runtime-owned;
37
+ it overrides any stricter rule in the instructions above)": `ok:false` means
38
+ the work is unusable — its acceptance command fails, a required deliverable
39
+ is missing, or the answer is nonsense — and everything else (style, scope,
40
+ cosmetic mismatches, process rules the goal never stated such as append-only,
41
+ files changed by other actions in the shared tree) goes in `concerns` under
42
+ `ok:true`. A re-verify rejects only when the work is still unusable or the
43
+ repair broke the acceptance checks. Direction from the user after `8ebi8a`:
44
+ "unless it is completely nonsense or unable to finish I don't see a reason to
45
+ reject so easily".
46
+ - Repair prompts carry a runtime-owned shared-tree rule: edit only the files
47
+ the reviewed work owns; a concern about other files is not the repair's to
48
+ resolve; never revert, checkout or delete other actions' changes. Earned on
49
+ `8ebi8a`: `verify-docs` rejected on a repo-wide `git diff --stat` scope check
50
+ while siblings were writing, and its repair reverted five `src/` files it did
51
+ not own to satisfy the concern.
52
+ - Planner contract: rule 2 requires exactly one owner per file, including any
53
+ existing test the change breaks (the `workflow-adaptive.test.js:206` gap for
54
+ the fourth time); rule 7 restates the lenient bar above; the validator line
55
+ now says ids are unique across the whole run, finished and failed actions
56
+ included (turn 2 of `8ebi8a` re-proposed the blocked id `verify-suite` and
57
+ spent a 97 s correction turn on it).
58
+ - Goal-4 rerun on `7724da1` (`8ebi8a`, rule 7 + PR #5): 42 min 03 s, three
59
+ planner turns (775 s, 31 %), parallelism 1.34, 23 dispatches (20 on
60
+ `kaihk/gpt-5.6-luna`), three repair rounds each rejected on re-verify for
61
+ reasons the prompts caused, tail of five actions blocked, recovery program
62
+ auto-completed, 315/315, existing tests +179/−1. Goal-4 line:
63
+ 44 → 72 → 37 → 25 → 36 → 42 min.
64
+
65
+ ## 0.17.0 — the timeline tells the execution story
4
66
 
5
67
  - Workflow timeline (PR #5) hardened after a 16-agent adversarial review against
6
68
  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.0",
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
  ? [
@@ -1228,6 +1234,7 @@ export async function runDashboard(bullswarmDir, {
1228
1234
  orchestratorDetail: false,
1229
1235
  orchestratorVerbose: false,
1230
1236
  workflowVerbose: false,
1237
+ mobileTimeline: true,
1231
1238
  spinnerFrame: 0,
1232
1239
  };
1233
1240
  const paintUnsafe = () => {
@@ -1298,8 +1305,10 @@ export async function runDashboard(bullswarmDir, {
1298
1305
  }
1299
1306
  const row = detailRow(bullswarmDir, selectedRunId);
1300
1307
  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);
1308
+ const narrowTimeline = output.columns < 100 && ui.mobileTimeline && ui.focus === 0;
1309
+ if (ui.orchestratorDetail || ui.workflowVerbose || narrowTimeline) {
1310
+ if (narrowTimeline) ui.detailScroll = Math.max(0, ui.detailScroll - delta);
1311
+ else ui.detailScroll = Math.max(0, ui.detailScroll + delta);
1303
1312
  return paint();
1304
1313
  }
1305
1314
  if (ui.focus === 0) {
@@ -1396,6 +1405,14 @@ export async function runDashboard(bullswarmDir, {
1396
1405
  } else message = 'This workflow has no autonomous orchestrator thread.';
1397
1406
  return paint();
1398
1407
  }
1408
+ if (key === 't' && detail && output.columns < 100 && !ui.orchestratorDetail && !ui.workflowVerbose) {
1409
+ ui.mobileTimeline = !ui.mobileTimeline;
1410
+ ui.focus = 0;
1411
+ ui.controlSelected = false;
1412
+ ui.detailScroll = 0;
1413
+ message = null;
1414
+ return paint();
1415
+ }
1399
1416
  if (key === '1' && detail) { ui.focus = 0; return paint(); }
1400
1417
  if (key === '2' && detail) { ui.focus = 1; return paint(); }
1401
1418
  if (key === '3' && detail) { ui.focus = 2; return paint(); }
@@ -57,8 +57,15 @@ export function looksLikeReviewPath(value) {
57
57
 
58
58
  export function normalizeDecisionProposal(proposal) {
59
59
  if (!proposal || typeof proposal !== 'object' || !Array.isArray(proposal.actions)) return proposal;
60
+ // An action-bearing "proceed" is unambiguously an executable program. The
61
+ // schema calls that `needs_more_work`; repairing the representation here
62
+ // avoids an otherwise identical frontier-model correction turn.
63
+ const decision = proposal.decision === 'proceed' && proposal.actions.length > 0
64
+ ? 'needs_more_work'
65
+ : proposal.decision;
60
66
  return {
61
67
  ...proposal,
68
+ decision,
62
69
  actions: proposal.actions.map((action) => {
63
70
  if (action?.type === 'fanout' && !Array.isArray(action.items) && looksLikeItemsFromPath(action.itemsFrom)) {
64
71
  // A fanout fed by an artifact implicitly depends on the producer.
@@ -187,7 +194,7 @@ export function validateDecisionProposal(proposal, {
187
194
  if (action.effort != null && !['high', 'medium', 'low'].includes(action.effort)) {
188
195
  issues.push(`${at}.effort must be high|medium|low`);
189
196
  }
190
- for (const runtimeOwned of ['pool', 'addDir', 'taskFile']) {
197
+ for (const runtimeOwned of ['pool', 'preferredPool', 'model', 'addDir', 'taskFile']) {
191
198
  if (action[runtimeOwned] != null) issues.push(`${at}.${runtimeOwned} is runtime-owned and cannot be proposed by a planner`);
192
199
  }
193
200
  if (action.type === 'run' && typeof action.prompt !== 'string') {
@@ -220,7 +227,7 @@ export function validateDecisionProposal(proposal, {
220
227
  }
221
228
  }
222
229
  if (!action.stepTemplate || typeof action.stepTemplate !== 'object') issues.push(`${at}.stepTemplate is required`);
223
- for (const runtimeOwned of ['pool', 'addDir', 'taskFile']) {
230
+ for (const runtimeOwned of ['pool', 'preferredPool', 'model', 'addDir', 'taskFile']) {
224
231
  if (action.stepTemplate?.[runtimeOwned] != null) {
225
232
  issues.push(`${at}.stepTemplate.${runtimeOwned} is runtime-owned and cannot be proposed by a planner`);
226
233
  }
@@ -248,7 +255,7 @@ export function validateDecisionProposal(proposal, {
248
255
  if (repair.effort != null && !['high', 'medium', 'low'].includes(repair.effort)) {
249
256
  issues.push(`${at}.repair.effort must be high|medium|low`);
250
257
  }
251
- for (const runtimeOwned of ['pool', 'addDir', 'taskFile']) {
258
+ for (const runtimeOwned of ['pool', 'preferredPool', 'model', 'addDir', 'taskFile']) {
252
259
  if (repair[runtimeOwned] != null) issues.push(`${at}.repair.${runtimeOwned} is runtime-owned and cannot be proposed by a planner`);
253
260
  }
254
261
  }
@@ -6,27 +6,28 @@
6
6
  import { resolve } from 'node:path';
7
7
 
8
8
  const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
9
+ const MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:/~-]*$/;
9
10
 
10
11
  export const PLANNER_RULES_SECTION = [
11
12
  '1. Compile the whole program in one decision: the runtime runs every proposed action and consults you only at a finished-or-blocked boundary, so deferred work costs a round trip.',
12
- '2. Make every worker prompt self-contained: include the exact goal, absolute cwd, the owned files you assign (never an and/or choice, which blocks a sibling) and a no-other-files boundary, expected artifact, acceptance command and report format: workers see only their own prompt.',
13
+ '2. Make every worker prompt self-contained: include the exact goal, absolute cwd, the owned files you assign (exactly one owner per file, including any existing test the change breaks; never an and/or choice, which blocks a sibling) and a no-other-files boundary, expected artifact, acceptance command and report format: workers see only their own prompt.',
13
14
  '3. A phase is a pipeline stage: one kebab-case name shared by its actions (implement, verify), never one per action; phases are forward-only, so recovery opens a new one and never repeats an identical failed plan. Wall-clock is the longest dependsOn chain, so depend only on real data or same-file ordering: a worker depends on the run that wrote its input files, never on that run\'s verify (a verdict is not data), so it starts as that verify runs.',
14
- '4. Split to the width the tree allows: each file-disjoint unit (module, test file, doc) is its own concurrent run plus its own verify depending only on that run, then one suite verify depending on all; one worker for N independent files is N chains in series. A verify judges the artifact in review (default: its last dependency; none: the repository).',
15
+ '4. Split only when parallel time saved repays dispatch cost. Give substantial file-disjoint units concurrent workers and focused verifies, but batch cheap homogeneous edits into one worker owning all named files and one final verify; never pay one worker plus verifier per tiny file. A verify judges its review artifact (default: last dependency; none: repository).',
15
16
  '5. For unknown items, create discovery ending with RETURN ONLY a JSON object containing an items array, then data-driven fan-out via itemsFrom outputs.<id>.outFile or outputs.<id>.data.<field>; the runtime extracts the list, retrying once read-only if needed.',
16
17
  '6. Put outputSchema only on a worker whose object a LATER action reads via itemsFrom or outputs.<id>.data.<field>, and tell it to RETURN ONLY the object; a prose report or any answer with fenced JSON gets no schema: the runtime parses the last {...} of the text, so a schema on prose costs a retry and a planner turn.',
17
- '7. Put verify.repair on every verify. A verify checks the goal\'s own acceptance criteria at its point in the graph: later-scheduled work is not a defect, cosmetic mismatches are concerns, and never add a process rule the goal does not state (append-only, tests untouched); when the implementation changes what an existing assertion pins, a worker must own updating it. ok:false is repaired and re-checked inside the program; the repair edits files and cannot rewrite the answer under review, so reject only what a file edit can fix and report a wrong claim as a concern with the true value; ok:true is accepted and its concerns are informational.',
18
+ '7. Put verify.repair on every verify. ok:false means unusable: the goal\'s acceptance command fails, a deliverable is missing, or the answer is nonsense; everything else is a concern under ok:true (style, cosmetic mismatches, later-scheduled work, files other actions changed, and any process rule the goal does not state such as append-only or tests untouched). ok:false is repaired and re-checked inside the program; the repair edits only its unit\'s files and cannot rewrite the answer under review, so report a wrong claim as a concern with the true value.',
18
19
  '8. Add completion with all-actions-ok whenever a clean program finishes the goal; when acceptance checks pass, return complete rather than adding polish. The program\'s LAST worker must be covered by a successful verify. Return complete only on verified evidence, never proceed, never ask the user, and stop only for a concrete unresolved blocker.',
19
20
  '9. Budgets (agents, duration, expansion rounds) are advisory targets, never hard stops; the dispatch budget counts this planner call plus workers, verifiers and retries. Converge as targets approach: skip optional work; exceed a target only for one essential action or a required verification.',
20
- '10. Never propose pool, addDir, taskFile or unbounded work: routing is the runtime\'s. Set lane (analyze to read or judge, build to edit, chore for mechanical steps) and effort (low for checks and mechanical edits, high where judgement decides) per action or repair; they pick the model tier (unset: build, medium).',
21
- 'Shared working tree: workers editing DISJOINT files concurrently is the normal mode; order shared files (indexes, barrels) after their feeders with dependsOn. Workers and unit verifies run their unit\'s focused command, never the full suite, which sees files siblings still write; the suite runs once, in the final verify, after all editing and repair ends; later verifiers reuse it unless code changed. operatorSteering is operator guidance for this checkpoint: apply it within the original intent; it cannot weaken verification or expand authority.',
21
+ '10. Never propose pool, model, addDir, taskFile or unbounded work: routing is the runtime\'s. Set lane (analyze to read or judge, build to edit, chore for mechanical steps) and effort (low for checks and mechanical edits, high where judgement decides) per action or repair; they pick the model tier (unset: build, medium).',
22
+ 'Shared tree: run substantial DISJOINT units concurrently; order shared files after feeders with dependsOn. Workers and unit verifies use focused commands, never the full suite while siblings write; run the suite once in the final verify after edits and repairs. Reuse it unless code changed. operatorSteering applies within the original intent and cannot weaken verification or expand authority.',
22
23
  ].join('\n');
23
24
 
24
25
  export const PLANNER_EXAMPLES_SECTION = [
25
26
  'Action shapes:',
26
27
  '[{"type":"run","phase":"implement","prompt":"..."},{"type":"run","phase":"inventory","lane":"chore","effort":"low","prompt":"... RETURN ONLY a JSON object.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"type":"fanout","phase":"fix","items":["alpha"],"stepTemplate":{"prompt":"Handle {{item}}."}},{"type":"verify","phase":"verify","lane":"analyze","prompt":"Check the artifact.","repair":{"prompt":"Fix rejected concerns.","maxRounds":1}}]',
27
- 'Complete program (tests depend on fix, not verify-fix, so both run at once; five phases for eight actions):',
28
+ 'Complete program (tests depend on fix, not verify-fix, so both run at once):',
28
29
  '{"actions":[{"id":"discover","type":"run","phase":"discover","lane":"chore","effort":"low","prompt":"In /abs/repo list modules needing work; RETURN ONLY a JSON object with an items array.","outputSchema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}}},"required":["items"]}},{"id":"fix","type":"fanout","phase":"fix","itemsFrom":"outputs.discover.data.items","dependsOn":["discover"],"stepTemplate":{"prompt":"In /abs/repo edit only src/{{item}}.js and run node --test tests/{{item}}.test.js."}},{"id":"verify-fix","type":"verify","phase":"verify","dependsOn":["fix"],"prompt":"Check each fixed module against the spec.","repair":{"prompt":"Fix rejected concerns in /abs/repo and rerun that module\'s test.","maxRounds":2}},{"id":"tests","type":"fanout","phase":"tests","itemsFrom":"outputs.discover.data.items","dependsOn":["fix"],"stepTemplate":{"prompt":"In /abs/repo write only tests/{{item}}.guards.test.js and run node --test on it."}},{"id":"verify-tests","type":"verify","phase":"verify","dependsOn":["tests"],"prompt":"Check the new tests are non-vacuous.","repair":{"prompt":"Fix rejected tests in /abs/repo.","maxRounds":1}},{"id":"verify-suite","type":"verify","phase":"verify","dependsOn":["verify-fix","verify-tests"],"effort":"low","prompt":"Run npm test in /abs/repo.","repair":{"prompt":"Fix the suite failure in /abs/repo and rerun it.","maxRounds":1}},{"id":"report","type":"run","phase":"report","lane":"chore","effort":"low","dependsOn":["verify-suite"],"prompt":"In /abs/repo list each changed file with a reason and quote the suite tail; plain markdown."},{"id":"verify-report","type":"verify","phase":"report","dependsOn":["report"],"prompt":"Check each claim against git status and a fresh suite run; a wrong number is a concern with the true value.","repair":{"prompt":"Fix any real repository defect in /abs/repo.","maxRounds":1}}],"completion":{"when":"all-actions-ok","reason":"Fix, tests, suite and report are each verified."}}',
29
- 'Rules the validator enforces: action type is run, fanout, or verify; fanout has stepTemplate and either items or itemsFrom; verify.review, when given, is outputs.<id>.outFile; dependsOn names existing or proposed actions; lane is analyze|build|chore and effort is low|medium|high; runtime-owned fields are rejected.',
30
+ 'Rules the validator enforces: action type is run, fanout, or verify; fanout has stepTemplate and either items or itemsFrom; verify.review, when given, is outputs.<id>.outFile; ids are unique across the whole run, finished and failed actions included; dependsOn names existing or proposed actions; lane is analyze|build|chore and effort is low|medium|high; runtime-owned fields are rejected.',
30
31
  ].join('\n');
31
32
 
32
33
  export const AUTONOMOUS_ORCHESTRATOR_PROMPT = [
@@ -78,6 +79,10 @@ export function buildGoalWorkflow({
78
79
  goal,
79
80
  cwd = process.cwd(),
80
81
  orchestrator = null,
82
+ strictOrchestrator = false,
83
+ orchestratorModel = null,
84
+ workerPool = null,
85
+ workerModel = null,
81
86
  name = null,
82
87
  settings = {},
83
88
  scout = true,
@@ -89,6 +94,17 @@ export function buildGoalWorkflow({
89
94
  if (orchestrator != null && (typeof orchestrator !== 'string' || !NAME_RE.test(orchestrator))) {
90
95
  throw new Error(`invalid orchestrator pool "${orchestrator}"`);
91
96
  }
97
+ if (typeof strictOrchestrator !== 'boolean') {
98
+ throw new Error('strictOrchestrator must be a boolean');
99
+ }
100
+ if (workerPool != null && (typeof workerPool !== 'string' || !NAME_RE.test(workerPool))) {
101
+ throw new Error(`invalid worker pool "${workerPool}"`);
102
+ }
103
+ for (const [label, model] of [['orchestrator', orchestratorModel], ['worker', workerModel]]) {
104
+ if (model != null && (typeof model !== 'string' || !MODEL_RE.test(model))) {
105
+ throw new Error(`invalid ${label} model "${model}"`);
106
+ }
107
+ }
92
108
 
93
109
  const targetDir = resolve(cwd);
94
110
  const workflowName = name ?? `goal-${Date.now().toString(36)}`;
@@ -120,12 +136,21 @@ export function buildGoalWorkflow({
120
136
  cwd: targetDir,
121
137
  autonomous: true,
122
138
  requestedOrchestrator: orchestrator ?? 'auto',
139
+ requestedOrchestratorModel: orchestratorModel ?? 'auto',
140
+ requestedWorkerPool: workerPool ?? 'auto',
141
+ requestedWorkerModel: workerModel ?? 'auto',
123
142
  worktreeIsolation,
124
143
  },
125
144
  orchestration: {
126
145
  mode: 'autonomous',
127
146
  requestedPool: orchestrator ?? null,
128
- selection: orchestrator ? 'user-pinned-for-testing' : 'capability-strategy-and-quota',
147
+ requestedModel: orchestratorModel ?? null,
148
+ workerPool: workerPool ?? null,
149
+ workerModel: workerModel ?? null,
150
+ strictPool: orchestrator && strictOrchestrator ? orchestrator : null,
151
+ selection: orchestrator
152
+ ? (strictOrchestrator ? 'user-strict-for-testing' : 'user-preferred-with-fallback')
153
+ : 'capability-strategy-and-quota',
129
154
  completionPolicy: {
130
155
  requireSuccessfulWorker: true,
131
156
  requireSuccessfulVerification: true,
@@ -159,16 +184,23 @@ export function buildGoalWorkflow({
159
184
  id: 'scout',
160
185
  type: 'run',
161
186
  lane: 'analyze',
187
+ ...(workerPool ? { pool: workerPool } : {}),
188
+ ...(workerModel ? { model: workerModel } : {}),
162
189
  addDir: targetDir,
163
190
  prompt: scoutPrompt('{{inputs.goal}}', targetDir),
164
191
  }] : []), {
165
192
  id: 'orchestrator',
166
193
  type: 'decide',
167
- ...(orchestrator ? { pool: orchestrator } : {}),
194
+ ...(orchestrator
195
+ ? (strictOrchestrator ? { pool: orchestrator } : { preferredPool: orchestrator })
196
+ : {}),
197
+ ...(orchestratorModel ? { model: orchestratorModel } : {}),
168
198
  lane: 'analyze',
169
199
  requiresCapabilities: ['strong-analysis', 'workflow-planning'],
170
200
  addDir: targetDir,
171
201
  actionDefaults: {
202
+ ...(workerPool ? { pool: workerPool } : {}),
203
+ ...(workerModel ? { model: workerModel } : {}),
172
204
  lane: 'build',
173
205
  requiresCapabilities: ['code-reading', 'file-editing'],
174
206
  addDir: targetDir,
@@ -18,6 +18,28 @@ export function buildWorkflowResult({ state, report, runId, shortId, ongoing })
18
18
  const artifact = readDelivery(outFile, deliveryOutput);
19
19
  const verificationAction = selectVerificationAction(ledger, deliveryAction?.id);
20
20
  const verificationOutput = verificationAction ? state?.outputs?.[verificationAction.id] : null;
21
+ const deliveryActions = selectDeliveryActions(
22
+ ledger,
23
+ state?.outputs,
24
+ verificationAction,
25
+ deliveryAction,
26
+ );
27
+ const deliveryRecord = (action) => {
28
+ const attempt = selectAttempt(attempts, action.id);
29
+ const output = state?.outputs?.[action.id];
30
+ const path = output?.outFile ?? attempt?.outFile ?? null;
31
+ const content = readDelivery(path, output);
32
+ return {
33
+ actionId: action.id,
34
+ phase: action.phase ?? null,
35
+ kind: action.kind ?? null,
36
+ outFile: path,
37
+ format: content.format,
38
+ content: content.content,
39
+ truncated: content.truncated,
40
+ bytes: content.bytes,
41
+ };
42
+ };
21
43
  const finishedAttempts = attempts.filter(
22
44
  (attempt) => attempt?.finishedAt || isTerminalAttempt(attempt?.status),
23
45
  ).length;
@@ -61,6 +83,10 @@ export function buildWorkflowResult({ state, report, runId, shortId, ongoing })
61
83
  truncated: artifact.truncated,
62
84
  bytes: artifact.bytes,
63
85
  } : null,
86
+ // Backward-compatible multi-delivery view. `delivery` remains the primary
87
+ // artifact, while this frontier contains every independently completed
88
+ // worker artifact covered by the selected final verifier.
89
+ deliveries: deliveryActions.map(deliveryRecord),
64
90
  verification: verificationAction ? {
65
91
  actionId: verificationAction.id,
66
92
  outFile: verificationOutput?.outFile ?? selectAttempt(attempts, verificationAction.id)?.outFile ?? null,
@@ -76,6 +102,39 @@ export function buildWorkflowResult({ state, report, runId, shortId, ongoing })
76
102
  };
77
103
  }
78
104
 
105
+ function ancestorIds(ledger, action) {
106
+ const byId = new Map(ledger.map((entry) => [entry?.id, entry]));
107
+ const ancestors = new Set();
108
+ const pending = [...(action?.dependsOn ?? [])];
109
+ while (pending.length) {
110
+ const id = pending.pop();
111
+ if (ancestors.has(id)) continue;
112
+ ancestors.add(id);
113
+ pending.push(...(byId.get(id)?.dependsOn ?? []));
114
+ }
115
+ return ancestors;
116
+ }
117
+
118
+ function selectDeliveryActions(ledger, outputs, verificationAction, primary) {
119
+ if (!primary) return [];
120
+ const verifiedAncestors = ancestorIds(ledger, verificationAction);
121
+ if (!verificationAction || !verifiedAncestors.has(primary.id)) return [primary];
122
+ const candidates = ledger.filter(
123
+ (action) => action?.id !== 'scout'
124
+ && action?.status === 'succeeded'
125
+ && !CONTROL_KINDS.has(action.kind)
126
+ && outputs?.[action.id]?.ok !== false
127
+ && verifiedAncestors.has(action.id),
128
+ );
129
+ const ancestorCache = new Map(candidates.map((action) => [action.id, ancestorIds(ledger, action)]));
130
+ const frontier = candidates.filter(
131
+ (action) => !candidates.some(
132
+ (other) => other.id !== action.id && ancestorCache.get(other.id)?.has(action.id),
133
+ ),
134
+ );
135
+ return frontier.length ? frontier : [primary];
136
+ }
137
+
79
138
  function selectDeliveryAction(ledger, outputs, preferredActionId) {
80
139
  const succeeded = ledger.filter(
81
140
  (action) => action?.status === 'succeeded'
@@ -90,8 +149,26 @@ function selectVerificationAction(ledger, deliveryId) {
90
149
  const verifications = ledger.filter(
91
150
  (action) => action?.status === 'succeeded' && action.kind === 'verify',
92
151
  );
152
+ const byId = new Map(ledger.map((action) => [action?.id, action]));
153
+ const coversDelivery = (action) => {
154
+ if (!deliveryId) return false;
155
+ const pending = [...(action?.dependsOn ?? [])];
156
+ const seen = new Set();
157
+ while (pending.length) {
158
+ const id = pending.pop();
159
+ if (id === deliveryId) return true;
160
+ if (seen.has(id)) continue;
161
+ seen.add(id);
162
+ pending.push(...(byId.get(id)?.dependsOn ?? []));
163
+ }
164
+ return false;
165
+ };
166
+ // Prefer the latest successful verifier that transitively covers the
167
+ // delivery. A final suite verify commonly depends on unit verifiers rather
168
+ // than directly on each worker; selecting only a direct edge hid the
169
+ // strongest acceptance evidence in the stable caller envelope.
93
170
  return [...verifications].reverse().find(
94
- (action) => deliveryId && Array.isArray(action.dependsOn) && action.dependsOn.includes(deliveryId),
171
+ coversDelivery,
95
172
  ) ?? [...verifications].reverse()[0] ?? null;
96
173
  }
97
174
 
@@ -677,6 +677,8 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
677
677
  type: 'run',
678
678
  phase: action.phase,
679
679
  ...(action.lane != null ? { lane: action.lane } : {}),
680
+ ...(action.pool != null ? { pool: action.pool } : {}),
681
+ ...(action.model != null ? { model: action.model } : {}),
680
682
  ...(action.addDir != null ? { addDir: action.addDir } : {}),
681
683
  ...(action.timeoutSec != null ? { timeoutSec: action.timeoutSec } : {}),
682
684
  dependsOn: producerId ? [producerId] : [],
@@ -724,6 +726,8 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
724
726
  type: 'run',
725
727
  phase: action.phase,
726
728
  ...(action.lane != null ? { lane: action.lane } : {}),
729
+ ...(action.pool != null ? { pool: action.pool } : {}),
730
+ ...(action.model != null ? { model: action.model } : {}),
727
731
  ...(action.addDir != null ? { addDir: action.addDir } : {}),
728
732
  ...(action.timeoutSec != null ? { timeoutSec: action.timeoutSec } : {}),
729
733
  ...(action.repair.effort != null ? { effort: action.repair.effort } : {}),
@@ -736,7 +740,7 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
736
740
  typeof verdict.summary === 'string' && verdict.summary.trim() ? `: ${verdict.summary.trim()}` : '.'}`,
737
741
  ...(concerns.length ? ['Concerns to resolve (verbatim from the verifier):', ...concerns.map((entry) => `- ${entry}`)] : []),
738
742
  '',
739
- `Repair round ${round} of ${maxRounds}. Resolve every concern above, re-run the acceptance command yourself, and report exactly what changed with evidence.`,
743
+ `Repair round ${round} of ${maxRounds}. This is a shared working tree: edit only the files the reviewed work owns; a concern about files outside them is not yours to resolve, and never revert, checkout or delete other actions' changes. Resolve the concerns above that a file edit inside the reviewed work can fix, re-run the acceptance command yourself, and report exactly what changed with evidence.`,
740
744
  ].join('\n'),
741
745
  };
742
746
  runtime.emit('action.repair_started', { verifyId: action.id, repairId, round, maxRounds, concerns });
@@ -949,7 +953,16 @@ async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
949
953
  rejection = { why: planner.why, issues: [planner.parseError ?? planner.why] };
950
954
  } else {
951
955
  try {
952
- proposal = validateDecisionProposal(normalizeDecisionProposal(planner.proposal), {
956
+ const normalizedProposal = normalizeDecisionProposal(planner.proposal);
957
+ if (normalizedProposal?.decision !== planner.proposal?.decision) {
958
+ runtime.emit('decision.normalized', {
959
+ gateId: gate.id,
960
+ from: planner.proposal?.decision ?? null,
961
+ to: normalizedProposal?.decision ?? null,
962
+ reason: 'action-bearing proceed is an executable needs_more_work program',
963
+ });
964
+ }
965
+ proposal = validateDecisionProposal(normalizedProposal, {
953
966
  knownActionIds: (state.plan?.actions ?? []).map((action) => action.id),
954
967
  closedPhases: (state.plan?.actions ?? [])
955
968
  .filter((action) => action.source === 'planner')
@@ -35,7 +35,7 @@ import { DECISION_SCHEMA_VERSION, parseDecisionText } from './decision.js';
35
35
  import { aggregateUsage } from '../lib/usage.js';
36
36
  import { classifyAgentProgress, recordAgentAction } from '../lib/agent-events.js';
37
37
  import { deliverSteering } from './steering.js';
38
- import { resolveDispatchModel } from '../lib/strategy.js';
38
+ import { isModelExcluded, resolveDispatchModel } from '../lib/strategy.js';
39
39
  import { getMeterReading } from '../meters/registry.js';
40
40
  import { validateAgainstSchema } from './schema.js';
41
41
  import { AUTONOMOUS_ORCHESTRATOR_PROMPT } from './goal.js';
@@ -302,7 +302,9 @@ export class WorkflowRuntime {
302
302
  callerSession: false, // workflow context: every pool is a worker
303
303
  now: Date.now(),
304
304
  requiredCapabilities: step.requiresCapabilities ?? [],
305
- preferredPool: assignment?.pool ?? null,
305
+ // A goal-level user preference wins while it remains eligible, but
306
+ // unlike step.pool it never removes healthy fallback candidates.
307
+ preferredPool: step.preferredPool ?? assignment?.pool ?? null,
306
308
  effortTier,
307
309
  });
308
310
  if (!route.pick) {
@@ -313,6 +315,8 @@ export class WorkflowRuntime {
313
315
  keepOnClaude: false,
314
316
  why: stillGated.length
315
317
  ? `no eligible pool: every candidate is burst-gated (${WorkflowRuntime.describeBurstGate(stillGated)}) and the wait for the window expired`
318
+ : step.model != null
319
+ ? `no eligible pool can guarantee requested model ${step.model}${step.pool ? ` on pool ${step.pool}` : ''}`
316
320
  : `no eligible pool (${route.why})`,
317
321
  pick: { pool: null },
318
322
  meta: { exitCode: null },
@@ -325,7 +329,7 @@ export class WorkflowRuntime {
325
329
  }
326
330
  const poolView = route.pick.connector;
327
331
  const connector = poolView?.connector ?? poolView;
328
- const selectedModel = poolView?.modelPolicy?.model
332
+ const selectedModel = step.model ?? poolView?.modelPolicy?.model
329
333
  ?? (assignment?.pool === connector.name && !poolView?.strategyExcludedModels?.includes(assignment.model)
330
334
  ? assignment.model : null);
331
335
  const runtimeConnector = {
@@ -718,10 +722,34 @@ export class WorkflowRuntime {
718
722
  (p.capabilities ?? p.connector?.capabilities ?? []).includes(capability)),
719
723
  ).map((pool) => {
720
724
  const assignment = pool.strategyAssignments?.[effortTier] ?? null;
721
- const modelPolicy = resolveDispatchModel(pool.connector ?? pool, effortTier, {
725
+ const connector = pool.connector ?? pool;
726
+ const excludedModels = pool.strategyExcludedModels ?? [];
727
+ let modelPolicy = resolveDispatchModel(connector, effortTier, {
722
728
  assignment,
723
- excludedModels: pool.strategyExcludedModels ?? [],
729
+ excludedModels,
724
730
  });
731
+ if (step.model != null) {
732
+ const requested = String(step.model).trim();
733
+ const configuredIndex = connector.spawn?.cmd?.indexOf('--model') ?? -1;
734
+ const configured = connector.model
735
+ ?? (configuredIndex >= 0 ? connector.spawn.cmd[configuredIndex + 1] ?? null : null);
736
+ const known = new Set([...(connector.knownModels ?? []), configured].filter(Boolean));
737
+ const selectable = Boolean(connector.modelSelection?.flag);
738
+ if (isModelExcluded(requested, excludedModels)) {
739
+ modelPolicy = { eligible: false, model: null, source: 'step-model-lock', reason: `requested model ${requested} is excluded` };
740
+ } else if (requested === configured || (selectable && (known.size === 0 || known.has(requested)))) {
741
+ modelPolicy = { eligible: true, model: requested, source: 'step-model-lock' };
742
+ } else {
743
+ modelPolicy = {
744
+ eligible: false,
745
+ model: null,
746
+ source: 'step-model-lock',
747
+ reason: selectable
748
+ ? `connector ${connector.name} does not advertise requested model ${requested}`
749
+ : `connector ${connector.name} cannot select requested model ${requested}`,
750
+ };
751
+ }
752
+ }
725
753
  return { ...pool, modelPolicy };
726
754
  }).filter((pool) => pool.modelPolicy.eligible);
727
755
  }
@@ -778,9 +806,19 @@ export class WorkflowRuntime {
778
806
  if (this.state.cancelRequested) return true;
779
807
  try { return JSON.parse(readFileSync(join(this.runDir, 'state.json'), 'utf8')).cancelRequested === true; } catch { return false; }
780
808
  };
809
+ const heartbeat = () => {
810
+ this.state.runner ??= {};
811
+ this.state.runner.lastHeartbeatAt = new Date().toISOString();
812
+ this.persist();
813
+ };
781
814
  let lifted = false;
782
815
  while (Date.now() < deadline && !cancelled()) {
783
- await new Promise((resolve) => setTimeout(resolve, Math.min(this.quotaPollMs, Math.max(1, deadline - Date.now()))));
816
+ heartbeat();
817
+ // Keep the durable owner heartbeat comfortably inside the 90-second
818
+ // reconciliation grace and make cooperative cancellation responsive,
819
+ // even when provider meter polling is configured less frequently.
820
+ const waitMs = Math.min(this.quotaPollMs, 30_000, Math.max(1, deadline - Date.now()));
821
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
784
822
  for (const gatedView of gated) {
785
823
  // preparePools hands out copies; the gate lives on the pool itself.
786
824
  const pool = this.pools.find((p) => p.name === gatedView.name) ?? gatedView;
@@ -797,6 +835,7 @@ export class WorkflowRuntime {
797
835
  pool.pace = reading.pacing.surplus ?? pool.pace;
798
836
  }
799
837
  }
838
+ heartbeat();
800
839
  gated = gatedOnly();
801
840
  if (!gated) { lifted = true; break; }
802
841
  }
@@ -1096,12 +1135,14 @@ export class WorkflowRuntime {
1096
1135
  ...(Array.isArray(reverify.concerns) && reverify.concerns.length
1097
1136
  ? ['Concerns you raised (verbatim):', ...reverify.concerns.map((entry) => `- ${entry}`)] : []),
1098
1137
  ...(reverify.repairExcerpt ? ['The repair reported:', reverify.repairExcerpt] : []),
1099
- 'Return ok:false ONLY if a listed concern is still unresolved or the repair introduced a regression in the acceptance checks. Anything you notice now that was already true before the repair goes in concerns as informational and never makes ok false: the first verdict was the moment to raise it.',
1138
+ 'Return ok:false ONLY if the work is still unusable by the acceptance standard below: a listed concern that was itself an acceptance failure is unresolved, or the repair broke the acceptance checks. A listed concern the repair chose not to address stays a concern; anything you notice now that was already true before the repair goes in concerns as informational and never makes ok false: the first verdict was the moment to raise it.',
1100
1139
  ] : []),
1101
1140
  '',
1141
+ 'Acceptance standard (runtime-owned; it overrides any stricter rule in the instructions above): ok:false means the work is unusable: its acceptance command fails, a required deliverable is missing, or the answer is nonsense or contradicts its own evidence. Everything else goes in concerns under ok:true: style, wording, scope, cosmetic mismatches, process rules the goal never stated (append-only, diff size), and files changed by other actions that share this working tree, which are never this unit\'s defect. Never reject for something the goal does not require.',
1142
+ '',
1102
1143
  'RETURN ONLY a single JSON object of the form',
1103
1144
  '{"ok": <true|false>, "concerns": [<string>...], "summary": <string>}.',
1104
- 'No prose and no markdown fences. Set ok:true only when the requested checks actually pass.',
1145
+ 'No prose and no markdown fences. Set ok by the acceptance standard above: true unless the work is unusable.',
1105
1146
  ].join('\n');
1106
1147
  // Only the reviewer INSTRUCTIONS are a template. The review target is a
1107
1148
  // worker's artifact — arbitrary text that routinely contains code, JSDoc
@@ -140,6 +140,10 @@ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
140
140
  collect(issues, validPools.has(step.pool),
141
141
  `${sat}.pool "${step.pool}" is not a known pool (${[...validPools].join(', ') || 'none discovered'})`);
142
142
  }
143
+ if (step.preferredPool != null) {
144
+ collect(issues, validPools.has(step.preferredPool),
145
+ `${sat}.preferredPool "${step.preferredPool}" is not a known pool (${[...validPools].join(', ') || 'none discovered'})`);
146
+ }
143
147
  if (step.requiresCapabilities != null) {
144
148
  collect(issues, Array.isArray(step.requiresCapabilities) &&
145
149
  step.requiresCapabilities.length > 0 &&
@@ -203,7 +207,7 @@ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
203
207
  if (step.actionDefaults != null) {
204
208
  collect(issues, step.actionDefaults && typeof step.actionDefaults === 'object' && !Array.isArray(step.actionDefaults),
205
209
  `${sat}.actionDefaults must be an object`);
206
- const allowedDefaults = new Set(['pool', 'lane', 'effort', 'requiresCapabilities', 'addDir', 'timeoutSec']);
210
+ const allowedDefaults = new Set(['pool', 'model', 'lane', 'effort', 'requiresCapabilities', 'addDir', 'timeoutSec']);
207
211
  for (const key of Object.keys(step.actionDefaults ?? {})) {
208
212
  collect(issues, allowedDefaults.has(key), `${sat}.actionDefaults.${key} is not runtime-controlled metadata`);
209
213
  }
@@ -211,6 +215,10 @@ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
211
215
  collect(issues, validPools.has(step.actionDefaults.pool),
212
216
  `${sat}.actionDefaults.pool "${step.actionDefaults.pool}" is not a known pool`);
213
217
  }
218
+ if (step.actionDefaults?.model != null) {
219
+ collect(issues, typeof step.actionDefaults.model === 'string' && step.actionDefaults.model.trim().length > 0,
220
+ `${sat}.actionDefaults.model must be a non-empty model identifier`);
221
+ }
214
222
  if (step.actionDefaults?.lane != null) {
215
223
  collect(issues, validLanes.has(step.actionDefaults.lane),
216
224
  `${sat}.actionDefaults.lane "${step.actionDefaults.lane}" is not a lane`);
@@ -241,6 +249,10 @@ export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
241
249
  collect(issues, Number.isFinite(step.timeoutSec) && step.timeoutSec > 0,
242
250
  `${sat}.timeoutSec must be a positive number`);
243
251
  }
252
+ if (step.model != null) {
253
+ collect(issues, typeof step.model === 'string' && step.model.trim().length > 0,
254
+ `${sat}.model must be a non-empty model identifier`);
255
+ }
244
256
  });
245
257
  });
246
258