@gr8ful/spf 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. package/dist/test/watch.test.js +0 -687
@@ -12,10 +12,10 @@ 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`
15
+ field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
16
16
  merge key-by-key — `notifications.channels` replaces wholesale, same as
17
- `quality.checks`; `agents` merges by `name`: a matching name patches that
18
- entry, a new name appends).
17
+ `quality.checks` and `tiering.tiers`/`tiering.roles`; `agents` merges by
18
+ `name`: a matching name patches that entry, a new name appends).
19
19
  3. An explicit `--config <path>` replaces both — standalone, no built-in
20
20
  underneath it.
21
21
 
@@ -76,6 +76,29 @@ agents:
76
76
  | `env_allowlist` | string[] \| null | Roster-wide env allowlist, back-filled the same way as `writes`. Unset/null = every agent gets the full operator environment (see `env_allowlist` under `agents[]`). |
77
77
  | `protected_files` | string[] | Paths no agent may touch unless named in its own `writes`. Default `[".spf/", "spf.config.yaml"]`. |
78
78
  | `data_dir` | path | Runtime home, repo-relative. Default `.spf/data`. |
79
+ | `max_run_cost` | number > 0 (USD) | Stops the NEXT agent call once a run has already spent this much — checked before each call, never after. A single call is never capped (a run can overshoot by one whole call), and a chain with only one agent dispatch (`scout`, `prompt`, `build`) can never trip it at all. Absent (default) = unbounded. Throws `BudgetExceeded`, which fails the phase closed. |
80
+ | `max_run_tokens` | integer > 0 | Same semantics as `max_run_cost`, on `sessions.total_tokens` instead of cost. Absent (default) = unbounded. |
81
+
82
+ ### `spf fanout`
83
+
84
+ `spf fanout <chain> "<prompt>" [--n 3] [--concurrency N] [--base <branch>]
85
+ [--adw-id <id>] [--first-success] [--config <path>] [--cwd <dir>]` — best-of-N:
86
+ runs `<chain>` N times in parallel, each in its own git worktree and branch
87
+ (`spf/fanout/<base-adw-id>-<i>`), then picks ONE winner by code (succeeded >
88
+ fewest gate failures > most gate passes > lowest cost > fewest tokens >
89
+ lowest wall time > adw_id) and deletes the rest. SPF never merges the
90
+ winner — it prints the branch and the basis; a human runs `git merge`. The
91
+ chain named MUST have a commit step (`plan-build`, `plan-build-test`,
92
+ `plan-build-test-quality`, `simple-sdlc`, or a repo-local chain with one) —
93
+ `spf fanout` refuses any chain without one, since a winner with no commit
94
+ leaves its only copy of the work as uncommitted edits in a worktree.
95
+ `--first-success` opts into stopping early once one attempt succeeds
96
+ (first-past-the-post) instead of the default, which runs every attempt to
97
+ completion for a true N-way comparison. `spf fanout --clean <base-adw-id>`
98
+ removes any worktrees/branches a killed or discarded run left behind under
99
+ that id — a `--adw-id` is never safe to reuse against session rows still in
100
+ the shared db, so a rerun with the same id fails loudly instead of mixing
101
+ the two runs' cost/tokens/gates together.
79
102
 
80
103
  ### `observability`
81
104
 
@@ -130,8 +153,10 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
130
153
  | `base_branch` | string | Branch worktrees fork from and PRs target. Default `main`. |
131
154
  | `poll_ms` | int | Tick interval. Default `60000`. |
132
155
  | `concurrency` | int ≥1 | Max issues claimed and run at once, the build lane's own budget (independent of `refine.concurrency`). Default `2`. |
156
+ | `chain_options` | map of string -> string | Options passed straight through to `chain` (and `refine.chain`) for every unattended dispatch — the same shape an interactive `spf <chain> --suite <name>` builds, e.g. `{suite: strict}` or `{agent: some-agent}`. Default `{}`. Only useful for a chain whose behavior actually reads the option (a step-derived chain's `--suite`; an imperative chain ignores an option it doesn't know about). |
133
157
  | `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
134
- | `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` `spf watch` fails loudly at startup otherwise, since issue authoring (create + link a hierarchy) isn't implemented for Jira yet. |
158
+ | `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` creates as on Jira defaults `epic`/`feature` `Epic`, `story` `Story`, `bug` `Bug`, `task` `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
159
+ | `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` — off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` or `"jira"` — both implement issue authoring (create + link a hierarchy); any other value fails loudly at startup. |
135
160
  | `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
136
161
  | `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
137
162
 
@@ -140,6 +165,8 @@ watch:
140
165
  repo: owner/name
141
166
  label_prefix: spf
142
167
  chain: plan-build-test
168
+ chain_options:
169
+ suite: strict # only if plan-build-test's chain declares a step-derived requiredSuites
143
170
  refine:
144
171
  enabled: true # decompose spf:spec-ready specs into a feature/story tree
145
172
  chain: refine
@@ -154,6 +181,61 @@ additionally gets `<prefix>:refined`, so a human can review and promote it to
154
181
  `<prefix>:ready` when it's worth building — the refine lane never
155
182
  auto-promotes anything.
156
183
 
184
+ A third label vocabulary — `<prefix>:priority:p0|p1|p2|p3` — is what
185
+ `claimNewWork` actually schedules `<prefix>:ready` issues by: priority first,
186
+ then sibling affinity (a leaf whose parent already has a sibling in flight
187
+ goes first), then creation order. The refiner proposes a priority per node
188
+ (clamped to the spec's own priority, when the spec issue has one, as a
189
+ ceiling — never a floor); a human can relabel before promoting, which is the
190
+ entire override mechanism. Independently, a `<prefix>:ready` leaf whose
191
+ `blocked_by` isn't fully `<prefix>:done` is skipped until it is — the
192
+ frontier check. This label is separate from any GitHub Projects v2
193
+ "Priority" field a repo's board might have; `spf watch` never touches
194
+ Projects v2, so the two are unreconciled if you use both.
195
+
196
+ The `refine` chain grounds its decomposition with a `scout` phase before the
197
+ refiner runs, so `scout` is a required agent for it — a roster that pruned
198
+ it fails `spf watch` startup by name.
199
+
200
+ The refine lane's own state machine has an extra loop beyond
201
+ `spec-ready → refining → spec-in-progress → done`/`blocked`: when the
202
+ refiner raises material ambiguity instead of a tree (see
203
+ `assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec moves to
204
+ `<prefix>:needs-feedback` with a comment naming its questions, instead of
205
+ publishing anything. A human answers in the issue's comments and adds
206
+ `<prefix>:continue-refinement`; `spf watch` claims that label back into
207
+ `refining` and resumes the **same** `adw_id` — the comment thread (split
208
+ into "answers to your open questions" and "earlier discussion") is folded
209
+ into the resumed prompt, and the refiner's own coding-agent session
210
+ continues rather than starting cold. This can loop any number of rounds.
211
+
212
+ Publishing a tree does NOT mean the spec is done: `<prefix>:spec-in-progress`
213
+ is where a spec lands right after publish, and it stays there — with a
214
+ summary comment listing every issue created — until **every one of those
215
+ issues** is itself `<prefix>:done` (`finishTrackedSpecs`, polled every tick,
216
+ reads `WatchMarker.refined` back to check). Only then does it move to
217
+ `<prefix>:done`, get a closing comment, and close on the tracker (GitHub
218
+ only, best-effort) — firing `spec_done`, distinct from the earlier
219
+ `spec_refined`. This exists so a spec's tracker status means what a product
220
+ manager expects it to mean: "done" is the work being finished, not merely
221
+ having been decomposed. `spf watch init` must be re-run after upgrading to
222
+ this version to seed the new labels.
223
+
224
+ Container roll-up mirrors that same "close, don't just label" behavior for a
225
+ generated feature/epic: once every child under it carries `<prefix>:done`
226
+ (checked reactively, each time a leaf's PR merges), the container gets a
227
+ summary comment and transitions to `<prefix>:done` — then the check repeats
228
+ on ITS OWN parent, so an epic rolls up once its last feature does. A
229
+ container with one unfinished child (including a leaf still sitting at
230
+ `<prefix>:refined`, never promoted) is left alone; nothing forces it. This
231
+ needs a native hierarchy read-back — GitHub's sub-issues API, or a Jira
232
+ `parent = "<id>"` JQL search — so it works on both today; a tracker that
233
+ implements neither gets a logged no-op, not a startup failure, since the
234
+ build lane still works fine without roll-up. Whether the container actually
235
+ CLOSES on the tracker (not just relabels) depends on `closeIssue` being
236
+ implemented at all — GitHub yes, Jira no (its own resolution workflow isn't
237
+ wired up), same asymmetry as leaf and spec completion already have on Jira.
238
+
157
239
  ### `notifications`
158
240
 
159
241
  Optional outbound push for unattended work — `spf watch`'s daemon lifecycle,
@@ -165,7 +247,7 @@ default; adding it is entirely additive.
165
247
 
166
248
  | Field | Type | Meaning |
167
249
  |---|---|---|
168
- | `events` | `"off"` \| `"errors"` \| `"all"` | The whole filter. `off` (default): nothing. `errors`: only failed runs/phases, blocked issues, watch errors. `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus errors. |
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. |
169
251
  | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
170
252
  | `channels[]` | array | See below. |
171
253
 
@@ -224,6 +306,111 @@ the repo (never `ENGINEER_NAME`/`$USER`, which are spoofable and fall back to
224
306
  the literal string `"engineer"`) — an unattended run's AI-only commit never
225
307
  carries one, because nobody said yes to attest to.
226
308
 
309
+ ### `tiering`
310
+
311
+ Risk-tiered per-role model routing (SPF #14) — a run-global cost/strength
312
+ shift on top of the static roster, decided ONCE at run start from signals
313
+ code can read without asking an agent anything (the chain's name and the
314
+ prompt's word count), never re-evaluated mid-run. **OFF by default, and
315
+ `enabled: false`/absent is a TOTAL no-op**: every agent dispatches at
316
+ exactly the model its roster entry names, byte-identical to before this key
317
+ existed — the same discipline `defaults.max_run_cost`/`max_run_tokens` hold
318
+ themselves to.
319
+
320
+ **Top-level, not nested under `defaults:`.** `loadConfig`'s back-fill loop
321
+ copies a fixed list of `defaults` keys down onto every agent that hasn't set
322
+ them, and a stray copy would be silently *stripped* at parse rather than
323
+ rejected — a top-level key sits outside that loop entirely, so the trap
324
+ cannot apply here. Merges **key-by-key** with a base config, same as
325
+ `review`/`notifications`: a repo that only flips `enabled: true` keeps the
326
+ base's `tiers`/`roles`; `tiers` and `roles` are each a **whole replace** on
327
+ override (a repo's own ladder replaces the packaged one wholesale, never an
328
+ unordered splice of both).
329
+
330
+ | Field | Type | Meaning |
331
+ |---|---|---|
332
+ | `enabled` | bool | Default `false`. `true` turns on the ladder walk described below; `false` (or the key absent) is a total no-op, checked nowhere and dispatched nowhere. |
333
+ | `tiers` | array, WEAKEST FIRST | The ladder. A risk level shifts every routed role UP or DOWN this list by the same step — order is the whole semantics, which is why this is a sequence and not a mapping. Default `[]`. |
334
+ | `tiers[].name` | string | What `roles` values point at. |
335
+ | `tiers[].coding_agent` | `"flue"` \| `"claude_code"` | Which backend's vocabulary this rung's `model` speaks. Default `flue`, same default an agent's own `coding_agent` uses. A tier changes an agent's `model` and **nothing else** — `coding_agent` always stays the agent's own — so a rung can only route roles whose backend matches (**rule T**, below). |
336
+ | `tiers[].model` | string | Same vocabulary as an agent's own `model:` for that backend: `provider/model-id` for `flue`, Claude Code's bare alias/full name for `claude_code`. No per-provider table — for `flue` the provider is already the string's first segment. |
337
+ | `roles` | map of agent name -> tier name | The baseline tier per **role**. Naming an agent here is the operator's statement "route this one by tier" — the resolved tier then wins over that agent's own `model:`. An agent **not** named here is never retiered; its `model:` stands, untouched. That is the whole precedence rule. Default `{}`. |
338
+
339
+ ```yaml
340
+ tiering:
341
+ enabled: true
342
+ tiers:
343
+ - { name: cheap, coding_agent: flue, model: ollama/granite4.1:8b }
344
+ - { name: strong, coding_agent: flue, model: ollama/qwen3.8:27b-mlx }
345
+ roles:
346
+ scout: cheap
347
+ builder: strong
348
+ reviewer: strong
349
+ ```
350
+
351
+ **The risk signal.** Two integer-weighted signals, summed: the chain's own
352
+ kind (`scout`/`prompt`/`document`/`quality` weigh `-1`; `simple-sdlc`/
353
+ `plan-build-test-quality`/`refine` weigh `+1`; everything else, including
354
+ every repo-local `.spf/chains/*.yaml` chain, weighs `0`) and the prompt's
355
+ word count (`-1` at ≤60 words, `+1` at ≥400, `0` between). The sum
356
+ classifies to `low`/`standard`/`high`: reaching `high` needs only one
357
+ signal to agree (a long prompt forces `high` outright), reaching `low`
358
+ needs both to agree — demoting a role a rung is the expensive mistake (a
359
+ wrong answer, a whole re-run); promoting one is the cheap mistake (some
360
+ extra tokens), so the classifier is deliberately asymmetric toward the
361
+ cheap failure. `standard` (step `0`) leaves every role on its configured
362
+ baseline rung.
363
+
364
+ **Rule T — the backend-compatibility rule.** `coding_agent` is a per-agent
365
+ field and model vocabulary is backend-dependent, so a tier is only usable
366
+ for a role whose `coding_agent` matches the tier's own declared
367
+ `coding_agent`. `enabled: true` on a repo that inherited the packaged
368
+ flue-shaped ladder (every repo does, via key-by-key merge, until it
369
+ declares its own) while running a `claude_code` roster fails **loudly, by
370
+ name** at `agents.validate()` time, before anything spawns — never a silent
371
+ skip, and never a `fireworks/...` id handed to Claude Code.
372
+
373
+ **Degradation.** A rung that is unusable — a model tag `probeServedOllamaTags`
374
+ found absent from a local Ollama server's `/models` list, or a rule-T
375
+ backend mismatch — is walked **down**, never up: a degradation must never
376
+ silently escalate spend. If every rung at or below the target is unusable,
377
+ the role dispatches on its own configured `model:` unchanged, plus one noted
378
+ line. The availability probe **fails open**: an unreachable server drops
379
+ nothing rather than silently downgrading every agent to the bottom rung.
380
+
381
+ **Visibility.** Because a retiered agent's *configured* `model:` (what
382
+ `spf.config.yaml` says) and its *effective* model (what actually dispatched)
383
+ can now disagree, every run traces one `tiering` log event carrying the full
384
+ resolved routing, and prints one console line per retiered agent
385
+ (`[spf] tiering <agent> <tier> (<configured> -> <effective>) risk=<risk>`).
386
+ `spf doctor` reports the same resolved ladder and every routed role's
387
+ effective model when `tiering.enabled` — see below.
388
+
389
+ `spf init`'s interview does not write this block: it collects one model for
390
+ the whole roster, and a `tiering` block over a single-model roster is a
391
+ no-op by construction. `spf init --template ts-flue-ollama` is the one
392
+ packaged template that ships it **on**, with two tags measured live against
393
+ a real local Ollama server; the non-interactive starter (`--yes` / piped
394
+ stdin) shows the shape commented out. Every other packaged template
395
+ inherits the built-in default's populated-but-`enabled: false` ladder
396
+ through key-by-key merge — harmless until a repo opts in, and invisible to
397
+ `spf doctor` until it does (see below).
398
+
399
+ **`spf doctor`**, only when `cfg.tiering.enabled` — a disabled ladder is
400
+ invisible to doctor, on purpose, so a repo that never opted in (which is
401
+ every packaged template except `ts-flue-ollama`, plus every `spf init`
402
+ starter) can't fail on an unset provider key for a rung that will never
403
+ dispatch:
404
+
405
+ - the same provider-key check the roster gets, run over `tiers[].model`
406
+ instead, branching on **the tier's own** `coding_agent`;
407
+ - the `OLLAMA_BASE_URL` reachability probe now also fires for an `ollama/*`
408
+ rung even when no `cfg.agents[]` entry itself names one;
409
+ - a new report section: the resolved ladder, every routed role's effective
410
+ model (including roles whose effective model equals their configured
411
+ one), any degradation note, and — probed the same way `probeServedOllamaTags`
412
+ does, fail-open — which `ollama/*` rungs are and aren't in `ollama list`.
413
+
227
414
  ### `agents[]`
228
415
 
229
416
  | Field | Required | Meaning |
@@ -65,3 +65,28 @@ agents:
65
65
  # a 3B model — a lighter tag here is a real, not merely theoretical,
66
66
  # option.
67
67
  model: ollama/granite4.1:8b
68
+
69
+ # Risk-tiered per-role model routing (SPF #14) — ON, local only. The two
70
+ # tags below are the MEASURED ones (last session, live on localhost:11434)
71
+ # and they ALREADY appear in this template today — ollama/qwen3.8:27b-mlx at
72
+ # :48 (defaults.model), :58 (planner), :60 (reviewer) and
73
+ # ollama/granite4.1:8b at :67 (documenter). This block introduces no new
74
+ # model string.
75
+ tiering:
76
+ enabled: true
77
+ # coding_agent: flue matches this template's `defaults.coding_agent: flue`
78
+ # (:46) — every roster agent here is flue, so every role is routable by
79
+ # these rungs. Stated explicitly rather than left to the default, because
80
+ # it is the field that keeps a rung from being handed to a backend that
81
+ # cannot parse it (rule T — see assets/skill/references/config.md's
82
+ # "tiering" section).
83
+ tiers:
84
+ - { name: scout, coding_agent: flue, model: ollama/granite4.1:8b } # cheap read/summarize/triage
85
+ - { name: builder, coding_agent: flue, model: ollama/qwen3.8:27b-mlx } # first-attempt implementation
86
+ roles:
87
+ scout: scout
88
+ documenter: scout
89
+ planner: builder
90
+ builder: builder
91
+ refiner: builder
92
+ reviewer: builder
@@ -69,10 +69,20 @@ agents:
69
69
  # label_prefix: spf
70
70
  # chain: plan-build-test
71
71
  # base_branch: main
72
+ # # Options threaded straight through to `chain` (and refine.chain below)
73
+ # # for every unattended dispatch — the same shape an interactive
74
+ # # `spf <chain> --suite <name>` builds. Only useful for a chain whose
75
+ # # behavior actually reads the option; empty/omitted by default.
76
+ # # chain_options:
77
+ # # suite: strict
72
78
  # # Optional second lane: decompose a spf:spec-ready product spec into a
73
79
  # # feature/story-or-bug tree of real issues instead of building it
74
- # # directly. Off by default; needs issue_provider: github (the default
75
- # # above) issue authoring isn't implemented for Jira yet.
80
+ # # directly. Off by default; needs issue_provider: github OR jira — both
81
+ # # support issue authoring (create + link a hierarchy). On Jira, each
82
+ # # RefinedIssue.kind maps to a real Jira issue type via
83
+ # # watch.jira.issue_types (defaults: epic/feature -> Epic, story -> Story,
84
+ # # bug -> Bug, task -> Task) — spf watch init validates this against the
85
+ # # real project when refine is enabled.
76
86
  # refine:
77
87
  # enabled: true
78
88
  # chain: refine
@@ -118,5 +118,16 @@ export declare function resolveRequiredSuites(chain: ChainDefinition, options: R
118
118
  * CLI dispatch sites (`spf <chain>` and `spf watch`) go through this, never
119
119
  * `chain.run(...)` directly — the whole reason to route through here is that
120
120
  * a `steps`-only chain has no `run` to call.
121
+ *
122
+ * Also the one call site both dispatch paths share on the way OUT — success
123
+ * or thrown error alike, `finally` runs either way — which is what makes it
124
+ * the right seam for `otel.releaseOtelExporter()` (see otel.ts's RUN-SCOPED
125
+ * CLEANUP note / #26): a `spf watch` daemon calls this once per issue, in
126
+ * the same process, for as long as it runs, so a run's exporter has to be
127
+ * dropped here rather than living until the whole daemon exits. `session.
128
+ * finalize()` is the same idea for the run itself — it stops the process-
129
+ * wide signal handler from reaching this (now finished) run, and closes its
130
+ * Tracer's sqlite handle, so a long `spf watch` daemon does not hold every
131
+ * issue it has ever processed reachable for the rest of its life.
121
132
  */
122
133
  export declare function runChain(chain: ChainDefinition, ctx: ChainContext, options?: Record<string, string>): Promise<number>;
@@ -1,5 +1,7 @@
1
1
  import * as steps from "./steps.js";
2
2
  import * as simpleSdlc from "./simple_sdlc.js";
3
+ import * as otel from "../core/otel.js";
4
+ import * as session from "../core/session.js";
3
5
  /**
4
6
  * Build a step-based ChainDefinition, deriving phases/requiredAgents/
5
7
  * requiredSuites from its steps.
@@ -66,6 +68,15 @@ export const CHAINS = [
66
68
  stepChain("document", "write up the work that was just done, from the diff", [steps.request(), steps.changes(), steps.document()]),
67
69
  stepChain("refine", "decompose a product spec into a feature/story-or-bug tree of tracker issues — spf watch's spec-ready lane", [
68
70
  steps.request(),
71
+ // Grounds the decomposition in code that actually exists, rather than
72
+ // leaving "explore the codebase" as prose the refiner may or may not
73
+ // follow (see assets/prompts/refiner/system.md). Its ScoutOutput flows
74
+ // into refine() as `previous` — agentStep() threads state.previous into
75
+ // every agent phase, and refiner/user.md already renders it as
76
+ // {{previous_envelope}}. This makes `scout` a required agent for this
77
+ // chain: a roster that pruned it fails agents.validate() by name at
78
+ // `spf watch` startup, same as any other missing required agent.
79
+ steps.scout({ description: "Map the subsystems this spec touches — change nothing" }),
69
80
  steps.refine(),
70
81
  steps.publishIssues(),
71
82
  ]),
@@ -147,9 +158,33 @@ export function resolveRequiredSuites(chain, options) {
147
158
  * CLI dispatch sites (`spf <chain>` and `spf watch`) go through this, never
148
159
  * `chain.run(...)` directly — the whole reason to route through here is that
149
160
  * a `steps`-only chain has no `run` to call.
161
+ *
162
+ * Also the one call site both dispatch paths share on the way OUT — success
163
+ * or thrown error alike, `finally` runs either way — which is what makes it
164
+ * the right seam for `otel.releaseOtelExporter()` (see otel.ts's RUN-SCOPED
165
+ * CLEANUP note / #26): a `spf watch` daemon calls this once per issue, in
166
+ * the same process, for as long as it runs, so a run's exporter has to be
167
+ * dropped here rather than living until the whole daemon exits. `session.
168
+ * finalize()` is the same idea for the run itself — it stops the process-
169
+ * wide signal handler from reaching this (now finished) run, and closes its
170
+ * Tracer's sqlite handle, so a long `spf watch` daemon does not hold every
171
+ * issue it has ever processed reachable for the rest of its life.
150
172
  */
151
173
  export async function runChain(chain, ctx, options = {}) {
152
- if (chain.run)
153
- return chain.run(ctx, options);
154
- return steps.runSteps(ctx, resolveRequiredAgents(chain, options), resolveRequiredSuites(chain, options), chain.steps, options);
174
+ try {
175
+ if (chain.run)
176
+ return await chain.run(ctx, options);
177
+ return await steps.runSteps(ctx, resolveRequiredAgents(chain, options), resolveRequiredSuites(chain, options), chain.steps, options);
178
+ }
179
+ finally {
180
+ // Both are no-ops for a one-shot `spf <chain>` invocation with no
181
+ // explicit `--adw-id` (ctx.adw_id is null; the id session.ensure()
182
+ // actually minted never makes it back up here) — that process exits
183
+ // right after this call returns, and the existing end-of-process
184
+ // `flushAll()` in `cli/index.ts`'s `finally` still drains the exporter
185
+ // exactly as it always did (the signal listener needs no such fallback:
186
+ // the process is gone).
187
+ await otel.releaseOtelExporter(ctx.adw_id);
188
+ session.finalize(ctx.adw_id);
189
+ }
155
190
  }
@@ -223,6 +223,7 @@ export const RepoChainFileSchema = v.strictObject({
223
223
  */
224
224
  const RESERVED_COMMAND_NAMES = new Set([
225
225
  "run",
226
+ "fanout",
226
227
  "list",
227
228
  "init",
228
229
  "install-skill",
@@ -148,7 +148,7 @@ export function trailerFor(outcome, identity) {
148
148
  }
149
149
  export async function main(ctx) {
150
150
  const { prompt } = ctx;
151
- const run = startRun(ctx, REQUIRED_AGENTS, REQUIRED_SUITES);
151
+ const run = await startRun(ctx, REQUIRED_AGENTS, REQUIRED_SUITES);
152
152
  const baseline = run.git.rev("HEAD"); // pinned before this run commits anything
153
153
  await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }), async (ph) => {
154
154
  ph.log({ input: prompt, baseline: run.git.shortSha(baseline) });
@@ -82,8 +82,14 @@ export interface Step {
82
82
  /** Display fragment for derivePhases() — e.g. "planner", "git(commit)". */
83
83
  label?: string;
84
84
  }
85
- /** The identical loadConfig -> validate -> session.ensure prologue every chain repeated. */
86
- export declare function startRun(ctx: ChainContext, requiredAgents: string[], requiredSuites: string[]): Run;
85
+ /**
86
+ * The identical loadConfig -> validate -> session.ensure prologue every
87
+ * chain repeated. `async` since the tiering availability probe below is an
88
+ * awaited `fetch` — firing it unawaited would let `agents.execute` read
89
+ * `run.tiering` before the probe resolved. Both call sites are already
90
+ * inside `async` functions, so this stays a one-token change for each.
91
+ */
92
+ export declare function startRun(ctx: ChainContext, requiredAgents: string[], requiredSuites: string[]): Promise<Run>;
87
93
  /**
88
94
  * Commit an envelope in its own author's words — the message-fallback four
89
95
  * chains repeated.
@@ -310,21 +316,6 @@ export declare function refine(opts?: {
310
316
  retries?: number;
311
317
  extraGates?: string[];
312
318
  }): Step;
313
- /**
314
- * Create the tree `refine()` produced on the tracker, in dependency order,
315
- * and link each node to its parent. A `code` phase, not an agent one — the
316
- * decision-making (topological order, label assignment, `## Blocked by`
317
- * rendering) is `core/refine.ts`'s job; this step is sequencing only, per
318
- * SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
319
- *
320
- * Writes what it created to `<context_handoff_dir>/refine_publish.json` —
321
- * the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
322
- * chain returns, since a chain's own return value is just an exit code.
323
- * `spf watch`'s own marker/comment/transition bookkeeping for the spec
324
- * issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
325
- * `spf refine` run (no daemon, no spec issue in play) still needs this step
326
- * to work standalone.
327
- */
328
319
  export declare function publishIssues(opts?: {
329
320
  description?: string;
330
321
  }): Step;
@@ -48,6 +48,7 @@ import * as quality from "../core/quality.js";
48
48
  import * as agentsCfg from "../core/agents.js";
49
49
  import * as session from "../core/session.js";
50
50
  import * as refineLib from "../core/refine.js";
51
+ import * as tiering from "../core/tiering.js";
51
52
  import { DOCUMENT_NOTES } from "../core/prompts.js";
52
53
  import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, RefineOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makeEventRecord, makePhaseParams, } from "../core/data_types.js";
53
54
  import { Run } from "../core/runner.js";
@@ -62,8 +63,14 @@ function makeStep(fn, meta = {}) {
62
63
  return step;
63
64
  }
64
65
  // ── layer 1: the shared prologue ────────────────────────────────────────
65
- /** The identical loadConfig -> validate -> session.ensure prologue every chain repeated. */
66
- export function startRun(ctx, requiredAgents, requiredSuites) {
66
+ /**
67
+ * The identical loadConfig -> validate -> session.ensure prologue every
68
+ * chain repeated. `async` since the tiering availability probe below is an
69
+ * awaited `fetch` — firing it unawaited would let `agents.execute` read
70
+ * `run.tiering` before the probe resolved. Both call sites are already
71
+ * inside `async` functions, so this stays a one-token change for each.
72
+ */
73
+ export async function startRun(ctx, requiredAgents, requiredSuites) {
67
74
  const cfg = agentsCfg.loadConfig(ctx.config_paths);
68
75
  agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
69
76
  const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
@@ -83,6 +90,41 @@ export function startRun(ctx, requiredAgents, requiredSuites) {
83
90
  if (ctx.chain_source) {
84
91
  run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
85
92
  }
93
+ // Tiering resolution (SPF #14) — one more run-scoped fact, computed once,
94
+ // before any phase opens, beside chain_source above. `risk`/`signals` are
95
+ // always computed (a pure function of chain name + prompt); `routing`/
96
+ // `notes` are only ever non-empty when `cfg.tiering.enabled` — see
97
+ // `resolveTiering`'s own no-op guarantee.
98
+ const servedOllamaTags = await tiering.probeServedOllamaTags(cfg);
99
+ run.tiering = tiering.resolveTiering({
100
+ cfg,
101
+ chainName: ctx.chain_name,
102
+ prompt: ctx.prompt,
103
+ servedOllamaTags,
104
+ required: requiredAgents,
105
+ });
106
+ run.tracer.event(makeEventRecord({
107
+ adw_id: run.adw_id,
108
+ type: "log",
109
+ name: "tiering",
110
+ payload: {
111
+ risk: run.tiering.risk,
112
+ signals: run.tiering.signals,
113
+ routing: run.tiering.routing,
114
+ notes: run.tiering.notes,
115
+ },
116
+ }));
117
+ // One console line per RETIERED agent (changedModels — not per routing
118
+ // entry: a role whose tier resolves to the model it was already
119
+ // configured with is not news). Routed through the existing
120
+ // `Console.note()` — no new Console method. This is what reconciles
121
+ // `run.cfg.agents[].model` (which keeps the configured value) with
122
+ // `agent_sessions.model` (which records the effective one) for a human
123
+ // reading the console when the two disagree.
124
+ for (const [agentName, effective] of Object.entries(tiering.changedModels(run.tiering))) {
125
+ const route = run.tiering.routing[agentName];
126
+ run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
127
+ }
86
128
  return run;
87
129
  }
88
130
  /** A trailer line: `Key: value...`, one per line, no blank lines inside the block. */
@@ -622,10 +664,19 @@ export function refine(opts = {}) {
622
664
  output_type: RefineOutput,
623
665
  description: opts.description ?? "Decompose the spec into a feature/story tree of vertical slices",
624
666
  // refinementWellFormed is NOT in GATE_ALLOWLIST — it is meaningless on
625
- // any other envelope type (it reads `issues`), so there is nothing to
626
- // gain by letting a definition name it, and it stays non-removable here.
667
+ // any other envelope type (it reads `issues`/`questions`), so there is
668
+ // nothing to gain by letting a definition name it, and it stays
669
+ // non-removable here.
670
+ //
671
+ // retries: 1, not 0 — this gate's own doc comment says a violation
672
+ // "re-prompts the SAME refiner session before publishIssues() ever
673
+ // runs." With 0 retries that was never true: agents.ts throws
674
+ // GateFailure on the first violation and the whole spec goes straight to
675
+ // spf:blocked with no correction round-trip. The mutual-exclusion rule
676
+ // between `issues` and `questions` (see refinementWellFormed) makes that
677
+ // mismatch more likely to bite in practice, not less.
627
678
  gates: withExtraGates([gates.refinementWellFormed], opts.extraGates),
628
- retries: opts.retries ?? 0,
679
+ retries: opts.retries ?? 1,
629
680
  });
630
681
  }
631
682
  /**
@@ -635,14 +686,43 @@ export function refine(opts = {}) {
635
686
  * rendering) is `core/refine.ts`'s job; this step is sequencing only, per
636
687
  * SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
637
688
  *
638
- * Writes what it created to `<context_handoff_dir>/refine_publish.json`
639
- * the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
640
- * chain returns, since a chain's own return value is just an exit code.
641
- * `spf watch`'s own marker/comment/transition bookkeeping for the spec
642
- * issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
643
- * `spf refine` run (no daemon, no spec issue in play) still needs this step
644
- * to work standalone.
689
+ * When the refiner raised material ambiguity instead of a tree
690
+ * (`envelope.questions` non-empty `gates.refinementWellFormed` already
691
+ * guarantees `issues` is empty whenever that's true), this publishes
692
+ * nothing: it writes those questions to
693
+ * `<context_handoff_dir>/refine_questions.json` instead of
694
+ * `refine_publish.json` and returns. That is a successful phase outcome, not
695
+ * a failure — escalating to a human is a legitimate way for this chain to
696
+ * end, same as publishing a tree is.
697
+ *
698
+ * Writes what it created (or asked) to `<context_handoff_dir>/`, one of
699
+ * `refine_publish.json` or `refine_questions.json` — the side channel
700
+ * `cli/commands/watch.ts`'s `runRefine` reads after the chain returns, since
701
+ * a chain's own return value is just an exit code. `spf watch`'s own
702
+ * marker/comment/transition bookkeeping for the spec issue (including
703
+ * posting the questions and moving it to `needs-feedback`) lives entirely in
704
+ * `core/watch.ts`'s `runSpec`/`escalateSpec`, never here — a bare `spf
705
+ * refine` run (no daemon, no spec issue in play) still needs this step to
706
+ * work standalone, and just leaves the questions on disk for a human to
707
+ * read.
645
708
  */
709
+ /**
710
+ * `state.options["priority"]` -> a `RefinedPriority` ceiling, or `null` when
711
+ * unset — the generic chain-options path (`cli/commands/run.ts`'s
712
+ * `--priority`, or `core/watch.ts`'s `runSpec` threading a spec's own
713
+ * `<prefix>:priority:pN` label) is the ONLY place this string enters the
714
+ * system, so it's validated here rather than trusted: a typo'd or stale
715
+ * `--priority urgent` fails the phase with a clear message instead of
716
+ * silently publishing every node at its own, un-clamped priority.
717
+ */
718
+ function parsePriorityOption(raw) {
719
+ if (raw === undefined)
720
+ return null;
721
+ if (raw !== "p0" && raw !== "p1" && raw !== "p2" && raw !== "p3") {
722
+ throw new Error(`publishIssues(): --priority ${JSON.stringify(raw)} is not one of p0|p1|p2|p3`);
723
+ }
724
+ return raw;
725
+ }
646
726
  export function publishIssues(opts = {}) {
647
727
  preflightDescription("publish", opts.description);
648
728
  const fn = async (run, state) => {
@@ -650,19 +730,27 @@ export function publishIssues(opts = {}) {
650
730
  if (!envelope || !Array.isArray(envelope.issues)) {
651
731
  throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
652
732
  }
733
+ const questions = envelope.questions ?? [];
734
+ const priorityCeiling = parsePriorityOption(state.options["priority"]);
653
735
  await run.phase(makePhaseParams({
654
736
  name: "publish",
655
737
  kind: "code",
656
738
  owner: "tracker",
657
739
  description: opts.description ?? "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
658
740
  }), async (ph) => {
741
+ if (questions.length > 0) {
742
+ writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
743
+ ph.log({ escalated: questions.length });
744
+ return;
745
+ }
659
746
  const tracker = refineLib.resolveAuthoringProvider(run.cfg);
660
747
  const created = await refineLib.publish(tracker, envelope.issues, {
661
748
  labelPrefix: run.cfg.watch.label_prefix,
662
749
  specIssueId: state.issue_id,
750
+ priorityCeiling,
663
751
  });
664
752
  writeFileSync(path.join(run.context_handoff_dir, "refine_publish.json"), JSON.stringify(created.map((c) => ({ id: c.issue.id, title: c.issue.title, kind: c.kind, isLeaf: c.isLeaf })), null, 2));
665
- ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length });
753
+ ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
666
754
  });
667
755
  };
668
756
  return makeStep(fn, { label: "code(publish)" });
@@ -709,7 +797,7 @@ export function derivePhases(steps) {
709
797
  // ── the driver ────────────────────────────────────────────────────────────
710
798
  /** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */
711
799
  export async function runSteps(ctx, requiredAgents, requiredSuites, steps, options = {}) {
712
- const run = startRun(ctx, requiredAgents, requiredSuites);
800
+ const run = await startRun(ctx, requiredAgents, requiredSuites);
713
801
  const state = makeState(ctx.prompt, options, ctx.issue_id ?? null);
714
802
  for (const step of steps) {
715
803
  await step(run, state);