@gr8ful/spf 0.8.3 → 0.9.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 +35 -4
- package/assets/skill/cookbooks/roster.md +19 -5
- package/assets/skill/references/config.md +55 -21
- package/dist/cli/index.js +20 -0
- package/dist/cli/interview.js +9 -2
- package/dist/core/agent_cc.d.ts +24 -2
- package/dist/core/agent_cc.js +27 -3
- package/dist/core/agents.d.ts +20 -0
- package/dist/core/agents.js +38 -0
- package/dist/core/data_types.d.ts +1 -0
- package/dist/core/data_types.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ A software factory does one thing: it gives you more leverage on your prompt. Ho
|
|
|
7
7
|
|
|
8
8
|
Everyone can get an agent to write code once. Almost nobody gets the same result twice. This fixes that by moving the control plane out of the prompt and into TypeScript. A chain script owns sequencing, retries, and acceptance. Agents work inside named phases. Typed JSON envelopes carry context across the seams. Every event streams into SQLite while it is still happening. **Agent proposes, code disposes.**
|
|
9
9
|
|
|
10
|
+
> **Beta.** SPF is under active development — config schema, chain/CLI surface, and trace/event shapes may change between releases without a deprecation period. Pin a version if you depend on any of these staying stable.
|
|
11
|
+
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
## Install
|
|
@@ -124,17 +126,34 @@ Set `coding_agent: claude_code` on any agent (or in `defaults`) to run it on you
|
|
|
124
126
|
|
|
125
127
|
#### Proxy or wrapper launchers
|
|
126
128
|
|
|
127
|
-
To route the `claude` command through a wrapper, proxy server, or launcher (e.g., [Ollama](https://ollama.com)), set
|
|
129
|
+
To route the `claude` command through a wrapper, proxy server, or launcher (e.g., [Ollama](https://ollama.com)), set `SPF_CLAUDE_CMD` — as a declarative `env:` entry in `spf.config.yaml`, since it's a launcher choice, not a secret (see [Declarative env vars](#declarative-env-vars) below), or as a plain shell export for a one-off override. Space-separated command chains are supported:
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
env:
|
|
133
|
+
SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
|
|
134
|
+
```
|
|
128
135
|
|
|
129
136
|
```bash
|
|
130
|
-
# Route through Ollama's launcher
|
|
131
|
-
export SPF_CLAUDE_CMD="ollama launch claude --model granite4.1:8b"
|
|
132
137
|
spf build "your prompt"
|
|
133
138
|
```
|
|
134
139
|
|
|
135
140
|
`ollama launch <cmd>` uses cobra flag parsing, which treats anything typed after it as its own flags unless a literal `--` says otherwise — without one, `claude`'s own flags (`-p`, `--json-schema`, ...) fail with `unknown shorthand flag: 'p' in -p` before `claude` is ever reached. `agent_cc.ts` detects exactly this `ollama launch ...` shape and inserts that `--` automatically, so you never add the separator by hand for this specific launcher.
|
|
136
141
|
|
|
137
|
-
That `--` alone is not enough to reach `claude`, though: `ollama launch` also needs its OWN `--model <tag>` flag
|
|
142
|
+
That `--` alone is not enough to reach `claude`, though: `ollama launch` also needs its OWN `--model <tag>` flag, typed BEFORE the auto-inserted `--`, whenever it runs headless — which it always does under SPF, since SPF spawns with piped stdio. Without it, `ollama launch` falls back to an interactive model picker that can never run, and fails one step later than the `--` problem, with `model selection requires an interactive terminal; use --model to run in headless mode`. A `--model` typed after the `--` doesn't help — at that point it belongs to `claude`, not to `ollama launch`. So `SPF_CLAUDE_CMD` must include `ollama launch`'s `--model` yourself; `spf doctor` hard-fails if it's missing.
|
|
143
|
+
|
|
144
|
+
**Per-agent model choice:** a literal `{model}` token in `SPF_CLAUDE_CMD` is substituted with each call's own agent-config `model:` field before spawning. Since `ollama launch`'s `--model` (its OWN flag) is what actually pins the model serving the whole launched process — not `claude`'s own `--model`, appended after the `--` — a FIXED tag in `SPF_CLAUDE_CMD` means every `claude_code` agent runs on that one model regardless of what each agent's `model:` says. Using `{model}` instead makes it genuinely per-agent:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
env:
|
|
148
|
+
SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
|
|
149
|
+
agents:
|
|
150
|
+
- name: planner
|
|
151
|
+
coding_agent: claude_code
|
|
152
|
+
model: kimi-k2.7-code:cloud
|
|
153
|
+
- name: builder
|
|
154
|
+
coding_agent: claude_code
|
|
155
|
+
model: qwen3-coder:cloud
|
|
156
|
+
```
|
|
138
157
|
|
|
139
158
|
```bash
|
|
140
159
|
# Or use a custom wrapper script
|
|
@@ -144,6 +163,18 @@ spf build "your prompt"
|
|
|
144
163
|
|
|
145
164
|
The command/launcher must support the full Claude Code CLI interface: `-p` for prompt, `--json-schema`, `--model`, `--session-id`/`--resume`, `--output-format stream-json`, and all other flags `agent_cc` uses. When unset, `SPF_CLAUDE_CMD` defaults to `claude` (resolved from `PATH` normally). A cmdSpec that already contains its own literal `--` is left completely alone — `agent_cc.ts` never inserts a second one.
|
|
146
165
|
|
|
166
|
+
#### Declarative env vars
|
|
167
|
+
|
|
168
|
+
`spf.config.yaml` supports an `env:` block for settings like `SPF_CLAUDE_CMD` that are configuration, not secrets — so they can be committed as plain, reviewable text instead of hidden in an out-of-band shell export:
|
|
169
|
+
|
|
170
|
+
```yaml
|
|
171
|
+
env:
|
|
172
|
+
SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
|
|
173
|
+
SOME_TOKEN: "${SOME_SECRET_FROM_DOTENV}"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Applied to `process.env` before any command runs. A plain value commits as literal text; `${VAR}` interpolates from whatever's already in `process.env` at that point (the real shell, or `.env`, both of which load first) — so a real secret can live in `.env` (gitignored) and be referenced here without ever being written into this file. An already-set `process.env` value always wins over `env:`'s — `spf.config.yaml` supplies the default, a real export still overrides it per machine/session. A `${VAR}` reference to something that's genuinely unset fails loudly at startup, naming the missing variable, rather than silently interpolating to an empty string.
|
|
177
|
+
|
|
147
178
|
### flue + local Ollama
|
|
148
179
|
|
|
149
180
|
Point the default `flue` backend at a local Ollama server the same way you'd pick any other Flue provider — the model string's own prefix, `ollama/<tag>` (whatever `ollama list` shows on your machine) instead of `openai/...`/`anthropic/...`:
|
|
@@ -99,11 +99,25 @@ SPF-specific plumbing, no `provider:` config section to write.
|
|
|
99
99
|
|
|
100
100
|
There's a second, different way to reach Ollama through `claude_code`: route
|
|
101
101
|
the `claude` command itself through `ollama launch claude` via
|
|
102
|
-
`SPF_CLAUDE_CMD
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
`SPF_CLAUDE_CMD` — as a declarative `env:` entry in `spf.config.yaml`
|
|
103
|
+
(non-secret; see `config.md`'s "Declarative env vars" section), not a raw
|
|
104
|
+
shell export. That launcher form needs its own `--model <tag>` flag in the
|
|
105
|
+
command string, since SPF always spawns headless — use the literal token
|
|
106
|
+
`{model}` there (substituted with each call's own agent `model:` field)
|
|
107
|
+
rather than one fixed tag, or every `claude_code` agent ends up sharing the
|
|
108
|
+
same model regardless of its own `model:`:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
env:
|
|
112
|
+
SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
|
|
113
|
+
agents:
|
|
114
|
+
- name: builder
|
|
115
|
+
coding_agent: claude_code
|
|
116
|
+
model: kimi-k2.7-code:cloud
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
See README.md's "Proxy or wrapper launchers" section for the full command
|
|
120
|
+
and why `--model` is mandatory there, not optional.
|
|
107
121
|
|
|
108
122
|
## Retune tools
|
|
109
123
|
|
|
@@ -12,7 +12,7 @@ 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 (`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
|
|
15
|
+
field by field (`env`/`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
|
|
16
16
|
merge key-by-key — `notifications.channels` replaces wholesale, same as
|
|
17
17
|
`quality.checks` and `tiering.tiers`/`tiering.roles`; `agents` merges by
|
|
18
18
|
`name`: a matching name patches that entry, a new name appends).
|
|
@@ -20,7 +20,9 @@ always shows the resolved, merged result for the repo you're in.
|
|
|
20
20
|
underneath it.
|
|
21
21
|
|
|
22
22
|
`spf init` seeds step 2 — on a TTY, via an interview that asks the fields
|
|
23
|
-
below and appends whatever secrets they imply to `.env
|
|
23
|
+
below and appends whatever secrets they imply to `.env` (a non-secret,
|
|
24
|
+
declarative setting like `SPF_CLAUDE_CMD` goes into this file's own `env:`
|
|
25
|
+
block instead — see "Declarative env vars" below); non-interactively
|
|
24
26
|
(`--yes`, `--template <name>`, or no TTY) it writes a commented starter
|
|
25
27
|
instead. Omitting `.spf/spf.config.yaml` entirely means running off pure
|
|
26
28
|
built-ins, which is a fully supported, valid state.
|
|
@@ -497,28 +499,60 @@ change made inside that backend's own module, not config — see
|
|
|
497
499
|
|
|
498
500
|
## Pointing `claude_code` at Ollama
|
|
499
501
|
|
|
500
|
-
|
|
501
|
-
`
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
exact commands. `spf doctor` probes `ANTHROPIC_BASE_URL` (informational — a
|
|
506
|
-
down endpoint or wrong path is reported, never a hard failure) and flags a
|
|
507
|
-
base URL that already ends in `/v1` as a likely double-path mistake, since
|
|
502
|
+
Set `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (local or cloud Ollama)
|
|
503
|
+
before running `spf`; see `roster.md`'s "Coding agent backends" section for
|
|
504
|
+
the exact commands. `spf doctor` probes `ANTHROPIC_BASE_URL` (informational
|
|
505
|
+
— a down endpoint or wrong path is reported, never a hard failure) and flags
|
|
506
|
+
a base URL that already ends in `/v1` as a likely double-path mistake, since
|
|
508
507
|
the `claude` CLI appends `/v1/messages` itself.
|
|
509
508
|
|
|
510
|
-
Routing through `SPF_CLAUDE_CMD
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
509
|
+
Routing through `SPF_CLAUDE_CMD` instead — e.g.
|
|
510
|
+
`env: { SPF_CLAUDE_CMD: "ollama launch claude --model {model}" }` in
|
|
511
|
+
`spf.config.yaml` (declarative, not a secret — see "Declarative env vars"
|
|
512
|
+
below; a plain shell export also works and still wins) — needs an explicit
|
|
513
|
+
`--` before `claude`'s own flags (cobra flag parsing otherwise consumes them
|
|
514
|
+
as `ollama launch`'s own) — `agent_cc.ts` detects this exact
|
|
515
|
+
`ollama launch ...` token shape and inserts that separator automatically, so
|
|
516
|
+
you never add it by hand. The `--model` before that separator is NOT
|
|
517
|
+
optional, though: it's `ollama launch`'s own flag, and it's mandatory in
|
|
518
|
+
headless mode (SPF always pipes stdio, so the interactive model picker
|
|
519
|
+
`ollama launch` falls back to without it can never run) — a `--model` typed
|
|
520
|
+
after the `--` belongs to `claude`, not to `ollama launch`, and doesn't help.
|
|
521
|
+
`spf doctor` hard-fails a `SPF_CLAUDE_CMD` missing it. A literal `{model}`
|
|
522
|
+
token anywhere in `SPF_CLAUDE_CMD` is substituted with the calling agent's
|
|
523
|
+
own `model:` field before spawning — since `ollama launch`'s `--model` is
|
|
524
|
+
what actually pins the model for the whole launched process, a FIXED tag
|
|
525
|
+
there means every `claude_code` agent shares one model regardless of its own
|
|
526
|
+
`model:`; `{model}` is what makes per-agent model choice real under this
|
|
527
|
+
launcher. See README.md's "Proxy or wrapper launchers" section for the full
|
|
528
|
+
explanation.
|
|
521
529
|
|
|
522
530
|
For `flue` (the default backend) pointed at Ollama instead of `claude_code`
|
|
523
531
|
— i.e. `model: ollama/<tag>` — see "Model resolution" above and README.md's
|
|
524
532
|
"flue + local Ollama" section.
|
|
533
|
+
|
|
534
|
+
## Declarative env vars
|
|
535
|
+
|
|
536
|
+
A top-level `env:` map in `spf.config.yaml` sets `process.env` defaults for
|
|
537
|
+
settings that are configuration, not secrets — `SPF_CLAUDE_CMD` above is the
|
|
538
|
+
motivating case. Applied once, in `cli/index.ts`'s `main()`, right after
|
|
539
|
+
`.env` loads and before any command runs. Merge is key-by-key (like
|
|
540
|
+
`defaults`/`watch`), so a `.spf/spf.config.yaml` override can add one key
|
|
541
|
+
without repeating the rest.
|
|
542
|
+
|
|
543
|
+
```yaml
|
|
544
|
+
env:
|
|
545
|
+
SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
|
|
546
|
+
SOME_TOKEN: "${SOME_SECRET_FROM_DOTENV}"
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
A plain value commits as literal text. `${VAR}` interpolates from whatever's
|
|
550
|
+
already in `process.env` at that point — the real shell, or `.env`, both of
|
|
551
|
+
which load first — so a genuine secret can stay in `.env` (gitignored) and
|
|
552
|
+
be referenced here without ever being written into this (committed) file.
|
|
553
|
+
Precedence: an already-set `process.env` value always wins over `env:`'s —
|
|
554
|
+
this block supplies a default, never forces an override, the same way
|
|
555
|
+
`process.loadEnvFile()` itself never overwrites an already-set var. A
|
|
556
|
+
`${VAR}` reference to something genuinely unset fails loudly at startup,
|
|
557
|
+
naming the missing variable — see `agents.ts`'s `interpolateEnvValue`/
|
|
558
|
+
`applyConfigEnv`.
|
package/dist/cli/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* common case. Everything else is a named subcommand.
|
|
6
6
|
*/
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import * as agents from "../core/agents.js";
|
|
8
9
|
import * as agentCc from "../core/agent_cc.js";
|
|
9
10
|
import * as agentFlue from "../core/agent_flue.js";
|
|
10
11
|
import * as notify from "../core/notify/notifier.js";
|
|
@@ -57,6 +58,11 @@ function findCwdFlag(argv) {
|
|
|
57
58
|
const idx = argv.indexOf("--cwd");
|
|
58
59
|
return idx !== -1 ? argv[idx + 1] : undefined;
|
|
59
60
|
}
|
|
61
|
+
/** Same idea, for `--config` — needed before dispatch too, to resolve `cfg.env` early. */
|
|
62
|
+
function findConfigFlag(argv) {
|
|
63
|
+
const idx = argv.indexOf("--config");
|
|
64
|
+
return idx !== -1 ? argv[idx + 1] : undefined;
|
|
65
|
+
}
|
|
60
66
|
/**
|
|
61
67
|
* The moment someone types a chain name that doesn't resolve is the highest-
|
|
62
68
|
* traffic place a broken `.spf/chains/*.yaml` file is ever discovered — and,
|
|
@@ -104,6 +110,20 @@ export async function main() {
|
|
|
104
110
|
catch {
|
|
105
111
|
// no .env there — fine, nothing to load
|
|
106
112
|
}
|
|
113
|
+
// `cfg.env` (spf.config.yaml's declarative env-var defaults — see
|
|
114
|
+
// data_types.ts's SFConfigSchema doc comment) has to land in process.env
|
|
115
|
+
// BEFORE any command reads it (SPF_CLAUDE_CMD, a provider key, ...), and
|
|
116
|
+
// .env/the real shell (just loaded above) must still win over it. Best-
|
|
117
|
+
// effort only: a config that fails to load here (missing, invalid yaml) is
|
|
118
|
+
// reported properly by whatever the actual command's own `agents.loadConfig`
|
|
119
|
+
// call does moments later — this pass exists purely to apply `env`, early.
|
|
120
|
+
try {
|
|
121
|
+
const resolution = paths.resolveConfigPaths(anchor, findConfigFlag(rest));
|
|
122
|
+
agents.applyConfigEnv(agents.loadConfig(resolution.paths).env);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// reported properly by the real command dispatch below, if it matters there
|
|
126
|
+
}
|
|
107
127
|
// Repo-local chains as DATA: a `.spf/chains/*.yaml` file names existing
|
|
108
128
|
// step factories, it never imports repo code — SPF's own dispatcher stays
|
|
109
129
|
// the one thing that ever executes. Registered here, before the command
|
package/dist/cli/interview.js
CHANGED
|
@@ -79,6 +79,11 @@ export async function runInterview(asker, ctx) {
|
|
|
79
79
|
const defaults = {};
|
|
80
80
|
const agentOverrides = [];
|
|
81
81
|
const env = {};
|
|
82
|
+
// Declarative, non-secret env vars — written to spf.config.yaml's `env:`
|
|
83
|
+
// (committable) rather than `.env` (gitignored, and read once from the
|
|
84
|
+
// shell rather than declared). `SPF_CLAUDE_CMD` is the one case so far:
|
|
85
|
+
// it names a launcher/wrapper command, not a credential.
|
|
86
|
+
const configEnv = {};
|
|
82
87
|
const envExampleKeys = [];
|
|
83
88
|
const notes = [];
|
|
84
89
|
// ── 1. coding agent ────────────────────────────────────────────────────────
|
|
@@ -92,9 +97,9 @@ export async function runInterview(asker, ctx) {
|
|
|
92
97
|
if (!ctx.claudeOnPath) {
|
|
93
98
|
asker.note("warning: `claude` was not found on PATH — install it (or set a launch command below) before running spf.");
|
|
94
99
|
}
|
|
95
|
-
const launchCommand = await asker.text('Launch command for the `claude` CLI — e.g. "ollama launch claude" to route through a wrapper (SPF_CLAUDE_CMD)', { default: "claude" });
|
|
100
|
+
const launchCommand = await asker.text('Launch command for the `claude` CLI — e.g. "ollama launch claude --model {model}" to route through a wrapper, with {model} substituted per-agent (writes env.SPF_CLAUDE_CMD to spf.config.yaml)', { default: "claude" });
|
|
96
101
|
if (launchCommand !== "claude")
|
|
97
|
-
|
|
102
|
+
configEnv["SPF_CLAUDE_CMD"] = launchCommand;
|
|
98
103
|
const model = await asker.select("Model (Claude Code's own vocabulary — not provider/model-id)", [
|
|
99
104
|
{ value: "sonnet", label: "sonnet" },
|
|
100
105
|
{ value: "opus", label: "opus" },
|
|
@@ -432,6 +437,8 @@ export async function runInterview(asker, ctx) {
|
|
|
432
437
|
const quality = defaults["__quality__"];
|
|
433
438
|
delete defaults["__quality__"];
|
|
434
439
|
const config = { defaults };
|
|
440
|
+
if (Object.keys(configEnv).length > 0)
|
|
441
|
+
config.env = configEnv;
|
|
435
442
|
if (agentOverrides.length > 0)
|
|
436
443
|
config.agents = agentOverrides;
|
|
437
444
|
if (quality)
|
package/dist/core/agent_cc.d.ts
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
|
|
9
9
|
*
|
|
10
10
|
* The `claude` command is resolved from `PATH` by default. To route it through
|
|
11
|
-
* a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
|
|
12
|
-
*
|
|
11
|
+
* a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD` —
|
|
12
|
+
* ordinarily via `env: { SPF_CLAUDE_CMD: "..." }` in `spf.config.yaml`
|
|
13
|
+
* (see `SFConfigSchema`'s doc comment), since this is a declarative launcher
|
|
14
|
+
* choice, not a secret; a real shell export still works and still wins, for
|
|
15
|
+
* a one-off local override. Space-separated command chains are supported:
|
|
13
16
|
* - `SPF_CLAUDE_CMD="claude"` (default)
|
|
14
17
|
* - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher —
|
|
15
18
|
* the `--model` is `ollama launch`'s OWN flag, and is mandatory in
|
|
@@ -17,6 +20,17 @@
|
|
|
17
20
|
* The command/launcher must support the full Claude Code CLI interface.
|
|
18
21
|
* When unset, defaults to `claude`.
|
|
19
22
|
*
|
|
23
|
+
* A literal `{model}` token anywhere in `SPF_CLAUDE_CMD` is substituted with
|
|
24
|
+
* THIS call's own `request.model` before spawning — e.g.
|
|
25
|
+
* `SPF_CLAUDE_CMD="ollama launch claude --model {model}"` with one agent's
|
|
26
|
+
* `model: kimi-k2.7-code:cloud` and another's `model: qwen3-coder:cloud`
|
|
27
|
+
* genuinely dispatches each to its own model, since `ollama launch`'s
|
|
28
|
+
* `--model` (its OWN flag, resolved BEFORE `--`, not `claude`'s) is what
|
|
29
|
+
* actually pins which model serves the whole launched process — without
|
|
30
|
+
* `{model}`, that flag would have to be one fixed value for every
|
|
31
|
+
* claude_code agent in the roster, making each agent's own `model:` field
|
|
32
|
+
* inert no matter what it said.
|
|
33
|
+
*
|
|
20
34
|
* Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
|
|
21
35
|
* `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
|
|
22
36
|
* ...args]`, so a plain passthrough shim needs nothing special, and a
|
|
@@ -101,6 +115,14 @@ export declare class CcToolCallTracker {
|
|
|
101
115
|
private finish;
|
|
102
116
|
}
|
|
103
117
|
export declare function isKnownToolName(name: string): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
|
|
120
|
+
* token with `model` — see the module doc comment for why a fixed tag baked
|
|
121
|
+
* into the wrapper string can't give per-agent model choice under a
|
|
122
|
+
* launcher like `ollama launch`. Exported as its own pure function so this
|
|
123
|
+
* substitution is unit-testable without spawning a real subprocess.
|
|
124
|
+
*/
|
|
125
|
+
export declare function resolveClaudeCmdSpec(model: string): string;
|
|
104
126
|
/** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
|
|
105
127
|
export declare function shutdown(): Promise<void>;
|
|
106
128
|
/**
|
package/dist/core/agent_cc.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
|
|
9
9
|
*
|
|
10
10
|
* The `claude` command is resolved from `PATH` by default. To route it through
|
|
11
|
-
* a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
|
|
12
|
-
*
|
|
11
|
+
* a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD` —
|
|
12
|
+
* ordinarily via `env: { SPF_CLAUDE_CMD: "..." }` in `spf.config.yaml`
|
|
13
|
+
* (see `SFConfigSchema`'s doc comment), since this is a declarative launcher
|
|
14
|
+
* choice, not a secret; a real shell export still works and still wins, for
|
|
15
|
+
* a one-off local override. Space-separated command chains are supported:
|
|
13
16
|
* - `SPF_CLAUDE_CMD="claude"` (default)
|
|
14
17
|
* - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher —
|
|
15
18
|
* the `--model` is `ollama launch`'s OWN flag, and is mandatory in
|
|
@@ -17,6 +20,17 @@
|
|
|
17
20
|
* The command/launcher must support the full Claude Code CLI interface.
|
|
18
21
|
* When unset, defaults to `claude`.
|
|
19
22
|
*
|
|
23
|
+
* A literal `{model}` token anywhere in `SPF_CLAUDE_CMD` is substituted with
|
|
24
|
+
* THIS call's own `request.model` before spawning — e.g.
|
|
25
|
+
* `SPF_CLAUDE_CMD="ollama launch claude --model {model}"` with one agent's
|
|
26
|
+
* `model: kimi-k2.7-code:cloud` and another's `model: qwen3-coder:cloud`
|
|
27
|
+
* genuinely dispatches each to its own model, since `ollama launch`'s
|
|
28
|
+
* `--model` (its OWN flag, resolved BEFORE `--`, not `claude`'s) is what
|
|
29
|
+
* actually pins which model serves the whole launched process — without
|
|
30
|
+
* `{model}`, that flag would have to be one fixed value for every
|
|
31
|
+
* claude_code agent in the roster, making each agent's own `model:` field
|
|
32
|
+
* inert no matter what it said.
|
|
33
|
+
*
|
|
20
34
|
* Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
|
|
21
35
|
* `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
|
|
22
36
|
* ...args]`, so a plain passthrough shim needs nothing special, and a
|
|
@@ -225,6 +239,16 @@ const EFFORT_MAP = {
|
|
|
225
239
|
xhigh: "xhigh",
|
|
226
240
|
max: "max",
|
|
227
241
|
};
|
|
242
|
+
/**
|
|
243
|
+
* Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
|
|
244
|
+
* token with `model` — see the module doc comment for why a fixed tag baked
|
|
245
|
+
* into the wrapper string can't give per-agent model choice under a
|
|
246
|
+
* launcher like `ollama launch`. Exported as its own pure function so this
|
|
247
|
+
* substitution is unit-testable without spawning a real subprocess.
|
|
248
|
+
*/
|
|
249
|
+
export function resolveClaudeCmdSpec(model) {
|
|
250
|
+
return (process.env.SPF_CLAUDE_CMD || "claude").replaceAll("{model}", model);
|
|
251
|
+
}
|
|
228
252
|
// ── process lifecycle ────────────────────────────────────────────────────────
|
|
229
253
|
const inFlight = new Set();
|
|
230
254
|
/** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
|
|
@@ -278,7 +302,7 @@ export async function run(request, onEvent, onSpawn, onExit) {
|
|
|
278
302
|
toolsFlagValue(request.tools),
|
|
279
303
|
"--strict-mcp-config", // see the module doc comment — required, not optional
|
|
280
304
|
];
|
|
281
|
-
const cmdSpec =
|
|
305
|
+
const cmdSpec = resolveClaudeCmdSpec(request.model);
|
|
282
306
|
const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
|
|
283
307
|
const [cmd, ...cmdArgs] = cmdTokens;
|
|
284
308
|
// See the module doc comment for why `ollama launch ...` (and ONLY that
|
package/dist/core/agents.d.ts
CHANGED
|
@@ -75,6 +75,26 @@ export declare function assertRunBudget(run: RunBudgetState): void;
|
|
|
75
75
|
* and agents.validate() reports that plainly.
|
|
76
76
|
*/
|
|
77
77
|
export declare function loadConfig(configPaths: string[]): SFConfig;
|
|
78
|
+
/**
|
|
79
|
+
* `${VAR}` substitutes from `process.env` at call time — a real secret can
|
|
80
|
+
* live in `.env` (gitignored) and be referenced from the committed
|
|
81
|
+
* `spf.config.yaml` without ever being written into it. An undefined
|
|
82
|
+
* reference throws rather than silently interpolating to `""`: a value like
|
|
83
|
+
* `SPF_CLAUDE_CMD="ollama launch claude --model ${OLLAMA_TAG}"` silently
|
|
84
|
+
* missing its tag would fail confusingly far downstream (a launcher error,
|
|
85
|
+
* or a wrong-but-plausible model), not here where the actual cause is known.
|
|
86
|
+
*/
|
|
87
|
+
export declare function interpolateEnvValue(key: string, raw: string): string;
|
|
88
|
+
/**
|
|
89
|
+
* Apply `cfg.env` to `process.env` — called once, in `cli/index.ts`'s
|
|
90
|
+
* `main()`, right after `.env` loads and before any command runs, so every
|
|
91
|
+
* later `process.env[...]` read (provider keys, `SPF_CLAUDE_CMD`, ...) sees
|
|
92
|
+
* the result. A key already set in `process.env` (a real shell export, or
|
|
93
|
+
* `.env`, both of which load before this) is left untouched — `cfg.env`
|
|
94
|
+
* supplies a DEFAULT, never forces an override, mirroring
|
|
95
|
+
* `process.loadEnvFile()`'s own precedence for `.env` itself.
|
|
96
|
+
*/
|
|
97
|
+
export declare function applyConfigEnv(env: Record<string, string>): void;
|
|
78
98
|
export declare function resolve(cfg: SFConfig, name: string): AgentConfig;
|
|
79
99
|
/** Fail fast: every required name must resolve to a usable agent. */
|
|
80
100
|
export declare function validate(cfg: SFConfig, required: string[], requiredSuites?: string[], cwd?: string): void;
|
package/dist/core/agents.js
CHANGED
|
@@ -146,6 +146,9 @@ function mergeAgentLists(base, override) {
|
|
|
146
146
|
*/
|
|
147
147
|
function mergeRawConfig(base, override) {
|
|
148
148
|
return {
|
|
149
|
+
// Key-by-key, like `defaults`/`watch` — a repo override adding ONE env
|
|
150
|
+
// var shouldn't have to repeat every built-in one.
|
|
151
|
+
env: { ...(base.env || {}), ...(override.env || {}) },
|
|
149
152
|
defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
|
|
150
153
|
observability: { ...(base.observability || {}), ...(override.observability || {}) },
|
|
151
154
|
quality: { ...(base.quality || {}), ...(override.quality || {}) },
|
|
@@ -220,6 +223,41 @@ export function loadConfig(configPaths) {
|
|
|
220
223
|
throw new Error(`invalid config (${configPaths.join(", ")}): ${describeParseError(error)}`);
|
|
221
224
|
}
|
|
222
225
|
}
|
|
226
|
+
const ENV_VAR_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
227
|
+
/**
|
|
228
|
+
* `${VAR}` substitutes from `process.env` at call time — a real secret can
|
|
229
|
+
* live in `.env` (gitignored) and be referenced from the committed
|
|
230
|
+
* `spf.config.yaml` without ever being written into it. An undefined
|
|
231
|
+
* reference throws rather than silently interpolating to `""`: a value like
|
|
232
|
+
* `SPF_CLAUDE_CMD="ollama launch claude --model ${OLLAMA_TAG}"` silently
|
|
233
|
+
* missing its tag would fail confusingly far downstream (a launcher error,
|
|
234
|
+
* or a wrong-but-plausible model), not here where the actual cause is known.
|
|
235
|
+
*/
|
|
236
|
+
export function interpolateEnvValue(key, raw) {
|
|
237
|
+
return raw.replace(ENV_VAR_REF, (_match, name) => {
|
|
238
|
+
const value = process.env[name];
|
|
239
|
+
if (value === undefined) {
|
|
240
|
+
throw new Error(`env.${key}: references \${${name}}, which is not set (.env or the shell) — set it before running spf`);
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Apply `cfg.env` to `process.env` — called once, in `cli/index.ts`'s
|
|
247
|
+
* `main()`, right after `.env` loads and before any command runs, so every
|
|
248
|
+
* later `process.env[...]` read (provider keys, `SPF_CLAUDE_CMD`, ...) sees
|
|
249
|
+
* the result. A key already set in `process.env` (a real shell export, or
|
|
250
|
+
* `.env`, both of which load before this) is left untouched — `cfg.env`
|
|
251
|
+
* supplies a DEFAULT, never forces an override, mirroring
|
|
252
|
+
* `process.loadEnvFile()`'s own precedence for `.env` itself.
|
|
253
|
+
*/
|
|
254
|
+
export function applyConfigEnv(env) {
|
|
255
|
+
for (const [key, rawValue] of Object.entries(env)) {
|
|
256
|
+
if (process.env[key] !== undefined)
|
|
257
|
+
continue;
|
|
258
|
+
process.env[key] = interpolateEnvValue(key, rawValue);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
223
261
|
export function resolve(cfg, name) {
|
|
224
262
|
const agent = cfg.agents.find((a) => a.name === name);
|
|
225
263
|
if (!agent) {
|
|
@@ -804,6 +804,7 @@ export declare const TieringConfigSchema: v.ObjectSchema<{
|
|
|
804
804
|
}, undefined>;
|
|
805
805
|
export type TieringConfig = v.InferOutput<typeof TieringConfigSchema>;
|
|
806
806
|
export declare const SFConfigSchema: v.ObjectSchema<{
|
|
807
|
+
readonly env: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
|
|
807
808
|
readonly defaults: v.OptionalSchema<v.ObjectSchema<{
|
|
808
809
|
readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
|
|
809
810
|
readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
|
package/dist/core/data_types.js
CHANGED
|
@@ -647,6 +647,17 @@ export const TieringConfigSchema = v.object({
|
|
|
647
647
|
roles: v.optional(v.record(v.string(), v.string()), () => ({})),
|
|
648
648
|
});
|
|
649
649
|
export const SFConfigSchema = v.object({
|
|
650
|
+
// Declarative env vars, applied to process.env before anything else reads
|
|
651
|
+
// it (see cli/index.ts's main()) — for non-secret settings that used to
|
|
652
|
+
// require an out-of-band shell export (e.g. SPF_CLAUDE_CMD) to be
|
|
653
|
+
// committable, plain text config instead of hidden process state. A value
|
|
654
|
+
// already set in process.env (the real shell, or .env, which loads first)
|
|
655
|
+
// always wins — this only supplies a DEFAULT, never forces an override.
|
|
656
|
+
// `${VAR}` inside a value interpolates from process.env at that same
|
|
657
|
+
// point, specifically so a real secret can be pulled in from `.env`
|
|
658
|
+
// without ever being written into this (committed) file — see
|
|
659
|
+
// `applyConfigEnv`/`interpolateEnvValue` in agents.ts.
|
|
660
|
+
env: v.optional(v.record(v.string(), v.string()), () => ({})),
|
|
650
661
|
defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
|
|
651
662
|
observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
|
|
652
663
|
agents: v.optional(v.array(AgentConfigSchema), () => []),
|