@gr8ful/spf 0.5.0 → 0.6.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 (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  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 +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  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 +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +51 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/paths.d.ts +3 -0
  46. package/dist/core/paths.js +48 -1
  47. package/dist/core/runner.d.ts +8 -0
  48. package/dist/core/runner.js +7 -0
  49. package/dist/core/session.d.ts +25 -0
  50. package/dist/core/session.js +97 -28
  51. package/dist/core/tiering.d.ts +145 -0
  52. package/dist/core/tiering.js +235 -0
  53. package/dist/core/tracer.d.ts +10 -0
  54. package/dist/core/tracer.js +12 -0
  55. package/dist/core/watch.d.ts +89 -9
  56. package/dist/core/watch.js +193 -19
  57. package/dist/ui/server/db.d.ts +39 -0
  58. package/dist/ui/server/db.js +61 -0
  59. package/package.json +2 -1
  60. package/dist/test/agent_cc.test.d.ts +0 -1
  61. package/dist/test/agent_cc.test.js +0 -95
  62. package/dist/test/agent_flue.test.d.ts +0 -1
  63. package/dist/test/agent_flue.test.js +0 -83
  64. package/dist/test/chains.test.d.ts +0 -12
  65. package/dist/test/chains.test.js +0 -92
  66. package/dist/test/data_types.test.d.ts +0 -10
  67. package/dist/test/data_types.test.js +0 -220
  68. package/dist/test/env_file.test.d.ts +0 -1
  69. package/dist/test/env_file.test.js +0 -74
  70. package/dist/test/fake_asker.d.ts +0 -23
  71. package/dist/test/fake_asker.js +0 -30
  72. package/dist/test/git_helper.test.d.ts +0 -1
  73. package/dist/test/git_helper.test.js +0 -59
  74. package/dist/test/hermetic_git.d.ts +0 -1
  75. package/dist/test/hermetic_git.js +0 -22
  76. package/dist/test/init_command.test.d.ts +0 -14
  77. package/dist/test/init_command.test.js +0 -136
  78. package/dist/test/interview.test.d.ts +0 -15
  79. package/dist/test/interview.test.js +0 -425
  80. package/dist/test/notify.test.d.ts +0 -1
  81. package/dist/test/notify.test.js +0 -174
  82. package/dist/test/ollama_provider.test.d.ts +0 -1
  83. package/dist/test/ollama_provider.test.js +0 -103
  84. package/dist/test/otel.test.d.ts +0 -26
  85. package/dist/test/otel.test.js +0 -512
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
package/README.md CHANGED
@@ -383,9 +383,10 @@ A product spec isn't individually workable — it needs to become a feature,
383
383
  broken down into user stories and bugs, before the build lane above has
384
384
  anything to claim. `watch.refine` is a second lane over the same poll loop
385
385
  that does exactly that: it polls `<prefix>:spec-ready`, runs a decomposition
386
- chain (`refine` by default) against the spec in its own worktree, and
387
- publishes what it produces as real tracker issues — a feature/epic container
388
- plus story/bug/task leaves, linked via GitHub's native sub-issue hierarchy.
386
+ chain (`refine` by default `request scout refiner publish`) against
387
+ the spec in its own worktree, and publishes what it produces as real tracker
388
+ issues — a feature/epic container plus story/bug/task leaves, linked via
389
+ GitHub's native sub-issue hierarchy.
389
390
 
390
391
  ```yaml
391
392
  watch:
@@ -406,22 +407,62 @@ into twenty stories doesn't turn into twenty unattended chain runs and twenty
406
407
  PRs with nobody having looked at the breakdown first. Once you do promote a
407
408
  leaf, the existing build lane picks it up completely unchanged.
408
409
 
409
- The spec issue itself gets a `spec-ready refining done`/`blocked`
410
- lifecycle, same shape as the build lane, and a summary comment listing every
411
- issue it created. Try it by hand first, against a real spec, before turning
412
- on the daemon:
410
+ Grounding the decomposition in real code is structural, not just prose: a
411
+ `scout` phase maps the subsystems the spec touches before the refiner ever
412
+ runs, and its findings flow straight into the refiner as context. This makes
413
+ `scout` a required agent for the `refine` chain — a roster that pruned it
414
+ fails `spf watch` startup by name, the same as any other missing required
415
+ agent.
416
+
417
+ #### Human-in-the-loop escalation
418
+
419
+ The refiner is instructed not to guess on anything material (scope, data
420
+ model, an external dependency choice, a UX contract, a breaking change, or
421
+ anything that would contradict an existing ADR — see
422
+ `assets/prompts/refiner/system.md`'s "Ask, don't decide"). When it hits real
423
+ ambiguity it raises questions instead of publishing a partial tree, and the
424
+ spec moves through an extra loop before it reaches `done`:
425
+
426
+ ```text
427
+ spec-ready → refining ──┬─→ done / blocked (published a tree)
428
+ └─→ needs-feedback (raised questions)
429
+ │ a human answers in the issue's comments,
430
+ │ then adds continue-refinement
431
+
432
+ refining (again, same adw_id) → ...
433
+ ```
434
+
435
+ Answer the refiner's questions as comments on the spec issue, then add the
436
+ `<prefix>:continue-refinement` label. `spf watch` resumes the **same**
437
+ `adw_id` — the refiner's own coding-agent session continues, with the
438
+ comment thread folded into its prompt — so it never re-derives context it
439
+ already had, and it can loop through as many rounds as it takes; there's no
440
+ cap. If channels are configured (see "Notifications" below), a
441
+ `spec_needs_feedback` event fires every round.
442
+
443
+ The spec issue itself moves through a `spec-ready → refining →
444
+ needs-feedback ⇄ refining → done`/`blocked` lifecycle (the `needs-feedback ⇄
445
+ refining` loop only when the refiner actually escalates), and a summary
446
+ comment lists every issue it created — noting how many rounds of feedback it
447
+ took, if any. Once a spec reaches `done`, `spf watch` also closes the issue
448
+ on the tracker (best-effort: a tracker or a failed close never turns a
449
+ successfully refined spec back to `blocked`). Try it by hand first, against
450
+ a real spec, before turning on the daemon:
413
451
 
414
452
  ```bash
415
453
  spf refine "<spec text or path/to/spec.md>" --issue 42 # --issue renders a "## Parent: #42" back-reference
416
454
  ```
417
455
 
418
- `spf watch init` seeds the type labels alongside the state ones. This lane's
419
- prompt (`assets/prompts/refiner/`) is adapted from a "tracer-bullet ticket"
420
- decomposition skill — vertical slices, a `blocked_by` dependency graph, and
421
- an expand/migrate/contract sequence for wide mechanical refactors — with a
422
- gate (`gates.refinementWellFormed`) added on top to enforce the
456
+ `spf watch init` seeds the type labels alongside the state ones **re-run
457
+ it** after upgrading to this version, so it can create the new
458
+ `<prefix>:needs-feedback` and `<prefix>:continue-refinement` labels. This
459
+ lane's prompt (`assets/prompts/refiner/`) is adapted from a "tracer-bullet
460
+ ticket" decomposition skill vertical slices, a `blocked_by` dependency
461
+ graph, and an expand/migrate/contract sequence for wide mechanical refactors
462
+ — with a gate (`gates.refinementWellFormed`) added on top to enforce the
423
463
  container/leaf shape that skill left as prose convention rather than a
424
- checked rule.
464
+ checked rule, now also enforcing that a refinement never publishes issues and
465
+ raises questions in the same round.
425
466
 
426
467
  ### GitHub (`issue_provider: github` and/or `code_host: github`)
427
468
 
@@ -482,10 +523,13 @@ notifications:
482
523
  ```
483
524
 
484
525
  `events` is the whole filter: `errors` sends only failed runs/phases, blocked
485
- issues, and watch errors; `all` adds every milestone run started/finished,
486
- issue claimed, PR opened, issue done. A channel's own `events` overrides the
487
- top-level scope for just that channel. `spf doctor` reports whether each
488
- configured channel's env var is set.
526
+ issues, watch errors, and a spec needing feedback (`spec_needs_feedback`
527
+ see "Human-in-the-loop escalation" above; it's `error`-level on purpose, the
528
+ same class of event as a blocked issue, so an `errors`-scope channel sees it
529
+ too); `all` adds every milestone — run started/finished, issue claimed, PR
530
+ opened, issue done. A channel's own `events` overrides the top-level scope
531
+ for just that channel. `spf doctor` reports whether each configured
532
+ channel's env var is set.
489
533
 
490
534
  The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
491
535
  names the key, never the URL itself, matching `GITHUB_TOKEN`/
@@ -52,6 +52,74 @@ observability:
52
52
  # test: [test]
53
53
  # all: [test, lint]
54
54
 
55
+ # OFF by default, and absence/false is a TOTAL no-op: every agent dispatches
56
+ # at exactly the model its roster entry names, byte-identically to before
57
+ # this key existed. Same discipline as max_run_cost/max_run_tokens
58
+ # (data_types.ts:463-491): a repo that upgrades must not silently start
59
+ # routing its phases to different models.
60
+ #
61
+ # Turning it on is a deliberate edit, or `spf init --template ts-flue-ollama`
62
+ # (the one packaged template whose two rungs are MEASURED). `spf init`'s
63
+ # interview does NOT write this block — see config.md's "tiering" section
64
+ # for why.
65
+ tiering:
66
+ enabled: false
67
+
68
+ # The ladder, WEAKEST FIRST. A SEQUENCE, not a mapping: order is the whole
69
+ # semantics here (a risk level shifts every routed role UP or DOWN this
70
+ # list), and a YAML sequence states order unambiguously where a mapping
71
+ # would make it depend on the parser preserving key order.
72
+ #
73
+ # `coding_agent` (default "flue", the same default an agent's own field
74
+ # uses) declares WHICH BACKEND'S VOCABULARY this rung's `model` speaks. It
75
+ # is not optional in spirit: a tier changes an agent's `model` and NOTHING
76
+ # else — `coding_agent` is a per-agent field and always stays the agent's
77
+ # own — so a rung can only route roles whose backend matches. See
78
+ # config.md's "tiering" section for rule T, and note that this is what
79
+ # makes "validated by the same backend branch agents.validate() uses" an
80
+ # answerable question: the branch is the TIER's declared backend.
81
+ #
82
+ # `model` then speaks EXACTLY the same vocabulary as an agent's own `model:`
83
+ # for that backend — provider/model-id for flue, Claude Code's bare alias for
84
+ # claude_code. There is deliberately no per-provider table: for flue the
85
+ # provider is already the first segment of the value.
86
+ #
87
+ # WHEN tiering.enabled, `spf doctor` runs the SAME provider-key and
88
+ # model-shape checks over these rungs that it already runs over
89
+ # cfg.agents[].model — a rung naming a provider whose key is unset is a
90
+ # green doctor and a first-dispatch failure otherwise. When tiering is
91
+ # DISABLED (which is this file's shipped state) doctor does not look at
92
+ # `tiers` at all, so shipping this populated ladder cannot fail `spf
93
+ # doctor` in a repo that never opted in.
94
+ tiers:
95
+ - { name: scout, coding_agent: flue, model: google/gemini-3.6-flash }
96
+ - { name: builder, coding_agent: flue, model: fireworks/accounts/fireworks/models/kimi-k3 }
97
+ - { name: deep, coding_agent: flue, model: openai/gpt-5.6-terra }
98
+
99
+ # Baseline tier per ROLE (an agent name). Naming an agent here IS the
100
+ # operator's statement "route this one by tier" — so the resolved tier wins
101
+ # over that agent's own `model:`. An agent NOT named here is never retiered:
102
+ # its `model:` stands, untouched. That is the whole precedence rule.
103
+ #
104
+ # A key naming an agent this repo's roster does not define is NOT a global
105
+ # error — it is only reported for a chain that actually requires that
106
+ # agent. A pruned or renamed roster must not fail every chain.
107
+ #
108
+ # NOTE for the two packaged templates that set `defaults.coding_agent:
109
+ # claude_code` (ts, ts-cc): this `roles` map and the flue-shaped `tiers`
110
+ # above survive into their effective config through key-by-key merge. That
111
+ # is harmless while `enabled: false`. Flip `enabled: true` there without
112
+ # also declaring your own ladder and validate() fails LOUDLY by name on the
113
+ # backend mismatch (rule T) rather than dispatching a `fireworks/...` id
114
+ # through Claude Code.
115
+ roles:
116
+ scout: scout
117
+ documenter: scout
118
+ planner: builder
119
+ builder: builder
120
+ refiner: builder
121
+ reviewer: deep
122
+
55
123
  agents:
56
124
  - name: planner
57
125
  model: fireworks/accounts/fireworks/models/kimi-k3
@@ -12,6 +12,38 @@ Decompose a product spec into a feature/story-or-bug tree of vertical slices the
12
12
  - Judge any command you run by its exit status, never by scanning its output for words. `error` or `not found` inside passing output is text, not a failure.
13
13
  - Write your working notes to `<context_handoff_dir>/refine_plan.md` before emitting your Report JSON.
14
14
 
15
+ ## Grounding: tie every slice to real code
16
+
17
+ A scout has already mapped the subsystems this spec touches — its findings arrive as `previous_envelope`. Read them, then read the files they name yourself: a scout finding is a pointer, not a substitute for looking. Before writing a single slice, be able to name the concrete modules, functions, or types each slice will touch. Record what you read and what you concluded in `refine_plan.md`.
18
+
19
+ A slice you cannot tie to code that actually exists is not a slice — it is a guess wearing a slice's shape. Turn it into a question instead (see below).
20
+
21
+ ## Ask, don't decide
22
+
23
+ You will hit real ambiguity: places where the spec (or the codebase, or both) genuinely supports more than one answer, and picking wrong wastes real engineering time on the wrong tree. Escalate those to a human rather than deciding for them. You MUST escalate, rather than guess, anything touching:
24
+
25
+ - **Scope boundaries** — is X in or out of this decomposition.
26
+ - **Data model or schema shape** — a new field, table, or type whose shape isn't implied by what already exists.
27
+ - **Choice of external service or dependency** — which library, API, or vendor.
28
+ - **Auth or permission semantics** — who can do what.
29
+ - **User-facing contract or UX behavior** — what the feature actually does from the outside, when the spec doesn't pin it down.
30
+ - **Breaking changes and migration strategy** — whether existing behavior/data changes shape, and how the transition is sequenced.
31
+ - **Non-functional targets** — performance, scale, availability numbers the spec doesn't state.
32
+ - **Anything that would contradict an existing ADR.**
33
+
34
+ You MAY decide these yourself, following whatever pattern the codebase already uses — that's ordinary judgment, not ambiguity: naming, file placement, test framework and layout, internal module structure, and the ordering of independent slices.
35
+
36
+ When you escalate, ask **everything you need in one batch** — don't trickle questions across rounds when you could have asked them all up front. Emit **no `issues`** in a round where you're asking questions; the two are mutually exclusive and a gate enforces it. For each question, give a human enough to answer in a word or two:
37
+
38
+ - `why_it_matters` — what goes wrong if this gets guessed instead of decided.
39
+ - `options` — the plausible answers you found, if there's a short list.
40
+ - `recommendation` — your own best guess, so "go with your recommendation" is a valid answer.
41
+ - `evidence` — the files/symbols that framed the question.
42
+
43
+ ## Resuming after a human answers
44
+
45
+ A resumed run's prompt includes the issue's comment thread, split into "answers to your open questions" and "earlier discussion." Treat the answers as **authoritative** — never re-ask a question that's been answered. If an answer is itself ambiguous or incomplete, ask one narrower follow-up rather than repeating the original question.
46
+
15
47
  ## The tree
16
48
 
17
49
  Every node you produce is either a **container** (`epic` or `feature` — exists only to group other nodes; never itself a unit of work) or a **leaf** (`story`, `bug`, or `task` — the independently workable unit). A node is a container exactly when some other node names it as `parent`; everything else is a leaf. At least one leaf is required — a decomposition that is all containers has produced nothing to build.
@@ -45,9 +77,3 @@ If even a batch can't stay green alone, keep the same three-stage sequence but l
45
77
  - Avoid specific file paths or code snippets; they go stale fast. Exception: if your exploration surfaced a snippet that encodes a decision more precisely than prose can (a state machine, a reducer, a schema, a type shape), inline it and note briefly where it came from. Trim to the decision-rich part, not a working demo.
46
78
  - Do **not** write a "Blocked by" or "Parent" section into `body` yourself — the harness renders both from `blocked_by`/the source issue automatically, with real issue numbers once everything is created. Writing your own would go stale or duplicate the real one.
47
79
  - `body` should read as `## What to build` followed by `## Acceptance criteria` (a checklist).
48
-
49
- ## Subagents
50
-
51
- `subagent_create` / `_continue` / `_list` / `_remove` fan out exploration — one per subsystem or open question — when the spec spans more than you can read cheaply. Give each a self-contained task, hold it to read-only work, and omit `model`.
52
-
53
- They run in the background. **Wait for every one you spawned to report before writing `refine_plan.md` or your Report JSON.** Skip them when a few reads would do.
@@ -6,25 +6,32 @@
6
6
 
7
7
  {{prompt}}
8
8
 
9
+ `prompt` may include a `## Discussion on the spec issue` section — the spec issue's own comment thread, if it has one. On a first pass this is whatever discussion already existed before the spec was labeled ready; on a resumed pass (after you raised questions and a human answered) it's split into "Answers to your open questions (round N)" and "Earlier discussion." Treat the answers section as authoritative — see your system instructions on resuming.
10
+
9
11
  ### previous_envelope
10
12
 
11
13
  {{previous_envelope}}
12
14
 
15
+ A scout's findings — the subsystems this spec touches, one entry per file with a note on what's there. Read them, then read the files yourself; a scout finding is a pointer, not a substitute for grounding your own decomposition in the actual code.
16
+
13
17
  ### context_handoff_dir
14
18
 
15
19
  {{context_handoff_dir}}
16
20
 
17
21
  ## Task
18
22
 
19
- Decompose the product spec in `prompt` into a feature/story-or-bug tree, following every rule in your system instructions (vertical slices, the `blocked_by` DAG, expand/migrate/contract for wide refactors, no "Blocked by"/"Parent" text of your own).
23
+ Decompose the product spec in `prompt` into a feature/story-or-bug tree, following every rule in your system instructions (grounding, ask-don't-decide, vertical slices, the `blocked_by` DAG, expand/migrate/contract for wide refactors, no "Blocked by"/"Parent" text of your own).
20
24
 
21
- 1. Explore the codebase only as far as you need to ground the decomposition in what actually exists.
22
- 2. Write your working notes to `<context_handoff_dir>/refine_plan.md`.
23
- 3. Emit your `Report` JSON, declaring that one file in `artifacts`.
25
+ 1. Read the scout's findings in `previous_envelope`, then explore the codebase yourself as far as you need to ground the decomposition in what actually exists.
26
+ 2. Decide whether you can decompose the whole spec now, or whether something in it is genuinely ambiguous (see "Ask, don't decide"). These are mutually exclusive outcomes for this round — see the two Report shapes below.
27
+ 3. Write your working notes to `<context_handoff_dir>/refine_plan.md`.
28
+ 4. Emit your `Report` JSON, declaring that one file in `artifacts`.
24
29
 
25
30
  ## Report
26
31
 
27
- Respond with ONLY valid JSON matching `RefineOutput` — no prose before or after:
32
+ Respond with ONLY valid JSON matching `RefineOutput` — no prose before or after. Exactly one of `issues` / `questions` is non-empty; the gate rejects a Report that populates both.
33
+
34
+ **Shape 1 — the decomposition is unambiguous: emit `issues`, leave `questions` empty.**
28
35
 
29
36
  ```json
30
37
  {
@@ -57,14 +64,39 @@ Respond with ONLY valid JSON matching `RefineOutput` — no prose before or afte
57
64
  "parent": "F1",
58
65
  "blocked_by": ["S1"]
59
66
  }
67
+ ],
68
+ "questions": []
69
+ }
70
+ ```
71
+
72
+ **Shape 2 — something material is ambiguous: emit `questions`, leave `issues` empty.**
73
+
74
+ ```json
75
+ {
76
+ "status": "success",
77
+ "summary": "<one sentence: how many questions, and what they're about>",
78
+ "artifacts": ["<context_handoff_dir>/refine_plan.md"],
79
+ "notes_for_next_agent": "<anything a human answering these should know>",
80
+ "issues": [],
81
+ "questions": [
82
+ {
83
+ "id": "Q1",
84
+ "question": "<the question, precise enough to answer in a word or two>",
85
+ "why_it_matters": "<what goes wrong if this is guessed instead of decided>",
86
+ "options": ["<plausible answer>", "<another plausible answer>"],
87
+ "recommendation": "<your own best guess, if you have one>",
88
+ "evidence": ["<file or symbol that framed this question>"]
89
+ }
60
90
  ]
61
91
  }
62
92
  ```
63
93
 
64
94
  Rules on the shape (enforced by a gate — a violation sends this back to you as a correction, not a silent acceptance):
65
95
 
96
+ - `issues` and `questions` are mutually exclusive: a non-empty `questions` requires an empty `issues`, and vice versa. Escalating means publishing nothing this round.
66
97
  - `key` is your own local id for this run, unique within `issues` — never a real tracker id.
67
98
  - A node is a **container** (`kind: "epic"` or `"feature"`) exactly when some other node names it in `parent`; every other node is a **leaf** (`kind: "story"`, `"bug"`, or `"task"`) and must not be a container. At least one leaf is required.
68
99
  - `parent` is another node's `key`, or `""` for a top-level feature/epic.
69
100
  - `blocked_by` is a list of other nodes' `key`s — real dependencies only, and no cycles (through `parent` or `blocked_by`, or both together).
70
101
  - `body` is `## What to build` then `## Acceptance criteria` only — no "Blocked by" or "Parent" section; those are rendered for you once every node has a real issue number.
102
+ - `questions[].id` is your own local id for this round, unique within `questions` — stable enough that, on a resumed run, an answer in the thread can be matched back to the question it answers.
@@ -46,6 +46,7 @@ phases`/`spf events` exist so nobody has to guess the schema again).
46
46
  | "add a chain / a phase / an output type / a gate" | `authoring_chains.md` |
47
47
  | "what is SPF" | `spf_overview.md` |
48
48
  | "fold a third-party review tool's findings into the reviewer" | `cookbooks/ocr_reviewer.md` |
49
+ | "try N attempts and pick the best one / best-of-N" | `spf fanout` — see `references/config.md`'s `spf fanout` section |
49
50
  | envelope/gate/session contract | `references/handoff.md` |
50
51
  | trace schema, spend vs. context | `references/observability.md` |
51
52
  | config field reference | `references/config.md` |
@@ -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,6 +153,7 @@ 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
158
  | `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. |
135
159
  | `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
@@ -140,6 +164,8 @@ watch:
140
164
  repo: owner/name
141
165
  label_prefix: spf
142
166
  chain: plan-build-test
167
+ chain_options:
168
+ suite: strict # only if plan-build-test's chain declares a step-derived requiredSuites
143
169
  refine:
144
170
  enabled: true # decompose spf:spec-ready specs into a feature/story tree
145
171
  chain: refine
@@ -154,6 +180,24 @@ additionally gets `<prefix>:refined`, so a human can review and promote it to
154
180
  `<prefix>:ready` when it's worth building — the refine lane never
155
181
  auto-promotes anything.
156
182
 
183
+ The `refine` chain grounds its decomposition with a `scout` phase before the
184
+ refiner runs, so `scout` is a required agent for it — a roster that pruned
185
+ it fails `spf watch` startup by name.
186
+
187
+ The refine lane's own state machine has an extra loop beyond
188
+ `spec-ready → refining → done`/`blocked`: when the refiner raises material
189
+ ambiguity instead of a tree (see `assets/prompts/refiner/system.md`'s "Ask,
190
+ don't decide"), the spec moves to `<prefix>:needs-feedback` with a comment
191
+ naming its questions, instead of publishing anything. A human answers in the
192
+ issue's comments and adds `<prefix>:continue-refinement`; `spf watch` claims
193
+ that label back into `refining` and resumes the **same** `adw_id` — the
194
+ comment thread (split into "answers to your open questions" and "earlier
195
+ discussion") is folded into the resumed prompt, and the refiner's own
196
+ coding-agent session continues rather than starting cold. This can loop any
197
+ number of rounds. `spf watch init` must be re-run after upgrading to this
198
+ version to seed the two new labels. Once a spec is fully published it's also
199
+ closed on the tracker (GitHub only, best-effort), not just labeled `done`.
200
+
157
201
  ### `notifications`
158
202
 
159
203
  Optional outbound push for unattended work — `spf watch`'s daemon lifecycle,
@@ -165,7 +209,7 @@ default; adding it is entirely additive.
165
209
 
166
210
  | Field | Type | Meaning |
167
211
  |---|---|---|
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. |
212
+ | `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
213
  | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
170
214
  | `channels[]` | array | See below. |
171
215
 
@@ -224,6 +268,111 @@ the repo (never `ENGINEER_NAME`/`$USER`, which are spoofable and fall back to
224
268
  the literal string `"engineer"`) — an unattended run's AI-only commit never
225
269
  carries one, because nobody said yes to attest to.
226
270
 
271
+ ### `tiering`
272
+
273
+ Risk-tiered per-role model routing (SPF #14) — a run-global cost/strength
274
+ shift on top of the static roster, decided ONCE at run start from signals
275
+ code can read without asking an agent anything (the chain's name and the
276
+ prompt's word count), never re-evaluated mid-run. **OFF by default, and
277
+ `enabled: false`/absent is a TOTAL no-op**: every agent dispatches at
278
+ exactly the model its roster entry names, byte-identical to before this key
279
+ existed — the same discipline `defaults.max_run_cost`/`max_run_tokens` hold
280
+ themselves to.
281
+
282
+ **Top-level, not nested under `defaults:`.** `loadConfig`'s back-fill loop
283
+ copies a fixed list of `defaults` keys down onto every agent that hasn't set
284
+ them, and a stray copy would be silently *stripped* at parse rather than
285
+ rejected — a top-level key sits outside that loop entirely, so the trap
286
+ cannot apply here. Merges **key-by-key** with a base config, same as
287
+ `review`/`notifications`: a repo that only flips `enabled: true` keeps the
288
+ base's `tiers`/`roles`; `tiers` and `roles` are each a **whole replace** on
289
+ override (a repo's own ladder replaces the packaged one wholesale, never an
290
+ unordered splice of both).
291
+
292
+ | Field | Type | Meaning |
293
+ |---|---|---|
294
+ | `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. |
295
+ | `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 `[]`. |
296
+ | `tiers[].name` | string | What `roles` values point at. |
297
+ | `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). |
298
+ | `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. |
299
+ | `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 `{}`. |
300
+
301
+ ```yaml
302
+ tiering:
303
+ enabled: true
304
+ tiers:
305
+ - { name: cheap, coding_agent: flue, model: ollama/granite4.1:8b }
306
+ - { name: strong, coding_agent: flue, model: ollama/qwen3.8:27b-mlx }
307
+ roles:
308
+ scout: cheap
309
+ builder: strong
310
+ reviewer: strong
311
+ ```
312
+
313
+ **The risk signal.** Two integer-weighted signals, summed: the chain's own
314
+ kind (`scout`/`prompt`/`document`/`quality` weigh `-1`; `simple-sdlc`/
315
+ `plan-build-test-quality`/`refine` weigh `+1`; everything else, including
316
+ every repo-local `.spf/chains/*.yaml` chain, weighs `0`) and the prompt's
317
+ word count (`-1` at ≤60 words, `+1` at ≥400, `0` between). The sum
318
+ classifies to `low`/`standard`/`high`: reaching `high` needs only one
319
+ signal to agree (a long prompt forces `high` outright), reaching `low`
320
+ needs both to agree — demoting a role a rung is the expensive mistake (a
321
+ wrong answer, a whole re-run); promoting one is the cheap mistake (some
322
+ extra tokens), so the classifier is deliberately asymmetric toward the
323
+ cheap failure. `standard` (step `0`) leaves every role on its configured
324
+ baseline rung.
325
+
326
+ **Rule T — the backend-compatibility rule.** `coding_agent` is a per-agent
327
+ field and model vocabulary is backend-dependent, so a tier is only usable
328
+ for a role whose `coding_agent` matches the tier's own declared
329
+ `coding_agent`. `enabled: true` on a repo that inherited the packaged
330
+ flue-shaped ladder (every repo does, via key-by-key merge, until it
331
+ declares its own) while running a `claude_code` roster fails **loudly, by
332
+ name** at `agents.validate()` time, before anything spawns — never a silent
333
+ skip, and never a `fireworks/...` id handed to Claude Code.
334
+
335
+ **Degradation.** A rung that is unusable — a model tag `probeServedOllamaTags`
336
+ found absent from a local Ollama server's `/models` list, or a rule-T
337
+ backend mismatch — is walked **down**, never up: a degradation must never
338
+ silently escalate spend. If every rung at or below the target is unusable,
339
+ the role dispatches on its own configured `model:` unchanged, plus one noted
340
+ line. The availability probe **fails open**: an unreachable server drops
341
+ nothing rather than silently downgrading every agent to the bottom rung.
342
+
343
+ **Visibility.** Because a retiered agent's *configured* `model:` (what
344
+ `spf.config.yaml` says) and its *effective* model (what actually dispatched)
345
+ can now disagree, every run traces one `tiering` log event carrying the full
346
+ resolved routing, and prints one console line per retiered agent
347
+ (`[spf] tiering <agent> <tier> (<configured> -> <effective>) risk=<risk>`).
348
+ `spf doctor` reports the same resolved ladder and every routed role's
349
+ effective model when `tiering.enabled` — see below.
350
+
351
+ `spf init`'s interview does not write this block: it collects one model for
352
+ the whole roster, and a `tiering` block over a single-model roster is a
353
+ no-op by construction. `spf init --template ts-flue-ollama` is the one
354
+ packaged template that ships it **on**, with two tags measured live against
355
+ a real local Ollama server; the non-interactive starter (`--yes` / piped
356
+ stdin) shows the shape commented out. Every other packaged template
357
+ inherits the built-in default's populated-but-`enabled: false` ladder
358
+ through key-by-key merge — harmless until a repo opts in, and invisible to
359
+ `spf doctor` until it does (see below).
360
+
361
+ **`spf doctor`**, only when `cfg.tiering.enabled` — a disabled ladder is
362
+ invisible to doctor, on purpose, so a repo that never opted in (which is
363
+ every packaged template except `ts-flue-ollama`, plus every `spf init`
364
+ starter) can't fail on an unset provider key for a rung that will never
365
+ dispatch:
366
+
367
+ - the same provider-key check the roster gets, run over `tiers[].model`
368
+ instead, branching on **the tier's own** `coding_agent`;
369
+ - the `OLLAMA_BASE_URL` reachability probe now also fires for an `ollama/*`
370
+ rung even when no `cfg.agents[]` entry itself names one;
371
+ - a new report section: the resolved ladder, every routed role's effective
372
+ model (including roles whose effective model equals their configured
373
+ one), any degradation note, and — probed the same way `probeServedOllamaTags`
374
+ does, fail-open — which `ollama/*` rungs are and aren't in `ollama list`.
375
+
227
376
  ### `agents[]`
228
377
 
229
378
  | 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,6 +69,12 @@ 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
80
  # # directly. Off by default; needs issue_provider: github (the default
@@ -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>;