@gr8ful/spf 0.15.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 (64) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/references/config.md +9 -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 +109 -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 +27 -27
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +86 -4
  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 +126 -12
  34. package/dist/core/data_types.js +101 -4
  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/paths.d.ts +41 -4
  39. package/dist/core/paths.js +32 -3
  40. package/dist/core/quality.d.ts +7 -7
  41. package/dist/core/quality.js +16 -10
  42. package/dist/core/runner.d.ts +9 -3
  43. package/dist/core/runner.js +39 -27
  44. package/dist/core/session.d.ts +2 -2
  45. package/dist/core/session.js +39 -18
  46. package/dist/core/sqlite.d.ts +14 -7
  47. package/dist/core/sqlite.js +14 -7
  48. package/dist/core/trace_db.d.ts +118 -0
  49. package/dist/core/trace_db.js +278 -0
  50. package/dist/core/tracer.d.ts +64 -34
  51. package/dist/core/tracer.js +141 -69
  52. package/dist/core/watch.d.ts +4 -4
  53. package/dist/core/watch.js +2 -2
  54. package/dist/ui/server/app.js +10 -10
  55. package/dist/ui/server/db.d.ts +89 -21
  56. package/dist/ui/server/db.js +235 -99
  57. package/dist/ui/server/serve.d.ts +5 -1
  58. package/dist/ui/server/serve.js +4 -5
  59. package/package.json +1 -1
  60. package/web/assets/index-CQ3k1Y1-.css +1 -0
  61. package/web/assets/index-CU8tom6S.js +21 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CRujNW-1.js +0 -11
  64. package/web/assets/index-Cto6nuQL.css +0 -1
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/...`:
@@ -901,14 +909,16 @@ Full field reference: `spf install-skill`'s installed skill
901
909
 
902
910
  ## Observability
903
911
 
904
- 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.
905
913
 
906
914
  ```bash
907
915
  spf ui # browser-based visualizer over the trace
908
916
  spf events <adw_id> --follow # live event stream, tailable
909
917
  ```
910
918
 
911
- 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:
912
922
 
913
923
  ```yaml
914
924
  observability:
@@ -921,9 +931,9 @@ observability:
921
931
  Authorization: Bearer ...
922
932
  ```
923
933
 
924
- 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`.
925
935
 
926
- **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.
927
937
 
928
938
  See `assets/skill/references/config.md`'s `observability` section for the full field reference.
929
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}`.
@@ -364,8 +368,8 @@ unordered splice of both).
364
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. |
365
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 `[]`. |
366
370
  | `tiers[].name` | string | What `roles` values point at. |
367
- | `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). |
368
- | `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. |
369
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 `{}`. |
370
374
 
371
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
  *
@@ -73,7 +73,7 @@ function makeStep(fn, meta = {}) {
73
73
  export async function startRun(ctx, requiredAgents, requiredSuites) {
74
74
  const cfg = agentsCfg.loadConfig(ctx.config_paths);
75
75
  agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
76
- const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
76
+ const run = await session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
77
77
  // Provenance, once per run, before any phase opens: a repo-local chain
78
78
  // (.spf/chains/*.yaml) records the file it came from. `chain_name` alone
79
79
  // stops being enough to reconstruct a run the moment a target repo can
@@ -88,7 +88,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
88
88
  // it is a note. Adding a picklist member would force a UI change for a
89
89
  // payload the UI already renders generically.
90
90
  if (ctx.chain_source) {
91
- run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
91
+ await run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
92
92
  }
93
93
  // Tiering resolution (SPF #14) — one more run-scoped fact, computed once,
94
94
  // before any phase opens, beside chain_source above. `risk`/`signals` are
@@ -103,7 +103,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
103
103
  servedOllamaTags,
104
104
  required: requiredAgents,
105
105
  });
106
- run.tracer.event(makeEventRecord({
106
+ await run.tracer.event(makeEventRecord({
107
107
  adw_id: run.adw_id,
108
108
  type: "log",
109
109
  name: "tiering",
@@ -123,7 +123,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
123
123
  // reading the console when the two disagree.
124
124
  for (const [agentName, effective] of Object.entries(tiering.changedModels(run.tiering))) {
125
125
  const route = run.tiering.routing[agentName];
126
- run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
126
+ await run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
127
127
  }
128
128
  return run;
129
129
  }
@@ -176,7 +176,23 @@ function appendTrailer(message, trailer) {
176
176
  * -> no trailer, ever: a trailer that lies launders an AI verdict into a git
177
177
  * attestation.
178
178
  */
179
- export function commitEnvelope(run, ph, envelope, signoff) {
179
+ export async function commitEnvelope(run, ph, envelope, signoff) {
180
+ // An agent's own plan can (and sometimes does) instruct it to `git commit`
181
+ // its own work directly — e.g. a "Commit convention" section the planner
182
+ // wrote into the plan, following a target repo's own commit-message
183
+ // rules. When that happens, this phase's working tree is already clean by
184
+ // the time it runs: `commitAll`'s `git status --porcelain` finds nothing
185
+ // staged and throws "nothing to commit", even though real, tested work
186
+ // already landed on the branch a phase ago. Distinguish that from the
187
+ // genuine "nothing happened at all" case by checking whether HEAD already
188
+ // holds committed changes ahead of `run.base` — if so, this phase has
189
+ // nothing left to do and the existing HEAD *is* the result, not a failure.
190
+ const base = run.cfg.watch?.base_branch ?? "main";
191
+ if (!run.git.isDirty() && run.git.diffFiles(base).length > 0) {
192
+ const sha = run.git.shortSha();
193
+ await ph.log({ sha, message: "(already committed by a preceding phase — nothing new to stage)" });
194
+ return;
195
+ }
180
196
  let message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
181
197
  if (signoff) {
182
198
  const trailerLine = `Signed-off-by: ${signoff.name} <${signoff.email}>`;
@@ -186,11 +202,11 @@ export function commitEnvelope(run, ph, envelope, signoff) {
186
202
  if (!alreadyPresent)
187
203
  message = appendTrailer(message, trailerLine);
188
204
  }
189
- ph.log({ sha: run.git.commitAll(message), message });
205
+ await ph.log({ sha: run.git.commitAll(message), message });
190
206
  }
191
207
  /** Log a change-capture result the same way every chain that captures one did. */
192
- export function logChangeset(ph, result) {
193
- ph.log({
208
+ export async function logChangeset(ph, result) {
209
+ await ph.log({
194
210
  base: `${result.base.label} @ ${result.base.commit.slice(0, 7)}`,
195
211
  reason: result.base.reason,
196
212
  files: result.files.length + result.untracked.length,
@@ -328,7 +344,7 @@ export function request(opts = {}) {
328
344
  const payload = { input: state.prompt };
329
345
  if (opts.logBaseline)
330
346
  payload.baseline = run.git.shortSha(state.baseline);
331
- ph.log(payload);
347
+ await ph.log(payload);
332
348
  });
333
349
  };
334
350
  return makeStep(fn, { label: "engineer(request)" });
@@ -454,8 +470,8 @@ export function qualityCheck(opts = { suite: "all" }) {
454
470
  description: opts.description ??
455
471
  (suiteName === "all" ? "Run the deterministic quality blocks" : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
456
472
  }), async (ph) => {
457
- const result = quality.runSuite(run, suiteName);
458
- quality.record(ph, result);
473
+ const result = await quality.runSuite(run, suiteName);
474
+ await quality.record(ph, result);
459
475
  state.quality = result;
460
476
  state.accepted = result.passed;
461
477
  state.reason = result.passed ? "" : `quality failed: ${result.failures.join("; ")}`;
@@ -498,8 +514,8 @@ export function fixLoop(opts = { suite: "test" }) {
498
514
  ? "Lint, typecheck, and build before testing"
499
515
  : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
500
516
  }), async (ph) => {
501
- const r = quality.runSuite(run, suiteName);
502
- quality.record(ph, r);
517
+ const r = await quality.runSuite(run, suiteName);
518
+ await quality.record(ph, r);
503
519
  return r;
504
520
  });
505
521
  if (result.passed)
@@ -597,7 +613,7 @@ export function commit(opts = {}) {
597
613
  owner: "git",
598
614
  description: opts.description ??
599
615
  (opts.onlyIfAccepted ? "Land the code only after the suite came back green" : "Land the builder's changes, using the message it wrote"),
600
- }), async (ph) => commitEnvelope(run, ph, state.previous));
616
+ }), async (ph) => await commitEnvelope(run, ph, state.previous));
601
617
  };
602
618
  return makeStep(fn, { label: "git(commit)" });
603
619
  }
@@ -616,7 +632,7 @@ export function changes(opts = {}) {
616
632
  description: opts.description ?? `Diff the working tree against ${base} — the change to be written up`,
617
633
  }), async (ph) => {
618
634
  const result = changesLib.capture(run, makeChangeCapture({ base }));
619
- logChangeset(ph, result);
635
+ await logChangeset(ph, result);
620
636
  if (result.empty) {
621
637
  throw new Error(`nothing changed since ${result.base.label} (${result.base.reason}) — documenting runs after a build. ` +
622
638
  `Build something first, or point --base at the ref the work should be measured from.`);
@@ -770,7 +786,7 @@ export function publishIssues(opts = {}) {
770
786
  clearStaleRefineOutputFiles(run.context_handoff_dir);
771
787
  if (questions.length > 0) {
772
788
  writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
773
- ph.log({ escalated: questions.length });
789
+ await ph.log({ escalated: questions.length });
774
790
  return;
775
791
  }
776
792
  if (split.length > 0) {
@@ -781,7 +797,7 @@ export function publishIssues(opts = {}) {
781
797
  // `proposeSpecSplit`, same division of labor as the `questions`
782
798
  // branch above (this writes, `runSpec` posts/transitions).
783
799
  writeFileSync(path.join(run.context_handoff_dir, "refine_split.json"), JSON.stringify(split, null, 2));
784
- ph.log({ split_proposed: split.length });
800
+ await ph.log({ split_proposed: split.length });
785
801
  return;
786
802
  }
787
803
  const tracker = refineLib.resolveAuthoringProvider(run.cfg);
@@ -791,7 +807,7 @@ export function publishIssues(opts = {}) {
791
807
  priorityCeiling,
792
808
  });
793
809
  writeFileSync(path.join(run.context_handoff_dir, "refine_publish.json"), JSON.stringify(created.map((c) => ({ id: c.issue.id, title: c.issue.title, kind: c.kind, isLeaf: c.isLeaf })), null, 2));
794
- ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
810
+ await ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
795
811
  });
796
812
  };
797
813
  return makeStep(fn, { label: "code(publish)" });
@@ -843,5 +859,5 @@ export async function runSteps(ctx, requiredAgents, requiredSuites, steps, optio
843
859
  for (const step of steps) {
844
860
  await step(run, state);
845
861
  }
846
- return run.finish(state.accepted, state.reason);
862
+ return await run.finish(state.accepted, state.reason);
847
863
  }
@@ -1 +1 @@
1
- export declare function abortCommand(argv: string[]): number;
1
+ export declare function abortCommand(argv: string[]): Promise<number>;
@@ -4,18 +4,45 @@
4
4
  * recorded as still running for this adw_id and SIGTERM them. Since Flue
5
5
  * runs in-process, that pid is the whole `spf` invocation driving the chain
6
6
  * — this stops the run, not just the current model call.
7
+ *
8
+ * LOCAL-FILE-SPECIFIC: reads the `processes` table through a second raw
9
+ * `Database` handle on the same sqlite file `db` already opened — there is
10
+ * no such file for a `kind:"d1"` repo (SPF #66), so this fails clearly
11
+ * instead of pretending a network-backed process table works the same way.
12
+ * A future release could reach the same table over `SfDb`'s own (now async)
13
+ * query surface instead of a raw second handle — not done here because nothing
14
+ * else in this command needs it, and the raw handle already matched
15
+ * `db.journalMode`'s existing guarantees for the local case.
7
16
  */
8
17
  import { Database } from "../../core/sqlite.js";
9
18
  import { parseCli } from "../../core/utils.js";
10
- import { openTrace } from "./trace.js";
11
- export function abortCommand(argv) {
19
+ import { openTrace, resolveTrace } from "./trace.js";
20
+ export async function abortCommand(argv) {
12
21
  const { positionals, options } = parseCli(argv, ["cwd", "config"]);
13
22
  if (positionals.length < 1) {
14
23
  console.error("usage: spf abort <adw_id> [--cwd <dir>] [--config <path>]");
15
24
  return 1;
16
25
  }
17
26
  const adwId = positionals[0];
18
- const { db, dataDir } = openTrace(options);
27
+ // Checked BEFORE opening anything: a d1-backed repo has no local process
28
+ // table regardless of whether its trace db can even be reached, so there
29
+ // is no reason to pay for (or risk failing) an `openTrace` HTTP round
30
+ // trip just to then refuse. This also means no `SfDb` handle is ever
31
+ // opened for the d1 case — nothing to leave unclosed.
32
+ const { dataPaths } = resolveTrace(options);
33
+ if (dataPaths.db.kind === "d1") {
34
+ console.error(`spf abort is not supported for a d1-backed repo (observability.db.kind: "d1") — ` +
35
+ `there is no local process table to read for database_id ${JSON.stringify(dataPaths.db.database_id)}. ` +
36
+ `Stop the process by pid/OS tooling directly, or on the machine that ran it.`);
37
+ return 1;
38
+ }
39
+ const { db, dataDir } = await openTrace(options);
40
+ if (db.path === null) {
41
+ // Unreachable in practice — the `dataPaths.db.kind === "d1"` guard above
42
+ // already rejected the only case `db.path` can be null for — but keeps
43
+ // this typed as a real narrowing rather than a `!` assertion.
44
+ throw new Error("spf abort: db.path is null after the d1 guard — this should never happen");
45
+ }
19
46
  // SfDb opens read-only; a live process row needs a separate writable
20
47
  // handle only to read it here (no write happens) — reuse the same file.
21
48
  const raw = new Database(db.path, { readonly: true });