@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 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 the `SPF_CLAUDE_CMD` environment variable before running `spf`. Space-separated command chains are supported:
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 (a tag from `ollama list`), 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, exactly as written above; `spf doctor` hard-fails if it's missing.
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: errors # off (default) | errors | all
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: `errors` sends only failed runs/phases, blocked
618
- issues, watch errors, and a spec needing feedback (`spec_needs_feedback` —
619
- see "Human-in-the-loop escalation" above; it's `error`-level on purpose, the
620
- same class of event as a blocked issue, so an `errors`-scope channel sees it
621
- too); `all` adds every milestone run started/finished, issue claimed, PR
622
- opened, issue done, a container's roll-up (`feature_done` see "Container
623
- roll-up" above), and a spec reaching actual completion (`spec_done` —
624
- distinct from `spec_refined`, which fires the moment a tree is published;
625
- see "Human-in-the-loop escalation" above). A channel's own `events`
626
- overrides the top-level scope for just that channel. `spf doctor` reports
627
- whether each configured channel's env var is set.
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`, instead of pointing `ANTHROPIC_BASE_URL` at Ollama's
103
- OpenAI-compatible surface. That launcher form needs its own `--model <tag>`
104
- flag (from `ollama list`) in the command string, since SPF always spawns
105
- headless see README.md's "Proxy or wrapper launchers" section for the
106
- full command and why `--model` is mandatory there, not optional.
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`; non-interactively
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, blocked issues, watch errors, and a spec needing feedback (`spec_needs_feedback` `error`-level on purpose, same class as a blocked issue). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus errors. |
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: errors
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
- No config section for this — it's an environment-variable recipe, since
501
- `agent_cc.ts` passes the operator's environment straight through to the
502
- `claude` subprocess, exactly like every other env var. Set
503
- `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (local or cloud Ollama) before
504
- running `spf`; see `roster.md`'s "Coding agent backends" section for the
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="ollama launch claude --model granite4.1:8b"`
511
- instead needs an explicit `--` before `claude`'s own flags (cobra flag
512
- parsing otherwise consumes them as `ollama launch`'s own) `agent_cc.ts`
513
- detects this exact `ollama launch ...` token shape and inserts that
514
- separator automatically, so you never add it by hand. The `--model` before
515
- that separator is NOT optional, though: it's `ollama launch`'s own flag, and
516
- it's mandatory in headless mode (SPF always pipes stdio, so the interactive
517
- model picker `ollama launch` falls back to without it can never run) — a
518
- `--model` typed after the `--` belongs to `claude`, not to `ollama launch`,
519
- and doesn't help. `spf doctor` hard-fails a `SPF_CLAUDE_CMD` missing it. See
520
- README.md's "Proxy or wrapper launchers" section for the full explanation.
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/blocked issues; "all" adds every milestone.
95
- # The webhook URL is a secret — put it in .env under the key named below,
96
- # never here. See the main README's "Notifications" section.
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: errors # off (default) | errors | all
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/blocked issues; "all" adds every milestone
116
- # (run started, issue claimed, PR opened, ...). The URL is a secret and
117
- # lives only in .env never in this file. See README.md's "Notifications"
118
- # section for how to get each webhook URL.
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: errors # off (default) | errors | all
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
@@ -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
- env["SPF_CLAUDE_CMD"] = launchCommand;
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, blocked issues, watch errors" },
349
- { value: "all", label: "allevery milestone (claimed, PR opened, done, ...) plus errors" },
350
- ], "errors");
353
+ { value: "errors", label: "errors — failed runs and watch errors only" },
354
+ { value: "attention", label: "attentionerrors, 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)
@@ -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
- * before running spf. Space-separated command chains are supported:
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
  /**
@@ -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
- * before running spf. Space-separated command chains are supported:
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 = process.env.SPF_CLAUDE_CMD || "claude";
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
@@ -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;
@@ -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: "off" sends nothing, "errors" sends only
685
- * NotifyEvents whose `level` is "error", "all" sends every curated
686
- * milestone. A channel's own `events` overrides the top-level scope for
687
- * just that channel (e.g. Slack gets everything, Teams gets errors only).
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
  }>;
@@ -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: "off" sends nothing, "errors" sends only
542
- * NotifyEvents whose `level` is "error", "all" sends every curated
543
- * milestone. A channel's own `events` overrides the top-level scope for
544
- * just that channel (e.g. Slack gets everything, Teams gets errors only).
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
- /** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
18
- level: "info" | "error";
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
- /** `errors` mode only sends `level: "error"`; `all` sends everything; `off` sends nothing. */
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",
@@ -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: "error",
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
- // "error" level, not "info" — this is the same class of event as
352
- // issue_blocked ("spf needs a human"), and it belongs on an `errors`-scope
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: "error",
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: "error",
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: "error",
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: "error",
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: "error",
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: "error",
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: "error",
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]],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",