bullswarm 0.13.1 → 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,19 @@
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
+
3
17
  ## 0.13.1 — a repaired verify counts as verification of its repair
4
18
 
5
19
  - `completionEvidenceGaps` accepted a verify as evidence for the latest worker
@@ -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`
@@ -393,27 +393,73 @@ Take the bug and the dependency slip out and this run is ~29 min of execution wi
393
393
  The originally planned 0.10.9 goal-2 run was dropped at the user's request
394
394
  (2026-08-29): the installed latest is the only baseline that matters.
395
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
+
396
441
  ## Behaviour differences observed
397
442
 
398
443
  Same goal, same fixture, same model (Opus for every worker and for bullswarm's
399
444
  planner; the Claude session's author was Opus too). Read left to right: what
400
445
  Claude did, what bullswarm 0.11.1 did on the identical run, and what 0.12.x
401
- now does about it (built and unit-tested; the live re-run below is the
402
- 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.
403
449
 
404
- | 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 |
405
451
  | --- | --- | --- | --- |
406
- | 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). |
407
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. |
408
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). |
409
- | 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. |
410
- | 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. |
411
- | 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. |
412
- | 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. |
413
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. |
414
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. |
415
- | 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) |
416
- | 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. |
417
463
 
418
464
  The short version: after 0.11.x the *shape* already matched (one decision =
419
465
  whole graph, six in parallel, per-item verify overlap). What still separated
@@ -425,8 +471,9 @@ the runtime.
425
471
 
426
472
  ## What to change in bullswarm
427
473
 
428
- **Shipped in this cycle** (0.12.0 `c1b71a8`, 0.12.1 `beeed94`; all covered by
429
- 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):
430
477
 
431
478
  1. Orchestrator as **compiler**: prompt reframed; planner consulted only at the
432
479
  program boundary; `programFeatures: ['itemsFrom', 'repair']` advertised.
@@ -442,11 +489,18 @@ unit tests, 295/295):
442
489
  8. Template refs are grammar-checked; review artifacts are never rendered.
443
490
  9. Doctrine: `ok:true` verifies are accepted; concerns are informational.
444
491
  10. Burst-gated providers are waited for (`waiting_for_quota`), not failed.
445
- 11. **Program-level completion predicate** (`0bcfd21`, 0.13.0 unreleased while
446
- the 0.12.1 observation run is in flight): `completion: { when:
492
+ 11. **Program-level completion predicate** (0.13.0): `completion: { when:
447
493
  "all-actions-ok", reason }` lets a clean program record its own `complete`
448
- decision — no final planner turn (110 s here) just to say so. Anything
449
- 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.
450
504
 
451
505
  **Still open, in priority order** (each is a measured gap, not a guess):
452
506
 
@@ -467,3 +521,14 @@ unit tests, 295/295):
467
521
  whatever the planner wrote. A default skeptic framing in the verify wrapper
468
522
  is cheap and would have caught nothing extra here — listed for parity, not
469
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.1",
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',
@@ -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;