@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.
- package/README.md +15 -5
- package/assets/skill/references/config.md +9 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +109 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +27 -27
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +86 -4
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +126 -12
- package/dist/core/data_types.js +101 -4
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/index.html +2 -2
- package/web/assets/index-CRujNW-1.js +0 -11
- 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 `
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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 |
|
|
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); **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
where — a 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/chains/index.js
CHANGED
|
@@ -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`,
|
|
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
|
}
|
package/dist/chains/steps.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/chains/steps.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|