@gr8ful/spf 0.14.0 → 0.16.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 (77) hide show
  1. package/README.md +51 -9
  2. package/assets/skill/references/config.md +12 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +121 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +91 -30
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +95 -5
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +356 -15
  34. package/dist/core/data_types.js +180 -7
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/issues/github_provider.d.ts +66 -2
  39. package/dist/core/issues/github_provider.js +161 -2
  40. package/dist/core/issues/jira_provider.d.ts +50 -9
  41. package/dist/core/issues/jira_provider.js +62 -2
  42. package/dist/core/paths.d.ts +41 -4
  43. package/dist/core/paths.js +32 -3
  44. package/dist/core/quality.d.ts +7 -7
  45. package/dist/core/quality.js +16 -10
  46. package/dist/core/refine.js +2 -2
  47. package/dist/core/runner.d.ts +9 -3
  48. package/dist/core/runner.js +39 -27
  49. package/dist/core/session.d.ts +2 -2
  50. package/dist/core/session.js +39 -18
  51. package/dist/core/sqlite.d.ts +14 -7
  52. package/dist/core/sqlite.js +14 -7
  53. package/dist/core/trace_db.d.ts +118 -0
  54. package/dist/core/trace_db.js +278 -0
  55. package/dist/core/tracer.d.ts +64 -34
  56. package/dist/core/tracer.js +141 -69
  57. package/dist/core/watch.d.ts +4 -4
  58. package/dist/core/watch.js +2 -2
  59. package/dist/ui/server/app.js +10 -10
  60. package/dist/ui/server/db.d.ts +89 -21
  61. package/dist/ui/server/db.js +235 -99
  62. package/dist/ui/server/serve.d.ts +5 -1
  63. package/dist/ui/server/serve.js +4 -5
  64. package/package.json +1 -1
  65. package/web/assets/index-CQ3k1Y1-.css +1 -0
  66. package/web/assets/index-CU8tom6S.js +21 -0
  67. package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  68. package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  69. package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
  70. package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  71. package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
  72. package/web/index.html +33 -2
  73. package/web/logo.svg +4 -4
  74. package/web/assets/index-C7nF068F.css +0 -1
  75. package/web/assets/index-mzSArcnQ.js +0 -11
  76. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  77. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
package/README.md CHANGED
@@ -36,7 +36,7 @@ spf init --template ts-cc # or start from a packaged, ready-to-run template
36
36
  spf list # every chain this install knows, its phases, what it needs
37
37
  ```
38
38
 
39
- On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code` or `flue`) and model (optionally customized per agent instead of one model for the whole roster), which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host, and whether to push notifications to Slack/Teams/a webhook — and writes `.spf/spf.config.yaml` with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to `.env` (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable `.env.example`. Re-running it later shows any existing `.env` value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, `--yes`, or `--template <name>` all skip the interview and fall back to the original non-interactive behavior — a scripted `spf init` never blocks on stdin.
39
+ On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code`, `flue`, or `opencode`) and model (optionally customized per agent instead of one model for the whole roster), which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host, and whether to push notifications to Slack/Teams/a webhook — and writes `.spf/spf.config.yaml` with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to `.env` (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable `.env.example`. Re-running it later shows any existing `.env` value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, `--yes`, or `--template <name>` all skip the interview and fall back to the original non-interactive behavior — a scripted `spf init` never blocks on stdin.
40
40
 
41
41
  Without an interview, `spf init` writes the same small starter `.spf/spf.config.yaml`, commented, that merges on top of the packaged built-ins field by field. `--template <name>` writes a real, filled-in config instead of the commented-out starter — every packaged template's name prints after `spf init` runs, and the same files live in [`assets/templates/`](assets/templates/) to browse directly. Nothing here needs to exist for `spf` to run; it's how you make one repo's roster diverge from the defaults.
42
42
 
@@ -175,6 +175,14 @@ env:
175
175
 
176
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
177
 
178
+ ### A third backend: opencode
179
+
180
+ Set `coding_agent: opencode` on any agent (or in `defaults`) to run it on your own installed [OpenCode](https://opencode.ai) CLI instead of Flue or Claude Code — install it with `npm install -g opencode-ai` (the package is `opencode-ai`; the binary it puts on `PATH` is `opencode`). `spf doctor` checks that `opencode` resolves on `PATH` and that `~/.local/share/opencode/auth.json` exists, the same informational, not-enforced posture as its `claude_code` check for `ANTHROPIC_API_KEY`. Model names follow the same `provider/model-id` shape Flue uses (e.g. `anthropic/claude-sonnet-4-20250514`, `ollama/qwen3-coder:30b`) — not Claude Code's own bare-alias vocabulary (`sonnet`, `opus`, ...); everything else — `tools`, `writes`, `thinking` — stays the same shape.
181
+
182
+ Authentication is handled entirely outside spf: run `opencode auth login` once (interactive), or `opencode auth list` to check non-interactively what's already configured. spf never drives this itself.
183
+
184
+ Like `claude_code`, routing the `opencode` command through a wrapper or launcher uses the equivalent `SPF_CLAUDE_CMD`-style env var for this backend, with the same `{model}` token-substitution mechanic — see [Declarative env vars](#declarative-env-vars) above and check `opencode --help` for the flags a wrapper needs to forward.
185
+
178
186
  ### flue + local Ollama
179
187
 
180
188
  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/...`:
@@ -766,9 +774,24 @@ A **classic** PAT (fine-grained tokens use different permission names — not co
766
774
  | Private | `repo` | Everything above, full read/write |
767
775
  | Public only | `public_repo` | The same, restricted to public repos |
768
776
 
769
- **Do not grant the `project` scope.** It's a separate, unrelated permission for GitHub Projects (classic/org/user boards)`spf watch` doesn't touch Projects at all, so granting it would just be more access than this tool ever uses.
777
+ There's no dedicated "issues" or "pull requests" scope on classic PATsGitHub bundles both into `repo`/`public_repo`, which is why that table covers everything by default.
778
+
779
+ Native GitHub Projects v2 board status (the board's Status column) is a separate, optional layer on top, off by default — labels alone don't move it, so an issue's Status sits wherever it started unless you opt in with `watch.github.project_number` and `watch.github.status_map`:
780
+
781
+ ```yaml
782
+ watch:
783
+ issue_provider: github
784
+ repo: owner/name
785
+ github:
786
+ project_number: 3 # owner's Projects v2 board number — see the board's own URL
787
+ status_map: # optional — unset by default, per state
788
+ ready: Todo
789
+ working: In Progress
790
+ review: In Review
791
+ done: Done
792
+ ```
770
793
 
771
- There's no dedicated "issues" or "pull requests" scope on classic PATs GitHub bundles both into `repo`/`public_repo`, which is why that's the whole table.
794
+ Only the states you map are touched; an unmapped state keeps today's label-only behavior, and `project_number: 0` (the default) disables sync outright regardless of `status_map`. This needs the **`project`** scope in addition to `repo`/`public_repo` Projects v2 has no REST API at all, only GraphQL, and that surface is gated by its own scope; grant it only if you're using `status_map`. A configured option name with no matching Status option (or a rejected mutation) is logged and skipped, never thrown — the label update is what `spf watch` actually depends on; status sync is a best-effort convenience on top of it. `spf watch init` and `spf watch`'s own startup check both validate a configured `status_map` against the real project's Status options, read-only, and exit non-zero on a mismatch, the same way Jira's `issue_types` is validated below.
772
795
 
773
796
  ### Jira (`issue_provider: jira`)
774
797
 
@@ -777,9 +800,26 @@ export JIRA_EMAIL=you@example.com
777
800
  export JIRA_API_TOKEN=... # id.atlassian.com -> Security -> API tokens
778
801
  ```
779
802
 
780
- State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly, rather than native workflow status transitions the latter would need per-project transition-id mapping, since workflows vary by project/scheme; labels work identically everywhere with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
803
+ State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly labels are spf's actual state machine and always get written unconditionally, with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
781
804
 
782
- `spf watch init` still doesn't create any labels here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) it reports the labels this run will use. But with `watch.refine.enabled`, it now also validates `watch.jira.issue_types` against the real project's issue types, read-only, and exits non-zero on a mismatch — see "Refining specs" above.
805
+ Native Jira workflow status (the board's Status column) is a separate, optional layer on top, off by default labels alone don't move it, so an issue's Status sits wherever it started unless you opt in with `watch.jira.status_map`:
806
+
807
+ ```yaml
808
+ watch:
809
+ issue_provider: jira
810
+ jira:
811
+ base_url: https://your-domain.atlassian.net
812
+ project_key: PROJ
813
+ status_map: # optional — unset by default, per state
814
+ ready: To Do
815
+ working: In Progress
816
+ review: In Review
817
+ done: Done
818
+ ```
819
+
820
+ Only the states you map are touched; an unmapped state keeps today's label-only behavior. This is opt-in rather than automatic because Jira workflows vary by project/scheme — status names and which transitions are reachable from where isn't something spf can assume, unlike labels. A configured name with no reachable transition (or a rejected one) is logged and skipped, never thrown — the label update is what `spf watch` actually depends on; status sync is a best-effort convenience on top of it.
821
+
822
+ `spf watch init` still doesn't create any labels here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) — it reports the labels this run will use. With `watch.refine.enabled`, it also validates `watch.jira.issue_types` against the real project's issue types, read-only, and exits non-zero on a mismatch — see "Refining specs" above. Independent of `refine.enabled`, if `watch.jira.status_map` has any entry configured, both `spf watch init` and `spf watch`'s own startup check validate it against the real project's statuses the same way, and exit non-zero on a mismatch.
783
823
 
784
824
  ### Bitbucket (`code_host: bitbucket`)
785
825
 
@@ -869,14 +909,16 @@ Full field reference: `spf install-skill`'s installed skill
869
909
 
870
910
  ## Observability
871
911
 
872
- Every run produces a complete trace: all events, phases, agent calls, and tool invocations stream into SQLite as they happen. The local trace stays the source of truth — prompts, envelopes, tool arguments, and your source code never leave the machine. Token counts and costs ride alongside.
912
+ Every run produces a complete trace: all events, phases, agent calls, and tool invocations stream into SQLite as they happen. By default the trace is local-only and stays the source of truth — prompts, envelopes, tool arguments, and your source code never leave the machine. Token counts and costs ride alongside.
873
913
 
874
914
  ```bash
875
915
  spf ui # browser-based visualizer over the trace
876
916
  spf events <adw_id> --follow # live event stream, tailable
877
917
  ```
878
918
 
879
- The default export is SQLite only (`.spf/data/spf.db`). Optionally, you can export **spans only** (phase/agent/tool timing and allowlisted metadata) to an OpenTelemetry collector for integration with a trace UI or observability platform:
919
+ The default backend is local SQLite (`.spf/data/spf.db`) — nothing leaves the machine. `observability.db` can instead be pointed at a remote Cloudflare D1 database (`{kind: d1, database_id, ...}`, offered as an `spf init` advanced option): in that mode the **complete trace** — the raw request text, tool arguments and results, envelope payloads, and gate violation details, not just spans or metadata — is written to Cloudflare over the network on every run, and the local-only guarantee above does not hold. Pick D1 only when you're comfortable with your trace data (including prompts and tool arguments) leaving the machine; stay on the default SQLite backend otherwise. See `assets/skill/references/observability.md`'s D1 section for what's sent and how the backend is selected.
920
+
921
+ Separately, and orthogonally to which trace-db backend you choose, you can export **spans only** (phase/agent/tool timing and allowlisted metadata, never prompts or tool arguments) to an OpenTelemetry collector for integration with a trace UI or observability platform:
880
922
 
881
923
  ```yaml
882
924
  observability:
@@ -889,9 +931,9 @@ observability:
889
931
  Authorization: Bearer ...
890
932
  ```
891
933
 
892
- OTEL export is **explicit config only** — an unrelated shell variable cannot become a data-egress switch. OTEL is strictly a spans-only export: each phase is a span with child spans for agent calls and tool calls, annotated with phase status, agent model, token/cost counts, and gate results. This export never blocks a run: if the collector is slow or unreachable, SPF continues normally and logs a single line per run when export fails (not one per batch), and reports the number of dropped spans on the final flush as `spf.otel.dropped_spans` for the backend to surface. The complete trace stays in SQLite regardless.
934
+ OTEL export is **explicit config only** — an unrelated shell variable cannot become a data-egress switch. OTEL is strictly a spans-only export: each phase is a span with child spans for agent calls and tool calls, annotated with phase status, agent model, token/cost counts, and gate results. This export never blocks a run: if the collector is slow or unreachable, SPF continues normally and logs a single line per run when export fails (not one per batch), and reports the number of dropped spans on the final flush as `spf.otel.dropped_spans` for the backend to surface. The complete trace stays in the trace db regardless of OTEL export — but, per above, the trace db itself is remote Cloudflare D1, not local SQLite, when `observability.db` is configured for `kind: d1`.
893
935
 
894
- **Attribute allowlist**: only phase name/kind/owner/status, chain name, adw_id, agent name/model/coding_agent, gate name + passed + violation count, token counts and cost, and durations. Prompts, envelopes, tool arguments, and your source code never leave — that guarantee is enforced in code, not just in documentation.
936
+ **OTEL attribute allowlist**: only phase name/kind/owner/status, chain name, adw_id, agent name/model/coding_agent, gate name + passed + violation count, token counts and cost, and durations. Prompts, envelopes, tool arguments, and your source code never leave *via OTEL* — that guarantee is enforced in code, not just in documentation, and it holds regardless of trace-db backend. It is separate from the trace-db egress question above: a `kind: d1` trace db ships the full trace (prompts, tool arguments, envelope payloads) to Cloudflare even with OTEL left unconfigured.
895
937
 
896
938
  See `assets/skill/references/config.md`'s `observability` section for the full field reference.
897
939
 
@@ -70,8 +70,8 @@ agents:
70
70
 
71
71
  | Field | Type | Meaning |
72
72
  |---|---|---|
73
- | `coding_agent` | `"flue"` \| `"claude_code"` | Which backend runs the agent. Default `flue`. `claude_code` shells out to your own installed `claude` CLI — `spf doctor` checks it's on `PATH`. |
74
- | `model` | string | Vocabulary depends on `coding_agent`: Flue wants `provider/model-id`; Claude Code wants its own bare alias/full name (`sonnet`, `claude-sonnet-5`, ...). Default `google/gemini-3.6-flash`. |
73
+ | `coding_agent` | `"flue"` \| `"claude_code"` \| `"opencode"` | Which backend runs the agent. Default `flue`. `claude_code` shells out to your own installed `claude` CLI — `spf doctor` checks it's on `PATH`. `opencode` shells out to your own installed `opencode` CLI (`npm install -g opencode-ai`) — `spf doctor` checks it's on `PATH` and that `~/.local/share/opencode/auth.json` exists. |
74
+ | `model` | string | Vocabulary depends on `coding_agent`: Flue and opencode want `provider/model-id`; Claude Code wants its own bare alias/full name (`sonnet`, `claude-sonnet-5`, ...). Default `google/gemini-3.6-flash`. |
75
75
  | `thinking` | enum | `off\|minimal\|low\|medium\|high\|xhigh\|max`. Default `medium`. On a `claude_code` agent this maps to `--effort` (`off`/`minimal` both floor to Claude Code's own minimum — it has no true "disabled" level for a headless run). |
76
76
  | `color` | hex string | Lane color fallback for agents that don't set their own. |
77
77
  | `harness_engineering` | string[] | **Must stay `[]`** — no analogue on any current backend; a non-empty entry fails validate(). |
@@ -108,7 +108,7 @@ the two runs' cost/tokens/gates together.
108
108
 
109
109
  | Field | Type | Meaning |
110
110
  |---|---|---|
111
- | `db` | path | The trace sqlite db. Default `.spf/data/spf.db`. |
111
+ | `db` | path \| `{kind: sqlite, path?}` \| `{kind: d1, database_id, account_id_env?, api_token_env?}` | Where the trace db lives. A bare string (or the equivalent spelled-out `{kind: sqlite, ...}`) is a repo-relative local sqlite path, default `.spf/data/spf.db`. `{kind: d1, ...}` points at a remote Cloudflare D1 database instead — `database_id` is required; `account_id_env`/`api_token_env` default to `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN` (the same env vars the `cloudflare` model provider reads), naming the env vars to read the credentials from, never the credentials themselves. **Egress**: `kind: d1` sends the complete trace — raw request text, tool arguments/results, envelope payloads, gate violation details — to Cloudflare's D1 HTTP API on every run; `kind: sqlite` (the default) never leaves the machine. See `observability.md`'s "Two stores, one truth" and "D1 ships the complete trace off-box" sections for what changes (and what deliberately doesn't) with a D1-backed trace db — notably, the local backend's WAL live-read guarantee does not hold for D1. |
112
112
  | `poll_ms` | int | UI live-poll cadence. Default `500`. |
113
113
  | `otel.endpoint` | string | OTLP/HTTP collector endpoint (e.g., `https://your-host/v1/traces`). Omit to disable OTel export. |
114
114
  | `otel.headers` | object | Optional HTTP headers (e.g., auth tokens). Each value is a string. |
@@ -116,6 +116,10 @@ the two runs' cost/tokens/gates together.
116
116
 
117
117
  **No ambient env activation**: OTEL export requires explicit `observability.otel` config — the `OTEL_EXPORTER_OTLP_ENDPOINT` shell variable is never consulted. An unrelated shell env variable must not become a data-egress switch.
118
118
 
119
+ **`spf init`'s interview** asks local-vs-D1 only inside the "advanced" gate (declining advanced settings, or `--yes`, leaves `observability.db` unset — local sqlite at its default path, today's behavior, unchanged). Choosing D1 asks for `database_id` (required) and, unless this same interview run already collected `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN` for the Cloudflare Workers AI provider, asks for those too — reusing them instead of asking twice when it did. The generated config omits `account_id_env`/`api_token_env` (they match the schema's own defaults above), so a D1 choice writes just `db: {kind: d1, database_id: ...}`.
120
+
121
+ **`spf doctor`** probes a `kind: d1` `observability.db` in two steps, right after its `db_path` check: first a static, hard check that the resolved `account_id_env`/`api_token_env` are actually set (naming whichever is missing) — informational, same never-a-hard-failure contract as every other credential-presence check in this file. Then, only when both are set and `--no-probe` isn't passed, a cheap read-only reachability check (`SfDb.exists()` — no `Tracer`, no write) reports `info` whenever the request succeeds, whether the database already has a `sessions` table or (expected before the first run against a fresh database) not — a reachable database is healthy either way, exactly like the local `db_path` check's plain `✓` for a not-yet-created sqlite file. Only when the request itself fails (bad credentials, wrong `database_id`, network error) does it report `warn` (never a hard failure), naming the actual error.
122
+
119
123
  ### `quality`
120
124
 
121
125
  `checks[]`: `{name, area: "frontend"|"backend", operation: "lint"|"typecheck"|"build", argv: string[], timeout_seconds}`.
@@ -160,6 +164,9 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
160
164
  | `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). |
161
165
  | `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
162
166
  | `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task`/`spec` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` (plus `spec`, a standalone spec proposed by a split — see below) creates as on Jira — defaults `epic`/`feature` → `Epic`, `story`/`spec` → `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. |
167
+ | `jira.status_map` | map: `spec-ready`/`ready`/`working`/`review`/`done`/`blocked` -> string | Only consulted when `issue_provider: jira`. Optional, empty (unset) by default — when a state has no entry, `spf watch` updates only the `<prefix>:<state>` label on that transition, exactly as before this field existed. When a state IS mapped to a real Jira status name (e.g. `working: "In Progress"`), `spf watch` also transitions the issue's native Status field there, best-effort: no reachable transition, or a rejected one, is logged and skipped rather than failing the run — the label update is what `spf watch` actually depends on, status sync is a convenience on top. Validated against the real project's statuses by both `spf watch init` and `spf watch`'s own startup check, same as `issue_types`. |
168
+ | `github.project_number` | int ≥0 | Only consulted when `issue_provider: github`. Which of the repo OWNER's Projects v2 boards `status_map` syncs against (Projects v2 numbers are per-owner, not per-repo — see the board's own URL). Default `0`, which disables status sync outright regardless of `status_map`; there's no sane "guess the board" default. |
169
+ | `github.status_map` | map: `spec-ready`/`ready`/`working`/`review`/`done`/`blocked` -> string | Only consulted when `issue_provider: github` AND `project_number` is set. Optional, empty (unset) by default — an unmapped state stays label-only, a mapped one also sets the Projects v2 "Status" field, best-effort (no matching Status option, or a rejected mutation, is logged and skipped, never thrown — the label update is what `spf watch` actually depends on, status sync is a convenience on top). Needs `GITHUB_TOKEN` to carry the `project` scope in addition to `repo`/`public_repo` — Projects v2 is GraphQL-only. Validated against the real project's Status options by both `spf watch init` and `spf watch`'s own startup check. |
163
170
  | `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. |
164
171
  | `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
165
172
  | `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
@@ -361,8 +368,8 @@ unordered splice of both).
361
368
  | `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. |
362
369
  | `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 `[]`. |
363
370
  | `tiers[].name` | string | What `roles` values point at. |
364
- | `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). |
365
- | `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. |
371
+ | `tiers[].coding_agent` | `"flue"` \| `"claude_code"` \| `"opencode"` | 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). |
372
+ | `tiers[].model` | string | Same vocabulary as an agent's own `model:` for that backend: `provider/model-id` for `flue` and `opencode`, Claude Code's bare alias/full name for `claude_code`. No per-provider table — for `flue`/`opencode` the provider is already the string's first segment. |
366
373
  | `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 `{}`. |
367
374
 
368
375
  ```yaml
@@ -6,12 +6,47 @@ always: **agents → sqlite → CLI / web UI.**
6
6
  ## Two stores, one truth
7
7
 
8
8
  **Files are the raw record** (`envelope.json`, `agent_map.json`, Flue's own
9
- `.spf/data/flue.db` conversation store); **SQLite (`.spf/data/spf.db`, via
10
- `node:sqlite`) is the queryable mirror** the CLI and UI read. `tracer.ts`
11
- writes both. Losing the db loses nothing that can't be rebuilt from files.
12
-
13
- Location comes from `observability.db` in `spf.config.yaml`, default
14
- `.spf/data/spf.db`inside the target repo, always gitignored.
9
+ `.spf/data/flue.db` conversation store); **the trace db is the queryable
10
+ mirror** the CLI and UI read. `tracer.ts` writes both. Losing the trace db
11
+ loses nothing that can't be rebuilt from files.
12
+
13
+ `observability.db` in `spf.config.yaml` names WHICH trace db backend and
14
+ wherea bare string (a local sqlite path, default `.spf/data/spf.db`,
15
+ inside the target repo, always gitignored), `{kind: sqlite, path?}` spelled
16
+ out the same way, or `{kind: d1, database_id, account_id_env?,
17
+ api_token_env?}` for a remote Cloudflare D1 database instead of a local
18
+ file. `core/trace_db.ts`'s `TraceDb` interface is the one seam both `Tracer`
19
+ (writes) and `SfDb` (reads) go through — `createTraceDb()` picks
20
+ `LocalTraceDb` or `D1TraceDb` from the resolved kind. See `config.md`'s
21
+ `observability.db` rows for the full field reference.
22
+
23
+ **The WAL live-read guarantee is LOCAL-ONLY.** The rest of this doc's "WAL
24
+ pragmas" section describes a guarantee specific to the local sqlite
25
+ backend — `spf ui` reading while a chain writes, through one shared file.
26
+ A D1-backed repo has no such file to share: `spf ui` and a running chain
27
+ each speak to D1 over independent HTTP calls, and D1's own consistency
28
+ model (sequential consistency, with a Sessions-API "bookmark" for
29
+ read-your-own-writes that this adapter does not use — see
30
+ `D1TraceDb`'s own doc comment in `core/trace_db.ts`) does not promise the
31
+ same instant. This is a deliberate, documented trade-off (SPF #66), not a
32
+ gap: no phase/gate/run OUTCOME depends on it — only how quickly `spf ui`
33
+ can catch up to a chain still writing.
34
+
35
+ **D1 ships the complete trace off-box.** The "Two stores, one truth"
36
+ guarantee above ("local sqlite, always gitignored") is specific to
37
+ `kind: sqlite`. Point `observability.db` at `kind: d1` and every write
38
+ `tracer.ts` makes — `sessions.request` (the operator's raw request text),
39
+ `events.payload_json` (including each `tool_call` event's `args` and
40
+ `result_snippet`), `envelopes.payload_json`, and
41
+ `gate_results.violations_json` — is POSTed over HTTPS to
42
+ `https://api.cloudflare.com/client/v4/accounts/{account_id}/d1/database/{database_id}/query`
43
+ (`trace_db.ts`'s `D1TraceDb`). This is the full trace, not the OTEL
44
+ spans-only attribute allowlist described elsewhere in this doc: prompts,
45
+ tool arguments, and tool results leave the machine on every run once
46
+ `kind: d1` is configured. There is no partial mode — a repo is either
47
+ fully local (`kind: sqlite`, the default) or fully remote for trace data
48
+ (`kind: d1`). Choose D1 only when shipping that data to Cloudflare is
49
+ acceptable for the repo in question.
15
50
 
16
51
  ## Event schema
17
52
 
@@ -86,6 +121,16 @@ up through `agents.execute`, `run.phase`, and every chain's `main()` is
86
121
 
87
122
  ## Tables
88
123
 
124
+ No `REFERENCES` / foreign keys on `adw_id` or `phase_id` anywhere below — a
125
+ deliberate change for the D1 backend (SPF #66), not an oversight: Cloudflare
126
+ D1 enforces FKs unconditionally with no way to disable them, and a session's
127
+ very first write (e.g. its first `events` row) can otherwise land before its
128
+ own `sessions` row has committed, throwing `FOREIGN KEY constraint failed` on
129
+ a perfectly ordinary run. The relationships still hold logically (every
130
+ `adw_id` should resolve to a `sessions` row eventually) — they are just no
131
+ longer enforced by the schema on either backend, so local sqlite and D1
132
+ behave identically.
133
+
89
134
  ```sql
90
135
  sessions (
91
136
  adw_id TEXT PRIMARY KEY, adw_name TEXT, request TEXT, status TEXT, engineer TEXT,
@@ -93,37 +138,37 @@ sessions (
93
138
  archived INTEGER DEFAULT 0 -- review triage, set by the UI; never by a run
94
139
  );
95
140
  phases (
96
- phase_id TEXT PRIMARY KEY, adw_id TEXT REFERENCES sessions, seq INTEGER,
141
+ phase_id TEXT PRIMARY KEY, adw_id TEXT, seq INTEGER,
97
142
  name TEXT, kind TEXT, owner TEXT, description TEXT,
98
143
  status TEXT DEFAULT 'fail', -- success must be earned
99
144
  attempt INTEGER DEFAULT 0, retries INTEGER DEFAULT 0, error TEXT,
100
145
  started_at TEXT, ended_at TEXT
101
146
  );
102
147
  events (
103
- event_id TEXT PRIMARY KEY, adw_id TEXT REFERENCES sessions, phase_id TEXT REFERENCES phases,
148
+ event_id TEXT PRIMARY KEY, adw_id TEXT, phase_id TEXT,
104
149
  parent_id TEXT, type TEXT, name TEXT, payload_json TEXT, tokens INTEGER,
105
150
  started_at TEXT, ended_at TEXT -- ended_at set only on events that span time
106
151
  );
107
152
  envelopes (
108
- envelope_id TEXT PRIMARY KEY, adw_id TEXT REFERENCES sessions, phase_id TEXT REFERENCES phases,
153
+ envelope_id TEXT PRIMARY KEY, adw_id TEXT, phase_id TEXT,
109
154
  agent TEXT, output_type TEXT, payload_json TEXT, valid INTEGER, attempt INTEGER, created_at TEXT
110
155
  );
111
156
  gate_results (
112
- id INTEGER PRIMARY KEY AUTOINCREMENT, adw_id TEXT REFERENCES sessions, phase_id TEXT REFERENCES phases,
157
+ id INTEGER PRIMARY KEY AUTOINCREMENT, adw_id TEXT, phase_id TEXT,
113
158
  attempt INTEGER, gate TEXT, passed INTEGER,
114
159
  violations_json TEXT, -- derived: the failed checks, as "item: note"
115
160
  checks_json TEXT, -- [{item, ok, note}] — everything the gate looked at
116
161
  created_at TEXT
117
162
  );
118
163
  processes ( -- adw_id -> pid, so a stuck run can be stopped
119
- id INTEGER PRIMARY KEY AUTOINCREMENT, adw_id TEXT REFERENCES sessions,
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT, adw_id TEXT,
120
165
  kind TEXT, -- 'adw' (the chain process) | 'agent' (a real child pid for claude_code; same as the chain's own pid for Flue, which is in-process)
121
166
  name TEXT, pid INTEGER,
122
167
  command TEXT, -- what the pid WAS; pids get recycled, verify before killing
123
168
  started_at TEXT, ended_at TEXT -- ended_at NULL = believed alive
124
169
  );
125
170
  agent_sessions ( -- the queryable mirror of agent_map.json
126
- adw_id TEXT REFERENCES sessions, agent TEXT,
171
+ adw_id TEXT, agent TEXT,
127
172
  coding_agent TEXT, model TEXT, color TEXT, session_id TEXT,
128
173
  context_tokens INTEGER, -- window occupancy after the agent's last turn
129
174
  context_window INTEGER, -- backend-dependent; 0 = unknown — see "Context is occupancy" above
@@ -0,0 +1,54 @@
1
+ # .spf/spf.config.yaml — OpenCode backend. `spf init --template ts-opencode`
2
+ # writes this file as-is.
3
+ #
4
+ # `coding_agent: opencode` shells out to your own locally installed
5
+ # `opencode` CLI — install it with:
6
+ # npm install -g opencode-ai
7
+ # (the package is `opencode-ai`; the binary it puts on PATH is `opencode` —
8
+ # a common point of confusion, so double-check `which opencode` if a run
9
+ # can't find it.)
10
+ #
11
+ # model: is provider/model-id, the same free-form vocabulary shape Flue
12
+ # uses — NOT Claude Code's own bare-alias vocabulary (sonnet/opus/...).
13
+ # A few concrete examples:
14
+ # anthropic/claude-sonnet-4-20250514
15
+ # ollama/qwen3-coder:30b
16
+ # opencode/gpt-5.1-codex
17
+ #
18
+ # Authentication is handled entirely outside spf: run `opencode auth login`
19
+ # (interactive) once, which writes credentials to
20
+ # ~/.local/share/opencode/auth.json. `spf doctor` checks that `opencode` is
21
+ # on PATH and that that auth.json file exists, but it does not drive login
22
+ # itself and does not know whether the credentials inside are actually
23
+ # valid — same informational, not-enforced posture as its claude_code check
24
+ # for ANTHROPIC_API_KEY.
25
+ quality:
26
+ checks:
27
+ - { name: typecheck, operation: typecheck, argv: ["npm", "run", "typecheck"], timeout_seconds: 60 }
28
+ - { name: lint, operation: lint, argv: ["npm", "run", "lint"], timeout_seconds: 60 }
29
+ - { name: build, operation: build, argv: ["npm", "run", "build"], timeout_seconds: 300 }
30
+ - { name: test, operation: build, argv: ["npm", "test"], timeout_seconds: 300 }
31
+ suites:
32
+ test: [test]
33
+ all: [typecheck, lint, build, test]
34
+
35
+ defaults:
36
+ coding_agent: opencode
37
+ model: anthropic/claude-sonnet-4-20250514
38
+
39
+ # REQUIRED, not just an example: the packaged default roster pins
40
+ # planner/reviewer/documenter to their own explicit provider/model-id
41
+ # strings, which an agent's own model always wins over defaults.model
42
+ # above — switching coding_agent globally does NOT reset those three, so
43
+ # they'd run on opencode with whatever model string the packaged roster
44
+ # happened to pin them to, which may point at a provider/model your
45
+ # `opencode auth login` was never set up for. builder/scout/refiner have no
46
+ # model of their own in the packaged roster, so they correctly inherit
47
+ # defaults.model above and need no override here.
48
+ agents:
49
+ - name: planner
50
+ model: anthropic/claude-sonnet-4-20250514
51
+ - name: reviewer
52
+ model: anthropic/claude-sonnet-4-20250514
53
+ - name: documenter
54
+ model: anthropic/claude-sonnet-4-20250514
@@ -195,6 +195,6 @@ export async function runChain(chain, ctx, options = {}) {
195
195
  // exactly as it always did (the signal listener needs no such fallback:
196
196
  // the process is gone).
197
197
  await otel.releaseOtelExporter(ctx.adw_id);
198
- session.finalize(ctx.adw_id);
198
+ await session.finalize(ctx.adw_id);
199
199
  }
200
200
  }
@@ -71,8 +71,8 @@ export interface SignoffParams {
71
71
  asker: Asker | null;
72
72
  /** `undefined` when `git config user.name`/`user.email` is unset at this repo — see `git_helper.committerIdentity`. */
73
73
  identity: CommitterIdentity | undefined;
74
- /** Routes into the phase's own trace record — `ph.log`, unchanged (no tracer changes on this thread). */
75
- log: (payload: Record<string, unknown>) => void;
74
+ /** Routes into the phase's own trace record — `ph.log`, now async (`Tracer`'s write methods all are — see `core/tracer.ts`'s header). */
75
+ log: (payload: Record<string, unknown>) => Promise<void>;
76
76
  /** Console-only, never traced — the prompt itself and its framing, kept out of the event stream on purpose (the DECISION is what `log` records). */
77
77
  warn: (line: string) => void;
78
78
  }
@@ -95,7 +95,7 @@ export async function decideSignoff(params) {
95
95
  "or run via `spf watch`, where a human merges the PR instead of this phase.");
96
96
  }
97
97
  warn(paint("bold yellow", `⚠ ${AI_ONLY_SIGNOFF_WARNING}`));
98
- log({
98
+ await log({
99
99
  decision: "ai_only",
100
100
  warning: AI_ONLY_SIGNOFF_WARNING,
101
101
  human: false,
@@ -118,7 +118,7 @@ export async function decideSignoff(params) {
118
118
  }
119
119
  warn("");
120
120
  const accepted = await asker.confirm(identity ? `${identity.name}, approve and commit?` : "Approve and commit?", false, { timeoutMs: signoffTimeoutSeconds * 1000 });
121
- log({
121
+ await log({
122
122
  decision: accepted ? "approved" : "declined",
123
123
  human: true,
124
124
  engineer: identity?.name ?? null,
@@ -128,7 +128,7 @@ export async function decideSignoff(params) {
128
128
  });
129
129
  if (accepted && !identity) {
130
130
  warn(paint("dim", " no git committer identity (user.name/user.email) is set — sign-off recorded, no Signed-off-by trailer"));
131
- log({ note: "signoff recorded without a git committer identity — trailer skipped" });
131
+ await log({ note: "signoff recorded without a git committer identity — trailer skipped" });
132
132
  }
133
133
  return { accepted, recordedYes: accepted };
134
134
  }
@@ -151,10 +151,10 @@ export async function main(ctx) {
151
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
- ph.log({ input: prompt, baseline: run.git.shortSha(baseline) });
154
+ await ph.log({ input: prompt, baseline: run.git.shortSha(baseline) });
155
155
  });
156
156
  const plan = await run.phase(makePhaseParams({ name: "plan", kind: "agent", owner: "planner", description: "Turn the request into an implementable plan" }), (ph) => ph.call(makeAgentCall({ output_type: PlanOutput, prompt, gates: [gates.artifactsExist, gates.filesNonEmpty] })));
157
- await run.phase(makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: "Put the spec on record before any code exists to blur it" }), async (ph) => commitEnvelope(run, ph, plan));
157
+ await run.phase(makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: "Put the spec on record before any code exists to blur it" }), async (ph) => await commitEnvelope(run, ph, plan));
158
158
  let build = await run.phase(makePhaseParams({ name: "build", kind: "agent", owner: "builder", description: "Implement the plan exactly" }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, previous: plan, gates: [gates.diffMatchesClaims] })));
159
159
  let test = null;
160
160
  for (let i = 1; i <= MAX_FIX_LOOPS; i++) {
@@ -164,8 +164,8 @@ export async function main(ctx) {
164
164
  owner: "quality",
165
165
  description: "Run the suite — a known command, so code runs it and no agent has to rediscover it",
166
166
  }), async (ph) => {
167
- const result = quality.runTests(run);
168
- quality.record(ph, result);
167
+ const result = await quality.runTests(run);
168
+ await quality.record(ph, result);
169
169
  return result;
170
170
  });
171
171
  if (test.passed)
@@ -198,8 +198,8 @@ export async function main(ctx) {
198
198
  owner: "quality",
199
199
  description: "Re-run the suite — the revision changed code after the last green result",
200
200
  }), async (ph) => {
201
- const result = quality.runTests(run);
202
- quality.record(ph, result);
201
+ const result = await quality.runTests(run);
202
+ await quality.record(ph, result);
203
203
  return result;
204
204
  });
205
205
  }
@@ -254,10 +254,10 @@ export async function main(ctx) {
254
254
  // trailerFor gates the trailer on recordedYes, not just `verified` — see
255
255
  // its own comment: a Signed-off-by line must trace back to an explicit
256
256
  // "yes", never to the AI-only path (where `verified` can also be true).
257
- async (ph) => commitEnvelope(run, ph, build, trailerFor({ accepted: verified, recordedYes }, identity)));
257
+ async (ph) => await commitEnvelope(run, ph, build, trailerFor({ accepted: verified, recordedYes }, identity)));
258
258
  const changeset = await run.phase(makePhaseParams({ name: "changes", kind: "code", owner: "git", description: "Diff the whole run against its pinned baseline, for the documenter" }), async (ph) => {
259
259
  const result = changes.capture(run, makeChangeCapture({ base: baseline }));
260
- logChangeset(ph, result);
260
+ await logChangeset(ph, result);
261
261
  if (result.empty) {
262
262
  throw new Error(`nothing changed since ${result.base.label} (${result.base.reason}) — there is nothing to document.`);
263
263
  }
@@ -269,7 +269,7 @@ export async function main(ctx) {
269
269
  previous: changes.asEnvelope(changeset, DOCUMENT_NOTES),
270
270
  gates: [gates.artifactsExist, gates.filesNonEmpty],
271
271
  })));
272
- await run.phase(makePhaseParams({ name: "commit_docs", kind: "code", owner: "git", description: "Ship the write-up in its own commit, beside the code it describes" }), async (ph) => commitEnvelope(run, ph, document));
272
+ await run.phase(makePhaseParams({ name: "commit_docs", kind: "code", owner: "git", description: "Ship the write-up in its own commit, beside the code it describes" }), async (ph) => await commitEnvelope(run, ph, document));
273
273
  }
274
- return run.finish(verified, "the suite or the review never came back clean, or sign-off never came");
274
+ return await run.finish(verified, "the suite or the review never came back clean, or sign-off never came");
275
275
  }
@@ -107,9 +107,9 @@ export declare function startRun(ctx: ChainContext, requiredAgents: string[], re
107
107
  */
108
108
  export declare function commitEnvelope(run: Run, ph: PhaseHandle, envelope: EnvelopeBase & {
109
109
  commit_message?: string;
110
- }, signoff?: CommitterIdentity | null): void;
110
+ }, signoff?: CommitterIdentity | null): Promise<void>;
111
111
  /** Log a change-capture result the same way every chain that captures one did. */
112
- export declare function logChangeset(ph: PhaseHandle, result: ChangeSet): void;
112
+ export declare function logChangeset(ph: PhaseHandle, result: ChangeSet): Promise<void>;
113
113
  /**
114
114
  * The gates a chain definition is allowed to name, by name.
115
115
  *