bullswarm 0.11.1 → 0.12.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,5 +1,102 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.12.1 — a burst-gated provider is waited for, never failed on the spot
4
+
5
+ - `workflow` runs no longer die with `no eligible pool` when every candidate
6
+ pool is burst-gated (provider 5-hour window ≥ 90 % used). The runtime parks
7
+ the dispatch in a new `waiting_for_quota` stage (`state.quotaWait` names the
8
+ pool, its 5h usage and reset time; events `dispatch.waiting_for_quota`,
9
+ `dispatch.quota_available`, `dispatch.quota_wait_expired`), re-reads the
10
+ provider meter every 60 s, and continues the moment the gate lifts. It gives
11
+ up — with the pool, usage and reset time in the failure reason — only after
12
+ the known reset time plus 10 min of grace (5 h when no reset time is known).
13
+ The planner's context is composed after the wait, so it never sees an empty
14
+ pool list. Observed 2026-08-28 19:09 Z: the first 0.12.0 comparison run
15
+ failed in 4 s because the account's Claude 5h window read 91 % (reset
16
+ 22:30 Z); Claude Code in the same situation waits on the rate limit.
17
+ Options for embedding callers/tests: `quotaPollMs`, `quotaWaitGraceMs`,
18
+ `quotaWaitUnknownResetMs` on `runWorkflow`; `readMeter` injection.
19
+
20
+ ## 0.12.0 — one decision is a whole program
21
+
22
+ Completes the convergence on Claude Code's dynamic-workflow mechanics
23
+ (`docs/claude-dynamic-workflow-mechanics.md` §1.11 and §4.2): the orchestrator
24
+ is positioned as the compiler of the goal into a program the runtime runs to
25
+ the end, and it is consulted again only at the program boundary (every action
26
+ finished, or the graph blocked).
27
+
28
+ - Data-driven fan-out in proposals: a planner `fanout` may carry
29
+ `itemsFrom: "outputs.<actionId>.outFile"` instead of inline `items`. The
30
+ producer becomes an implicit `dependsOn` and the runtime resolves the item
31
+ list when the producer finishes, so the planner no longer spends a round trip
32
+ waiting to see how many items discovery found. If the producer's output has
33
+ no parseable JSON array, the runtime runs ONE bounded, read-only extraction
34
+ action (`<fanoutId>-items`, `source: "runtime-extraction"`) over that output
35
+ before failing the fan-out truthfully. Resolved lists above
36
+ `maxItemsPerExpansion` fail the fan-out with the count. Events:
37
+ `action.items_resolved`, `action.items_extraction_requested`,
38
+ `action.items_extracted`.
39
+ - Repair policy on verify: `repair: { prompt, maxRounds (1–3), effort? }` on a
40
+ planner `verify`. When the verifier returns `ok:false`, the executor runs a
41
+ fix action (`<verifyId>-repair-<n>`, `source: "repair-policy"`) carrying the
42
+ verifier's concerns verbatim and re-runs the same verify, without a planner
43
+ turn. Dispatch or JSON-parse failures are not repaired. Events:
44
+ `action.repair_started`, `action.reverify_started`, `action.repaired`,
45
+ `action.reverify_rejected`, `action.repair_failed`.
46
+ - Fan-out artifact: a fan-out now writes `out-<id>-summary-*.md` (every item's
47
+ verdict plus an output excerpt) and records it as `outputs.<id>.outFile`, so a
48
+ verify can depend on a fan-out directly and `review` is inferred as usual.
49
+ - Fix: fan-out outputs stored the success COUNT in `ok`, so a dynamic action
50
+ depending on a fan-out could never become ready ("blocked by failed or
51
+ unresolved dependencies") and a fan-out never counted as a successful worker
52
+ for completion evidence. `ok` is now a boolean and the count moved to
53
+ `succeeded`; `fanoutSucceededCount()` reads pre-0.12 state files.
54
+ - Fix: the content gate rejected a worker whose whole answer is a JSON array
55
+ or object as an "announcement without substance", which is exactly what a
56
+ discovery step is told to return. Structured answers now pass
57
+ (`hasStructuredAnswer`).
58
+ - `parseJsonArray` prefers the trailing array, so prose containing brackets
59
+ before the list no longer poisons `itemsFrom`.
60
+ - Planner prompt: PLANNING DOCTRINE rewritten around "you are compiling the
61
+ goal into a program"; new data-driven fanout and repair skeletons; a
62
+ four-action program skeleton (discover → fanout(itemsFrom) → verify(repair)
63
+ → verify-suite); `executionConstraints.programFeatures` and
64
+ `plannerConsultedOnlyAtProgramBoundary`. The goal orchestrator prompt is
65
+ reframed the same way.
66
+ - Literal double braces in prompts no longer kill actions. Only a known root
67
+ followed by dotted identifiers (`{{item}}`, `{{outputs.<id>.outFile}}`,
68
+ `{{inputs.x}}`, `{{runId}}`, `{{wfDir}}`) is a template ref; any other
69
+ `{{…}}` text (a JSDoc type such as `{{maxLength?: number}}`, Mustache, a JS
70
+ object in a template literal) is left exactly as written by the renderer
71
+ and ignored by the validator. Observed in the 0.11.1 comparison run: a
72
+ planner-authored verify prompt containing `{{maxLength?: number}}` failed
73
+ at render time with zero attempts. Relatedly, `verify` no longer
74
+ template-renders the artifact it reviews: only the reviewer instructions
75
+ are a template; the worker's report is appended verbatim.
76
+ - Doctrine: an `ok:true` verify is accepted; its concerns are informational.
77
+ The 0.11.1 comparison run spent a whole extra program round (7 actions,
78
+ ~10 min) polishing "non-blocking" nits reported by verifiers that had
79
+ passed, which Claude's fix stage never does.
80
+ - Scout before compiling: `workflow goal` now starts with a read-only `scout`
81
+ run action (tree, manifest, test status, units of work with the files each
82
+ owns, shared files, risks; ends with a JSON array of unit names) so the
83
+ orchestrator's first program names real files and commands — the counterpart
84
+ of the inline scouting a Claude Code session does before authoring a
85
+ Workflow script. `--no-scout` skips it. A failed scout is non-fatal: the
86
+ planner still runs and sees `outputs.scout.ok=false` with the reason, and
87
+ the scout never counts as a delivery worker.
88
+ - The planner finally sees what workers said: every `outputs.<id>` in the
89
+ durable planner context carries `outputExcerpt` (up to 3 000 chars each,
90
+ 36 000 total, newest first) instead of only `ok`/`why`/`outFile`.
91
+ - `workflow goal` default `maxItemsPerExpansion` raised 8 → 24 so a
92
+ data-driven fan-out over a medium repository does not fail on the bound.
93
+ - Known limitation: `itemsFrom` removes the planner turn, not the stage
94
+ barrier. A verify that depends on a data-driven fan-out waits for all items;
95
+ per-item verify overlap on discovered items would need chained
96
+ `stepTemplate`s (not in this release). For known items keep proposing N fix
97
+ + N verify chains inline, which already overlap under the ready-set
98
+ scheduler.
99
+
3
100
  ## 0.11.1 — reliable `--watch` handoff
4
101
 
5
102
  - `workflow goal --watch` no longer races the detached child: the watcher now
@@ -149,6 +149,53 @@ multi-phase work (understand → design → implement → review), that often me
149
149
  several workflows in sequence — one per phase — so you stay in the loop between
150
150
  them."
151
151
 
152
+ ## 1.11 Upfront plan or mid-flight steering? — the answer
153
+
154
+ The question that decides how bullswarm should converge: does Claude prepare
155
+ all phases and parallelism **before** the workflow starts, or does it keep
156
+ planning **during** execution?
157
+
158
+ **Before. Entirely.** The orchestrator model writes one complete program, the
159
+ harness validates it, and then executes it without consulting the model again.
160
+ Evidence:
161
+
162
+ - **[SPEC]** the script is passed whole and parsed before any agent runs;
163
+ "Use this tool for multi-step orchestration where control flow should be
164
+ deterministic (loops, conditionals, fan-out) rather than model-driven";
165
+ "Workflows run in the background — this tool returns immediately … a
166
+ task-notification arrives when the workflow completes."
167
+ - **[OBSERVED, goal 2]** ~4 minutes of inline scouting and advisor review →
168
+ one 23 k-character script (probe → author → adversarial verify → fix-loop,
169
+ per module) → rejected on a parse error before any agent spawned → corrected
170
+ in 80 s → ~20 minutes of execution with six agents concurrent and **zero
171
+ orchestrator decisions**. The session's own words: "the harness will
172
+ re-invoke me when it completes, so polling would just burn tokens. Waiting."
173
+
174
+ What *looks* like mid-flight steering is pre-authored into the program:
175
+
176
+ 1. **Data-driven shape.** A stage's structured result (`schema`) becomes the
177
+ next stage's items: `pipeline(discovery.failures, fix, verify)`,
178
+ loop-until-dry. The author fixes the *policy*; the runtime fixes the *size*.
179
+ 2. **Repair as code.** `if (!verify.ok) { fix; verify }` bounded by a counter.
180
+ Every fix → re-verify handoff observed in the goal-2 journal was this `if`.
181
+ 3. **Budget as data.** `budget.remaining()` scales loops; the ceiling is hard.
182
+
183
+ Model-level re-planning exists only **at workflow boundaries**: "run several
184
+ in sequence — read each result before deciding the next phase"; the hybrid
185
+ "scout inline first … then call Workflow"; and edit-and-resume ("the longest
186
+ unchanged prefix of agent() calls returns cached results instantly; the first
187
+ edited/new call and everything after it runs live"). Steering means stop, edit
188
+ the program, resume — never a per-step decision.
189
+
190
+ **Consequence for bullswarm.** Converge on *Direction A — a program, not a
191
+ step list*: one planning turn produces the complete graph **plus** the
192
+ adaptation policy, the runtime executes it to completion, and the planner is
193
+ consulted again only at the boundary (complete, or next program). Do not build
194
+ a continuous mid-flight steering loop (Direction B); Claude has none inside a
195
+ workflow, and bullswarm already has `steer`, cancel and resume for the
196
+ boundary-level levers. What the runtime still lacks to express a program is
197
+ listed in §4.2 (0.12.0 scope).
198
+
152
199
  ## 2. What that looks like from the outside **[OBSERVED]**
153
200
 
154
201
  Filled from the experiment report as runs complete. Numbers here are copied
@@ -182,6 +229,8 @@ from `docs/experiments/2026-08-29-ultracode-vs-bullswarm.md`, never projected.
182
229
  | Per-agent prompt | Self-contained, plus JSON schema enforced at tool layer | Planner-authored prompt; free-text answer, content-verified by heuristics; `verify` returns JSON verdict | Partial. Schema-enforced worker output is a candidate, not adopted yet |
183
230
  | Failure handling | Loops in code; `null` on agent death | Planner replans (costly); 0.10.9 added corrective turns for invalid decisions and 0.11.0 recovers mis-shaped `verify.review` before dispatch | Improved; retry-in-code per action still absent |
184
231
  | Determinism / resume | Journal of return values; prefix cache | Durable `state.json` + `events.jsonl` + action ledger; resume skips durable outputs | Equivalent |
232
+ | Data-driven fan-out | `pipeline(discovered.items, …)` — count unknown when the script is written | Decision schema forced inline `items`; the planner spent a turn waiting for discovery | **Fixed in 0.12.0** — `itemsFrom` on proposed fan-outs + one bounded extraction retry |
233
+ | Repair loops | `while`/retry in code | Planner replanned after every failed verify | **Fixed in 0.12.0** — `verify.repair` policy runs fix → re-verify inside the executor |
185
234
  | Budget | Hard ceiling | Advisory targets (user decision) | Intentional difference |
186
235
  | Observability | Progress tree, narrator, `/workflows` | `watch` heartbeat (semantic quiet + agent-output quiet since 0.10.9), `tui`, events | Comparable |
187
236
  | Isolation | `isolation: 'worktree'` per agent | Shared `addDir`; planner-declared file ownership | Candidate |
@@ -212,6 +261,69 @@ from `docs/experiments/2026-08-29-ultracode-vs-bullswarm.md`, never projected.
212
261
  validation (so the 0.10.9 corrective turn fixes it) instead of failing a
213
262
  dispatch after a planning round trip.
214
263
 
264
+ ### 4.2 Adopted in 0.12.0 — a *program* expressible in one decision
265
+
266
+ The user's framing for this release: position the orchestrator as the
267
+ **compiler** of the goal into a workflow program; the program drives every
268
+ phase and turn; the model is consulted again only at a boundary that needs
269
+ judgement — the same division of labour Claude Code uses between the script
270
+ author and the `Workflow` runtime.
271
+
272
+ 1. **Data-driven fan-out in proposals** — shipped. A proposed `fanout` takes
273
+ `itemsFrom: "outputs.<actionId>.outFile"` (producer may be co-proposed; it
274
+ becomes an implicit `dependsOn`). The runtime resolves the list when the
275
+ producer finishes. This is Claude's `pipeline(discovery.failures, …)`: the
276
+ planner no longer spends a turn waiting to see how many items there are.
277
+ 2. **Structured worker output** — shipped in its cheap form. Discovery workers
278
+ are told to end with a JSON array; `parseJsonArray` prefers the trailing
279
+ array; the content gate accepts a bare JSON array/object as substance; and
280
+ if the output still has no array the runtime runs ONE bounded, read-only
281
+ extraction action over it (never re-running the producer, which may have
282
+ mutated files). That is the "schema retry" of Claude's `StructuredOutput`,
283
+ done as a second cheap agent instead of a tool-layer retry. A general
284
+ `outputSchema` on run actions is still open (§5).
285
+ 3. **Pre-authored repair** — shipped. `repair: { prompt, maxRounds }` on a
286
+ verify: verify-fail → `<verifyId>-repair-<n>` (concerns verbatim) →
287
+ re-verify, inside the executor. Claude's fix-loop as code.
288
+ 4. **Boundary-only consultation** — the loop is now: decision 1 = the program;
289
+ the ready-set executor runs it to completion (fan-outs resolve, repairs run);
290
+ decision 2 = `complete` or the next program. The planner prompt says so
291
+ explicitly (`plannerConsultedOnlyAtProgramBoundary`), and the goal
292
+ orchestrator prompt is reframed as "compile the goal into a complete
293
+ workflow program".
294
+ 5. **Scout, then compile** — shipped. Claude's author reads the repo inline
295
+ for ~4 min before writing the script; bullswarm's orchestrator was compiling
296
+ blind (goal text + cwd, forbidden to run commands, and the planner context
297
+ exposed no worker output text at all — only `ok`/`why`/`outFile`). `workflow
298
+ goal` now runs a read-only `scout` action first, and every output in the
299
+ planner context carries an `outputExcerpt`, so the first program is written
300
+ against a real survey and boundary decisions read what workers reported.
301
+ 6. Two bugs found on the way that had silently blocked this shape in ≤ 0.11.1:
302
+ fan-out outputs recorded the success *count* in `ok`, so nothing could ever
303
+ depend on a fan-out (the ready-set test is `ok === true`); and the content
304
+ gate rejected a worker whose whole answer was a JSON array as an
305
+ "announcement without substance".
306
+
307
+ 7. Two robustness gaps the 0.11.1 comparison run itself exposed, fixed
308
+ before 0.12.0 shipped: (a) a planner-authored verify prompt that quoted a
309
+ JSDoc type literally — `{{maxLength?: number}}` — was parsed as a template
310
+ ref and killed the action at render time with zero attempts, forcing an
311
+ extra planner turn to re-issue it. Only a known root plus dotted
312
+ identifiers is a ref now; other double-brace text is prompt content.
313
+ (Claude never has this class of bug: prompts are JS strings, the runtime
314
+ does no substitution.) (b) The new scout is a failable step ahead of the
315
+ planner; it is non-fatal by construction (`onError: continue`), the planner
316
+ sees `outputs.scout.ok=false` with the reason, and a run where only the
317
+ scout succeeded is `blocked`, never "delivered".
318
+
319
+ **Honest limitation.** `itemsFrom` removes the planner *turn*, not the stage
320
+ *barrier*: a verify depending on a data-driven fan-out waits for all items,
321
+ whereas Claude's `pipeline()` overlaps verify-B with fix-C for discovered items
322
+ too. Per-item overlap on unknown items would need a fan-out whose
323
+ `stepTemplate` is itself a chain — not in 0.12.0. For known items the planner
324
+ proposes N fix + N verify inline and the ready-set scheduler already overlaps
325
+ them.
326
+
215
327
  ## 5. Not adopted (yet), and why
216
328
 
217
329
  - **Schema-enforced worker output.** bullswarm's content verification and the
@@ -221,15 +221,116 @@ session #1; goal submitted 17:24:4x Z. Observed driving sequence:
221
221
  bullswarm 0.10.9 now has for planner decisions, except here the correction is
222
222
  an inline retry inside one turn rather than a fresh planning process.
223
223
 
224
- (execution numbers pending)
224
+ Execution numbers (from the workflow journal `wf_b235c760…/journal.jsonl` and
225
+ the session transcript; read-only, no interference):
225
226
 
226
- ### bullswarm 0.11.0 code (`node bin/bullswarm.js` from the repo checkout)
227
+ | Measure | Value |
228
+ | --- | --- |
229
+ | Goal submitted → session idle | 17:24:46 → 18:22:45 Z = **58 min 0 s** |
230
+ | Inline scouting + advisor before the Workflow call | 4 min (17:24:46 → 17:28:42) |
231
+ | Workflow call rejected (parse error) → corrected resend | 94 s (17:28:42 → 17:30:16) |
232
+ | Workflow execution | 17:30:16 → 18:19:07 = **48 min 51 s**, 24 agents, 0 errors |
233
+ | Agents by stage | 6 probe, 6 author, 9 adversarial verify, 3 fix (slugify ×2, intervals ×1) |
234
+ | Max concurrent agents / mean parallelism | **6** (= the six items; cap was 8) / 3.1 |
235
+ | Orchestrator (session) model turns during execution | **0** — the session sat in "Waiting for 1 dynamic workflow to finish" |
236
+ | Session tool calls overall | 16 Bash, 2 Workflow, 1 ToolSearch, 0 Agent |
237
+ | Post-workflow inline verification | 18:19:15 → 18:22:44 (3.5 min): own `npm test`, comment-stripped code identity diff, executing every `@example`, link check, advisor |
238
+ | Session output tokens (orchestrator only) | 91 k; workers 534 k output, 52.9 M cache-read |
239
+
240
+ How the program actually ran (agent start → end):
227
241
 
228
- (pending)
242
+ ```text
243
+ probe ×6 17:30:16 → 17:34:36 … 17:36:53 all six in flight at once
244
+ author ×6 17:34:36 → 17:41:20 … 17:46:59 each starts the second ITS probe ends (pipeline, no barrier)
245
+ verify ×6 17:41:20 → 17:45:48 … 17:52:08 each starts the second ITS author ends
246
+ fix slugify 17:46:45→17:55:10 · intervals 17:48:41→17:53:06 · slugify 18:01:34→18:11:52
247
+ re-verify intervals 17:53:07→17:58:36 · slugify 17:55:10→18:01:34 · slugify 18:11:52→18:19:07
248
+ ```
229
249
 
230
- ### bullswarm 0.10.9 (installed binary), if time allows
250
+ Four of the six modules were completely done by 17:49; the remaining 30 min of
251
+ wall time was one module's (slugify) two-round fix loop, pre-authored in the
252
+ script as `while (!verdict.ok && rounds < N)`. No planner turn was spent on
253
+ "how many items", "did the verify pass", or "repair or not" — all three were
254
+ data-driven inside the program. This is the concrete shape 0.12.0 reproduces
255
+ (`itemsFrom`, `repair`).
231
256
 
232
- (pending)
257
+ Correctness audit of `g2-claude` (mine, read-only): `npm test` 168/168 (52 → 116
258
+ new); every existing `tests/<module>.test.js` SHA unchanged; every `src/*.js`
259
+ byte-identical to the base after stripping comment/blank lines (JSDoc only);
260
+ 6 edge test files (16–24 tests each), 6 docs pages + `docs/README.md` index,
261
+ `@example` on every export. No deliverable missing.
262
+
263
+ ### bullswarm 0.11.1 (installed binary) — same goal, `g2-bs-v2`
264
+
265
+ Launched 18:23:39 Z via `run-bs-g2-v2.sh` (`--concurrency 8 --max-agents 40
266
+ --max-expansion-rounds 8`, orchestrator `claude-code`, single pool, Fable
267
+ excluded). Started only after the Claude session went idle so the two never
268
+ competed for the machine.
269
+
270
+ Run `wf-mtda5qq5-c6166f`, isolated `BULLSWARM_HOME`, single pool
271
+ `claude-code` / `claude-opus-5`, `--concurrency 8 --max-agents 40
272
+ --max-expansion-rounds 8 --foreground`, launched 18:23:42 Z on a pristine copy
273
+ of the fixture. Observed read-only from `state.json` / `events.jsonl`.
274
+
275
+ **Timeline**
276
+
277
+ | When (Z) | What happened |
278
+ | --- | --- |
279
+ | 18:23:42 → 18:27:55 | Planner turn 1 (253 s). ONE decision carrying the whole graph: **14 actions** — `module-{csv,duration,intervals,lru,semver,slugify}` (run, no deps), `verify-<module>` ×6 (each depending only on its own module), `docs-index` (depends on all six modules), `verify-suite` (depends on `docs-index` + all six verifies, `review: outputs.docs-index.outFile`). |
280
+ | 18:27:55 | All six module writers start together — **6 concurrent workers** (cap 8, six items). |
281
+ | 18:33:17 → 18:36:39 | Writers finish one by one; each `verify-<module>` starts the moment its own writer finishes (ready-set scheduler); `docs-index` starts at 18:36:40 when the last writer lands. |
282
+ | 18:35:26 | **`verify-slugify` dies with zero attempts**: `template ref "{{maxLength?: number}}" unresolved at render time`. The planner had quoted a JSDoc record type literally in the prompt; the renderer treated the double braces as a template ref. `verify-suite` is then blocked by a failed dependency. |
283
+ | 18:41 → 18:46:01 | Planner turn 2 (~5 min). It diagnosed the render-time death correctly ("two consecutive opening curly braces … treated as an unresolved reference") and proposed **7 actions**: `slugify-recheck` + `verify-slugify-2`, `polish-semver` + `verify-semver-2`, `polish-lru` + `verify-lru-2`, `final-suite`. The two `polish-*` actions react to *non-blocking* nits reported by verifiers that had **passed** (`verify-semver` and `verify-lru` were `ok:true`). |
284
+ | 18:46:01 → 19:03:06 | Remediation round runs (3 fixes → 3 re-verifies → `final-suite`), max 3 concurrent. |
285
+ | 19:03:06 → 19:04:56 | Planner turn 3 (110 s): `complete`, verified, no concerns. |
286
+
287
+ What this run settles, before the numbers: 0.11.x's planning doctrine already
288
+ produces the Claude shape — one decision = the whole graph, six writers in
289
+ parallel, per-item verify overlapping other items' writes, a final
290
+ whole-suite verify at the end. The remaining differences are (a) a runtime
291
+ robustness bug (literal braces), (b) repair happening as a *planner turn*
292
+ rather than inside the program, (c) the planner treating informational
293
+ concerns as work, and (d) no scout before the first program (the planner
294
+ compiled from the goal text alone — correctly here, because the goal names the
295
+ six modules).
296
+
297
+ **Numbers** (from `state.json`/`events.jsonl` via `metrics-bullswarm.mjs`; audit
298
+ via `audit-fixture.sh`, read-only, after the run ended)
299
+
300
+ | Measure | bullswarm 0.11.1 | Claude Code #2 (for reference) |
301
+ | --- | --- | --- |
302
+ | Goal submitted → terminal | 18:23:42 → 19:04:56 Z = **41 min 14 s** (2 474 s) | 58 min 0 s (48 min 51 s of it inside `Workflow`) |
303
+ | Planner / orchestrator time | **667 s = 27 % of wall**, 3 turns (253 s, 304 s, 110 s) | 4 min scouting + 94 s script fix before execution; **0 turns during** the 48 min 51 s execution |
304
+ | Agents dispatched | **22** (19 workers + 3 planner) | 24 workers (6 probe, 6 author, 9 verify, 3 fix) |
305
+ | Max concurrent / mean parallelism | **6** / 2.74 | 6 / 3.1 |
306
+ | Actions per decision | 14, 7, 0 | one script (24 agents) |
307
+ | Actions that died without running | 2 (`verify-slugify` render-time template ref; `verify-suite` blocked by it) | 0 |
308
+ | Remediation rounds | 1 (as a planner turn) | fix loops inside the script (3 fix agents) |
309
+ | Outcome | `completed`, verified, no concerns | done, self-verified inline |
310
+ | Tests after | **130/130** (52 existing + 78 new: 15/14/12/11/13/13 per module) | 168/168 (52 + 116 new: 24/18/19/21/… per module) |
311
+ | Existing `tests/*.test.js` | byte-identical to base (all 7) | byte-identical (all 7) |
312
+ | `src/` changes | comment-only in all 6 modules (0 non-comment line diffs) | comment-only in all 7 files |
313
+ | Deliverables | 6 edge suites, 6 docs pages, `docs/README.md` index (6 links) — all present | same set, all present |
314
+ | Tokens | 148 k *estimated* (utf8 bytes/4 — provider gave no usage; not comparable) | orchestrator 91 k output; workers 534 k output, 52.9 M cache-read (real usage) |
315
+
316
+ Two things worth noting beyond the table. (1) The run *modified the fixture
317
+ to route around the tool's bug*: the planner's remediation rewrote
318
+ `src/slugify.js`'s JSDoc from `@param {{maxLength?: number}} [options]` to the
319
+ dotted `@param {number} [options.maxLength=0]` form so that no later verify
320
+ would trip on the double braces. Comment-only, goal-compliant, but a
321
+ runtime defect leaked into the deliverable. Root cause is two-fold and both
322
+ are fixed in 0.12.0: the renderer treated any `{{…}}` as a ref, and `verify`
323
+ template-rendered the *review artifact* (a worker's report, arbitrary text)
324
+ together with its instructions. (2) The remediation round spent two of its
325
+ three fixes on "non-blocking" nits from verifiers that had returned
326
+ `ok:true`; 0.12.0's doctrine tells the planner those are informational.
327
+
328
+ ### bullswarm 0.12.0 (installed binary) — same goal, fresh copy `g2-bs-v3`
329
+
330
+ (pending — after the 0.12.0 release)
331
+
332
+ The originally planned 0.10.9 goal-2 run was dropped at the user's request
333
+ (2026-08-29): the installed latest is the only baseline that matters.
233
334
 
234
335
  ## Behaviour differences observed
235
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.11.1",
3
+ "version": "0.12.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
@@ -340,6 +340,35 @@ per-item fix→verify chains plus one whole-system verify — because each plann
340
340
  turn is a full orchestrator round trip. See
341
341
  `docs/claude-dynamic-workflow-mechanics.md` for the model this follows.
342
342
 
343
+ Since 0.12.0 a decision is meant to be a whole *program*: the runtime executes
344
+ it to the end and consults the planner again only at the program boundary
345
+ (every action finished, or the graph blocked). Two planner-level features make
346
+ that expressible without extra turns:
347
+
348
+ - `fanout.itemsFrom: "outputs.<actionId>.outFile"` — fan out over the JSON
349
+ array an earlier (or co-proposed) action ends its output with. The producer
350
+ becomes an implicit dependency and the list is resolved at execution time. A
351
+ producer that answered in prose gets one bounded read-only extraction action
352
+ (`<fanoutId>-items`, `source: "runtime-extraction"`) before the fan-out fails
353
+ truthfully.
354
+ - `verify.repair: { prompt, maxRounds }` — when the verifier returns
355
+ `ok:false`, the executor runs `<verifyId>-repair-<n>` (`source:
356
+ "repair-policy"`) with the verifier's concerns verbatim and re-runs the same
357
+ verify, up to `maxRounds` (1–3), without a planner turn.
358
+
359
+ Every fan-out records a summary artifact as `outputs.<id>.outFile` and a
360
+ boolean `ok` (item count in `succeeded`), so a verify may depend on a fan-out
361
+ directly. The planner context carries `outputs.<id>.outputExcerpt` (what each
362
+ finished action reported), and `workflow goal` runs a read-only `scout` action
363
+ first (`--no-scout` to skip) so the first program is compiled from a real
364
+ survey of the repository rather than from the goal text alone.
365
+
366
+ A burst-gated provider (5-hour window ≥ 90 % used) is a *wait*, not a
367
+ failure: the run parks in stage `waiting_for_quota` (`state.quotaWait` shows
368
+ the pool, usage and reset time), re-reads the meter every 60 s, and continues
369
+ when the window resets. Only after the reset time plus 10 min of grace does the
370
+ action fail, with the gate named in `why`.
371
+
343
372
  Allowed planner decisions are `proceed`, `complete`, `needs_more_work`,
344
373
  `retry`, `escalate`, `wait_for_approval`, and `stop`. Expansion decisions must
345
374
  contain bounded actions; malformed or over-budget output executes nothing.
package/src/help.js CHANGED
@@ -585,7 +585,8 @@ const workflowGoalText = rich({
585
585
  { flag: '--max-agents <n>', desc: 'planning target for total dispatched agents (soft, not a hard stop)', default: '30 (max 500)' },
586
586
  { flag: '--max-expansion-rounds <n>', desc: 'planning target for planner replanning rounds', default: '8 (max 50)' },
587
587
  { flag: '--max-actions <n>', desc: 'planning target for total dispatched actions', default: '40 (max 1000)' },
588
- { flag: '--max-items-per-expansion <n>', desc: 'cap on fanout items added per planner round', default: '8 (max 100)' },
588
+ { flag: '--max-items-per-expansion <n>', desc: 'cap on fanout items per planner round, inline or resolved from itemsFrom at execution time', default: '24 (max 100)' },
589
+ { flag: '--no-scout', desc: 'skip the read-only scout action that surveys the repository (tree, manifest, test status, units of work) before the orchestrator compiles its first program', default: 'scout runs first' },
589
590
  { flag: '--max-workflow-seconds <n>', desc: 'planning target for total wall-clock seconds', default: '3600 (max 86400)' },
590
591
  { flag: '--concurrency <n>', desc: 'max parallel dispatches; dependency-ready actions from one decision run concurrently up to this cap', default: '8 (max 16)' },
591
592
  { flag: '--retry-attempts <0..3>', desc: 'same-pool retries per failed action', default: '1' },
package/src/lib/verify.js CHANGED
@@ -120,6 +120,28 @@ function hasVerifyJson(text) {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * A structured answer: the output is, or ends with, a JSON array (a discovery
125
+ * step's item list, possibly empty) or is a single JSON object. Such output is
126
+ * substance by construction; the prose heuristics must not reject it as an
127
+ * announcement.
128
+ */
129
+ export function hasStructuredAnswer(text) {
130
+ const trimmed = text.trim();
131
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
132
+ try { return typeof JSON.parse(trimmed) === 'object'; } catch { /* not a single object */ }
133
+ }
134
+ const end = trimmed.lastIndexOf(']');
135
+ if (end === -1 || trimmed.slice(end + 1).trim().length > 0) return false;
136
+ for (let start = trimmed.lastIndexOf('[', end); start !== -1; start = trimmed.lastIndexOf('[', start - 1)) {
137
+ try {
138
+ if (Array.isArray(JSON.parse(trimmed.slice(start, end + 1)))) return true;
139
+ } catch { /* keep widening */ }
140
+ if (start === 0) break;
141
+ }
142
+ return false;
143
+ }
144
+
123
145
  /**
124
146
  * Judge delegate output content.
125
147
  * @param {string} text full delegate output
@@ -134,7 +156,7 @@ export function judgeContent(text, { exitCode, expectWork = true, acceptVerifyJs
134
156
  if (scanForFailure(text)) {
135
157
  return { verdict: 'fail', why: 'failure pattern at output head' };
136
158
  }
137
- if (expectWork && !looksLikeWork(text) && !(acceptVerifyJson && hasVerifyJson(text))) {
159
+ if (expectWork && !looksLikeWork(text) && !(acceptVerifyJson && hasVerifyJson(text)) && !hasStructuredAnswer(text)) {
138
160
  return { verdict: 'intent_only', why: 'announcement without substance' };
139
161
  }
140
162
  return { verdict: 'pass', why: 'content passed all gates' };
@@ -372,6 +372,7 @@ async function wfGoal(opts) {
372
372
  cwd: opts.cwd ?? process.cwd(),
373
373
  orchestrator,
374
374
  settings: goalSettings(opts),
375
+ scout: !opts.noScout,
375
376
  worktreeIsolation: loadState(BULLSWARM_DIR()).config?.worktreeIsolation ?? 'agent-decides',
376
377
  });
377
378
  } catch (err) {
@@ -622,6 +623,7 @@ function parseFlags(argv) {
622
623
  const a = argv[i];
623
624
  if (a === '--json') out.json = true;
624
625
  else if (a === '--quiet') out.quiet = true;
626
+ else if (a === '--no-scout') out.noScout = true;
625
627
  else if (a === '--resume') out.resume = argv[++i];
626
628
  else if (a === '--after') out.after = argv[++i];
627
629
  else if (a === '--input') {
@@ -2,6 +2,7 @@
2
2
  // It deliberately uses only ANSI sequences and Node's standard streams.
3
3
 
4
4
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
5
+ import { fanoutSucceededCount } from './runner.js';
5
6
  import { join } from 'node:path';
6
7
  import { listRuns, resolveRunId } from './short-id.js';
7
8
  import { appendEvent, readEvents } from './events.js';
@@ -64,7 +65,7 @@ export function dashboardRows(bullswarmDir) {
64
65
  const state = r.state ?? {};
65
66
  const steps = state.steps ?? [];
66
67
  const fanout = Object.values(state.outputs ?? {}).filter((v) => v?.items).reduce((acc, v) => ({
67
- total: acc.total + (v.total ?? 0), ok: acc.ok + (v.ok ?? 0), failed: acc.failed + (v.failed ?? 0),
68
+ total: acc.total + (v.total ?? 0), ok: acc.ok + fanoutSucceededCount(v), failed: acc.failed + (v.failed ?? 0),
68
69
  }), { total: 0, ok: 0, failed: 0 });
69
70
  return {
70
71
  ...r,
@@ -38,6 +38,14 @@ export function parseDecisionText(text) {
38
38
  // dispatch: instructions move to prompt, and a single-dependency verify reviews
39
39
  // its dependency's artifact.
40
40
  export const REVIEW_PATH_RE = /^outputs\.([A-Za-z0-9_-]+(?:\[\d+\])?)\.outFile$/;
41
+ // Data-driven fan-out source: the artifact of an earlier (or co-proposed)
42
+ // action whose output ends with a JSON array of items.
43
+ export const ITEMS_FROM_RE = /^outputs\.([A-Za-z0-9_-]+)(?:\.outFile)?$/;
44
+ export const REPAIR_MAX_ROUNDS = 3;
45
+
46
+ export function looksLikeItemsFromPath(value) {
47
+ return typeof value === 'string' && ITEMS_FROM_RE.test(value.trim());
48
+ }
41
49
 
42
50
  export function looksLikeReviewPath(value) {
43
51
  return typeof value === 'string' && REVIEW_PATH_RE.test(value.trim());
@@ -48,6 +56,17 @@ export function normalizeDecisionProposal(proposal) {
48
56
  return {
49
57
  ...proposal,
50
58
  actions: proposal.actions.map((action) => {
59
+ if (action?.type === 'fanout' && !Array.isArray(action.items) && looksLikeItemsFromPath(action.itemsFrom)) {
60
+ // A fanout fed by an artifact implicitly depends on the producer.
61
+ const itemsFrom = action.itemsFrom.trim();
62
+ const producer = ITEMS_FROM_RE.exec(itemsFrom)[1];
63
+ const dependsOn = Array.isArray(action.dependsOn) ? action.dependsOn : [];
64
+ return {
65
+ ...action,
66
+ itemsFrom,
67
+ ...(dependsOn.includes(producer) || producer === action.id ? {} : { dependsOn: [...dependsOn, producer] }),
68
+ };
69
+ }
51
70
  if (action?.type !== 'verify') return action;
52
71
  const singleDependency = Array.isArray(action.dependsOn) && action.dependsOn.length === 1
53
72
  ? action.dependsOn[0] : null;
@@ -137,8 +156,23 @@ export function validateDecisionProposal(proposal, {
137
156
  issues.push(`${at} needs a prompt`);
138
157
  }
139
158
  if (action.type === 'fanout') {
140
- if (!Array.isArray(action.items)) issues.push(`${at}.items must be an inline array`);
141
- else proposedItems += action.items.length;
159
+ const hasItems = Array.isArray(action.items);
160
+ const hasItemsFrom = action.itemsFrom != null;
161
+ if (!hasItems && !hasItemsFrom) {
162
+ issues.push(`${at}.items must be an inline array, or ${at}.itemsFrom must be "outputs.<actionId>.outFile" naming the action whose output ends with the JSON array of items`);
163
+ } else if (hasItems) {
164
+ proposedItems += action.items.length;
165
+ if (hasItemsFrom) issues.push(`${at} must use either items or itemsFrom, not both`);
166
+ }
167
+ if (hasItemsFrom && !hasItems) {
168
+ if (!looksLikeItemsFromPath(action.itemsFrom)) {
169
+ issues.push(`${at}.itemsFrom must be a dotted artifact path like "outputs.<actionId>.outFile" (the action whose output ends with a JSON array), not inline items, instructions, or a filesystem path`);
170
+ } else {
171
+ const producer = ITEMS_FROM_RE.exec(action.itemsFrom.trim())[1];
172
+ if (producer === action.id) issues.push(`${at}.itemsFrom cannot reference the fanout itself`);
173
+ else if (!known.has(producer) && !proposedIds.has(producer)) issues.push(`${at}.itemsFrom references unknown action "${producer}"`);
174
+ }
175
+ }
142
176
  if (!action.stepTemplate || typeof action.stepTemplate !== 'object') issues.push(`${at}.stepTemplate is required`);
143
177
  for (const runtimeOwned of ['pool', 'addDir', 'taskFile']) {
144
178
  if (action.stepTemplate?.[runtimeOwned] != null) {
@@ -146,6 +180,28 @@ export function validateDecisionProposal(proposal, {
146
180
  }
147
181
  }
148
182
  }
183
+ if (action.repair != null && action.type !== 'verify') {
184
+ issues.push(`${at}.repair is only valid on verify actions`);
185
+ }
186
+ if (action.type === 'verify' && action.repair != null) {
187
+ const repair = action.repair;
188
+ if (!repair || typeof repair !== 'object' || Array.isArray(repair)) {
189
+ issues.push(`${at}.repair must be an object like {"prompt":"<how to fix what the verifier rejects>","maxRounds":1}`);
190
+ } else {
191
+ if (typeof repair.prompt !== 'string' || !repair.prompt.trim()) {
192
+ issues.push(`${at}.repair.prompt must be a non-empty string telling a worker how to fix what the verifier rejected`);
193
+ }
194
+ if (repair.maxRounds != null && !(Number.isInteger(repair.maxRounds) && repair.maxRounds >= 1 && repair.maxRounds <= REPAIR_MAX_ROUNDS)) {
195
+ issues.push(`${at}.repair.maxRounds must be an integer from 1 to ${REPAIR_MAX_ROUNDS}`);
196
+ }
197
+ if (repair.effort != null && !['high', 'medium', 'low'].includes(repair.effort)) {
198
+ issues.push(`${at}.repair.effort must be high|medium|low`);
199
+ }
200
+ for (const runtimeOwned of ['pool', 'addDir', 'taskFile']) {
201
+ if (repair[runtimeOwned] != null) issues.push(`${at}.repair.${runtimeOwned} is runtime-owned and cannot be proposed by a planner`);
202
+ }
203
+ }
204
+ }
149
205
  if (action.type === 'verify') {
150
206
  if (typeof action.review !== 'string') {
151
207
  issues.push(`${at}.review is required: a verify with one dependsOn reviews that artifact automatically; with several, set review to "outputs.<actionId>.outFile" and put reviewer instructions in prompt`);