@gr8ful/spf 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -9
- package/assets/skill/references/config.md +12 -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 +121 -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 +91 -30
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +95 -5
- 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 +356 -15
- package/dist/core/data_types.js +180 -7
- 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/issues/github_provider.d.ts +66 -2
- package/dist/core/issues/github_provider.js +161 -2
- package/dist/core/issues/jira_provider.d.ts +50 -9
- package/dist/core/issues/jira_provider.js +62 -2
- 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/refine.js +2 -2
- 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/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
- package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
- package/web/index.html +33 -2
- package/web/logo.svg +4 -4
- package/web/assets/index-C7nF068F.css +0 -1
- package/web/assets/index-mzSArcnQ.js +0 -11
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ spf init --template ts-cc # or start from a packaged, ready-to-run template
|
|
|
36
36
|
spf list # every chain this install knows, its phases, what it needs
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code` or `
|
|
39
|
+
On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code`, `flue`, or `opencode`) and model (optionally customized per agent instead of one model for the whole roster), which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host, and whether to push notifications to Slack/Teams/a webhook — and writes `.spf/spf.config.yaml` with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to `.env` (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable `.env.example`. Re-running it later shows any existing `.env` value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, `--yes`, or `--template <name>` all skip the interview and fall back to the original non-interactive behavior — a scripted `spf init` never blocks on stdin.
|
|
40
40
|
|
|
41
41
|
Without an interview, `spf init` writes the same small starter `.spf/spf.config.yaml`, commented, that merges on top of the packaged built-ins field by field. `--template <name>` writes a real, filled-in config instead of the commented-out starter — every packaged template's name prints after `spf init` runs, and the same files live in [`assets/templates/`](assets/templates/) to browse directly. Nothing here needs to exist for `spf` to run; it's how you make one repo's roster diverge from the defaults.
|
|
42
42
|
|
|
@@ -175,6 +175,14 @@ env:
|
|
|
175
175
|
|
|
176
176
|
Applied to `process.env` before any command runs. A plain value commits as literal text; `${VAR}` interpolates from whatever's already in `process.env` at that point (the real shell, or `.env`, both of which load first) — so a real secret can live in `.env` (gitignored) and be referenced here without ever being written into this file. An already-set `process.env` value always wins over `env:`'s — `spf.config.yaml` supplies the default, a real export still overrides it per machine/session. A `${VAR}` reference to something that's genuinely unset fails loudly at startup, naming the missing variable, rather than silently interpolating to an empty string.
|
|
177
177
|
|
|
178
|
+
### A third backend: opencode
|
|
179
|
+
|
|
180
|
+
Set `coding_agent: opencode` on any agent (or in `defaults`) to run it on your own installed [OpenCode](https://opencode.ai) CLI instead of Flue or Claude Code — install it with `npm install -g opencode-ai` (the package is `opencode-ai`; the binary it puts on `PATH` is `opencode`). `spf doctor` checks that `opencode` resolves on `PATH` and that `~/.local/share/opencode/auth.json` exists, the same informational, not-enforced posture as its `claude_code` check for `ANTHROPIC_API_KEY`. Model names follow the same `provider/model-id` shape Flue uses (e.g. `anthropic/claude-sonnet-4-20250514`, `ollama/qwen3-coder:30b`) — not Claude Code's own bare-alias vocabulary (`sonnet`, `opus`, ...); everything else — `tools`, `writes`, `thinking` — stays the same shape.
|
|
181
|
+
|
|
182
|
+
Authentication is handled entirely outside spf: run `opencode auth login` once (interactive), or `opencode auth list` to check non-interactively what's already configured. spf never drives this itself.
|
|
183
|
+
|
|
184
|
+
Like `claude_code`, routing the `opencode` command through a wrapper or launcher uses the equivalent `SPF_CLAUDE_CMD`-style env var for this backend, with the same `{model}` token-substitution mechanic — see [Declarative env vars](#declarative-env-vars) above and check `opencode --help` for the flags a wrapper needs to forward.
|
|
185
|
+
|
|
178
186
|
### flue + local Ollama
|
|
179
187
|
|
|
180
188
|
Point the default `flue` backend at a local Ollama server the same way you'd pick any other Flue provider — the model string's own prefix, `ollama/<tag>` (whatever `ollama list` shows on your machine) instead of `openai/...`/`anthropic/...`:
|
|
@@ -766,9 +774,24 @@ A **classic** PAT (fine-grained tokens use different permission names — not co
|
|
|
766
774
|
| Private | `repo` | Everything above, full read/write |
|
|
767
775
|
| Public only | `public_repo` | The same, restricted to public repos |
|
|
768
776
|
|
|
769
|
-
|
|
777
|
+
There's no dedicated "issues" or "pull requests" scope on classic PATs — GitHub bundles both into `repo`/`public_repo`, which is why that table covers everything by default.
|
|
778
|
+
|
|
779
|
+
Native GitHub Projects v2 board status (the board's Status column) is a separate, optional layer on top, off by default — labels alone don't move it, so an issue's Status sits wherever it started unless you opt in with `watch.github.project_number` and `watch.github.status_map`:
|
|
780
|
+
|
|
781
|
+
```yaml
|
|
782
|
+
watch:
|
|
783
|
+
issue_provider: github
|
|
784
|
+
repo: owner/name
|
|
785
|
+
github:
|
|
786
|
+
project_number: 3 # owner's Projects v2 board number — see the board's own URL
|
|
787
|
+
status_map: # optional — unset by default, per state
|
|
788
|
+
ready: Todo
|
|
789
|
+
working: In Progress
|
|
790
|
+
review: In Review
|
|
791
|
+
done: Done
|
|
792
|
+
```
|
|
770
793
|
|
|
771
|
-
|
|
794
|
+
Only the states you map are touched; an unmapped state keeps today's label-only behavior, and `project_number: 0` (the default) disables sync outright regardless of `status_map`. This needs the **`project`** scope in addition to `repo`/`public_repo` — Projects v2 has no REST API at all, only GraphQL, and that surface is gated by its own scope; grant it only if you're using `status_map`. A configured option name with no matching Status option (or a rejected mutation) is logged and skipped, never thrown — the label update is what `spf watch` actually depends on; status sync is a best-effort convenience on top of it. `spf watch init` and `spf watch`'s own startup check both validate a configured `status_map` against the real project's Status options, read-only, and exit non-zero on a mismatch, the same way Jira's `issue_types` is validated below.
|
|
772
795
|
|
|
773
796
|
### Jira (`issue_provider: jira`)
|
|
774
797
|
|
|
@@ -777,9 +800,26 @@ export JIRA_EMAIL=you@example.com
|
|
|
777
800
|
export JIRA_API_TOKEN=... # id.atlassian.com -> Security -> API tokens
|
|
778
801
|
```
|
|
779
802
|
|
|
780
|
-
State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly
|
|
803
|
+
State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly — labels are spf's actual state machine and always get written unconditionally, with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
|
|
781
804
|
|
|
782
|
-
|
|
805
|
+
Native Jira workflow status (the board's Status column) is a separate, optional layer on top, off by default — labels alone don't move it, so an issue's Status sits wherever it started unless you opt in with `watch.jira.status_map`:
|
|
806
|
+
|
|
807
|
+
```yaml
|
|
808
|
+
watch:
|
|
809
|
+
issue_provider: jira
|
|
810
|
+
jira:
|
|
811
|
+
base_url: https://your-domain.atlassian.net
|
|
812
|
+
project_key: PROJ
|
|
813
|
+
status_map: # optional — unset by default, per state
|
|
814
|
+
ready: To Do
|
|
815
|
+
working: In Progress
|
|
816
|
+
review: In Review
|
|
817
|
+
done: Done
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Only the states you map are touched; an unmapped state keeps today's label-only behavior. This is opt-in rather than automatic because Jira workflows vary by project/scheme — status names and which transitions are reachable from where isn't something spf can assume, unlike labels. A configured name with no reachable transition (or a rejected one) is logged and skipped, never thrown — the label update is what `spf watch` actually depends on; status sync is a best-effort convenience on top of it.
|
|
821
|
+
|
|
822
|
+
`spf watch init` still doesn't create any labels here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) — it reports the labels this run will use. With `watch.refine.enabled`, it also validates `watch.jira.issue_types` against the real project's issue types, read-only, and exits non-zero on a mismatch — see "Refining specs" above. Independent of `refine.enabled`, if `watch.jira.status_map` has any entry configured, both `spf watch init` and `spf watch`'s own startup check validate it against the real project's statuses the same way, and exit non-zero on a mismatch.
|
|
783
823
|
|
|
784
824
|
### Bitbucket (`code_host: bitbucket`)
|
|
785
825
|
|
|
@@ -869,14 +909,16 @@ Full field reference: `spf install-skill`'s installed skill
|
|
|
869
909
|
|
|
870
910
|
## Observability
|
|
871
911
|
|
|
872
|
-
Every run produces a complete trace: all events, phases, agent calls, and tool invocations stream into SQLite as they happen.
|
|
912
|
+
Every run produces a complete trace: all events, phases, agent calls, and tool invocations stream into SQLite as they happen. By default the trace is local-only and stays the source of truth — prompts, envelopes, tool arguments, and your source code never leave the machine. Token counts and costs ride alongside.
|
|
873
913
|
|
|
874
914
|
```bash
|
|
875
915
|
spf ui # browser-based visualizer over the trace
|
|
876
916
|
spf events <adw_id> --follow # live event stream, tailable
|
|
877
917
|
```
|
|
878
918
|
|
|
879
|
-
The default
|
|
919
|
+
The default backend is local SQLite (`.spf/data/spf.db`) — nothing leaves the machine. `observability.db` can instead be pointed at a remote Cloudflare D1 database (`{kind: d1, database_id, ...}`, offered as an `spf init` advanced option): in that mode the **complete trace** — the raw request text, tool arguments and results, envelope payloads, and gate violation details, not just spans or metadata — is written to Cloudflare over the network on every run, and the local-only guarantee above does not hold. Pick D1 only when you're comfortable with your trace data (including prompts and tool arguments) leaving the machine; stay on the default SQLite backend otherwise. See `assets/skill/references/observability.md`'s D1 section for what's sent and how the backend is selected.
|
|
920
|
+
|
|
921
|
+
Separately, and orthogonally to which trace-db backend you choose, you can export **spans only** (phase/agent/tool timing and allowlisted metadata, never prompts or tool arguments) to an OpenTelemetry collector for integration with a trace UI or observability platform:
|
|
880
922
|
|
|
881
923
|
```yaml
|
|
882
924
|
observability:
|
|
@@ -889,9 +931,9 @@ observability:
|
|
|
889
931
|
Authorization: Bearer ...
|
|
890
932
|
```
|
|
891
933
|
|
|
892
|
-
OTEL export is **explicit config only** — an unrelated shell variable cannot become a data-egress switch. OTEL is strictly a spans-only export: each phase is a span with child spans for agent calls and tool calls, annotated with phase status, agent model, token/cost counts, and gate results. This export never blocks a run: if the collector is slow or unreachable, SPF continues normally and logs a single line per run when export fails (not one per batch), and reports the number of dropped spans on the final flush as `spf.otel.dropped_spans` for the backend to surface. The complete trace stays in SQLite
|
|
934
|
+
OTEL export is **explicit config only** — an unrelated shell variable cannot become a data-egress switch. OTEL is strictly a spans-only export: each phase is a span with child spans for agent calls and tool calls, annotated with phase status, agent model, token/cost counts, and gate results. This export never blocks a run: if the collector is slow or unreachable, SPF continues normally and logs a single line per run when export fails (not one per batch), and reports the number of dropped spans on the final flush as `spf.otel.dropped_spans` for the backend to surface. The complete trace stays in the trace db regardless of OTEL export — but, per above, the trace db itself is remote Cloudflare D1, not local SQLite, when `observability.db` is configured for `kind: d1`.
|
|
893
935
|
|
|
894
|
-
**
|
|
936
|
+
**OTEL attribute allowlist**: only phase name/kind/owner/status, chain name, adw_id, agent name/model/coding_agent, gate name + passed + violation count, token counts and cost, and durations. Prompts, envelopes, tool arguments, and your source code never leave *via OTEL* — that guarantee is enforced in code, not just in documentation, and it holds regardless of trace-db backend. It is separate from the trace-db egress question above: a `kind: d1` trace db ships the full trace (prompts, tool arguments, envelope payloads) to Cloudflare even with OTEL left unconfigured.
|
|
895
937
|
|
|
896
938
|
See `assets/skill/references/config.md`'s `observability` section for the full field reference.
|
|
897
939
|
|
|
@@ -70,8 +70,8 @@ agents:
|
|
|
70
70
|
|
|
71
71
|
| Field | Type | Meaning |
|
|
72
72
|
|---|---|---|
|
|
73
|
-
| `coding_agent` | `"flue"` \| `"claude_code"` | Which backend runs the agent. Default `flue`. `claude_code` shells out to your own installed `claude` CLI — `spf doctor` checks it's on `PATH`. |
|
|
74
|
-
| `model` | string | Vocabulary depends on `coding_agent`: Flue
|
|
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}`.
|
|
@@ -160,6 +164,9 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
|
|
|
160
164
|
| `chain_options` | map of string -> string | Options passed straight through to `chain` (and `refine.chain`) for every unattended dispatch — the same shape an interactive `spf <chain> --suite <name>` builds, e.g. `{suite: strict}` or `{agent: some-agent}`. Default `{}`. Only useful for a chain whose behavior actually reads the option (a step-derived chain's `--suite`; an imperative chain ignores an option it doesn't know about). |
|
|
161
165
|
| `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
|
|
162
166
|
| `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task`/`spec` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` (plus `spec`, a standalone spec proposed by a split — see below) creates as on Jira — defaults `epic`/`feature` → `Epic`, `story`/`spec` → `Story`, `bug` → `Bug`, `task` → `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
|
|
167
|
+
| `jira.status_map` | map: `spec-ready`/`ready`/`working`/`review`/`done`/`blocked` -> string | Only consulted when `issue_provider: jira`. Optional, empty (unset) by default — when a state has no entry, `spf watch` updates only the `<prefix>:<state>` label on that transition, exactly as before this field existed. When a state IS mapped to a real Jira status name (e.g. `working: "In Progress"`), `spf watch` also transitions the issue's native Status field there, best-effort: no reachable transition, or a rejected one, is logged and skipped rather than failing the run — the label update is what `spf watch` actually depends on, status sync is a convenience on top. Validated against the real project's statuses by both `spf watch init` and `spf watch`'s own startup check, same as `issue_types`. |
|
|
168
|
+
| `github.project_number` | int ≥0 | Only consulted when `issue_provider: github`. Which of the repo OWNER's Projects v2 boards `status_map` syncs against (Projects v2 numbers are per-owner, not per-repo — see the board's own URL). Default `0`, which disables status sync outright regardless of `status_map`; there's no sane "guess the board" default. |
|
|
169
|
+
| `github.status_map` | map: `spec-ready`/`ready`/`working`/`review`/`done`/`blocked` -> string | Only consulted when `issue_provider: github` AND `project_number` is set. Optional, empty (unset) by default — an unmapped state stays label-only, a mapped one also sets the Projects v2 "Status" field, best-effort (no matching Status option, or a rejected mutation, is logged and skipped, never thrown — the label update is what `spf watch` actually depends on, status sync is a convenience on top). Needs `GITHUB_TOKEN` to carry the `project` scope in addition to `repo`/`public_repo` — Projects v2 is GraphQL-only. Validated against the real project's Status options by both `spf watch init` and `spf watch`'s own startup check. |
|
|
163
170
|
| `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` — off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` or `"jira"` — both implement issue authoring (create + link a hierarchy); any other value fails loudly at startup. |
|
|
164
171
|
| `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
|
|
165
172
|
| `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
|
|
@@ -361,8 +368,8 @@ unordered splice of both).
|
|
|
361
368
|
| `enabled` | bool | Default `false`. `true` turns on the ladder walk described below; `false` (or the key absent) is a total no-op, checked nowhere and dispatched nowhere. |
|
|
362
369
|
| `tiers` | array, WEAKEST FIRST | The ladder. A risk level shifts every routed role UP or DOWN this list by the same step — order is the whole semantics, which is why this is a sequence and not a mapping. Default `[]`. |
|
|
363
370
|
| `tiers[].name` | string | What `roles` values point at. |
|
|
364
|
-
| `tiers[].coding_agent` | `"flue"` \| `"claude_code"` | Which backend's vocabulary this rung's `model` speaks. Default `flue`, same default an agent's own `coding_agent` uses. A tier changes an agent's `model` and **nothing else** — `coding_agent` always stays the agent's own — so a rung can only route roles whose backend matches (**rule T**, below). |
|
|
365
|
-
| `tiers[].model` | string | Same vocabulary as an agent's own `model:` for that backend: `provider/model-id` for `flue`, Claude Code's bare alias/full name for `claude_code`. No per-provider table — for `flue` the provider is already the string's first segment. |
|
|
371
|
+
| `tiers[].coding_agent` | `"flue"` \| `"claude_code"` \| `"opencode"` | Which backend's vocabulary this rung's `model` speaks. Default `flue`, same default an agent's own `coding_agent` uses. A tier changes an agent's `model` and **nothing else** — `coding_agent` always stays the agent's own — so a rung can only route roles whose backend matches (**rule T**, below). |
|
|
372
|
+
| `tiers[].model` | string | Same vocabulary as an agent's own `model:` for that backend: `provider/model-id` for `flue` and `opencode`, Claude Code's bare alias/full name for `claude_code`. No per-provider table — for `flue`/`opencode` the provider is already the string's first segment. |
|
|
366
373
|
| `roles` | map of agent name -> tier name | The baseline tier per **role**. Naming an agent here is the operator's statement "route this one by tier" — the resolved tier then wins over that agent's own `model:`. An agent **not** named here is never retiered; its `model:` stands, untouched. That is the whole precedence rule. Default `{}`. |
|
|
367
374
|
|
|
368
375
|
```yaml
|
|
@@ -6,12 +6,47 @@ always: **agents → sqlite → CLI / web UI.**
|
|
|
6
6
|
## Two stores, one truth
|
|
7
7
|
|
|
8
8
|
**Files are the raw record** (`envelope.json`, `agent_map.json`, Flue's own
|
|
9
|
-
`.spf/data/flue.db` conversation store); **
|
|
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
|
*
|