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 +97 -0
- package/docs/claude-dynamic-workflow-mechanics.md +112 -0
- package/docs/experiments/2026-08-29-ultracode-vs-bullswarm.md +106 -5
- package/package.json +1 -1
- package/skill/SKILL.md +29 -0
- package/src/help.js +2 -1
- package/src/lib/verify.js +23 -1
- package/src/workflow/cli.js +2 -0
- package/src/workflow/dashboard.js +2 -1
- package/src/workflow/decision.js +58 -2
- package/src/workflow/goal.js +35 -5
- package/src/workflow/runner.js +213 -12
- package/src/workflow/runtime.js +209 -35
- package/src/workflow/template.js +41 -9
- package/src/workflow/validate.js +10 -3
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
|
-
|
|
224
|
+
Execution numbers (from the workflow journal `wf_b235c760…/journal.jsonl` and
|
|
225
|
+
the session transcript; read-only, no interference):
|
|
225
226
|
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
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
|
|
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' };
|
package/src/workflow/cli.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/workflow/decision.js
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
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`);
|