bullswarm 0.13.0 → 0.13.2

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,32 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.13.2 — user text is never a template
4
+
5
+ - `workflow goal` failed before anything ran when the goal text quoted
6
+ something shaped like a template ref (`{{outputs.x.data.field}}` in a goal
7
+ *about* templates): the goal was spliced into the scout prompt and the
8
+ workflow validator rejected the ref as unresolvable — "autonomous workflow
9
+ invalid (nothing ran)". The goal is now a declared input (`inputs.goal`)
10
+ inserted at render time, so nothing in user text is ever parsed.
11
+ - A grammar-valid ref with nothing behind it no longer kills the action at
12
+ render time. It is left literally in the prompt and reported as
13
+ `template.unresolved_ref { actionId, ref }`; planner-authored prompts may
14
+ quote refs as text, and a worker can usually still act on the literal.
15
+ `renderTemplate(str, scope, { strict: true })` keeps the old hard failure.
16
+
17
+ ## 0.13.1 — a repaired verify counts as verification of its repair
18
+
19
+ - `completionEvidenceGaps` accepted a verify as evidence for the latest worker
20
+ only when the verify depended on that worker. The executor's repair loop
21
+ produces the reverse edge — `<verify>-repair-N` depends on `<verify>`, then
22
+ the same verify re-runs — so after a clean repair round every `complete`
23
+ was rejected with "missing a successful verification of latest worker
24
+ <verify>-repair-1", and 0.13.0's `all-actions-ok` auto-completion would
25
+ have been blocked the same way. Observed on goal-2 run `wf-mtdcghw0`
26
+ (2026-08-28): three extra planner turns and one redundant verify (~11 min)
27
+ to prove what the re-verify had already shown. A verify that ended ok:true
28
+ after its own repair action now verifies that repair.
29
+
3
30
  ## 0.13.0 — programs can complete themselves
4
31
 
5
32
  - A planner may attach `completion: { when: "all-actions-ok", reason }` to a
@@ -216,7 +216,15 @@ from `docs/experiments/2026-08-29-ultracode-vs-bullswarm.md`, never projected.
216
216
  policy and race on the barrel file." The policy it cites was a caution line
217
217
  in the planner prompt; a concurrency cap of 8 was available and unused.
218
218
  Discovery alone then ran 458 s. (Final numbers: experiment report.)
219
- - (comparison fixture final results: pending)
219
+ - Final comparison on the 6-module fixture (experiment report for the full
220
+ tables). Goal 2 (known items): Claude 58 min, 24 agents, 0 orchestrator
221
+ turns during execution, parallelism 3.1 · bullswarm 0.11.1 41 min, 3 planner
222
+ turns (27 %), max 6 concurrent, parallelism 2.74 · bullswarm 0.12.1 48 min of
223
+ execution after a 3 h quota wait it survived, 4 planner turns (two caused by
224
+ the bug fixed in 0.13.1), max 7 concurrent, one live repair round. Goal 3
225
+ (discovered items) on 0.13.1: 28 min 42 s, **one planner turn**, the runtime
226
+ recorded `complete` itself, exactly the three unguarded modules fixed,
227
+ 75/75 tests.
220
228
 
221
229
  ## 3. bullswarm today, mechanic by mechanic
222
230
 
@@ -316,7 +324,7 @@ author and the `Workflow` runtime.
316
324
  sees `outputs.scout.ok=false` with the reason, and a run where only the
317
325
  scout succeeded is `blocked`, never "delivered".
318
326
 
319
- 8. **Program-level completion** (0.13.0, unreleased at the time of writing) —
327
+ 8. **Program-level completion** (0.13.0) —
320
328
  `completion: { when: "all-actions-ok", reason }` on a program. Claude's
321
329
  script simply returns when its code is done; bullswarm still spent a final
322
330
  planner turn (110–250 s measured) to say `complete` after a clean run. Now
@@ -324,10 +332,24 @@ author and the `Workflow` runtime.
324
332
  never below the completion policy) and consults the planner only when
325
333
  something failed. With 0.12.0's repair-in-program this makes a clean run
326
334
  **one planner turn**: compile, execute, done — Claude's "0 orchestrator turns
327
- during execution" for the passing case.
335
+ during execution" for the passing case. **[OBSERVED]** goal-3 run
336
+ `wf-mtdkvx0k` (0.13.1): the planner attached the predicate on its own, the
337
+ runtime emitted `decision.auto_completed` (`source: program-completion`),
338
+ one planner process for the whole 28 min run.
328
339
  9. **Rate limits are waited for** (0.12.1): a burst-gated provider parks the
329
340
  dispatch in `waiting_for_quota` until the window resets instead of failing
330
341
  the run in 4 s, which is what the first 0.12.0 comparison launch did.
342
+ **[OBSERVED]** goal-2 run `wf-mtdcghw0`: parked at 95 % for 3 h 2 min,
343
+ dispatched 17 s after the provider reset.
344
+ 10. **A repair is verified by its verify's re-run** (0.13.1). The executor's
345
+ repair loop creates `<verify>-repair-N` *depending on* the verify, then runs
346
+ the verify again; the completion-evidence check only followed
347
+ `verify.dependsOn` and so never saw a repair as verified. **[OBSERVED]** on
348
+ `wf-mtdcghw0`: a clean `complete` rejected, three more planner turns
349
+ (~11 min) to re-prove a passed re-verify. In Claude's model this bug cannot
350
+ exist — the script's `while (!ok)` loop *is* the evidence — which is the
351
+ general lesson: every piece of control flow bullswarm moves from planner
352
+ into runtime needs its evidence rule moved with it.
331
353
 
332
354
  **Honest limitation.** `itemsFrom` removes the planner *turn*, not the stage
333
355
  *barrier*: a verify depending on a data-driven fan-out waits for all items,
@@ -325,7 +325,7 @@ together with its instructions. (2) The remediation round spent two of its
325
325
  three fixes on "non-blocking" nits from verifiers that had returned
326
326
  `ok:true`; 0.12.0's doctrine tells the planner those are informational.
327
327
 
328
- ### bullswarm 0.12.0 (installed binary) — same goal, fresh copy `g2-bs-v3`
328
+ ### bullswarm 0.12.0 → 0.12.1 (installed binary) — same goal, fresh copy `g2-bs-v3`
329
329
 
330
330
  **First launch, 19:09:48 Z, installed 0.12.0 — failed in 4 s.** Both the
331
331
  scout and the orchestrator were `failed_terminal` with `no eligible pool`
@@ -339,32 +339,127 @@ the runtime parks the dispatch in `waiting_for_quota`, re-reads the meter
339
339
  every 60 s, continues when the window resets, and only fails — naming pool,
340
340
  usage and reset time — after reset + 10 min grace.
341
341
 
342
- (re-run on 0.12.1 pending it starts as soon as the 5h window resets at 22:30 Z)
342
+ **Second launch, 19:28:03 Z, installed 0.12.1, run `wf-mtdcghw0-bfefc7` on a
343
+ fresh pristine `g2-bs-v3` — observed the wait working.** The meter read 95 %
344
+ at launch, so the runtime parked the first dispatch (the scout) in stage
345
+ `waiting_for_quota` with `until 22:40:00 Z` (reset 22:30 + 10 min grace),
346
+ event `dispatch.waiting_for_quota` carrying pool, usage and reset time. It
347
+ re-read the meter every 60 s for **3 h 2 min 14 s** (`waitedMs 10933739`) and
348
+ emitted `dispatch.quota_available` at **22:30:16.979 Z** — 17 s after the
349
+ provider reset — then dispatched the scout with no operator action. Wall-clock
350
+ numbers below therefore exclude this wait (`metrics-bullswarm.mjs` reports
351
+ `quotaWaitSec` and `wallExclWaitSec` separately); the wait is a provider
352
+ constraint, not execution time.
353
+
354
+ **Outcome: `completed`, `verified: true`, 23:18:38 Z.** Timeline (all Z):
355
+
356
+ | when | what |
357
+ |---|---|
358
+ | 22:30:17 | scout dispatched (read-only survey), 197 s |
359
+ | 22:33:34 → 22:39:14 | planner turn 1, **340 s** → one `needs_more_work` program of 14 actions |
360
+ | 22:39:14 | **7 workers started in the same second**: `build-{csv,duration,intervals,lru,semver,slugify}` + `write-docs-index` |
361
+ | 22:44:45 → 22:46:36 | each `verify-<module>` started the moment its own builder finished — per-chain pipelining, no stage barrier (`verify-intervals` was running while `build-slugify` still built) |
362
+ | 22:52:29 | all six module verifies `ok:true` (`verify-slugify` 354 s — see landmine note) → `verify-full-delivery` |
363
+ | 22:59:02 | `verify-full-delivery` **ok:false**: `docs/README.md` missing → runtime spawned `verify-full-delivery-repair-1` (`source: repair-policy`), no planner turn |
364
+ | 23:01:19 → 23:07:58 | repair wrote `docs/README.md` (137 s); re-verify passed (399 s) |
365
+ | 23:07:58 → 23:09:42 | planner turn 2 (105 s): `complete` — **rejected by the runtime**: "missing a successful verification of latest worker verify-full-delivery-repair-1" |
366
+ | 23:09:42 → 23:12:25 | planner turn 3 (163 s): diagnosed the rejection as mechanical, added one read-only `verify-final-acceptance` depending on the repair |
367
+ | 23:12:25 → 23:17:54 | `verify-final-acceptance` ok:true (328 s) |
368
+ | 23:17:54 → 23:18:38 | planner turn 4 (44 s): `complete`, accepted |
369
+
370
+ Numbers (`metrics-bullswarm.mjs`, wait excluded):
371
+
372
+ | metric | 0.11.1 (`g2-bs-v2`) | **0.12.1 (`g2-bs-v3`)** | Claude #2 |
373
+ |---|---|---|---|
374
+ | execution wall | 41 min 14 s | **48 min 21 s** (2 901 s; +10 934 s quota wait) | 58 min |
375
+ | planner turns / seconds | 3 / 667 s (27 %) | **4 / 652 s (22 %)** — turns 2–4 (312 s) plus `verify-final-acceptance` (328 s) exist only because of the rejection bug below | 0 during execution |
376
+ | dispatches / max concurrent | 22 / 6 | **22 / 7** | 24 / ~10 |
377
+ | parallelism (busy ÷ wall) | 2.74 | **2.11** | 3.1 |
378
+ | actions by source | planner 14+7+0 | **planner 14 + 1, repair-policy 1** | script |
379
+ | tests after | 130/130 | **120/120** (52 + 68 new, 6 files ≥ 9 tests each) | 168/168 |
380
+ | existing tests / src | byte-identical / comment-only | **byte-identical / comment-only** (audit-fixture.sh: 0 non-comment line diffs in all 7 src files) | same |
381
+ | deliverables | all | **all** (6 docs pages, `docs/README.md` 6-row index) | all |
382
+ | estimated tokens | — | 201 568 (utf8/4 estimate) | — |
383
+
384
+ What the run showed:
385
+
386
+ 1. **The brace landmine is closed (controlled A/B).** `g2-bs-v3` is a pristine copy, so `src/slugify.js` still carries the `@param {{maxLength?: number}}` JSDoc that killed 0.11.1's `verify-slugify` with zero attempts. On 0.12.1 the same verify dispatched, reviewed the artifact with the braces intact, and returned `ok:true` (with three informational concerns, none of which spawned a polish action — the doctrine held).
387
+ 2. **The planner compiled a Claude-shaped program on the first turn.** Six independent `build → verify` chains + a parallel docs-index builder + one final gate, each verify carrying `repair {maxRounds: 1}`. The runtime then ran it as a pipeline: verifies started per chain, not after a barrier.
388
+ 3. **The repair loop worked live, and paid for a planner mistake.** `write-docs-index` was compiled with `dependsOn: []`, so it launched with the builders and found no `docs/` to index; the worker refused to invent summaries and returned a status note. The final verify caught the missing file and the runtime's repair round fixed it — no planner turn, ~9 min. In Claude's model the same mistake is an authoring error in the script; here it is a compile error by the planner. Neither runtime can catch it deterministically; both recover through verification.
389
+ 4. **Runtime bug found: a repair action is never "verified".** `completionEvidenceGaps` accepts a verify as evidence for the latest worker only if `verify.dependsOn` includes that worker. A repair action depends on its verify (the reverse edge), and the verify's post-repair re-run *is* its verification, but the check does not know that — so a clean `complete` was rejected and the run spent 3 more turns and ~11 min proving what it already had. The same check gates 0.13.0's `all-actions-ok` auto-completion, which would have been blocked the same way. Fix: 0.13.1 (below).
390
+
391
+ Take the bug and the dependency slip out and this run is ~29 min of execution with two planner turns — the shape the 0.12.0 design targeted.
343
392
 
344
393
  The originally planned 0.10.9 goal-2 run was dropped at the user's request
345
394
  (2026-08-29): the installed latest is the only baseline that matters.
346
395
 
396
+ ### bullswarm 0.13.1 (installed binary) — goal 3, discovery-shaped, fresh copy `g3-bs-v3`
397
+
398
+ Goal 3 was written to exercise what goal 2 cannot: an **unknown item list**.
399
+ "Some — not all — of the exported functions accept a wrong-typed argument and
400
+ misbehave. Find out which modules actually have this problem (probe every
401
+ export; keep only the misbehaving modules), then for EACH affected module only:
402
+ add top-of-function argument validation (TypeError naming function, parameter,
403
+ expected type; behaviour for valid input unchanged) and `tests/<module>.guards.test.js`
404
+ (node:test, one test per guard). Do not modify existing tests or touch modules
405
+ that already validate. Finish with `npm test` passing and report exactly which
406
+ modules you changed and which you left alone, with evidence." Same fixture
407
+ family, same single pool (`claude-opus-5`), 0.13.1 installed after the goal-2
408
+ run ended so the binary each run used is unambiguous. Run `wf-mtdkvx0k-c40480`,
409
+ 23:23:56 → 23:52:41 Z.
410
+
411
+ | when (Z) | what |
412
+ |---|---|
413
+ | 23:23:59 → 23:28:05 | scout (246 s): probed all six modules; found exactly three misbehaving (csv, slugify, semver) with per-function evidence |
414
+ | 23:28:05 → 23:32:59 | planner turn 1 (294 s): **one 9-action program with `completion: {when: "all-actions-ok"}`** — `fix-{csv,slugify,semver}` + `audit-remaining` (independently re-probe duration/intervals/lru/index) in parallel, each with its own `verify-*` carrying `repair {maxRounds: 2}`, then `verify-suite` |
415
+ | 23:32:59 | 4 workers started in the same second |
416
+ | 23:37:04 → 23:39:31 | each `verify-<m>` started as its own fix finished (pipeline, no barrier) |
417
+ | 23:42:44 → 23:52:41 | `verify-suite` (597 s) ok:true |
418
+ | 23:52:41 | **runtime recorded `complete` itself** — `decision.auto_completed`, `source: program-completion`; no second planner process |
419
+
420
+ | metric | value |
421
+ |---|---|
422
+ | wall | **28 min 42 s** (1 722 s), no quota wait |
423
+ | planner turns / seconds | **1 / 294 s (17 %)** |
424
+ | dispatches / max concurrent / parallelism | 11 / 4 / 1.82 (four items → four chains; width was item-bound, cap 8 unused) |
425
+ | repairs | 0 needed (every verify ok:true first time) |
426
+ | actions by source | planner 9; completion recorded by the runtime |
427
+ | result (audit-fixture.sh + `npm test`) | `src/csv.js`, `src/semver.js`, `src/slugify.js` modified (24/9/9 non-comment lines); duration/intervals/lru/index untouched; 3 new `*.guards.test.js`; existing tests byte-identical; **75/75** (52 + 23) |
428
+ | tokens (estimate) | 51 422 |
429
+
430
+ Two notes. First, the planner did **not** use `fanout.itemsFrom` — it inlined
431
+ the three modules the scout had already named and gave the "not yet confirmed"
432
+ half of the repo to one `audit-remaining` worker. That is the right call (the
433
+ scout had done the discovery), and it is exactly what Claude's author does when
434
+ it discovers the list inline before writing the script; `itemsFrom` stays the
435
+ tool for lists that only exist after a worker runs. Second, `verify-others` and
436
+ `verify-suite` both flagged `lru` throwing `RangeError` rather than `TypeError`
437
+ for a wrong-typed capacity and both correctly treated it as informational (the
438
+ existing test pins `RangeError`): passing-with-nits produced no extra work,
439
+ as the doctrine intends.
440
+
347
441
  ## Behaviour differences observed
348
442
 
349
443
  Same goal, same fixture, same model (Opus for every worker and for bullswarm's
350
444
  planner; the Claude session's author was Opus too). Read left to right: what
351
445
  Claude did, what bullswarm 0.11.1 did on the identical run, and what 0.12.x
352
- now does about it (built and unit-tested; the live re-run below is the
353
- confirmation).
446
+ now does about it. Every 0.12.x/0.13.x cell is unit-tested; cells marked
447
+ **observed** were also seen live in the `g2-bs-v3` (0.12.1) and `g3-bs-v3`
448
+ (0.13.1) runs above.
354
449
 
355
- | Dimension | Claude Code `Workflow` (ultracode) — observed | bullswarm 0.11.1 — observed | bullswarm 0.12.0 / 0.12.1 — built |
450
+ | Dimension | Claude Code `Workflow` (ultracode) — observed | bullswarm 0.11.1 — observed | bullswarm 0.12.1 / 0.13.1 |
356
451
  | --- | --- | --- | --- |
357
- | Who plans, and when | The session author read every file and ran the tests inline (4 min), then wrote **one script** (23 k chars, 5 `agent()` sites). **0 orchestrator turns during the 48 min 51 s of execution.** | The planner compiled the **whole 14-action graph in one decision** (253 s) — but blind: goal text + cwd only, no repo survey, no worker output text in its context. Consulted **3 times** (253 s, 304 s, 110 s) = **27 % of wall**. | Read-only `scout` action before the planner; `outputExcerpt` of every finished action in the planner context; prompt reframed as "compile the goal into a PROGRAM"; planner told it is consulted only at the program boundary. |
452
+ | Who plans, and when | The session author read every file and ran the tests inline (4 min), then wrote **one script** (23 k chars, 5 `agent()` sites). **0 orchestrator turns during the 48 min 51 s of execution.** | The planner compiled the **whole 14-action graph in one decision** (253 s) — but blind: goal text + cwd only, no repo survey, no worker output text in its context. Consulted **3 times** (253 s, 304 s, 110 s) = **27 % of wall**. | Read-only `scout` action before the planner; `outputExcerpt` of every finished action in the planner context; prompt reframed as "compile the goal into a PROGRAM"; planner told it is consulted only at the program boundary. **Observed:** both runs compiled the whole program on turn 1 from the scout's survey; goal 3 ran on **one planner turn** (0.13.0 self-completion). |
358
453
  | Item discovery | `pipeline(MODULES, probe, author, verify, fix-loop)` over a known list; when a list is unknown Claude discovers it inline *before* writing the script. | Goal named the six modules → inlined them. Nothing to discover here. | `fanout.itemsFrom: "outputs.<discovery>.outFile"` resolved at run time (+ one bounded read-only extraction retry), so an unknown item count never costs a planner turn. |
359
454
  | Parallel width and overlap | **6 concurrent** (= six items, cap 8), mean parallelism 3.1. Per-item pipeline: author-B starts the second probe-B ends; no barriers. | **6 concurrent**, mean parallelism 2.74. Ready-set scheduler: each `verify-<m>` started the second its own `module-<m>` finished; `docs-index` waited for all six by design. | Unchanged for known items. Limitation stays: a verify on a *discovered* fan-out waits for all items (no per-item chain inside a fan-out yet). |
360
- | Verify → fix | Fix loops **pre-authored in code** (`while (!verdict.ok && rounds < N)`): slugify ×2, intervals ×1, all inside the script; 9 verifies, 3 fixes, 0 planner involvement. | A failed/blocked verify came back to the **planner** (turn 2, 304 s), which authored `slugify-recheck` + `verify-slugify-2`. Round trip ≈ 5 min before the fix even started. | `verify.repair { prompt, maxRounds 1–3 }` — the executor runs `<verify>-repair-<n>` with the concerns verbatim and re-runs the same verify; only still-failing verifies return to the planner. |
361
- | Passing verifies with nits | Schema-forced `{ok, issues}`; the script fixes only when `!ok`. Nits on passing modules were ignored. | Planner spent **2 of 3 remediation fixes** (`polish-semver`, `polish-lru`) on "non-blocking" notes from verifiers that had returned `ok:true` — an extra ~10 min program round. | Doctrine line: an `ok:true` verify is accepted; its concerns are informational. |
362
- | Robustness to content | Prompts are JS strings; the runtime substitutes nothing. A parse error in the *script* was caught by the harness and corrected inline in 94 s. | The template renderer parsed **any** `{{…}}` — in a planner prompt *and* in the review artifact it appended. `verify-slugify` died at render time with **0 attempts**, blocked `verify-suite`, cost a planner round, and the fix **rewrote fixture source** (JSDoc) to dodge the bug. | Only a known root + dotted identifiers is a template ref; other double braces are text. `verify` appends the reviewed artifact verbatim, never rendered. |
363
- | Provider rate limits | Agents retry on API errors; a terminal error resolves the agent to `null`, the script keeps going. The session waits. | Pool burst-gated (5h window 91 %) → the whole run **failed in 4 s** with `no eligible pool`, no reset time named (first 0.12.0 launch, 19:09 Z). | 0.12.1: `waiting_for_quota` stage, meter re-read every 60 s, continue when the window resets; fail only after reset + 10 min grace, naming pool / usage / reset time. |
455
+ | Verify → fix | Fix loops **pre-authored in code** (`while (!verdict.ok && rounds < N)`): slugify ×2, intervals ×1, all inside the script; 9 verifies, 3 fixes, 0 planner involvement. | A failed/blocked verify came back to the **planner** (turn 2, 304 s), which authored `slugify-recheck` + `verify-slugify-2`. Round trip ≈ 5 min before the fix even started. | `verify.repair { prompt, maxRounds 1–3 }` — the executor runs `<verify>-repair-<n>` with the concerns verbatim and re-runs the same verify; only still-failing verifies return to the planner. **Observed** (goal 2): `verify-full-delivery` failed on a missing `docs/README.md`, the repair wrote it and the re-verify passed, ~9 min, no planner turn. Exposed the 0.13.1 bug (repair never counted as verified). |
456
+ | Passing verifies with nits | Schema-forced `{ok, issues}`; the script fixes only when `!ok`. Nits on passing modules were ignored. | Planner spent **2 of 3 remediation fixes** (`polish-semver`, `polish-lru`) on "non-blocking" notes from verifiers that had returned `ok:true` — an extra ~10 min program round. | Doctrine line: an `ok:true` verify is accepted; its concerns are informational. **Observed:** 6 + 4 passing verifies with concerns in the two runs, zero polish actions. |
457
+ | Robustness to content | Prompts are JS strings; the runtime substitutes nothing. A parse error in the *script* was caught by the harness and corrected inline in 94 s. | The template renderer parsed **any** `{{…}}` — in a planner prompt *and* in the review artifact it appended. `verify-slugify` died at render time with **0 attempts**, blocked `verify-suite`, cost a planner round, and the fix **rewrote fixture source** (JSDoc) to dodge the bug. | Only a known root + dotted identifiers is a template ref; other double braces are text. `verify` appends the reviewed artifact verbatim, never rendered. **Observed:** same pristine `slugify.js` with `{{maxLength?: number}}`, `verify-slugify` ran and passed. |
458
+ | Provider rate limits | Agents retry on API errors; a terminal error resolves the agent to `null`, the script keeps going. The session waits. | Pool burst-gated (5h window 91 %) → the whole run **failed in 4 s** with `no eligible pool`, no reset time named (first 0.12.0 launch, 19:09 Z). | 0.12.1: `waiting_for_quota` stage, meter re-read every 60 s, continue when the window resets; fail only after reset + 10 min grace, naming pool / usage / reset time. **Observed:** waited 3 h 2 min at 95 %, resumed 17 s after the reset, no operator action. |
364
459
  | Structured worker output | `schema:` forces a `StructuredOutput` tool call; mismatches retry at the tool layer, so the script never parses prose. | Prose "content gate" (`looksLikeWork`); a bare JSON array answer was rejected as an "announcement"; the verify verdict is the only structured channel. | Content gate accepts JSON; `parseJsonArray` prefers the trailing array; one extraction action when discovery output has no array. A general `outputSchema` on run actions is still open. |
365
460
  | Failure semantics | In code: `parallel()` never rejects, a throwing stage drops its item to `null`, `.filter(Boolean)`. | Runtime `onError: continue` per step; failed dependencies block dependents; blocked graph → planner. | Same, plus: a failed scout is non-fatal; fan-out `ok` is a boolean so dependents can wait on a whole fan-out. |
366
- | Outcome quality (audits, read-only) | 168/168 tests (52 + 116 new); existing tests byte-identical; `src` comment-only; every deliverable present. | 130/130 tests (52 + 78 new); existing tests byte-identical; `src` comment-only; every deliverable present. | (re-run below) |
367
- | Time and agents | 58 min end to end; 24 agents. | 41 min end to end; 22 dispatches. Faster because it wrote fewer tests per module (11–15 vs 16–24) and skipped Claude's probe stage — not because it orchestrated better. | — |
461
+ | Outcome quality (audits, read-only) | 168/168 tests (52 + 116 new); existing tests byte-identical; `src` comment-only; every deliverable present. | 130/130 tests (52 + 78 new); existing tests byte-identical; `src` comment-only; every deliverable present. | Goal 2 on 0.12.1: 120/120 (52 + 68); existing tests byte-identical; `src` comment-only; every deliverable present. Goal 3 on 0.13.1: exactly the three unguarded modules changed, 75/75. |
462
+ | Time and agents | 58 min end to end; 24 agents. | 41 min end to end; 22 dispatches. Faster because it wrote fewer tests per module (11–15 vs 16–24) and skipped Claude's probe stage — not because it orchestrated better. | Goal 2 on 0.12.1: 48 min of execution (+3 h quota wait), 22 dispatches, max 7 concurrent ~11 min of it spent on the 0.13.1 bug and ~9 min on one repair round. Goal 3 on 0.13.1: 28 min 42 s, 11 dispatches, 1 planner turn. |
368
463
 
369
464
  The short version: after 0.11.x the *shape* already matched (one decision =
370
465
  whole graph, six in parallel, per-item verify overlap). What still separated
@@ -376,8 +471,9 @@ the runtime.
376
471
 
377
472
  ## What to change in bullswarm
378
473
 
379
- **Shipped in this cycle** (0.12.0 `c1b71a8`, 0.12.1 `beeed94`; all covered by
380
- unit tests, 295/295):
474
+ **Shipped in this cycle** (0.12.0 `c1b71a8`, 0.12.1 `beeed94`, 0.13.0
475
+ `6e5f620`, 0.13.1 `1bf0840` — all released to npm and installed; unit suite
476
+ 299/299):
381
477
 
382
478
  1. Orchestrator as **compiler**: prompt reframed; planner consulted only at the
383
479
  program boundary; `programFeatures: ['itemsFrom', 'repair']` advertised.
@@ -393,11 +489,18 @@ unit tests, 295/295):
393
489
  8. Template refs are grammar-checked; review artifacts are never rendered.
394
490
  9. Doctrine: `ok:true` verifies are accepted; concerns are informational.
395
491
  10. Burst-gated providers are waited for (`waiting_for_quota`), not failed.
396
- 11. **Program-level completion predicate** (`0bcfd21`, 0.13.0 unreleased while
397
- the 0.12.1 observation run is in flight): `completion: { when:
492
+ 11. **Program-level completion predicate** (0.13.0): `completion: { when:
398
493
  "all-actions-ok", reason }` lets a clean program record its own `complete`
399
- decision — no final planner turn (110 s here) just to say so. Anything
400
- failing still returns to the planner.
494
+ decision — no final planner turn just to say so. Anything failing still
495
+ returns to the planner. **Observed** on goal 3: the planner attached it
496
+ unprompted, the runtime recorded `complete` (`source: program-completion`)
497
+ at 23:52:41 Z, one planner turn for the whole run.
498
+ 12. **A repaired verify counts as verification of its repair** (0.13.1): the
499
+ completion-evidence check only followed `verify.dependsOn`; a repair action
500
+ depends on its verify (reverse edge), so after a clean repair round every
501
+ `complete` was rejected as "missing a successful verification of latest
502
+ worker <verify>-repair-1" — observed on goal 2 (three extra planner turns,
503
+ ~11 min) and it would have blocked item 11 in the same situation.
401
504
 
402
505
  **Still open, in priority order** (each is a measured gap, not a guess):
403
506
 
@@ -418,3 +521,14 @@ unit tests, 295/295):
418
521
  whatever the planner wrote. A default skeptic framing in the verify wrapper
419
522
  is cheap and would have caught nothing extra here — listed for parity, not
420
523
  urgency.
524
+ 5. **Dependency slips by the planner.** Goal 2's `write-docs-index` was
525
+ compiled with `dependsOn: []` although it reads the six `docs/<m>.md` files
526
+ the builders create; it ran first and found nothing. The verify + repair
527
+ loop recovered it (~9 min). Claude has the same failure class (a mis-ordered
528
+ `pipeline` stage) and the same recovery. A doctrine line — "an action that
529
+ reads another proposed action's deliverable must depend on it" — is free;
530
+ a deterministic check is not possible without declared outputs, which would
531
+ be a small schema addition (`produces: [paths]`).
532
+ 6. **Record `completion` on the decision.** The planner artifact carries the
533
+ predicate but `state.decisions[]` does not, so `watch`/metrics cannot show
534
+ that a program declared itself self-completing until it does. One field.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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": {
@@ -122,7 +122,17 @@ export function buildGoalWorkflow({
122
122
  requireSuccessfulVerification: true,
123
123
  },
124
124
  },
125
- inputs: {},
125
+ // The goal is user text. It is declared as an input and inserted into
126
+ // prompts at render time ({{inputs.goal}}), so anything in it that looks
127
+ // like a template ref — a goal about templates quoting
128
+ // `{{outputs.x.data.field}}`, say — is inserted verbatim, never resolved.
129
+ inputs: {
130
+ goal: {
131
+ description: 'The user goal, verbatim.',
132
+ required: true,
133
+ default: goal.trim(),
134
+ },
135
+ },
126
136
  settings: {
127
137
  concurrency,
128
138
  retryAttempts,
@@ -141,7 +151,7 @@ export function buildGoalWorkflow({
141
151
  type: 'run',
142
152
  lane: 'analyze',
143
153
  addDir: targetDir,
144
- prompt: scoutPrompt(goal.trim(), targetDir),
154
+ prompt: scoutPrompt('{{inputs.goal}}', targetDir),
145
155
  }] : []), {
146
156
  id: 'orchestrator',
147
157
  type: 'decide',
@@ -475,6 +475,19 @@ function actionOutputOk(action, outputs) {
475
475
  : action.status === 'succeeded';
476
476
  }
477
477
 
478
+ // A verify is evidence for a worker when the worker feeds it (dependsOn), or
479
+ // when the worker is that verify's own repair action: the executor re-runs the
480
+ // verify after every repair round, so a verify that ended ok:true after its
481
+ // `<verify>-repair-N` has verified the repair even though the dependency edge
482
+ // points the other way. Without this a clean run's `complete` was rejected as
483
+ // "missing a successful verification of latest worker <verify>-repair-1"
484
+ // (observed on goal-2 run wf-mtdcghw0, 2026-08-28) and cost three planner turns.
485
+ export function verifiesWorker(verify, worker) {
486
+ if ((verify.dependsOn ?? []).includes(worker.id)) return true;
487
+ return (worker.dependsOn ?? []).includes(verify.id)
488
+ && new RegExp(`^${verify.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-repair-\\d+$`).test(worker.id);
489
+ }
490
+
478
491
  export function completionEvidenceGaps(dynamicActions, policy, outputs = {}) {
479
492
  const missing = [];
480
493
  const successfulWorkers = dynamicActions.filter(
@@ -490,7 +503,7 @@ export function completionEvidenceGaps(dynamicActions, policy, outputs = {}) {
490
503
  && action.status === 'succeeded'
491
504
  && actionOutputOk(action, outputs)
492
505
  && latestSuccessfulWorker
493
- && (action.dependsOn ?? []).includes(latestSuccessfulWorker.id),
506
+ && verifiesWorker(action, latestSuccessfulWorker),
494
507
  )) {
495
508
  missing.push(latestSuccessfulWorker
496
509
  ? `a successful verification of latest worker ${latestSuccessfulWorker.id}`
@@ -878,6 +878,13 @@ export class WorkflowRuntime {
878
878
  }
879
879
  }
880
880
 
881
+ /** renderDeep options that turn an unresolved ref into a visible event. */
882
+ renderOpts(actionId) {
883
+ return {
884
+ onUnresolved: (ref) => this.emit('template.unresolved_ref', { actionId, ref }),
885
+ };
886
+ }
887
+
881
888
  async runSingle(step, scope, opts = {}) {
882
889
  this.enforceRequiredInputs(step.id);
883
890
  const rendered = renderDeep(
@@ -888,6 +895,7 @@ export class WorkflowRuntime {
888
895
  taskFile: step.taskFile,
889
896
  },
890
897
  scope,
898
+ this.renderOpts(step.id),
891
899
  );
892
900
  const taskText = rendered.prompt
893
901
  ?? readFileSync(rendered.taskFile, 'utf8');
@@ -965,7 +973,7 @@ export class WorkflowRuntime {
965
973
  lane: step.lane ?? 'analyze',
966
974
  addDir: step.addDir,
967
975
  prompt: reviewInstructions,
968
- }, scope);
976
+ }, scope, this.renderOpts(step.id));
969
977
  // A custom prompt changes the review instructions, never the review
970
978
  // input. Always append the resolved artifact so the skeptic receives
971
979
  // the thing it is meant to judge.
@@ -1160,7 +1168,7 @@ export class WorkflowRuntime {
1160
1168
  const rendered = renderDeep({
1161
1169
  prompt: step.prompt ?? 'Judge whether the workflow has enough evidence to finish.',
1162
1170
  addDir: step.addDir,
1163
- }, scope);
1171
+ }, scope, this.renderOpts(step.id));
1164
1172
  const taskText = [
1165
1173
  rendered.prompt,
1166
1174
  '',
@@ -1328,7 +1336,7 @@ export class WorkflowRuntime {
1328
1336
  const itemScope = { ...scope, item };
1329
1337
  let template;
1330
1338
  try {
1331
- template = renderDeep(step.stepTemplate, itemScope);
1339
+ template = renderDeep(step.stepTemplate, itemScope, this.renderOpts(`${step.id}[${i}]`));
1332
1340
  } catch (err) {
1333
1341
  const itemAction = this.ensureAction(step, { ...opts, item, itemIndex: i });
1334
1342
  itemAction.status = 'failed_terminal';
@@ -32,25 +32,35 @@ export function isTemplateRef(ref) {
32
32
  * {{runId}}, {{wfDir}}. Non-string values are JSON-stringified. Double-brace
33
33
  * text that is not a ref (see isTemplateRef) is returned untouched.
34
34
  */
35
- export function renderTemplate(str, scope) {
35
+ export function renderTemplate(str, scope, opts = {}) {
36
36
  if (typeof str !== 'string') return str;
37
37
  return str.replace(TEMPLATE_TOKEN_RE, (match, ref) => {
38
38
  if (!isTemplateRef(ref)) return match;
39
39
  const v = getPath(scope, ref.trim());
40
40
  if (v === undefined) {
41
- throw new Error(`template ref "{{${ref.trim()}}}" unresolved at render time`);
41
+ // A grammar-valid ref with nothing behind it. Planner-authored prompts
42
+ // legitimately quote refs as text (a goal *about* templates does), and a
43
+ // worker can usually still act on the literal — so this is reported,
44
+ // never fatal. Callers that want the old hard failure pass strict:true.
45
+ // (Observed 2026-08-29: a goal quoting `{{outputs.x.data.field}}` failed
46
+ // the whole run at validation with "nothing ran".)
47
+ if (opts.strict === true) {
48
+ throw new Error(`template ref "{{${ref.trim()}}}" unresolved at render time`);
49
+ }
50
+ if (typeof opts.onUnresolved === 'function') opts.onUnresolved(ref.trim());
51
+ return match;
42
52
  }
43
53
  return typeof v === 'string' ? v : JSON.stringify(v);
44
54
  });
45
55
  }
46
56
 
47
57
  /** Deep-render every string in a step-like object. */
48
- export function renderDeep(obj, scope) {
49
- if (typeof obj === 'string') return renderTemplate(obj, scope);
50
- if (Array.isArray(obj)) return obj.map((v) => renderDeep(v, scope));
58
+ export function renderDeep(obj, scope, opts = {}) {
59
+ if (typeof obj === 'string') return renderTemplate(obj, scope, opts);
60
+ if (Array.isArray(obj)) return obj.map((v) => renderDeep(v, scope, opts));
51
61
  if (obj && typeof obj === 'object') {
52
62
  const out = {};
53
- for (const [k, v] of Object.entries(obj)) out[k] = renderDeep(v, scope);
63
+ for (const [k, v] of Object.entries(obj)) out[k] = renderDeep(v, scope, opts);
54
64
  return out;
55
65
  }
56
66
  return obj;