@gr8ful/spf 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +354 -51
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -441,6 +441,9 @@ watch:
441
441
  concurrency: 1 # this lane's own budget, independent of watch.concurrency
442
442
  ```
443
443
 
444
+ (A third, independent budget lives at `watch.fanout.concurrency` — best-of-N
445
+ attempts per claimed issue, not specs. See "Best-of-N per issue" below.)
446
+
444
447
  On Jira, every `RefinedIssue.kind` (`epic`/`feature`/`story`/`bug`/`task`) maps
445
448
  to a real Jira issue type through `watch.jira.issue_types` — defaults
446
449
  `epic`/`feature` → `Epic`, `story` → `Story`, `bug` → `Bug`, `task` → `Task`,
@@ -587,6 +590,59 @@ container/leaf shape that skill left as prose convention rather than a
587
590
  checked rule, now also enforcing that a refinement never publishes issues and
588
591
  raises questions in the same round.
589
592
 
593
+ ### Best-of-N per issue (`watch.fanout`)
594
+
595
+ `spf fanout` runs N sibling attempts of one prompt and lets code pick the
596
+ winner (`pickBest`: succeeded > fewest gate failures > most gate passes >
597
+ lowest cost > fewest tokens > lowest wall time > adw_id, see the `spf fanout`
598
+ section above). `watch.fanout` is that same mechanism pointed at `spf watch`'s
599
+ per-issue dispatch: a claimed issue runs `n` sibling attempts instead of one,
600
+ and everything downstream of the winner — push, PR, review digest, marker,
601
+ transition, notify — is the same code that already runs for a single attempt.
602
+
603
+ ```yaml
604
+ watch:
605
+ repo: owner/name
606
+ chain: plan-build-test # must have a commit step once n > 1 — see below
607
+ concurrency: 2 # issues claimed and run at once — UNCHANGED meaning
608
+ fanout:
609
+ n: 1 # attempts per claimed issue. 1 (the default) = today's single dispatch, no-op for the daemon
610
+ concurrency: 2 # attempts of ONE issue's fan-out in flight — NOT watch.concurrency
611
+ ```
612
+
613
+ Two budgets, two names, on purpose: `watch.concurrency` counts **issues** in
614
+ flight (unchanged); `watch.fanout.concurrency` counts **attempts of one
615
+ issue's fan-out** in flight. They multiply rather than share one number — an
616
+ operator who wants "2 issues at a time, 1 attempt each" (serialize attempts,
617
+ keep issue-level parallelism) can say exactly that by setting
618
+ `fanout.concurrency: 1`, which a single shared knob couldn't express. `spf
619
+ doctor`'s `watch.fanout` line prints the product (`concurrency × fanout.concurrency`
620
+ chain runs in flight) so nobody has to compute it by hand.
621
+
622
+ **Disk, not just chain runs.** `fanout.concurrency` bounds attempts *in
623
+ flight*; it does **not** bound worktrees *on disk*. A successful attempt's
624
+ worktree is kept until every sibling in its fan-out has settled (it's still a
625
+ selection candidate), so peak disk per wave is `watch.concurrency ×
626
+ watch.fanout.n` full checkouts, not `watch.concurrency × watch.fanout.concurrency`
627
+ — at `concurrency: 4, fanout.n: 8` that's 32 working copies at once under
628
+ `worktreesDir` (`~/.spf/watch/<repo>/worktrees`). Plan disk against `n`, not
629
+ against `fanout.concurrency`.
630
+
631
+ `n > 1` requires `chain` to have a commit step — `spf watch` refuses to start
632
+ otherwise. The reason is sharper than `spf fanout`'s own version of this
633
+ check: best-of-N force-removes every losing attempt's worktree once a winner
634
+ is picked, `--force` included, and a chain that never commits leaves its
635
+ entire payload as uncommitted edits — fan-out would destroy N-1 candidates
636
+ outright rather than just fail one attempt. `n: 1` has no such requirement (a
637
+ chain that commits nothing is handled the same honest way it always has been:
638
+ blocked, worktree cleaned, nothing discarded).
639
+
640
+ `n: 1` (the default) changes nothing about a running daemon's observable
641
+ behaviour — same adw_id, same branch, same worktree, same fetch semantics,
642
+ same marker, same PR. `spf doctor` still prints one informational
643
+ `watch.fanout` line either way, so the posture is visible even when best-of-N
644
+ is off.
645
+
590
646
  ### GitHub (`issue_provider: github` and/or `code_host: github`)
591
647
 
592
648
  ```bash
@@ -120,6 +120,81 @@ tiering:
120
120
  refiner: builder
121
121
  reviewer: deep
122
122
 
123
+ # Remote sandbox backends (SPF #15) — everything below is optional; omitting
124
+ # the whole block means backend: local, i.e. byte-identical to SPF before
125
+ # this feature existed (every agent's tools run against the real working
126
+ # tree, exactly as today). See config.md's "sandbox" section for the full
127
+ # field-by-field rationale — this is the commented-out shape only.
128
+ # sandbox:
129
+ # backend: local # local | opensandbox | cloudflare (default: local)
130
+ # scope: agent # agent (DEFAULT) | run — see config.md: "run" is only safe
131
+ # # when every dispatched agent resolves to the SAME env key set.
132
+ # workspace_dir: /workspace # absolute path INSIDE the sandbox; the agent's cwd
133
+ # handoff_dir: /spf/handoff # absolute path INSIDE the sandbox — MUST NOT be under
134
+ # # workspace_dir (validate() rejects it if it is)
135
+ # scratch_dir: /spf/tmp # the transport's own scratch plane — same rule as handoff_dir
136
+ # image: "" # opensandbox only. NO DEFAULT — required (must contain
137
+ # # git + tar + base64) when the resolved backend is opensandbox
138
+ # setup: [] # shell commands run ONCE per sandbox, after seeding
139
+ # # e.g. ["npm ci --omit=dev"]
140
+ # request_timeout_seconds: 960 # the SDK's client-side HTTP timeout on control-plane calls
141
+ # lifetime_seconds: 3600 # provider-side expiry, renewed while a lease is live
142
+ # max_total_lifetime_seconds: 21600 # hard ceiling from creation; renew refuses past it
143
+ # exec_timeout_seconds: 900 # the DEFAULT per-command deadline (not a ceiling — a
144
+ # # caller-supplied timeout is honored, never clamped)
145
+ # env_allowlist: [] # e.g. ["GH_TOKEN"] — DEFAULT IS EMPTY, deliberately NOT the
146
+ # # operator env backend: local gets today. The set that
147
+ # # actually reaches a container is this list INTERSECTED
148
+ # # with that agent's own env_allowlist.
149
+ # credentials:
150
+ # broker: static # static is the only REGISTERED broker in this build — an
151
+ # # unrecognized name is a hard validate() failure, never a
152
+ # # silent fallback to static
153
+ # egress:
154
+ # default: deny # deny | allow
155
+ # allow: [] # hostnames, e.g. ["registry.npmjs.org", "github.com"]
156
+ # transport:
157
+ # max_patch_bytes: 8388608 # 8 MiB — exceeding it FAILS the phase, never truncates
158
+ # max_seed_bytes: 134217728 # 128 MiB seed archive ceiling
159
+ # max_mirror_bytes: 4194304 # 4 MiB per handoff-mirror transfer, each direction
160
+ # mirror: [] # extra repo-relative paths to sync out that git can't carry
161
+ # fanout: worktree # worktree | sandbox — "sandbox" fans out THROUGH sandboxes
162
+ # # too: each attempt gets its own lease(s), keyed on its own
163
+ # # derived <base>-<i> adw_id. The winning branch/worktree stay
164
+ # # local either way.
165
+ # opensandbox:
166
+ # base_url: http://127.0.0.1:8090
167
+ # api_key_env: OPENSANDBOX_API_KEY # env KEY NAME, never the key itself
168
+ # use_server_proxy: false
169
+ # metadata_prefix: spf
170
+ # cloudflare:
171
+ # bridge_url: "" # a deployed @cloudflare/sandbox bridge
172
+ # api_token_env: CLOUDFLARE_API_TOKEN
173
+ # sandbox_name: "" # optional stable name; default derives from the lease key
174
+ #
175
+ # Per-agent backend override, added to an `agents[]` entry alongside `writes`/
176
+ # `env_allowlist` above — a bare backend name, never a nested block:
177
+ # agents:
178
+ # - name: builder
179
+ # sandbox: opensandbox # unset -> inherit sandbox.backend; "local" -> force local
180
+
181
+ # `spf watch` — off unless watch.repo is set. `watch.fanout` (best-of-N per
182
+ # claimed issue) is one sub-key of it, shown here in isolation since it's the
183
+ # one with its own budget math; see README.md's "spf watch" section and
184
+ # config.md's "watch" table for the rest of the block (repo, chain,
185
+ # label_prefix, ...). Not to be confused with `sandbox.fanout` above, a
186
+ # different key entirely (worktree vs sandbox execution for `spf fanout`'s
187
+ # own attempts).
188
+ # watch:
189
+ # chain: plan-build-test # must have a commit step once fanout.n > 1 — spf watch refuses to start otherwise
190
+ # concurrency: 2 # issues claimed and run at once — UNCHANGED by fanout
191
+ # fanout:
192
+ # n: 1 # attempts per claimed issue. 1 (default) = single dispatch, a total no-op for the daemon
193
+ # concurrency: 2 # attempts of ONE issue's fan-out in flight — NOT watch.concurrency, which counts issues.
194
+ # # The two multiply (spf doctor prints the product); disk peak is concurrency x fanout.n,
195
+ # # not concurrency x fanout.concurrency — a kept attempt's worktree survives until every
196
+ # # sibling in its own fan-out has settled.
197
+
123
198
  agents:
124
199
  - name: planner
125
200
  model: fireworks/accounts/fireworks/models/kimi-k3
@@ -12,10 +12,12 @@ always shows the resolved, merged result for the repo you're in.
12
12
  1. The packaged built-in default (`assets/defaults/spf.config.yaml` inside
13
13
  the installed CLI).
14
14
  2. `.spf/spf.config.yaml` in the target repo, if present — merged on top,
15
- field by field (`env`/`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
15
+ field by field (`env`/`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`/`sandbox`
16
16
  merge key-by-key — `notifications.channels` replaces wholesale, same as
17
- `quality.checks` and `tiering.tiers`/`tiering.roles`; `agents` merges by
18
- `name`: a matching name patches that entry, a new name appends).
17
+ `quality.checks`, `tiering.tiers`/`tiering.roles`, and `sandbox`'s own
18
+ nested blocks (`opensandbox`/`cloudflare`/`egress`/`transport`/
19
+ `credentials`); `agents` merges by `name`: a matching name patches that
20
+ entry, a new name appends).
19
21
  3. An explicit `--config <path>` replaces both — standalone, no built-in
20
22
  underneath it.
21
23
 
@@ -154,13 +156,15 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
154
156
  | `chain` | string | Which registered chain runs per claimed `<prefix>:ready` issue. Default `plan-build-test`. |
155
157
  | `base_branch` | string | Branch worktrees fork from and PRs target. Default `main`. |
156
158
  | `poll_ms` | int | Tick interval. Default `60000`. |
157
- | `concurrency` | int ≥1 | Max issues claimed and run at once, the build lane's own budget (independent of `refine.concurrency`). Default `2`. |
159
+ | `concurrency` | int ≥1 | Max ISSUES claimed and run at once not attempts; see `fanout.concurrency`. The build lane's own budget (independent of `refine.concurrency`). Default `2`. |
158
160
  | `chain_options` | map of string -> string | Options passed straight through to `chain` (and `refine.chain`) for every unattended dispatch — the same shape an interactive `spf <chain> --suite <name>` builds, e.g. `{suite: strict}` or `{agent: some-agent}`. Default `{}`. Only useful for a chain whose behavior actually reads the option (a step-derived chain's `--suite`; an imperative chain ignores an option it doesn't know about). |
159
161
  | `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
160
162
  | `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` creates as on Jira — defaults `epic`/`feature` → `Epic`, `story` → `Story`, `bug` → `Bug`, `task` → `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
161
163
  | `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` — off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` or `"jira"` — both implement issue authoring (create + link a hierarchy); any other value fails loudly at startup. |
162
164
  | `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
163
165
  | `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
166
+ | `fanout.n` | int, 1-8 | Best-of-N per claimed issue: run `n` sibling attempts of the same issue and let code pick a winner (`core/fanout.ts`'s `pickBest`, the same mechanism `spf fanout` uses standalone). Default `1` — single dispatch, byte-identical to `spf watch` before this key existed. `n > 1` requires `chain` to have a commit step (`spf watch` refuses to start otherwise — a chain with no commit step would have its N-1 losing attempts' uncommitted work destroyed by best-of-N's own cleanup). |
167
+ | `fanout.concurrency` | int ≥1 | Attempts of ONE issue's fan-out IN FLIGHT at once — **not** `concurrency`, which counts issues. The two multiply: `concurrency: 2` × `fanout.concurrency: 2` is up to 4 chain runs in flight; `spf doctor`'s `watch.fanout` line prints the exact product. Does **not** bound worktrees on disk — a successful attempt's tree is kept until every sibling in its fan-out has settled, so disk peak is `concurrency × fanout.n`, not `concurrency × fanout.concurrency`. Default `2`. |
164
168
 
165
169
  ```yaml
166
170
  watch:
@@ -173,6 +177,9 @@ watch:
173
177
  enabled: true # decompose spf:spec-ready specs into a feature/story tree
174
178
  chain: refine
175
179
  concurrency: 1
180
+ fanout:
181
+ n: 1 # attempts per claimed issue. 1 = single dispatch (default) — no-op for the daemon
182
+ concurrency: 2 # attempts IN FLIGHT per issue — not watch.concurrency (issues in flight)
176
183
  ```
177
184
 
178
185
  Generated issues carry a second, independent label vocabulary —
@@ -413,6 +420,92 @@ dispatch:
413
420
  one), any degradation note, and — probed the same way `probeServedOllamaTags`
414
421
  does, fail-open — which `ollama/*` rungs are and aren't in `ollama list`.
415
422
 
423
+ ### `sandbox`
424
+
425
+ Remote sandbox backends (SPF #15) — runs an agent's `bash`/`read`/`write`/
426
+ `edit` tools inside an isolated remote container instead of the real working
427
+ tree. **Absent, or `backend: local` (the default), is byte-identical to SPF
428
+ before this feature existed**: every agent runs against the real working
429
+ tree, exactly as today. Top-level, not nested under `defaults:` — same
430
+ reasoning as `tiering` above (a stray copy onto `loadConfig`'s `defaults`
431
+ back-fill loop would be silently *stripped*, not rejected). Nested blocks
432
+ (`opensandbox`, `cloudflare`, `egress`, `transport`, `credentials`) are a
433
+ **whole-object replace** on override, same rule as `observability.otel` — a
434
+ half-merged `bridge_url`/`api_token_env` pair would authenticate to the
435
+ wrong control plane; `sandbox`'s own scalar keys still merge key-by-key
436
+ around them.
437
+
438
+ | Field | Type | Meaning |
439
+ |---|---|---|
440
+ | `backend` | `local` \| `opensandbox` \| `cloudflare` | Default `local`. Repo-wide default; an agent's own `sandbox:` field can override it. |
441
+ | `scope` | `agent` \| `run` | Default `agent` — **the default because it is the only scope under which per-agent `env_allowlist` is honored by construction.** One sandbox per `(run, agent)`, created with exactly that agent's resolved env. `run` — one sandbox for the whole run (cheaper: one create, one seed, one `setup`), admitted only when every agent the chain dispatches resolves to the SAME backend and the SAME resolved env key set — `spf validate`/`spf doctor` hard-reject `scope: run` otherwise, naming the agents and keys that differ. |
442
+ | `workspace_dir` | absolute path (in-sandbox) | Default `/workspace` — the agent's cwd inside the sandbox. |
443
+ | `handoff_dir` | absolute path (in-sandbox) | Default `/spf/handoff` — the **root** of the handoff plane (session artifacts, `context_handoff_dir`'s remote counterpart). The per-run directory a rendered prompt actually sees is derived from it as `<handoff_dir>/sessions/<adw_id>/context_handoff`, mirroring the host's own `context_handoff_dir` shape — this is not cosmetic: the shipped `planner`/`documenter` prompts parse `<adw_id>` out of that exact shape to name `specs/<adw_id>_<slug>.md`/`app_docs/<adw_id>_<slug>.md`. **MUST NOT be under `workspace_dir`** — `validate()` rejects it if it is: nested, it would ride the extract's `git add -A` onto the host at a path `spf init`'s `.gitignore` entries don't cover, and `permissions.enforce` kills the run on a `protected_files` breach. |
444
+ | `scratch_dir` | absolute path (in-sandbox) | Default `/spf/tmp` — the transport's OWN scratch plane (the seed archive, the in-flight patches). Same "not under `workspace_dir`" rule, same reason, same enforcement as `handoff_dir`; also must not equal `handoff_dir`. |
445
+ | `image` | string | **`opensandbox` only. No default** — `validate()`/`spf doctor` reject an empty `image` when the resolved backend is `opensandbox`, naming the three required binaries. MUST contain `git`, `tar`, and `base64`: the workspace transport shells out to all three to keep the sandbox's tree byte-identical to the host's. A `git`-less base (`python:*-slim`, bare `alpine`) fails the mandatory create-time preflight unless `setup` installs `git` first. Meaningless (and not required) on `cloudflare`. |
446
+ | `setup` | array of strings | Default `[]`. Shell commands run **once** per sandbox, right after seeding — e.g. `["npm ci --omit=dev"]`. |
447
+ | `request_timeout_seconds` | number | Default `960`. The **client-side** HTTP timeout on control-plane calls (create/renew/kill/file ops) — raised well above the SDK's own default because a first-run image pull can take a while. **Not** the bound on a long agent command: `exec` streams, so a caller-supplied timeout is honored unclamped (see `exec_timeout_seconds`). Must stay `>= exec_timeout_seconds`. |
448
+ | `lifetime_seconds` | number | Default `3600`. Provider-side expiry, renewed while a lease stays live — the teardown backstop that survives a `SIGKILL`ed `spf`. |
449
+ | `max_total_lifetime_seconds` | number | Default `21600`. A hard ceiling from creation; renewal refuses past it rather than letting a long-lived `spf watch` daemon renew one lease forever. |
450
+ | `exec_timeout_seconds` | number | Default `900`. The **default** per-command deadline, used only when the model's own tool call supplies none. **Not a ceiling** — a caller-supplied timeout (the model's own `timeout` argument to `bash`) passes through unmodified, exactly as `backend: local` behaves today; there is no clamp. |
451
+ | `env_allowlist` | array of strings | Default `[]` — **deliberately empty**, unlike `backend: local`, which passes the whole operator environment through today. A secret going into a remote container is a change of kind, not degree. The set that actually reaches a given agent's container is this list **intersected with that agent's own `env_allowlist`** (unset = no further narrowing) — per-agent, which is exactly why `scope: agent` is the default. `spf doctor` prints the resulting key **names**, never values, one line per agent. |
452
+ | `credentials.broker` | `static` | Default, and the only **registered** value in this release — plain env injection at create time, nothing really revocable yet (`revoke()` just clears SPF's own in-memory copy). An unrecognized name is a hard `validate()` failure naming the key, never a silent fallback to `static`. `spf doctor` prints the resulting grant's own honest one-liner per agent — key **names** only, never values. |
453
+ | `egress.default` / `egress.allow` | `deny`\|`allow` / array of hostnames | Default `deny` / `[]`. Repo-wide network egress policy for a remote sandbox. |
454
+ | `transport.max_patch_bytes` / `.max_seed_bytes` / `.max_mirror_bytes` | number | Defaults `8388608` (8 MiB) / `134217728` (128 MiB) / `4194304` (4 MiB, **each direction**, per file). Exceeding any of them **fails the phase**; none of them ever silently truncate. |
455
+ | `transport.mirror` | array of repo-relative paths | Default `[]`. Extra paths to sync out of the sandbox that git cannot carry (gitignored build artifacts you still want back on the host). |
456
+ | `fanout` | `worktree` \| `sandbox` | Default `worktree`. `sandbox` fans out **through** sandboxes too: each `spf fanout` attempt gets its own sandbox lease(s), keyed on that attempt's own derived `<base>-<i>` adw_id — never a session id, never shared across attempts. The local worktree and branch stay the record of truth either way; `spf fanout`'s output adds one line noting where the attempts ran (`attempts ran in <backend> sandboxes; branch is local`). See `sandbox.scope` above for the per-attempt sandbox count. |
457
+ | `opensandbox.base_url` | string | Default `http://127.0.0.1:8090`. |
458
+ | `opensandbox.api_key_env` | string | Default `OPENSANDBOX_API_KEY` — an env **key name**, never the key itself, same spelling as `GITHUB_TOKEN`. |
459
+ | `opensandbox.use_server_proxy` | bool | Default `false`. |
460
+ | `opensandbox.metadata_prefix` | string | Default `spf`. Best-effort create-time labelling only — the teardown sweep relies on SPF's own host-side lease record, not on reading this back. |
461
+ | `cloudflare.bridge_url` | string | Default `""`. The base URL of a deployed `@cloudflare/sandbox` bridge — **required** when the resolved backend is `cloudflare` (`validate()`/`spf doctor` reject an empty value). SPF is a Node CLI, not a Worker, so it talks to the bridge's HTTP API rather than a `getSandbox()` binding. |
462
+ | `cloudflare.api_token_env` | string | Default `CLOUDFLARE_API_TOKEN` — an env **key name**, never the key itself. |
463
+ | `cloudflare.sandbox_name` | string | Default `""` (derives a stable name from the lease key instead). Optional stable name for the bridge-side sandbox. |
464
+
465
+ ```yaml
466
+ sandbox:
467
+ backend: opensandbox
468
+ image: ghcr.io/you/spf-sandbox:debian-git # must contain git + tar + base64
469
+ env_allowlist: ["GH_TOKEN"]
470
+ egress:
471
+ default: deny
472
+ allow: ["github.com", "registry.npmjs.org"]
473
+ opensandbox:
474
+ base_url: http://127.0.0.1:8090
475
+
476
+ agents:
477
+ - name: builder
478
+ env_allowlist: ["GH_TOKEN"] # this agent's own narrowing — see the table above
479
+ - name: reviewer
480
+ sandbox: local # force this one agent back to the real working tree
481
+ ```
482
+
483
+ **Env posture, stated plainly.** `backend: local` today hands an agent's
484
+ subprocess the *entire* operator environment. A remote backend does the
485
+ opposite by design: nothing crosses unless it is named in **both**
486
+ `sandbox.env_allowlist` and (if set) that agent's own `env_allowlist`. This
487
+ is why `scope: agent` is the default rather than `scope: run` — a
488
+ container's process environment is fixed once, at creation, and there is no
489
+ verb to change it afterward, so two agents with different allowlists cannot
490
+ safely share one container.
491
+
492
+ **`spf doctor`**, gated on the **resolved backend, per check** — not a
493
+ coarse "not local": checks that only make sense for `opensandbox` (SDK
494
+ presence, control-plane reachability, the `[docker].host_ip` gotcha, image
495
+ pre-pull timing) fire only when some agent resolves to `opensandbox`;
496
+ `cloudflare`'s config/bridge-reachability checks fire only when some agent
497
+ resolves to `cloudflare` — a repo running only `cloudflare` never fails
498
+ doctor over a missing OpenSandbox SDK it will never load. Backend-agnostic
499
+ checks (the resolved sandbox count per chain, per-agent env injection
500
+ posture, egress policy, live in-process leases, the `claude_code` x remote
501
+ rejection, `image`/handoff-scratch-placement/`scope: run`/timeout-relation
502
+ roster-wide static checks, a `.gitmodules` warning) run whenever any agent
503
+ resolves to a non-local backend, regardless of which one.
504
+
505
+ **Submodules are out of scope for this release** — the transport seeds,
506
+ syncs, and extracts the superproject only; `spf doctor` warns (never fails)
507
+ when `.gitmodules` is present.
508
+
416
509
  ### `agents[]`
417
510
 
418
511
  | Field | Required | Meaning |
@@ -424,6 +517,7 @@ dispatch:
424
517
  | `tools` | no | Allowlist. Omitting it means all tools usable. A capability list, not a boundary — see `writes`. |
425
518
  | `writes` | no | What this agent may modify **in the repo**, enforced after every call. `undefined`/`null` = unrestricted (still barred from `protected_files`); `[]` = no repo writes; a list = only those paths (trailing `/` = directory prefix, `*` = one path segment, `**` = crosses segments, anything else = exact path). |
426
519
  | `env_allowlist` | no | Opt-in filter on the environment handed to this agent's subprocess/sandbox. `undefined` (default) = the full operator environment, unchanged. A list = only those keys, plus the baseline (`PATH`, `HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`) either backend keeps regardless. |
520
+ | `sandbox` | no | Per-agent backend override — see `sandbox` below. `undefined`/`null` = inherit `sandbox.backend`; `"local"` = force local for this agent regardless of the repo-wide setting; a backend name = use that backend for this agent only. A bare name, never a nested block — everything else (`image`, `egress`, `transport`, credentials *policy*) stays repo-wide. |
427
521
 
428
522
  Output types are deliberately absent from config: an entry defines who an
429
523
  agent *is*; the call site defines how it's *used*.
@@ -55,6 +55,8 @@ export interface ChainDefinition {
55
55
  */
56
56
  source?: string;
57
57
  }
58
+ /** Chains eligible to fan out: their derived phase string must show at least one commit step. */
59
+ export declare function hasCommitStep(phases: string): boolean;
58
60
  /**
59
61
  * Build a step-based ChainDefinition, deriving phases/requiredAgents/
60
62
  * requiredSuites from its steps.
@@ -2,6 +2,10 @@ import * as steps from "./steps.js";
2
2
  import * as simpleSdlc from "./simple_sdlc.js";
3
3
  import * as otel from "../core/otel.js";
4
4
  import * as session from "../core/session.js";
5
+ /** Chains eligible to fan out: their derived phase string must show at least one commit step. */
6
+ export function hasCommitStep(phases) {
7
+ return phases.includes("git(commit");
8
+ }
5
9
  /**
6
10
  * Build a step-based ChainDefinition, deriving phases/requiredAgents/
7
11
  * requiredSuites from its steps.