@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.
- package/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- 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
|
|
18
|
-
|
|
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
|
|
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*.
|
package/dist/chains/index.d.ts
CHANGED
|
@@ -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.
|
package/dist/chains/index.js
CHANGED
|
@@ -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.
|