@gr8ful/spf 0.8.2 → 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 +52 -16
- package/assets/skill/cookbooks/roster.md +19 -5
- package/assets/skill/references/config.md +57 -23
- package/assets/templates/ts.spf.config.yaml +5 -4
- package/dist/cli/commands/init.js +6 -5
- package/dist/cli/index.js +20 -0
- package/dist/cli/interview.js +13 -5
- 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 +18 -12
- package/dist/core/data_types.js +21 -5
- package/dist/core/notify/channel.d.ts +8 -2
- package/dist/core/notify/notifier.js +6 -1
- package/dist/core/notify/slack_channel.js +1 -1
- package/dist/core/notify/teams_channel.js +1 -1
- package/dist/core/watch.js +11 -11
- 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/...`:
|
|
@@ -608,23 +639,28 @@ for those.
|
|
|
608
639
|
```yaml
|
|
609
640
|
# .spf/spf.config.yaml
|
|
610
641
|
notifications:
|
|
611
|
-
events:
|
|
642
|
+
events: attention # off (default) | errors | attention | all
|
|
612
643
|
channels:
|
|
613
644
|
- kind: slack # slack | teams | webhook
|
|
614
645
|
webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
|
|
615
646
|
```
|
|
616
647
|
|
|
617
|
-
`events` is the whole filter
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
648
|
+
`events` is the whole filter, narrowest to widest:
|
|
649
|
+
|
|
650
|
+
- `errors` sends only true failures — a failed run/phase (`run_failed`,
|
|
651
|
+
`phase_failed`) or a `watch_error`.
|
|
652
|
+
- `attention` adds anything that needs a human but isn't itself a failure —
|
|
653
|
+
a blocked issue (`issue_blocked`) or a spec needing feedback
|
|
654
|
+
(`spec_needs_feedback` — see "Human-in-the-loop escalation" above).
|
|
655
|
+
- `all` adds every remaining milestone — run started/finished, issue
|
|
656
|
+
claimed, PR opened, issue done, a container's roll-up (`feature_done` —
|
|
657
|
+
see "Container roll-up" above), and a spec reaching actual completion
|
|
658
|
+
(`spec_done` — distinct from `spec_refined`, which fires the moment a
|
|
659
|
+
tree is published; see "Human-in-the-loop escalation" above).
|
|
660
|
+
|
|
661
|
+
Each tier includes everything the narrower tiers send. A channel's own
|
|
662
|
+
`events` overrides the top-level scope for just that channel. `spf doctor`
|
|
663
|
+
reports whether each configured channel's env var is set.
|
|
628
664
|
|
|
629
665
|
The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
|
|
630
666
|
names the key, never the URL itself, matching `GITHUB_TOKEN`/
|
|
@@ -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.
|
|
@@ -247,7 +249,7 @@ default; adding it is entirely additive.
|
|
|
247
249
|
|
|
248
250
|
| Field | Type | Meaning |
|
|
249
251
|
|---|---|---|
|
|
250
|
-
| `events` | `"off"` \| `"errors"` \| `"all"` | The whole filter. `off` (default): nothing. `errors`: only failed runs/phases,
|
|
252
|
+
| `events` | `"off"` \| `"errors"` \| `"attention"` \| `"all"` | The whole filter, narrowest to widest. `off` (default): nothing. `errors`: only true failures — failed runs/phases (`run_failed`/`phase_failed`), `watch_error`. `attention`: `errors` plus anything needing a human but not itself a failure — a blocked issue (`issue_blocked`) or a spec needing feedback (`spec_needs_feedback`). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus `attention` and `errors`. |
|
|
251
253
|
| `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
|
|
252
254
|
| `channels[]` | array | See below. |
|
|
253
255
|
|
|
@@ -261,7 +263,7 @@ kind.
|
|
|
261
263
|
|
|
262
264
|
```yaml
|
|
263
265
|
notifications:
|
|
264
|
-
events:
|
|
266
|
+
events: attention
|
|
265
267
|
channels:
|
|
266
268
|
- kind: slack
|
|
267
269
|
webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
|
|
@@ -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`.
|
|
@@ -91,11 +91,12 @@ agents:
|
|
|
91
91
|
# Optional: push notifications for unattended work — spf watch's daemon
|
|
92
92
|
# lifecycle, and every chain run (including watch's own per-issue runs).
|
|
93
93
|
# Interactive commands (doctor, list, sessions, ...) never notify. events:
|
|
94
|
-
# "errors" sends only failures
|
|
95
|
-
#
|
|
96
|
-
#
|
|
94
|
+
# "errors" sends only true failures; "attention" adds blocked issues and
|
|
95
|
+
# feedback requests; "all" adds every milestone too. The webhook URL is a
|
|
96
|
+
# secret — put it in .env under the key named below, never here. See the
|
|
97
|
+
# main README's "Notifications" section.
|
|
97
98
|
# notifications:
|
|
98
|
-
# events:
|
|
99
|
+
# events: attention # off (default) | errors | attention | all
|
|
99
100
|
# channels:
|
|
100
101
|
# - kind: slack # slack | teams | webhook
|
|
101
102
|
# webhook_url_env: SLACK_WEBHOOK_URL
|
|
@@ -112,12 +112,13 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
|
|
|
112
112
|
# lifecycle, and every chain run (spf <chain> / spf run, including watch's
|
|
113
113
|
# own per-issue runs). Interactive commands (doctor, list, sessions, ...)
|
|
114
114
|
# never notify — you're already looking at the terminal for those. events:
|
|
115
|
-
# "errors" sends only failures
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
# section for how to
|
|
115
|
+
# "errors" sends only true failures; "attention" adds blocked issues and
|
|
116
|
+
# feedback requests; "all" adds every milestone too (run started, issue
|
|
117
|
+
# claimed, PR opened, ...). The URL is a secret and lives only in .env —
|
|
118
|
+
# never in this file. See README.md's "Notifications" section for how to
|
|
119
|
+
# get each webhook URL.
|
|
119
120
|
# notifications:
|
|
120
|
-
# events:
|
|
121
|
+
# events: attention # off (default) | errors | attention | all
|
|
121
122
|
# channels:
|
|
122
123
|
# - kind: slack # slack | teams | webhook
|
|
123
124
|
# webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
|
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" },
|
|
@@ -345,9 +350,10 @@ export async function runInterview(asker, ctx) {
|
|
|
345
350
|
let notifications = null;
|
|
346
351
|
if (enableNotify) {
|
|
347
352
|
const events = await asker.select("Notify on", [
|
|
348
|
-
{ value: "errors", label: "errors — failed runs
|
|
349
|
-
{ value: "
|
|
350
|
-
|
|
353
|
+
{ value: "errors", label: "errors — failed runs and watch errors only" },
|
|
354
|
+
{ value: "attention", label: "attention — errors, plus blocked issues and feedback requests" },
|
|
355
|
+
{ value: "all", label: "all — every milestone (claimed, PR opened, done, ...) plus attention and errors" },
|
|
356
|
+
], "attention");
|
|
351
357
|
const channels = [];
|
|
352
358
|
for (;;) {
|
|
353
359
|
const kind = await asker.select("Channel", [
|
|
@@ -431,6 +437,8 @@ export async function runInterview(asker, ctx) {
|
|
|
431
437
|
const quality = defaults["__quality__"];
|
|
432
438
|
delete defaults["__quality__"];
|
|
433
439
|
const config = { defaults };
|
|
440
|
+
if (Object.keys(configEnv).length > 0)
|
|
441
|
+
config.env = configEnv;
|
|
434
442
|
if (agentOverrides.length > 0)
|
|
435
443
|
config.agents = agentOverrides;
|
|
436
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) {
|
|
@@ -681,33 +681,38 @@ export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
|
|
|
681
681
|
* everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
|
|
682
682
|
* it stays console-only on purpose; see `core/notify/notifier.ts`.
|
|
683
683
|
*
|
|
684
|
-
* `events` is the whole filter
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
684
|
+
* `events` is the whole filter, from narrowest to widest:
|
|
685
|
+
* - "off": sends nothing.
|
|
686
|
+
* - "errors": only NotifyEvents whose `level` is "error" (true failures —
|
|
687
|
+
* run_failed, phase_failed, watch_error).
|
|
688
|
+
* - "attention": "errors" PLUS `level: "notice"` events — things that
|
|
689
|
+
* need a human but aren't a failure (issue_blocked, spec_needs_feedback).
|
|
690
|
+
* - "all": every curated milestone, `level: "info"` included.
|
|
691
|
+
* A channel's own `events` overrides the top-level scope for just that
|
|
692
|
+
* channel (e.g. Slack gets everything, Teams gets errors only).
|
|
688
693
|
*
|
|
689
694
|
* `webhook_url_env` names the .env key holding the secret URL — never the
|
|
690
695
|
* URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
|
|
691
696
|
* default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
|
|
692
697
|
*/
|
|
693
|
-
export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "all"], undefined>;
|
|
698
|
+
export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "attention", "all"], undefined>;
|
|
694
699
|
export type NotifyScope = v.InferOutput<typeof NotifyScopeSchema>;
|
|
695
700
|
export declare const NotifyChannelKindSchema: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
696
701
|
export type NotifyChannelKind = v.InferOutput<typeof NotifyChannelKindSchema>;
|
|
697
702
|
export declare const NotifyChannelSchema: v.ObjectSchema<{
|
|
698
703
|
readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
699
704
|
readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
700
|
-
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
|
|
705
|
+
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
701
706
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
702
707
|
}, undefined>;
|
|
703
708
|
export type NotifyChannel = v.InferOutput<typeof NotifyChannelSchema>;
|
|
704
709
|
export declare const NotificationsConfigSchema: v.ObjectSchema<{
|
|
705
|
-
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
|
|
710
|
+
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, "off">;
|
|
706
711
|
readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
|
|
707
712
|
readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
|
|
708
713
|
readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
709
714
|
readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
710
|
-
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
|
|
715
|
+
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
711
716
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
712
717
|
}, undefined>, undefined>, () => never[]>;
|
|
713
718
|
}, undefined>;
|
|
@@ -799,6 +804,7 @@ export declare const TieringConfigSchema: v.ObjectSchema<{
|
|
|
799
804
|
}, undefined>;
|
|
800
805
|
export type TieringConfig = v.InferOutput<typeof TieringConfigSchema>;
|
|
801
806
|
export declare const SFConfigSchema: v.ObjectSchema<{
|
|
807
|
+
readonly env: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
|
|
802
808
|
readonly defaults: v.OptionalSchema<v.ObjectSchema<{
|
|
803
809
|
readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
|
|
804
810
|
readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
|
|
@@ -1013,21 +1019,21 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1013
1019
|
};
|
|
1014
1020
|
}>;
|
|
1015
1021
|
readonly notifications: v.OptionalSchema<v.ObjectSchema<{
|
|
1016
|
-
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
|
|
1022
|
+
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, "off">;
|
|
1017
1023
|
readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
|
|
1018
1024
|
readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
|
|
1019
1025
|
readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
1020
1026
|
readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1021
|
-
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
|
|
1027
|
+
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
1022
1028
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1023
1029
|
}, undefined>, undefined>, () => never[]>;
|
|
1024
1030
|
}, undefined>, () => {
|
|
1025
|
-
events: "all" | "errors" | "off";
|
|
1031
|
+
events: "all" | "attention" | "errors" | "off";
|
|
1026
1032
|
timeout_ms: number;
|
|
1027
1033
|
channels: {
|
|
1028
1034
|
kind: "slack" | "teams" | "webhook";
|
|
1029
1035
|
webhook_url_env: string;
|
|
1030
|
-
events?: "all" | "errors" | "off" | null | undefined;
|
|
1036
|
+
events?: "all" | "attention" | "errors" | "off" | null | undefined;
|
|
1031
1037
|
name: string;
|
|
1032
1038
|
}[];
|
|
1033
1039
|
}>;
|
package/dist/core/data_types.js
CHANGED
|
@@ -538,16 +538,21 @@ export const WatchConfigSchema = v.object({
|
|
|
538
538
|
* everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
|
|
539
539
|
* it stays console-only on purpose; see `core/notify/notifier.ts`.
|
|
540
540
|
*
|
|
541
|
-
* `events` is the whole filter
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
541
|
+
* `events` is the whole filter, from narrowest to widest:
|
|
542
|
+
* - "off": sends nothing.
|
|
543
|
+
* - "errors": only NotifyEvents whose `level` is "error" (true failures —
|
|
544
|
+
* run_failed, phase_failed, watch_error).
|
|
545
|
+
* - "attention": "errors" PLUS `level: "notice"` events — things that
|
|
546
|
+
* need a human but aren't a failure (issue_blocked, spec_needs_feedback).
|
|
547
|
+
* - "all": every curated milestone, `level: "info"` included.
|
|
548
|
+
* A channel's own `events` overrides the top-level scope for just that
|
|
549
|
+
* channel (e.g. Slack gets everything, Teams gets errors only).
|
|
545
550
|
*
|
|
546
551
|
* `webhook_url_env` names the .env key holding the secret URL — never the
|
|
547
552
|
* URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
|
|
548
553
|
* default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
|
|
549
554
|
*/
|
|
550
|
-
export const NotifyScopeSchema = v.picklist(["off", "errors", "all"]);
|
|
555
|
+
export const NotifyScopeSchema = v.picklist(["off", "errors", "attention", "all"]);
|
|
551
556
|
export const NotifyChannelKindSchema = v.picklist(["slack", "teams", "webhook"]);
|
|
552
557
|
export const NotifyChannelSchema = v.object({
|
|
553
558
|
kind: NotifyChannelKindSchema,
|
|
@@ -642,6 +647,17 @@ export const TieringConfigSchema = v.object({
|
|
|
642
647
|
roles: v.optional(v.record(v.string(), v.string()), () => ({})),
|
|
643
648
|
});
|
|
644
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()), () => ({})),
|
|
645
661
|
defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
|
|
646
662
|
observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
|
|
647
663
|
agents: v.optional(v.array(AgentConfigSchema), () => []),
|
|
@@ -14,8 +14,14 @@
|
|
|
14
14
|
export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined" | "spec_needs_feedback" | "feature_done" | "spec_done";
|
|
15
15
|
export interface NotifyEvent {
|
|
16
16
|
kind: NotifyKind;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The whole filter predicate a scope applies (see `NotifyScopeSchema` in
|
|
19
|
+
* `core/data_types.ts`): "error" is a true failure (sends under `errors`,
|
|
20
|
+
* `attention`, and `all`); "notice" needs a human but isn't a failure
|
|
21
|
+
* (sends under `attention` and `all`); "info" is a routine milestone
|
|
22
|
+
* (sends only under `all`).
|
|
23
|
+
*/
|
|
24
|
+
level: "info" | "notice" | "error";
|
|
19
25
|
/** One line, e.g. "run failed — plan-build-test". */
|
|
20
26
|
title: string;
|
|
21
27
|
/** The error text / PR body / block detail, if any. */
|
|
@@ -7,12 +7,17 @@ export const DEFAULT_NOTIFY_ENV_KEY = {
|
|
|
7
7
|
teams: "TEAMS_WEBHOOK_URL",
|
|
8
8
|
webhook: "SPF_WEBHOOK_URL",
|
|
9
9
|
};
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* `off` sends nothing; `errors` only `level: "error"`; `attention` also
|
|
12
|
+
* lets `level: "notice"` through; `all` sends everything.
|
|
13
|
+
*/
|
|
11
14
|
function scopeAllows(scope, level) {
|
|
12
15
|
if (scope === "off")
|
|
13
16
|
return false;
|
|
14
17
|
if (scope === "all")
|
|
15
18
|
return true;
|
|
19
|
+
if (scope === "attention")
|
|
20
|
+
return level === "error" || level === "notice";
|
|
16
21
|
return level === "error";
|
|
17
22
|
}
|
|
18
23
|
export class Notifier {
|
|
@@ -6,7 +6,7 @@ export class SlackChannel {
|
|
|
6
6
|
this.label = name ? `slack (${name})` : "slack";
|
|
7
7
|
}
|
|
8
8
|
async send(event, timeoutMs) {
|
|
9
|
-
const emoji = event.level === "error" ? ":x:" : ":white_check_mark:";
|
|
9
|
+
const emoji = event.level === "error" ? ":x:" : event.level === "notice" ? ":warning:" : ":white_check_mark:";
|
|
10
10
|
const fieldsText = event.fields.map(([k, v]) => `*${k}:* ${v}`).join(" · ");
|
|
11
11
|
const body = {
|
|
12
12
|
text: `${emoji} ${event.title}`,
|
|
@@ -6,7 +6,7 @@ export class TeamsChannel {
|
|
|
6
6
|
this.label = name ? `teams (${name})` : "teams";
|
|
7
7
|
}
|
|
8
8
|
async send(event, timeoutMs) {
|
|
9
|
-
const color = event.level === "error" ? "attention" : "good";
|
|
9
|
+
const color = event.level === "error" ? "attention" : event.level === "notice" ? "warning" : "good";
|
|
10
10
|
const facts = event.fields.map(([title, value]) => ({ title, value }));
|
|
11
11
|
const card = {
|
|
12
12
|
type: "AdaptiveCard",
|
package/dist/core/watch.js
CHANGED
|
@@ -134,7 +134,7 @@ export async function reconcileOrphans(deps, state) {
|
|
|
134
134
|
deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
|
|
135
135
|
deps.notify({
|
|
136
136
|
kind: "issue_blocked",
|
|
137
|
-
level: "
|
|
137
|
+
level: "notice",
|
|
138
138
|
title: `issue ${issue.id} blocked`,
|
|
139
139
|
detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`,
|
|
140
140
|
fields: [["issue", issue.id], ["title", issue.title]],
|
|
@@ -348,11 +348,11 @@ async function escalateSpec(deps, issue, marker, questions, adwId, round) {
|
|
|
348
348
|
.join("\n\n---\n\n") +
|
|
349
349
|
`\n\n---\n\nAnswer inline, then add the \`${deps.labelPrefix}:continue-refinement\` label — refinement resumes from where it left off (adw_id \`${adwId}\`).`;
|
|
350
350
|
deps.notify({
|
|
351
|
-
// "
|
|
352
|
-
// issue_blocked ("spf needs a human"), and it belongs on an
|
|
353
|
-
// channel just as much as an `all`-scope one.
|
|
351
|
+
// "notice" level, not "info" — this is the same class of event as
|
|
352
|
+
// issue_blocked ("spf needs a human"), and it belongs on an
|
|
353
|
+
// `attention`-scope channel just as much as an `all`-scope one.
|
|
354
354
|
kind: "spec_needs_feedback",
|
|
355
|
-
level: "
|
|
355
|
+
level: "notice",
|
|
356
356
|
title: `spec ${issue.id} needs feedback`,
|
|
357
357
|
detail: `${questions.length} question(s), round ${round}.`,
|
|
358
358
|
fields: [
|
|
@@ -398,7 +398,7 @@ export async function reconcileRefining(deps, state) {
|
|
|
398
398
|
deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
|
|
399
399
|
deps.notify({
|
|
400
400
|
kind: "spec_needs_feedback",
|
|
401
|
-
level: "
|
|
401
|
+
level: "notice",
|
|
402
402
|
title: `spec ${issue.id} needs feedback`,
|
|
403
403
|
detail: `Round ${marker.feedback.rounds}.`,
|
|
404
404
|
fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.feedback.rounds)]],
|
|
@@ -419,7 +419,7 @@ export async function reconcileRefining(deps, state) {
|
|
|
419
419
|
deps.log(`watch: spec ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
|
|
420
420
|
deps.notify({
|
|
421
421
|
kind: "issue_blocked",
|
|
422
|
-
level: "
|
|
422
|
+
level: "notice",
|
|
423
423
|
title: `spec ${issue.id} blocked`,
|
|
424
424
|
detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`,
|
|
425
425
|
fields: [["issue", issue.id], ["title", issue.title]],
|
|
@@ -530,7 +530,7 @@ export async function finishReviews(deps) {
|
|
|
530
530
|
deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
|
|
531
531
|
deps.notify({
|
|
532
532
|
kind: "issue_blocked",
|
|
533
|
-
level: "
|
|
533
|
+
level: "notice",
|
|
534
534
|
title: `issue ${issue.id} blocked`,
|
|
535
535
|
detail: `PR #${marker.pr} was closed without merging.`,
|
|
536
536
|
fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
|
|
@@ -569,7 +569,7 @@ async function runIssue(deps, issue) {
|
|
|
569
569
|
const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
570
570
|
deps.notify({
|
|
571
571
|
kind: "issue_blocked",
|
|
572
|
-
level: "
|
|
572
|
+
level: "notice",
|
|
573
573
|
title: `issue ${issue.id} blocked`,
|
|
574
574
|
detail,
|
|
575
575
|
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
@@ -583,7 +583,7 @@ async function runIssue(deps, issue) {
|
|
|
583
583
|
deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
|
|
584
584
|
deps.notify({
|
|
585
585
|
kind: "issue_blocked",
|
|
586
|
-
level: "
|
|
586
|
+
level: "notice",
|
|
587
587
|
title: `issue ${issue.id} blocked`,
|
|
588
588
|
detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
|
|
589
589
|
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
@@ -722,7 +722,7 @@ async function runSpec(deps, issue) {
|
|
|
722
722
|
const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
723
723
|
deps.notify({
|
|
724
724
|
kind: "issue_blocked",
|
|
725
|
-
level: "
|
|
725
|
+
level: "notice",
|
|
726
726
|
title: `spec ${issue.id} blocked`,
|
|
727
727
|
detail,
|
|
728
728
|
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain], ["adw_id", adwId]],
|