--schema # prune an existing schema.yml's tests (ingest -> prune -> diff, no LLM) [v0.2]
+signalforge prune-existing --schema # prune an existing schema.yml's tests (ingest -> prune -> diff, no LLM)
signalforge init-demo [] # copy the bundled Austin demo project into
signalforge lint # validate signalforge.yml config blocks (no LLM/warehouse calls)
signalforge version # print the SignalForge version
@@ -367,13 +393,59 @@ reference, exit-code taxonomy, and environment variables.
## Configuration
-### Configuring the BigQuery adapter
+SignalForge reads your existing dbt `profiles.yml` and dispatches on
+`type:` — no second profile to maintain. The dispatch happens in
+`WarehouseAdapter.from_profile(profile)`; each adapter then exposes the
+same `sample_rows` / `materialise_sample` / `run_test_sql` /
+`estimate_query_bytes` surface to the rest of the pipeline.
+
+### BigQuery
+
+A standard `type: bigquery` dbt target works. Authenticate via
+Application Default Credentials and set the billing project:
+
+```bash
+gcloud auth application-default login
+export GOOGLE_CLOUD_PROJECT=
+```
-SignalForge reads your dbt profile and instantiates a `BigQueryAdapter`
-via `WarehouseAdapter.from_profile(profile)`. See
-[docs/warehouse-adapter-ops.md](docs/warehouse-adapter-ops.md) for ADC
-setup, cost defaults, sampling strategy (and the TABLESAMPLE
-cost-asterisk), `PartitionFilter` use, and the typed-error reference.
+Cost is bounded by `maximum_bytes_billed` (100 MB default; the bundled
+demo `profiles.yml` raises it to 1 GB so the materialised-sample scan
+clears the cap). `use_query_cache` is forced off for reproducibility.
+Full reference — ADC setup, sampling strategy (and the TABLESAMPLE
+cost-asterisk), `PartitionFilter` use, and the typed-error reference —
+is in [docs/warehouse-adapter-ops.md](docs/warehouse-adapter-ops.md).
+
+### Snowflake
+
+A standard `type: snowflake` dbt target works — `account`, `user`,
+`warehouse`, plus either `password`, key-pair (`private_key_path` +
+`private_key_passphrase`), or SSO (`authenticator: externalbrowser`).
+`database` / `schema` / `role` are optional at profile level; SignalForge
+will not override them at runtime.
+
+Recommended cost guardrails before pointing it at a real Snowflake
+account: create a **resource monitor** (e.g. 1-credit daily cap), use
+an **X-Small warehouse with aggressive auto-suspend**, and start with
+`prune.scope: sample` + `prune.sample_strategy: materialised`. Setup
+walkthrough (incl. an `.env.example`) is in
+[docs/snowflake-e2e-setup.md](docs/snowflake-e2e-setup.md); adapter
+reference (sampling, session cleanup, `EXPLAIN`-based bytes estimation,
+known limitations) is in
+[docs/warehouse-adapter-ops.md § Snowflake adapter](docs/warehouse-adapter-ops.md).
+
+> **Known limitation:** `safety: aggregate-only` (Snowflake `column_stats`)
+> is not yet implemented. Every other combination is functional.
+
+### Pipeline-stage configuration
+
+Cross-cutting behaviour (sampling mode, prune scope, grade thresholds,
+diff rendering) is configured per stage in `signalforge.yml` — see
+[docs/safety-ops.md](docs/safety-ops.md),
+[docs/prune-ops.md](docs/prune-ops.md),
+[docs/grade-ops.md](docs/grade-ops.md), and
+[docs/diff-ops.md](docs/diff-ops.md). `signalforge lint` validates the
+file with no LLM or warehouse calls.
## Data safety
@@ -419,12 +491,20 @@ response audit are all owned by the layer.
```text
Manifest + Model + LLMRequest (from safety layer)
-> render_prompt (system + cached manifest summary + dynamic per-model SQL)
- -> call_anthropic (1 SDK seam, full retry taxonomy, prompt caching)
+ -> call_llm (provider-neutral seam, full retry taxonomy, prompt caching)
-> parse_draft_response (JSON + anchor-contract validator)
-> write_response_event (fail-closed JSONL audit)
-> DraftOutcome(candidate, request, result)
```
+`call_llm` dispatches the vendor-specific request shape / response
+parse / exception classification to the registered `LLMProvider`
+strategy (Anthropic / OpenAI / Gemini). See
+[docs/llm-providers-ops.md](docs/llm-providers-ops.md) for the
+capability matrix, the per-provider gotchas (Gemini truncation, the
+`finish_reason` degrade path, server-side JSON modes), and the
+recipe for adding a fourth provider.
+
### Auditability
Two parallel audit streams sit under `policy.audit_path.parent`:
@@ -445,19 +525,28 @@ reference.
## Roadmap
-| Version | Scope |
-| ------- | ---------------------------------------------------------------------------------- |
-| v0.1 | Single-model draft + warehouse prune; first warehouse adapter (BigQuery); CLI only |
-| v0.2 | Prune externally-authored tests (`prune-existing`); additional warehouse adapters (Snowflake, Postgres); project-wide drift detection |
-| v0.3 | GitHub Action with PR comment integration |
-| v0.4 | Rubric customization; organization-wide style profiles |
-| v1.0 | dbt Fusion engine compatibility; dbt MCP server consumption |
+Shipped:
+
+| Version | Released | Scope |
+| ------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| v0.1 | 2026-05-20 | Single-model draft + warehouse prune + LLM-as-judge grade + diff renderer; BigQuery adapter; `signalforge` CLI (`generate`, `lint`, `version`) |
+| v0.2 | 2026-05-21 | Ingest externally-authored `schema.yml`; `signalforge prune-existing` (no-LLM prune path); `signalforge init-demo` first-run UX; uv tooling; Python 3.11–3.13 |
+| v0.3 | 2026-05-27 | Snowflake warehouse adapter (full sampling, materialised-sample CTAS, `EXPLAIN`-based bytes estimation); custom business-rule tests (`custom_sql`, the 5th test type) — drafted from `meta.signalforge.business_rules` or LLM inference, then pruned like any other test |
+| v0.4 | 2026-05-28 | **Multi-provider LLM support** — OpenAI (#136) and Google Gemini (#137) behind the provider-neutral seam established in #135 (Anthropic remains the default). `--estimate` is provider-aware: Anthropic uses `messages.count_tokens` (live SDK call), OpenAI uses local `tiktoken`, Gemini uses native `client.models.count_tokens` |
+
+Planned:
+
+| Version | Scope |
+| ------- | ---------------------------------------------------------------------------------------------------------------- |
+| v0.5 | **Installable Claude Code skill** — `signalforge install-skill` ships a SKILL.md that teaches Claude to drive the CLI |
+| v0.6 | **Airflow operator** — drop SignalForge into a scheduled DAG for periodic schema drift / signal-rot detection |
+| v0.7 | **GitHub Action** — PR-time invocation with inline comment integration (kept/dropped/flagged surfaced on the PR) |
+| v0.8 | **Rubric customization** — project-specific grading criteria; organization-wide style profiles |
+| v1.0 | **dbt Fusion engine compatibility** — dbt MCP server consumption; first-class Fusion integration |
-The architecture is warehouse-agnostic — adapters plug in behind a thin
-sampling/profiling interface. BigQuery is the v0.1 target because of its
-generous query-bytes pricing for sampled reads and its first-class
-`INFORMATION_SCHEMA.JOBS` history for downstream cost analysis. Snowflake,
-Databricks, Postgres, and Redshift are all on the roadmap; PRs welcome.
+Warehouse coverage beyond BigQuery + Snowflake — Postgres (stub today),
+Databricks, Redshift — slots in behind the existing `WarehouseAdapter`
+ABC and is roadmap-tracked but not version-pinned; PRs welcome.
Detail is tracked in GitHub Issues against this repo.
@@ -482,4 +571,4 @@ Apache-2.0. See [LICENSE](LICENSE).
## Contributing
-Pre-alpha — issues welcome to shape the design. Open one against the `dev` branch describing the use case you'd like SignalForge to handle. Code contributions will open with the v0.1 milestone.
+Issues welcome to shape the design. Open one against the `dev` branch describing the use case you'd like SignalForge to handle.
diff --git a/docs/cli-ops.md b/docs/cli-ops.md
index 5d56dbea..cd81be17 100644
--- a/docs/cli-ops.md
+++ b/docs/cli-ops.md
@@ -57,10 +57,10 @@ After install, the `signalforge` console script is registered via
## Subcommands
-The CLI exposes five subcommands: `generate`, `init-demo`, `lint`,
-`prune-existing`, `version`. `signalforge --help` prints the
-top-level help; each subcommand has its own `--help` page (e.g.
-`signalforge generate --help`).
+The CLI exposes six subcommands: `generate`, `init-demo`,
+`install-skill`, `lint`, `prune-existing`, `version`. `signalforge
+--help` prints the top-level help; each subcommand has its own
+`--help` page (e.g. `signalforge generate --help`).
### `signalforge generate `
@@ -335,6 +335,99 @@ signalforge lint
signalforge generate models/staging/stg_bikeshare_trips.sql --dry-run
```
+### `signalforge install-skill []`
+
+Copy the bundled SignalForge Claude Code skill into
+`/.claude/skills/signalforge/`. With the skill installed, a
+Claude Code session in `` recognises requests like "draft tests
+for `dim_customers`" or "prune my existing `schema.yml`," picks the
+right `signalforge` subcommand and flags, and explains the resulting
+kept / dropped / flagged diff back to the user. See
+[docs/skills.md](skills.md) for the skill catalog entry and the body
+sections it covers (DEC-021 of
+[`plans/super/141-claude-skill-install.md`](../plans/super/141-claude-skill-install.md)).
+
+Wraps the public library entry point
+`signalforge.skill.install_skill(dest) -> Path`; the CLI re-raises
+the lower-level `SkillError` subclasses as `CliInstallSkill*Error`
+wrappers at the handler boundary so the four-tier exit-code taxonomy
+stays homogeneous (DEC-008).
+
+Positional argument:
+
+- `` — Destination directory. Optional; default `.` (the
+ current working directory), so the common invocation from a dbt
+ project root is just `signalforge install-skill`. Relative paths
+ resolve against the current working directory; `~` expands.
+ Symlink-cycle defence applies (resolves via `.resolve(strict=True)`,
+ falling back to `.resolve(strict=False)` on
+ `FileNotFoundError` / `NotADirectoryError`) and raises
+ `CliInstallSkillPathError` on a cycle on every supported Python
+ version (gh-108958). **No `--project-dir` containment gate applies**
+ — `install-skill` is the second subcommand that *creates* a project
+ context rather than operating *inside* one (the first is
+ `init-demo`), so the `canonicalise_user_path(...)` containment
+ helper used by every other CLI flag is deliberately bypassed
+ (DEC-006).
+
+Flags: none. There is **no `--force` flag** (DEC-003): the library
+seam always overwrites every file SignalForge ships and preserves
+every other file in the destination tree, so the
+`--force`-against-symlink-dest hazard `init-demo --force` defends
+against does not apply here.
+
+Install path: `/.claude/skills/signalforge/SKILL.md`. The
+companion `assets/` subtree (also part of the bundled skill) lands
+alongside it.
+
+Exit codes (four-tier taxonomy; see § Four-tier exit-code taxonomy
+for the full table):
+
+- `0` — install succeeded; INFO line printed to stdout.
+- `1` — `CliInstallSkillPathError` (symlink cycle on ``) or
+ `CliInstallSkillPackageDataMissingError` (broken wheel install:
+ the bundled skill tree could not be located via
+ `importlib.resources` — practically unreachable on a clean
+ `pip install signalforge-dbt` run).
+- `2` — `CliInstallSkillDestUnsafeError`: `` exists as a
+ regular file (not a directory), OR the existing `SKILL.md` is a
+ symlink (writing would follow the link and clobber an arbitrary
+ destination).
+- `3` — n/a. `install-skill` makes no network, warehouse, or LLM
+ call.
+
+Stdout shapes:
+
+- New install (no existing `SKILL.md` at the target):
+ ```text
+ Installed SignalForge skill to
+ ```
+- Upgrade-in-place (existing `SKILL.md` was overwritten — detected
+ via `Path.exists()` BEFORE the copy, DEC-017):
+ ```text
+ Installed SignalForge skill to (replaced existing SKILL.md)
+ ```
+
+ The `(replaced existing SKILL.md)` suffix surfaces the lib seam's
+ upgrade-in-place overwrite policy so operators know their
+ hand-edited `SKILL.md` was replaced. The operator can `git diff` if
+ they had the file under version control.
+
+Stderr shapes: standard `ERROR: ` + optional
+`↳ Remediation: ` per tier (see § Stderr message shape per
+tier); no multi-violation header / bullet form fires from this
+subcommand.
+
+Example:
+
+```bash
+cd /repo/dbt/analytics
+signalforge install-skill
+# stdout: Installed SignalForge skill to /repo/dbt/analytics/.claude/skills/signalforge/SKILL.md
+echo $?
+# 0
+```
+
### `signalforge lint`
Validate the five existing `signalforge.yml` config blocks (`safety:`,
diff --git a/docs/cost-estimate-ops.md b/docs/cost-estimate-ops.md
new file mode 100644
index 00000000..1f5897a4
--- /dev/null
+++ b/docs/cost-estimate-ops.md
@@ -0,0 +1,215 @@
+# `--estimate` — operations guide
+
+Operational reference for `signalforge generate --estimate`, the
+pre-flight cost-preview path. Companion to
+[`docs/cli-ops.md`](cli-ops.md) (where `--estimate` is registered as a
+`generate` flag), [`docs/draft-ops.md`](draft-ops.md) (the LLM seam the
+estimate counts tokens against), and
+[`docs/grade-ops.md`](grade-ops.md) (the per-criterion grading fan-out
+the estimate projects).
+
+## What it does
+
+`signalforge generate --estimate` runs the full pipeline
+prelude (manifest load, safety policy resolve, draft + grade + diff
+config load, warehouse profile load, adapter construction) so that any
+typo in `--profiles-dir` / `signalforge.yml` surfaces BEFORE the
+estimate is computed (DEC-009 of
+[`plans/super/36-estimate-cost-preview.md`](../plans/super/36-estimate-cost-preview.md)).
+It then issues a small set of cheap calls to project the cost of the
+billable pipeline that `signalforge generate` *without* `--estimate`
+would perform:
+
+- **Drafter half** — one token-count for the drafter prompt plus the
+ per-criterion judge token-counts (`1 + len(rubric)` calls). The
+ full `messages.create` LLM call is never invoked.
+- **Warehouse half** — one BigQuery `dryRun` (or the warehouse's
+ equivalent — see
+ [`docs/warehouse-adapter-ops.md`](warehouse-adapter-ops.md)) to
+ project the bytes the prune step will scan, multiplied by the
+ `3.5 tests/column` heuristic (DEC-012 of
+ [`plans/super/36-estimate-cost-preview.md`](../plans/super/36-estimate-cost-preview.md)).
+
+Output goes to stdout as plain text with three sections (Draft /
+Grade / Warehouse) followed by totals and a footer listing the
+price-table version
+(`signalforge.llm.pricing.PRICE_TABLE_VERSION`).
+
+`--estimate` reports a **billing ceiling** — actual scans usually
+come in lower because cache hits, sampled rows, and shorter LLM
+responses all trim the projected numbers. Treat the figure as a
+calibration signal, not a billing guarantee (mirrors the
+planner-estimate caveats in
+[`docs/warehouse-adapter-ops.md`](warehouse-adapter-ops.md) for
+Snowflake `EXPLAIN`).
+
+## Provider-aware token counting (issue #136 US-005)
+
+The drafter and grader token counts are computed via the
+`LLMProvider.estimate_input_tokens(model, text) -> int` ABC method
+(issue #135's provider-neutral seam, extended in issue #136 DEC-003).
+Each registered provider supplies its own implementation:
+
+- **Anthropic (default)** — calls the SDK's
+ `client.messages.count_tokens(...)` (one real round-trip per
+ count). `ANTHROPIC_API_KEY` is required because this is a live API
+ call, not a local computation (DEC-006 of #36).
+- **OpenAI** — uses `tiktoken` locally (BPE tokeniser, no extra API
+ round-trip — DEC-012 of #136). Resolves the model id via
+ `tiktoken.encoding_for_model(model)` with a graceful `cl100k_base`
+ fallback for unknown ids.
+- **Google Gemini** — calls the SDK's native
+ `client.models.count_tokens(model=, contents=)` (issue #137 US-007;
+ DEC-016). First-party token counter, one extra API round-trip per
+ count (comparable in shape to the Anthropic path; distinct from
+ OpenAI's local `tiktoken` approach because Gemini has no equivalent
+ client-side BPE). `system` is concatenated with `text` into a single
+ `contents` entry — Gemini's count endpoint doesn't distinguish a
+ system envelope from regular tokens, so every token contributes to
+ the same total (matching what `generate_content` will bill at
+ runtime). `GOOGLE_API_KEY` is required because this is a live API
+ call.
+
+Anthropic stdout is byte-identical before and after the #136 refactor
+— pinned via a snapshot test in `tests/cli/test_estimate.py` per
+DEC-013. Selecting a different provider routes the token count
+through that provider's strategy method without touching the
+Anthropic path.
+
+## OpenAI provider — `[openai]` install extra
+
+Selecting `grade.provider: openai` and/or `llm.provider: openai` in
+`signalforge.yml` requires the `openai` install extra so both the SDK
+and `tiktoken` are available:
+
+```bash
+pip install signalforge-dbt[openai]
+# or, in a contributor checkout
+uv sync --dev # the dev group already pulls openai + tiktoken
+```
+
+`tiktoken` is OpenAI's local BPE tokeniser (MIT-licensed, no native
+build; wheels for CPython 3.11–3.13). It runs entirely client-side —
+no API round-trip per count — which is the main reason the OpenAI
+estimate path is meaningfully faster than the Anthropic one on
+multi-criterion grading runs.
+
+### Registered OpenAI pricing SKUs
+
+`signalforge.llm.pricing._PRICES_MUTABLE` ships four OpenAI SKUs
+(issue #136 US-004):
+
+| Model id | Notes |
+|---|---|
+| `gpt-4o` | Default judge model for the OpenAI provider (DEC-004). |
+| `gpt-4o-mini` | Budget tier — cheapest OpenAI SKU registered. |
+| `gpt-4.1` | Newer flagship variant. |
+| `gpt-4-turbo` | Back-compat for projects pinned to the prior generation. |
+
+Each SKU carries `input_per_mtok` and `output_per_mtok` rates; the
+cache fields are `0.0` because OpenAI's Chat Completions surface does
+not expose Anthropic-style prompt caching (see
+[`docs/grade-ops.md` § OpenAI provider](grade-ops.md#openai-provider)
+and [`docs/draft-ops.md` § OpenAI provider](draft-ops.md#openai-provider)
+for the no-cache cost note).
+
+### `EstimateUnknownModelError` for unknown SKUs
+
+Setting `grade.model` or `llm.model` to an id that is **not** in the
+pricing table raises `EstimateUnknownModelError` from the
+`--estimate` path at config-load resolution time (the live draft /
+grade calls themselves still run; only `--estimate` requires a
+pricing row). Common cases:
+
+- A model id that hasn't been added to `_PRICES_MUTABLE` yet (file an
+ issue with the public pricing for the SKU).
+- A typo (e.g. `gpt-4o-min` instead of `gpt-4o-mini`).
+
+Maps to CLI exit-code tier 2 (`INPUT`) — see
+[`docs/cli-ops.md`](cli-ops.md) for the full exit-code taxonomy.
+
+## Gemini provider — `[gemini]` install extra
+
+Selecting `grade.provider: gemini` and/or `llm.provider: gemini` in
+`signalforge.yml` requires the `gemini` install extra so the
+`google-genai` SDK is available:
+
+```bash
+pip install signalforge-dbt[gemini]
+# or, in a contributor checkout
+uv sync --dev # the dev group already pulls google-genai
+```
+
+Unlike OpenAI's local `tiktoken` path, Gemini's count surface is a
+real API call (`client.models.count_tokens`); the SDK does not ship a
+client-side BPE tokeniser. The single extra round-trip per count is
+modest at the drafter level (one call per `signalforge generate`
+invocation) and grows linearly with the grader's per-criterion
+fan-out. Mirrors the Anthropic round-trip shape.
+
+### Registered Gemini pricing SKUs
+
+`signalforge.llm.pricing._PRICES_MUTABLE` ships three Gemini SKUs
+(issue #137 US-006; DEC-017):
+
+| Model id | Notes |
+|---|---|
+| `gemini-2.5-pro` | Flagship judge — strongest reasoning, highest cost (base ≤200K context tier). |
+| `gemini-2.5-flash` | Recommended default for the Gemini provider — middle-of-the-road cost/quality. |
+| `gemini-2.0-flash` | Budget tier — cheapest Gemini SKU registered. |
+
+Each SKU carries `input_per_mtok` and `output_per_mtok` rates; the
+cache fields are `0.0` because v0.3 Gemini ships without
+Anthropic-style prompt caching (DEC-003 of #137 — see
+[`docs/grade-ops.md` § Gemini provider](grade-ops.md#gemini-provider)
+and [`docs/draft-ops.md` § Gemini provider](draft-ops.md#gemini-provider)
+for the no-cache cost note).
+
+## Maintainer-only live smoke tests
+
+Three `@pytest.mark.openai` gated tests exercise the OpenAI half of
+the estimate path against the real API (DEC-008 of #136):
+
+```bash
+SF_RUN_OPENAI=1 OPENAI_API_KEY=sk-... uv run pytest -m openai --no-cov
+```
+
+The marker is excluded from the default CI run via
+`addopts -m 'not openai'`. Both env vars are required (each missing
+var produces a clear skip reason naming the var). Mirrors the
+`@pytest.mark.anthropic` precedent:
+
+```bash
+ANTHROPIC_API_KEY=sk-... uv run pytest -m anthropic --no-cov
+```
+
+Three `@pytest.mark.gemini` gated tests cover the Gemini half (DEC-012
+of #137):
+
+```bash
+SF_RUN_GEMINI=1 GOOGLE_API_KEY=... uv run pytest -m gemini --no-cov
+```
+
+The `--estimate` Gemini path is exercised by the live `test_gemini_grade_live.py` /
+`test_gemini_draft_live.py` rounds via the same engine `estimate(...)` plus the
+offline-fake test at `tests/cli/test_estimate.py::test_estimate_gemini_provider_produces_nonzero_tokens_and_usd`.
+
+The Anthropic suite also includes the byte-identity snapshot for the
+estimate stdout that DEC-013 of #136 pins as the refactor floor.
+
+## References
+
+- Design records:
+ [`plans/super/36-estimate-cost-preview.md`](../plans/super/36-estimate-cost-preview.md)
+ (the original `--estimate` design),
+ [`plans/super/135-provider-neutral-llm-seam.md`](../plans/super/135-provider-neutral-llm-seam.md)
+ (the provider-neutral seam),
+ [`plans/super/136-openai-grading-provider.md`](../plans/super/136-openai-grading-provider.md)
+ (the OpenAI provider + tiktoken estimate path).
+- CLI flag reference: [`docs/cli-ops.md`](cli-ops.md) `--estimate`.
+- Per-provider config / cost notes:
+ [`docs/draft-ops.md`](draft-ops.md) and
+ [`docs/grade-ops.md`](grade-ops.md) (OpenAI provider sections).
+- Warehouse-side estimate (BigQuery `dryRun`, Snowflake `EXPLAIN
+ USING JSON`):
+ [`docs/warehouse-adapter-ops.md`](warehouse-adapter-ops.md).
diff --git a/docs/draft-ops.md b/docs/draft-ops.md
index d3323e01..832c709e 100644
--- a/docs/draft-ops.md
+++ b/docs/draft-ops.md
@@ -15,10 +15,15 @@ call. It sits **after** the safety layer (which produces the
`LLMRequest`) and **before** prune / grade / diff render
(#6 / #7 / #8). Two subpackages share the work (DEC-001):
-- `signalforge.llm` — the centralized SDK seam. One function,
- `call_anthropic`, owns retry policy, prompt-cache pre-send checks,
- exception translation, and the `LLMResult` value object. No other
- module imports the `anthropic` SDK.
+- `signalforge.llm` — the centralized, provider-neutral LLM seam. One
+ function, `call_llm`, owns the retry loop, backoff math, prompt-cache
+ pre-send checks, and the `LLMResult` value object; a pluggable
+ `LLMProvider` strategy (resolved from a process-level registry) owns
+ the vendor-specific request build, response extraction, and
+ exception classification (issue #135). The default provider is
+ `anthropic`; its SDK noise (type-stub gaps, lazy exception-class
+ import) stays confined to `signalforge.llm._anthropic_client`. No
+ other module imports the `anthropic` SDK.
- `signalforge.draft` — the orchestration layer on top of that seam.
Owns the prompt builder, the JSON + anchor-contract parser, the
fail-closed response-audit JSONL writer, and the `draft_schema` /
@@ -51,7 +56,7 @@ the layer stays SDK-agnostic and pyright-clean.
| Name | Kind | Description |
| ------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------- |
-| `call_anthropic` | function | The single Anthropic `messages.create` seam. Owns retry policy + cache pre-send check. Returns `LLMResult`. |
+| `call_llm` | function | The single provider-neutral LLM seam. Owns retry policy + cache pre-send check; selects an `LLMProvider` strategy by name (default `"anthropic"`). Returns `LLMResult`. |
| `LLMResult` | model | Frozen result shape: `text_blocks`, `response_text`, token counts (input/output/cache_creation/cache_read), `model`, `prompt_version`, `raw_message`. |
| `LLMError` | exception | Base class for everything in `signalforge.llm.errors`. |
| `LLMHelperError` | exception | Umbrella for SDK-call failures. Subclasses cover the retry-taxonomy branches. |
@@ -275,6 +280,80 @@ Business-rule reading is **best-effort, never fail-loud.** A
inferred-fallback path below covers the gap. Whitespace-only strings
collapse to nothing, so an empty `meta` value emits no section.
+### Numbered envelope shape (`…`)
+
+As of #163, each rule renders inside a numbered envelope rather than a
+bare bullet:
+
+```text
+## BUSINESS RULES
+
+Operator-supplied business rules for this model. Draft one custom_sql
+test per rule below, using the rule ID as a reference:
+
+
+ (model) total_amount must never be negative
+
+
+ (column discount_pct) discount_pct stays between 0 and 100 inclusive
+
+```
+
+IDs start at 1; bodies are indented 2 spaces and carry the existing
+`(model)` / `(column X)` scope prefix. The envelope gives the LLM
+unambiguous reference targets and parallels the existing ``
+fence around the model's raw SQL.
+
+**Envelope-breach guard.** A rule body containing the literal
+`` substring would terminate the fence early and let
+downstream content escape the data block. Before rendering, the
+drafter scans every rule for that exact substring (boring substring
+match — no whitespace / case normalisation, mirrors the ``
+precedent) and raises `PromptEnvelopeBreachError(envelope="BUSINESS_RULE",
+rule_index=N)` if found. **The opening tag `` alone
+is fine** (only the closing tag breaks the fence), as is any truncated
+fragment like `` — extended in #163 with `envelope=` / `rule_index=`
+kwargs rather than subclassed. Future envelopes follow the same shape.
+
+### Cardinality contract (at-least-one-per-rule)
+
+The drafter's anchor-contract validator enforces a hard contract on
+the LLM's output: when `meta.signalforge.business_rules` declares N
+rules AND `custom_sql` is NOT excluded via `DraftConfig.exclude_tests`,
+the response MUST carry **at least N `custom_sql` tests** (counted
+across both model-level `tests:` and per-column `columns[*].tests`).
+Fewer is rejected loudly via `LLMOutputAnchorContractError` with a
+violation message that lists every declared rule verbatim:
+
+```text
+Expected ≥2 custom_sql test(s) (one per declared business rule), got 1.
+Declared rules: '(model) total_amount must never be negative',
+'(column discount_pct) discount_pct stays between 0 and 100 inclusive'.
+```
+
+The gate is **at-least, not exact-equality** — the LLM may legitimately
+decompose a complex rule into two SELECTs, and the excess is allowed.
+The collect-all invariant is preserved: a candidate with both a
+hallucinated column AND a cardinality miss surfaces both violations in
+one error. The gate is a no-op when no rules are declared (the
+inferred-fallback path stays open) and a no-op when `custom_sql` is in
+`DraftConfig.exclude_tests` (see below).
+
+### `exclude_tests` short-circuit
+
+When `draft.exclude_tests` in `signalforge.yml` contains `"custom_sql"`,
+both surfaces no-op:
+
+- `_render_business_rules_section` returns `""` — the drafter does not
+ send rules to the LLM (no point asking for tests the operator forbade).
+- The parser's cardinality gate is skipped — no violation fires even
+ when rules are declared.
+
+The per-test `exclude_tests` filter at the parser still catches any
+`custom_sql` an LLM defies-the-prompt to emit. The two layers are
+orthogonal — together they preserve the operator's choice.
+
### Inferred fallback
You do **not** have to declare any rules. When no
@@ -339,6 +418,14 @@ file in the diff (see
## Cache behaviour
+Prompt caching is a **provider capability** (issue #135): the seam
+emits a `cache_control` marker, the `extended-cache-ttl-2025-04-11`
+beta header, and the pre-send `count_tokens` gate only when the
+selected `LLMProvider` reports `supports_prompt_caching` /
+`supports_token_count`. A provider that supports neither simply reports
+0 cache tokens and skips the marker. The default `anthropic` provider
+supports both, so the behaviour below is unchanged.
+
Default `cache_ttl="5m"`; opt in to `"1h"` via `DraftConfig.cache_ttl`
(DEC-005, DEC-009). The `extended-cache-ttl-2025-04-11` beta header
is auto-set when `cache_ttl="1h"`; sending it for `"5m"` is at best
@@ -457,6 +544,103 @@ except LLMOutputAnchorContractError as exc:
raise
```
+## Type-coherence defence (issue #159)
+
+`_validate_anchor_contract` extends the structural anchor-contract
+checks with a sqlglot-based type-coherence pass for `custom_sql`
+business-rule tests. Cooperative LLMs see real warehouse column types
+in the cached manifest summary and emit type-coherent SQL on their
+own; this parser-side check is the belt-and-braces defence for
+candidates that slip past — a dual-defence pattern mirroring
+`exclude_tests` (prompt filter + parser rejection).
+
+### What it catches
+
+The check walks binary comparison nodes (`<>`, `=`, `<`, `>`, `<=`,
+`>=`) in the drafted SQL and flags violations where:
+
+- Both operands are bare column references (NOT `CAST`, `SAFE_CAST`,
+ `COALESCE`, `IFNULL`, function calls, subqueries, literals, `NULL`,
+ or window functions).
+- Both columns have known types in the manifest's `Column.data_type`
+ field.
+- The two types are incompatible per sqlglot's BigQuery
+ `TypeAnnotator.COERCES_TO` table (bidirectional check —
+ `INT64 ↔ STRING` is flagged; `INT64 ↔ FLOAT64` and
+ `NUMERIC ↔ BIGNUMERIC` are accepted as legal cross-numeric coercions
+ per BigQuery's conversion rules).
+
+Violations append to the same `LLMOutputAnchorContractError.violations`
+tuple as the structural checks — no new error class. A candidate with
+BOTH a hallucinated column AND a type mismatch surfaces BOTH
+violations in one error (collect-all invariant preserved).
+
+### What it deliberately skips
+
+Zero false-positives on legitimate SQL is the design contract; missing
+some real bugs is the acceptable tradeoff. The check skips silently
+when:
+
+- Either operand is wrapped in `CAST` / `SAFE_CAST` / `COALESCE` /
+ `IFNULL` / a function call / a subquery — the operator's intent is
+ ambiguous from a type perspective; defer to the warehouse.
+- Either side is a literal, `NULL`, or a window-function expression.
+- Either column's `data_type` is `None` (unknown — the drafter prompt
+ rendered "UNKNOWN" for the same column; the check can't be more
+ certain than the prompt).
+- The SQL fails to parse (`sqlglot.errors.ParseError`); the warehouse
+ adapter will catch it downstream and route through
+ `kept-without-evidence`.
+- The drafted SQL references `{{ this }}` or other Jinja templates —
+ these are substituted to a placeholder before parsing so the
+ WHERE-clause comparison nodes remain analysable.
+
+When the check skips, the prune engine remains the safety net:
+type-incoherent SQL that reaches the warehouse compiles to a
+`QuerySyntaxError`, which routes through `_InvalidIdentifier` →
+`kept-without-evidence` per `prune-engine.md` § "Conservative-bias
+routing template."
+
+### Threading column types into the parser
+
+`parse_draft_response` accepts two new keyword-only parameters:
+
+```python
+parse_draft_response(
+ raw_text,
+ model_columns,
+ *,
+ model_columns_by_type: Mapping[str, str | None] | None = None,
+ dialect_name: str = "bigquery",
+ exclude_tests: frozenset[str] = frozenset(),
+)
+```
+
+`draft_from_request` builds `model_columns_by_type` from
+`model.columns_list` and threads through. When
+`model_columns_by_type=None` or every column's `data_type` is `None`,
+the type-coherence arm is a no-op (structural anchor-contract checks
+still run as before). For populating `data_type` from your dbt
+project, see
+[`docs/manifest-loader-ops.md` § Column types from catalog.json](manifest-loader-ops.md#column-types-from-catalogjson-issue-159).
+
+### Dialect support
+
+v0.3 hardcodes `dialect_name="bigquery"` at the orchestrator (a TODO
+in `draft.schema` marks the v0.2-of-multi-warehouse handoff). sqlglot
+also supports Snowflake and Postgres dialects; when the warehouse
+layer surfaces those as first-class adapters, the orchestrator will
+source `dialect_name` from the safety policy or adapter. No prompt or
+config change planned.
+
+### Dependency
+
+sqlglot is pinned at `sqlglot>=30,<31` in
+`[project].dependencies`. It was a dev-only transitive (via
+`fakesnow`) before #159; promoting to runtime is a deliberate
+~15MB add for `signalforge-dbt` PyPI users in exchange for the
+type-coherence defence.
+
## `prompt_version` cross-reference
`prompt_version` is a deterministic 16-hex-char blake2b digest of the
@@ -507,6 +691,7 @@ other stages and silently ignored by the draft loader.
```yaml
# signalforge.yml
llm:
+ provider: anthropic # registry-validated; "anthropic" + "openai" + "gemini" are registered (see provider sections below)
model: claude-sonnet-4-6
cheap_model: claude-haiku-4-5-20251001
max_output_tokens: 4096
@@ -519,9 +704,16 @@ llm:
Field-by-field:
-- **`model`** — the Anthropic model id used by every `call_anthropic`
- invocation. Default `claude-sonnet-4-6`. Any string the SDK accepts
- is allowed.
+- **`provider`** — the LLM provider strategy name (issue #135 DEC-007),
+ resolved against the `signalforge.llm.providers` registry and threaded
+ into `call_llm` from `draft_schema`. Default `"anthropic"`. An unknown
+ value fails loud at config-load, listing the registered provider
+ names. Deliberately a registry-validated `str`, not a `Literal` — the
+ provider registry is a forward-looking plugin point. Today `anthropic`,
+ `openai`, and `gemini` are registered; see [OpenAI provider](#openai-provider)
+ and [Gemini provider](#gemini-provider) below for the non-default options.
+- **`model`** — the model id used by every `call_llm` invocation.
+ Default `claude-sonnet-4-6`. Any string the SDK accepts is allowed.
- **`cheap_model`** — informational; not selected automatically.
The CLI (#9) flips on `--cheap` to swap `model` for this value.
Default `claude-haiku-4-5-20251001`.
@@ -558,6 +750,116 @@ If `signalforge.yml` is missing entirely (or the `llm:` key is absent),
`load_draft_config(project_dir)` returns the built-in defaults
silently — same behaviour as `load_safety_config`.
+### Per-provider `max_output_tokens` recommended floors
+
+No per-provider override is enforced in code — `DraftConfig.max_output_tokens`
+is one knob across every provider. The floors below are observed-data
+recommendations from live drafting runs; operators can lower for cost-cutting
+but must validate quality afterward (truncated draft responses surface as
+`LLMOutputJSONError` or `LLMOutputAnchorContractError` and fail the run rather
+than ship a partial `schema.yml`).
+
+| Provider | Recommended floor | Rationale |
+|--------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Anthropic (Sonnet 4.6+) | 1024 | Sufficient for full reasoning; tested in BQ smoke. |
+| OpenAI (gpt-4o) | 1024 | Same headroom; no observed truncation. |
+| Gemini (2.5-flash+) | **4096** | Verbose reasoning style; 512 / 1024 observed truncating mid-string (#155 DEC-008). The 4096 figure is a **conservative mirror of the #158 Gemini-grader floor** (Gemini's per-pair output is high-variance enough that the grader's 5–6/108 degrades-at-2048 finding applies to any verbose response). The drafter currently runs Anthropic in every shipped e2e, so this row is **pending Gemini-drafter live validation** — when that lands, update with measured evidence. |
+
+## OpenAI provider
+
+Issue #136 registered `OpenAIProvider` as the second
+`signalforge.llm.providers.LLMProvider`. Select it by setting
+`llm.provider: openai` in `signalforge.yml`:
+
+```yaml
+llm:
+ provider: openai
+ model: gpt-4o # default drafter model for the OpenAI provider; any model id the SDK accepts is allowed
+ max_output_tokens: 4096
+ # cache_ttl, max_retries_*, exclude_tests — same shape as the anthropic provider (cache_ttl is ignored, see below)
+```
+
+Requirements:
+
+- **Install extra:** `pip install signalforge-dbt[openai]` (or `uv sync --dev` in a contributor checkout). Pulls `openai>=1.40` plus `tiktoken` for the `--estimate` cost-preview path.
+- **Env var:** `OPENAI_API_KEY` (mirrors `ANTHROPIC_API_KEY` for the default provider).
+- **Pricing SKUs registered:** `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4-turbo`. Other model ids raise `EstimateUnknownModelError` from the `--estimate` path (the live draft call still runs; `--estimate` is the only surface that requires a pricing row). See [`docs/cost-estimate-ops.md`](cost-estimate-ops.md).
+
+**No prompt caching (cost note).** OpenAI's Chat Completions surface
+does not expose Anthropic-style prompt caching. `OpenAIProvider`
+reports `supports_prompt_caching=False` and `supports_token_count=False`,
+which means the orchestrator (`signalforge.llm.call_llm`) skips both
+the `cache_control` marker and the pre-send `count_tokens` gate
+(issue #135 DEC-008). **The `cache_ttl` config knob is silently
+ignored** for the OpenAI provider — every drafting call ships the full
+system + cached manifest summary on every invocation, with no read
+discount. For batch-CLI usage (`signalforge generate --select`) the
+absence of caching is the main cost delta vs. the Anthropic provider;
+budget input-token spend at full per-call rates. v0.3 ships without
+OpenAI prompt caching; their recent prompt-cache mechanism is a
+candidate for a follow-up.
+
+**Server-enforced JSON.** `OpenAIProvider.build_create_kwargs`
+attaches `response_format={"type": "json_object"}` so the drafter
+model is forced to emit valid JSON server-side (DEC-006). The tolerant
+`extract_json_payload` parser (issue #144) remains as defence-in-depth.
+
+**Live smoke gating.** A gated `@pytest.mark.openai` real-API
+end-to-end test exercises drafting against `gpt-4o`. Run it with:
+
+```bash
+SF_RUN_OPENAI=1 OPENAI_API_KEY=sk-... uv run pytest -m openai --no-cov
+```
+
+Mirrors the `@pytest.mark.anthropic` precedent — excluded from the
+default CI run via `addopts -m 'not openai'`; both env vars are
+required (each missing var produces a clear skip reason). See
+[`docs/cost-estimate-ops.md`](cost-estimate-ops.md) for the
+maintainer's three-test smoke set (drafter + grader + `--estimate`).
+
+## Gemini provider
+
+Issue #137 registered `GeminiProvider` as the third LLM provider behind the
+provider-neutral seam (#135). Select it via `llm.provider: gemini` in
+`signalforge.yml`:
+
+```yaml
+llm:
+ provider: gemini
+ model: gemini-2.5-flash # default mid-tier drafter; gemini-2.5-pro and gemini-2.0-flash are also registered
+ cache_ttl: 1h # accepted but ignored — Gemini ships without caching in v0.3
+```
+
+- **Install extra:** `pip install signalforge-dbt[gemini]` (or `uv sync --dev` in a contributor checkout). Pulls `google-genai>=0.5,<1`.
+- **Env var:** `GOOGLE_API_KEY` (read by the SDK; SignalForge never logs it).
+- **Server-side JSON enforcement:** `GeminiProvider.build_create_kwargs` sets `response_mime_type="application/json"` on the `GenerateContentConfig` (DEC-018 of #137).
+
+**No prompt caching (cost note — DEC-013 of #137).** v0.3 Gemini ships
+**without** prompt caching. `GeminiProvider` reports
+`supports_prompt_caching=False` / `supports_token_count=False`, so
+`call_llm` skips the `cache_control` marker, the `extended-cache-ttl` beta
+header, and the pre-send `count_tokens` gate. Every drafter call
+transmits the full cached_block + dynamic_block; there is no
+Anthropic-style discount on the cached prefix. The drafter is one call
+per `signalforge generate` invocation, so the per-call overhead is
+modest compared to the grader's 4-criterion fan-out — but explicit
+Gemini context caching is still a tracked follow-up.
+
+**`--estimate` integration (active).** `signalforge generate --estimate`
+with `llm.provider: gemini` works end-to-end via Gemini's native
+`client.models.count_tokens` (US-007 of #137; DEC-016). One extra API
+round-trip per estimate call. The drafter-side USD figure uses the
+Gemini pricing SKUs registered in `signalforge.llm.pricing`. Network
+or auth failures surface as `>` via the
+conservative-bias supplementary-failure path.
+
+**Live smoke.** A `@pytest.mark.gemini` gated end-to-end test exercises
+drafting against `gemini-2.5-flash`. Run it with:
+
+```bash
+SF_RUN_GEMINI=1 GOOGLE_API_KEY=... uv run pytest -m gemini --no-cov
+```
+
## Error hierarchy
### `signalforge.llm.errors`
diff --git a/docs/grade-ops.md b/docs/grade-ops.md
index 617ba484..e05ab41e 100644
--- a/docs/grade-ops.md
+++ b/docs/grade-ops.md
@@ -107,15 +107,16 @@ DEC-020 — every pipeline stage gets one top-level key). Sibling keys
(`safety:`, `llm:`, `prune:`, future `diff:` …) are reserved for other
stages and silently ignored by the grade loader.
-The full schema (every knob, every default, all v0.1 types) — extracted
-verbatim from `tests/fixtures/grade/example_config.yml` and exercised
-by `test_load_grade_config_doc_example_round_trips` so the doc and the
-loader cannot drift:
+The full schema (every knob, every default, all v0.1 types), mirroring
+`tests/fixtures/grade/example_config.yml` (exercised by
+`test_load_grade_config_doc_example_round_trips` so the example and the
+loader cannot drift):
```yaml
# signalforge.yml — grade stage configuration (v0.1)
grade:
- model: claude-sonnet-4-6 # Anthropic model id (default)
+ provider: anthropic # registry-validated; "anthropic" + "openai" + "gemini" are registered (see provider sections below)
+ model: claude-sonnet-4-6 # model id (default)
cache_ttl: 1h # Prompt-cache TTL ('5m' or '1h')
max_output_tokens: 256 # Per-criterion JSON response cap
max_retries_429: 3 # Rate-limit retry budget
@@ -156,10 +157,11 @@ grade:
Field-by-field:
-- **`model`** — The Anthropic model id used by every per-pair judge call. Default `claude-sonnet-4-6`. Mirrors `DraftConfig.model` default. Haiku 4.5 is documented as a v0.2 cost-conscious option but not exposed in v0.1.
+- **`provider`** — The LLM provider strategy name (issue #135 DEC-007), resolved against the `signalforge.llm.providers` registry and threaded into `call_llm` from the per-criterion judge call, independently of the drafter's `DraftConfig.provider`. Default `"anthropic"`. An unknown value fails loud at config-load, listing the registered provider names. Deliberately a registry-validated `str`, not a `Literal` — the provider registry is a forward-looking plugin point. Today `anthropic`, `openai`, and `gemini` are registered; see [OpenAI provider](#openai-provider) and [Gemini provider](#gemini-provider) below for the non-default options.
+- **`model`** — The model id used by every per-pair judge call. Default `claude-sonnet-4-6`. Mirrors `DraftConfig.model` default. Haiku 4.5 is documented as a v0.2 cost-conscious option but not exposed in v0.1.
- **`cache_ttl`** — `Literal["5m", "1h"]`. Default `"1h"` (vs. the drafter's `"5m"`) because 60 sequential per-criterion calls under retry backoff can stretch beyond a 5-minute window; `"1h"` gives margin at no extra cost (cache writes are one-shot regardless of TTL).
- **`max_output_tokens`** — Per-criterion judge response cap. Default `256`. The expected JSON response is ~150 tokens; 256 gives 2× safety. Independent of `DraftConfig.max_output_tokens`.
-- **`max_retries_429` / `max_retries_5xx` / `max_retries_conn`** — Per-call retry budgets at the centralised `signalforge.llm.call_anthropic` seam (#5 DEC-012). Defaults `3 / 1 / 1` mirror `DraftConfig`; dial down for batch CLI mode where one retry-exhaustion is preferable to dozens of stalled calls.
+- **`max_retries_429` / `max_retries_5xx` / `max_retries_conn`** — Per-call retry budgets at the centralised, provider-neutral `signalforge.llm.call_llm` seam (#5 DEC-012; #135 DEC-005). Defaults `3 / 1 / 1` mirror `DraftConfig`; dial down for batch CLI mode where one retry-exhaustion is preferable to dozens of stalled calls.
- **`total_budget_seconds`** — Whole-run wall-clock budget. Default `300` (5 minutes — ~3× safety on 60 calls × 1s p50). Mirrors `PruneConfig.total_budget_seconds` semantics: when the budget trips, every remaining `(artefact, criterion)` pair lands as a degraded `GradingResult(score=None)` rather than silently dropped. **Crucially** the LLM-layer retry budget does NOT count against this — `total_budget_seconds` is a top-of-loop wall-clock check; an in-flight call is allowed to complete before the next iteration's check fires.
- **`min_pass_rate`** — Floor on the fraction of `(artefact, criterion)` pairs that scored `passed=True` for the rubric to count as passed overall. Default `0.7`. Bounded `[0.0, 1.0]`. Mirrors `GradeThresholds.min_pass_rate`.
- **`min_mean_score`** — Floor on the mean numeric score across non-null verdicts. Default `0.5`. Bounded `[0.0, 1.0]`. Mirrors `GradeThresholds.min_mean_score`.
@@ -431,12 +433,33 @@ what this costs.
**Reference numbers, with the assumptions.** The default rubric has
**4 criteria**; a typical drafted dbt model has **~12 artefacts**
(column descriptions + column rationales + per-test rationales + model
-description + model rationale). With the default `model:
-claude-sonnet-4-6` and `cache_ttl: 1h`, a representative run costs:
-
-- **~$0.18 per model on Sonnet 4.6** (4 criteria × 12 artefacts × ~600
- input tokens dynamic block + ~150 output tokens per call), pricing
- date 2026-05.
+description + model rationale). A richer real-world fixture (the
+Austin bikeshare project used by the live e2e suite) exercises ~27
+artefacts/model → ~108 grade calls/model — adjust the per-model figures
+below proportionally for your own model shape.
+
+**Per-provider per-model cost (Austin bikeshare fixture, 2026-05-29
+measurement at pricing-table version `2026-05-28`):**
+
+| Provider × model | Per-model cost | Notes |
+|---------------------------------|----------------|------------------------------------------------------------------------------------------------------------------|
+| Anthropic `claude-sonnet-4-6` | ~$0.38 | Drafter + grader on the BQ `[anthropic]` variant; baseline for the cost-control discussion below. |
+| OpenAI `gpt-4o` | ~$0.21 | Grader-only on the BQ `[openai]` variant; drafter still Anthropic (DEC-011 of #155 pins drafter fixture stability). |
+| Gemini `gemini-2.5-flash` | ~$0.045 | Grader-only on the BQ `[gemini]` variant; cheapest grade run by ~10× thanks to flash-tier pricing. |
+
+These figures are a single 2026-05-29 measurement at pricing-table
+version `2026-05-28` — **calibration signal, not a billing guarantee.**
+Vendor pricing rotates; per-fixture artefact count varies; cache hit/miss
+state across a run drives ±5–10% noise on the Anthropic figure
+specifically. See
+[`plans/super/157-e2e-cost-and-parallel.md`](../plans/super/157-e2e-cost-and-parallel.md)
+§ "Measured baseline (2026-05-29)" for the full-suite rollup
+($1.38/run across the three providers).
+
+**Fan-out comparison vs the batched alternative:**
+
+- The per-criterion fan-out (one LLM call per `(criterion × artefact)`)
+ is what the figures above measure.
- vs. **~$0.05 per model batched** (Q4=A in the plan — single judge
call covering all criteria for one artefact at once). The
per-criterion fan-out is **~3.4× more expensive**.
@@ -473,10 +496,16 @@ default fan-out is too expensive for their use case:
off the output-token bill at marginal risk of truncated JSON
(handled by `GradeOutputError(violation_type="json_parse")` and the
degraded path).
-- **`cache_ttl: "1h"`** (default) — Cache-read economics. The cached
- block (system prompt + rubric block) is constant across every call
- in one `grade_artifacts` invocation; a 60-call run reads the cache
- ~59 times after one write. Cache reads are 0.1× input pricing vs.
+- **`cache_ttl: "1h"`** (default) — Cache-read economics. Prompt
+ caching is a **provider capability** (issue #135): the `cache_control`
+ marker, the extended-cache-ttl beta header, and the pre-send
+ `count_tokens` gate are emitted only when the selected `LLMProvider`
+ reports `supports_prompt_caching` / `supports_token_count`. A provider
+ that supports neither reports 0 cache tokens and skips the marker; the
+ default `anthropic` provider supports both, so the economics below are
+ unchanged. The cached block (system prompt + rubric block) is constant
+ across every call in one `grade_artifacts` invocation; a 60-call run
+ reads the cache ~59 times after one write. Cache reads are 0.1× input pricing vs.
1.25× for writes; the break-even is ~2 reads per write. Switching
to `cache_ttl: "5m"` is rarely worth it — the only failure mode the
shorter TTL catches is a multi-hour run where the cache would otherwise
@@ -489,6 +518,141 @@ operators (DEC-014). The current architecture preserves the option:
each criterion has its own prompt seam already, so a `cost_mode:
batched` flag is additive rather than a rewrite.
+### Per-provider `max_output_tokens` recommended floors
+
+No per-provider override is enforced in code — `GradeConfig.max_output_tokens`
+is one knob across every provider. The floors below are observed-data
+recommendations from live grading runs; operators can lower for cost-cutting
+but must validate quality afterward. Truncated judge responses surface as
+`LLMResponseFormatError` (the provider-neutral `is_clean_completion` gate raises
+on any non-clean finish_reason — Anthropic `stop_reason="max_tokens"`, OpenAI
+`finish_reason="length"`, Gemini `finish_reason="MAX_TOKENS"`) and degrade
+the pair with `reasoning="call failed: GradeLLMError: "`
+per #155 DEC-005 + #158 (the inner provider message — naming the actual
+`finish_reason` value — is surfaced into the audit JSONL so a residual
+degrade is self-diagnosing without re-reading stderr).
+
+| Provider | Recommended floor | Rationale |
+|--------------------------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Anthropic (Sonnet 4.6+) | 1024 | Sufficient for full reasoning; tested in BQ smoke. |
+| OpenAI (gpt-4o) | 1024 | Same headroom; no observed truncation. |
+| Gemini (2.5-flash+) | **4096** | Verbose reasoning style; 512 / 1024 observed truncating mid-string (#155 DEC-008). 2048 verified safe at the 5-pair in-isolation smoke scale but **#158 found 5–6/108 pairs still degrade at 2048 on the full Austin fixture** — 4096 is the fixture-scale floor. |
+
+> **Fixture-scale caveat (issue #158):** these floors are *necessary
+> but not sufficient* — Gemini's per-pair `reasoning` length is
+> high-variance, so a fixture with substantially more artifacts than
+> the Austin bikeshare e2e (~27 artifacts × 4 criteria = ~108 pairs)
+> may still see residual `MAX_TOKENS` degrades at 4096. The honest
+> guidance is to treat the floor as fixture-scale-dependent: validate
+> with a full-fixture run, watch `GradingReport.aggregate_complete`,
+> and bump if any pair degrades with `reasoning` mentioning
+> `finish_reason='MAX_TOKENS'`. The diagnostic in the degrade reasoning
+> tells you exactly which `finish_reason` fired.
+
+## OpenAI provider
+
+Issue #136 registered `OpenAIProvider` as the second
+`signalforge.llm.providers.LLMProvider`. Select it by setting
+`grade.provider: openai` in `signalforge.yml`:
+
+```yaml
+grade:
+ provider: openai
+ model: gpt-4o # default judge model for the OpenAI provider; any model id the SDK accepts is allowed
+ # cache_ttl, max_retries_*, total_budget_seconds, thresholds — same shape as the anthropic provider
+```
+
+Requirements:
+
+- **Install extra:** `pip install signalforge-dbt[openai]` (or `uv sync --dev` in a contributor checkout). Pulls `openai>=1.40` plus `tiktoken` for the `--estimate` cost-preview path.
+- **Env var:** `OPENAI_API_KEY` (mirrors `ANTHROPIC_API_KEY` for the default provider).
+- **Pricing SKUs registered:** `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4-turbo`. Other model ids raise `EstimateUnknownModelError` from the `--estimate` path (the live judge call still runs; `--estimate` is the only surface that requires a pricing row). See [`docs/cost-estimate-ops.md`](cost-estimate-ops.md).
+
+**No prompt caching (cost note).** OpenAI's Chat Completions surface
+does not expose Anthropic-style prompt caching. `OpenAIProvider`
+reports `supports_prompt_caching=False` and `supports_token_count=False`,
+which means the orchestrator (`signalforge.llm.call_llm`) skips both
+the `cache_control` marker and the pre-send `count_tokens` gate
+(issue #135 DEC-008). **Every grading call ships the full system +
+rubric block** — there is no cached read discount on subsequent
+criteria, so the per-`(artefact × criterion)` fan-out (see
+[Cost guidance](#cost-guidance-dec-014) above) costs a flat
+input-token bill on every call. Budget accordingly: a 4-criterion ×
+12-artefact run is 48 full system+rubric sends, not one write + 47
+reads. v0.3 ships without prompt caching; OpenAI's recent prompt-cache
+mechanism is a candidate for a follow-up.
+
+**Server-enforced JSON.** `OpenAIProvider.build_create_kwargs`
+attaches `response_format={"type": "json_object"}` so the judge model
+is forced to emit valid JSON server-side (DEC-006). The tolerant
+`extract_json_payload` parser (issue #144) remains as defence-in-depth
+for the same prose-preamble drift class the Anthropic path handles.
+
+**Live smoke gating.** A gated `@pytest.mark.openai` real-API
+end-to-end test exercises grading against `gpt-4o`. Run it with:
+
+```bash
+SF_RUN_OPENAI=1 OPENAI_API_KEY=sk-... uv run pytest -m openai --no-cov
+```
+
+Mirrors the `@pytest.mark.anthropic` precedent — excluded from the
+default CI run via `addopts -m 'not openai'`; both env vars are
+required (each missing var produces a clear skip reason). See
+[`docs/cost-estimate-ops.md`](cost-estimate-ops.md) for the
+maintainer's three-test smoke set (grader + drafter + `--estimate`).
+
+## Gemini provider
+
+Issue #137 registered `GeminiProvider` as the third LLM provider behind the
+provider-neutral seam (#135). Select it via `grade.provider: gemini` in
+`signalforge.yml`:
+
+```yaml
+grade:
+ provider: gemini
+ model: gemini-2.5-flash # default mid-tier judge; gemini-2.5-pro and gemini-2.0-flash are also registered
+ cache_ttl: 1h # accepted but ignored — Gemini ships without caching in v0.3
+```
+
+- **Install extra:** `pip install signalforge-dbt[gemini]` (or `uv sync --dev` in a contributor checkout). Pulls `google-genai>=0.5,<1`.
+- **Env var:** `GOOGLE_API_KEY` (read by the SDK; SignalForge never logs it).
+- **Server-side JSON enforcement:** `GeminiProvider.build_create_kwargs` sets `response_mime_type="application/json"` on the `GenerateContentConfig` (DEC-018 of #137). Belt-and-braces with the tolerant `extract_json_payload` parser.
+- **Safety-filter / non-clean finish_reason handling:** Two related paths both route through the same degrade. (1) The provider-neutral `LLMProvider.is_clean_completion(response)` gate inside `call_llm` (DEC-005 of #155) raises `LLMResponseFormatError` when `finish_reason` is anything but `STOP` — including `SAFETY`, `RECITATION`, `OTHER`, `MAX_TOKENS` (even when partial text is present). (2) The legacy `GeminiProvider.extract_text_blocks` raise (DEC-005 of #137) still fires when zero text parts are returned. Either way, the grade engine wraps the result as `GradeLLMError` and degrades the affected pair via the conservative `score=None` / `reasoning="call failed: GradeLLMError: "` taxonomy (#158 surfaces the inner provider message into the audit field so the actual `finish_reason` value — `SAFETY` vs `RECITATION` vs `MAX_TOKENS` — is recoverable from `.signalforge/grade.jsonl` alone).
+
+**No prompt caching (cost note — DEC-013 of #137).** v0.3 Gemini ships
+**without** prompt caching. `GeminiProvider` reports
+`supports_prompt_caching=False` / `supports_token_count=False`, so
+`call_llm` skips the `cache_control` marker, the `extended-cache-ttl` beta
+header, the dual-zero cache-anomaly WARNING, AND the pre-send
+`count_tokens` gate. Every grade call transmits the full system + rubric
+prompt; there is no Anthropic-style discount on the cached prefix. For a
+default 4-criterion rubric over a 12-column model (~48 sequential calls),
+budget the per-call cost accordingly. Explicit Gemini context caching is
+tracked as a follow-up.
+
+**`--estimate` integration (active).** `signalforge generate --estimate`
+with `grade.provider: gemini` works end-to-end via Gemini's native
+`client.models.count_tokens` (US-007 of #137; DEC-016). One extra API
+round-trip per estimate call — comparable in shape to Anthropic's
+`messages.count_tokens` and distinct from OpenAI's local `tiktoken`
+path. The grader-side USD figure uses the Gemini pricing SKUs registered
+in `signalforge.llm.pricing` (`gemini-2.5-pro`, `gemini-2.5-flash`,
+`gemini-2.0-flash`). Network or auth failures surface as
+`>` via the conservative-bias supplementary-
+failure path (DEC-005 of #36); operators see a calibration signal, not
+an aborted run.
+
+**Live smoke.** A `@pytest.mark.gemini` gated end-to-end test exercises
+grading against `gemini-2.5-flash`. Run it with:
+
+```bash
+SF_RUN_GEMINI=1 GOOGLE_API_KEY=... uv run pytest -m gemini --no-cov
+```
+
+Mirrors the `@pytest.mark.anthropic` / `@pytest.mark.openai` precedent —
+excluded from the default CI run via `addopts -m 'not gemini'`; both env
+vars are required.
+
## Prompt-injection mitigation
The grader's only LLM-prompt defence is the
diff --git a/docs/llm-providers-ops.md b/docs/llm-providers-ops.md
new file mode 100644
index 00000000..2063421f
--- /dev/null
+++ b/docs/llm-providers-ops.md
@@ -0,0 +1,567 @@
+# LLM usage & providers
+
+Where SignalForge calls an LLM, which providers are supported, and
+how to pick one. The deep-dive companion to the brief mention in
+[the README](../README.md) and the per-stage ops references
+([`draft-ops.md`](draft-ops.md), [`grade-ops.md`](grade-ops.md),
+[`cost-estimate-ops.md`](cost-estimate-ops.md)).
+
+> Issue [#134](https://github.com/wjduenow/SignalForge/issues/134)
+> shipped the pluggable provider epic
+> ([#135](https://github.com/wjduenow/SignalForge/issues/135) +
+> [#136](https://github.com/wjduenow/SignalForge/issues/136) +
+> [#137](https://github.com/wjduenow/SignalForge/issues/137)). Before
+> #134, the LLM seam was Anthropic-only.
+
+## Where LLMs are (and aren't) used
+
+SignalForge is a five-stage pipeline; exactly **two** stages issue
+LLM calls:
+
+| Stage | Module | LLM calls per `signalforge generate ` |
+|---|---|---|
+| **Manifest loader** | `signalforge.manifest` | 0 — deterministic JSON parse. |
+| **Safety layer** | `signalforge.safety` | 0 — redacts PII before the drafter ever runs. |
+| **Drafter** | `signalforge.draft` | **1** — drafts `schema.yml` + tests + docs from the model SQL. |
+| **Prune engine** | `signalforge.prune` | 0 — compiles candidate tests to SQL, runs them against the warehouse. |
+| **Grader** | `signalforge.grade` | **N × M** — one judge call per `(artifact × rubric criterion)`. Default 4 criteria; ~12 artifacts on a typical staging model → ~48 calls. |
+| **Diff renderer** | `signalforge.diff` | 0 — renders the kept/dropped/flagged tier table + unified diff. |
+| **Ingest layer** | `signalforge.ingest` | 0 — reads existing `schema.yml` / `tests/*.sql` for `prune-existing`. |
+
+`signalforge prune-existing` issues **zero** LLM calls — it skips the
+drafter and the grader entirely and runs warehouse-only pruning over
+your already-authored tests.
+
+`signalforge lint` issues zero LLM calls and makes no warehouse
+calls — it loads `signalforge.yml` and the dbt manifest and reports
+typos / missing keys offline.
+
+`signalforge generate --estimate` issues a small number of cheap
+calls per provider to project cost (one `count_tokens` per prompt,
+plus a warehouse `dryRun`); the full `messages.create` call is never
+invoked. See [`cost-estimate-ops.md`](cost-estimate-ops.md).
+
+### Two independent provider knobs
+
+The drafter and grader resolve their providers separately:
+
+```yaml
+# signalforge.yml
+llm:
+ provider: anthropic # drafter — one call per `generate` run
+grade:
+ provider: gemini # grader — N × M calls per `generate` run
+```
+
+Common pattern: **Anthropic drafter, Gemini grader** — the drafter
+call benefits from Anthropic's prompt caching (the cached manifest
+summary is read across siblings within a `--select` batch), while the
+grader fan-out runs against Gemini's cheaper per-token rates. See
+[Choosing a provider](#choosing-a-provider) below.
+
+## The provider-neutral seam
+
+Issue [#135](https://github.com/wjduenow/SignalForge/issues/135)
+replaced the Anthropic-bound `call_anthropic` helper with a
+provider-neutral `call_llm` orchestrator. The shape:
+
+```text
+signalforge.llm
+├── client.py — call_llm (retry loop, backoff, budgets, logs, LLMResult assembly)
+├── providers.py — LLMProvider ABC + register_provider + provider_for
+├── _anthropic_client.py — SDK shim; every `# pyright: ignore` for anthropic confined here
+├── _openai_client.py — SDK shim; every `# pyright: ignore` for openai + tiktoken confined here
+├── _gemini_client.py — SDK shim; every `# pyright: ignore` for google-genai confined here
+└── cost/ — pricing table + `rollup_audit_dir` for post-run USD tallies
+```
+
+`call_llm` owns the generic machinery — retry loop with
+`(2 ** attempt) * uniform(0.75, 1.25)` backoff, per-class budgets,
+WARNING/INFO logs, `LLMResult` assembly. It dispatches the
+vendor-specific bits (request shape, response parsing, exception
+classification, token counting) to an `LLMProvider` strategy
+resolved from the registry. Capability flags
+(`supports_prompt_caching`, `supports_token_count`) govern whether
+the orchestrator attaches a `cache_control` marker or runs the
+pre-send `count_tokens` gate.
+
+Adding a fourth provider is a one-file shim + a `LLMProvider`
+subclass + `register_provider("", )`. The drafter and
+grader pick it up automatically; no edits to `call_llm`, no edits to
+`DraftConfig`/`GradeConfig` (provider is a registry-validated `str`,
+not a `Literal`). See [Adding a provider](#adding-a-provider) below.
+
+## Capability matrix
+
+| Capability | Anthropic | OpenAI | Gemini |
+|---|---|---|---|
+| **Install** | base (`pip install signalforge-dbt`) | `pip install signalforge-dbt[openai]` | `pip install signalforge-dbt[gemini]` |
+| **Env var** | `ANTHROPIC_API_KEY` | `OPENAI_API_KEY` | `GOOGLE_API_KEY` |
+| **Drafter (`llm.provider`)** | ✅ default | ✅ | ✅ |
+| **Grader (`grade.provider`)** | ✅ default | ✅ | ✅ |
+| **`--estimate` integration** | ✅ live `messages.count_tokens` | ✅ local `tiktoken` | ✅ live `models.count_tokens` |
+| **Prompt caching** | ✅ `cache_control` (5m / 1h tiers) | ❌ no Chat Completions caching tier | ❌ explicit caching deferred |
+| **Server-side JSON mode** | n/a (Anthropic parser tolerant) | ✅ `response_format={"type":"json_object"}` | ✅ `response_mime_type="application/json"` |
+| **Pre-send `count_tokens` gate** | ✅ | ❌ (no SDK token-count API) | ❌ (deferred — Gemini has the API but we don't gate on it for cache parity) |
+| **`cache_ttl` config** | honoured (`"5m"` / `"1h"`) | silently ignored | silently ignored |
+| **Default model** | `claude-sonnet-4-6` (drafter + grader) | `gpt-4o` | drafter unset; grader `gemini-2.5-flash` |
+| **Live smoke marker** | `@pytest.mark.anthropic` | `@pytest.mark.openai` | `@pytest.mark.gemini` |
+| **Live smoke env** | `ANTHROPIC_API_KEY` | `SF_RUN_OPENAI=1` + `OPENAI_API_KEY` | `SF_RUN_GEMINI=1` + `GOOGLE_API_KEY` |
+
+A `❌` on prompt caching does **not** mean the provider is unusable —
+it means every drafter call ships the full system + cached_block
+without a read discount. For a one-call-per-`generate` drafter this
+is modest; for the multi-call grader, budget per-call input-token
+spend at full rates.
+
+## Supported providers
+
+### Anthropic (default)
+
+The shipped default for both stages. No extra install; set
+`ANTHROPIC_API_KEY` and SignalForge runs out of the box.
+
+- **Default models:** `claude-sonnet-4-6` (drafter + grader),
+ `claude-haiku-4-5-20251001` (drafter `cheap_model`).
+- **Prompt caching:** active. Drafter caches the manifest summary
+ block; grader caches the rubric criterion list. `cache_ttl: 1h`
+ opts into the `extended-cache-ttl-2025-04-11` beta header. The
+ drafter's cached prefix is amortised across siblings within a
+ single `--select` batch but **NOT** across process boundaries —
+ the cache lives in Anthropic's infrastructure.
+- **`--estimate`:** one live `messages.count_tokens` round-trip per
+ prompt. Reports a billing ceiling.
+- **Pricing SKUs:** `claude-sonnet-4-6`, `claude-opus-4-7`,
+ `claude-haiku-4-5` (4-tier rate: input / output / cache-write 5m /
+ cache-read).
+- **Reference:** [`docs/draft-ops.md`](draft-ops.md) for the drafter
+ configuration block, retry taxonomy, and prompt-injection
+ envelope. [`docs/grade-ops.md`](grade-ops.md) for the grader's
+ per-criterion fan-out and the `` envelope.
+
+### OpenAI
+
+Registered by issue
+[#136](https://github.com/wjduenow/SignalForge/issues/136). Select
+via `llm.provider: openai` and/or `grade.provider: openai`.
+
+```yaml
+# signalforge.yml
+llm:
+ provider: openai
+ model: gpt-4o # default; any model id the SDK accepts is allowed
+ max_output_tokens: 4096
+grade:
+ provider: openai
+ model: gpt-4o
+```
+
+- **Install:** `pip install signalforge-dbt[openai]` — pulls
+ `openai>=1.40,<3.0` plus `tiktoken` for local `--estimate` token
+ counting.
+- **Env var:** `OPENAI_API_KEY`.
+- **Prompt caching:** none. `OpenAIProvider.supports_prompt_caching`
+ is `False`; the orchestrator skips the `cache_control` marker and
+ the pre-send `count_tokens` gate. `cache_ttl` in `signalforge.yml`
+ is accepted but silently ignored. The grader's 48-call fan-out
+ ships the full system + rubric block on every call.
+- **Server-side JSON:** active. `OpenAIProvider.build_create_kwargs`
+ attaches `response_format={"type": "json_object"}`; the tolerant
+ `extract_json_payload` parser remains as defence-in-depth.
+- **`--estimate`:** local `tiktoken` (no extra API round-trip per
+ count). `tiktoken.encoding_for_model(model)` with a graceful
+ `cl100k_base` fallback for unknown ids.
+- **Pricing SKUs:** `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4-turbo`
+ (cache fields zero — no discount tier).
+- **`.messages.create` adapter:** OpenAI's SDK exposes
+ `client.chat.completions.create(...)`; the SignalForge shim wraps
+ it in a `_OpenAIClientAdapter.messages` namespace so the
+ orchestrator's vendor-neutral call shape (`llm_client.messages.create(...)`)
+ works unchanged.
+- **Reference:** [`docs/draft-ops.md` § OpenAI provider](draft-ops.md#openai-provider)
+ · [`docs/grade-ops.md` § OpenAI provider](grade-ops.md#openai-provider)
+ · [`docs/cost-estimate-ops.md` § OpenAI provider](cost-estimate-ops.md#openai-provider--openai-install-extra).
+
+### Google Gemini
+
+Registered by issue
+[#137](https://github.com/wjduenow/SignalForge/issues/137). Select
+via `llm.provider: gemini` and/or `grade.provider: gemini`.
+
+```yaml
+# signalforge.yml
+llm:
+ provider: gemini
+ model: gemini-2.5-flash # mid-tier; gemini-2.5-pro and gemini-2.0-flash are also registered
+ max_output_tokens: 4096 # see Gemini truncation note below
+grade:
+ provider: gemini
+ model: gemini-2.5-flash
+ max_output_tokens: 4096
+```
+
+- **Install:** `pip install signalforge-dbt[gemini]` — pulls
+ `google-genai>=0.5,<1`.
+- **Env var:** `GOOGLE_API_KEY` (read by the SDK; SignalForge never
+ logs it).
+- **Prompt caching:** none. v0.3 ships without Anthropic-style prompt
+ caching (`supports_prompt_caching=False`). Explicit Gemini context
+ caching is a tracked follow-up.
+- **Server-side JSON:** active.
+ `GeminiProvider.build_create_kwargs` sets
+ `response_mime_type="application/json"` on the
+ `GenerateContentConfig`.
+- **`--estimate`:** native `client.models.count_tokens(...)` — one
+ extra API round-trip per estimate call. Distinct from OpenAI's
+ local `tiktoken` path because Gemini has no equivalent client-side
+ BPE.
+- **Pricing SKUs:** `gemini-2.5-pro` (flagship, base ≤200K-context
+ tier), `gemini-2.5-flash` (mid-tier; default judge),
+ `gemini-2.0-flash` (budget). Cache fields zero.
+- **`.messages.create` adapter:** `google-genai`'s native surface is
+ `client.models.generate_content(...)`; the SignalForge shim wraps
+ it in a `_GeminiClientAdapter.messages` namespace so the
+ orchestrator's call shape is unchanged. The SDK ships as a
+ namespace package (`from google import genai`), confined by an AST
+ scan to `_gemini_client.py` only.
+- **Truncation / non-clean finish_reason handling:**
+ `LLMProvider.is_clean_completion(response)` (issue
+ [#155](https://github.com/wjduenow/SignalForge/issues/155))
+ raises `LLMResponseFormatError` when Gemini's `finish_reason` is
+ anything but `STOP` — including `MAX_TOKENS` with partial text,
+ `SAFETY`, `RECITATION`, `OTHER`. The grader wraps the result as
+ `GradeLLMError` and degrades the affected pair to
+ `score=None, passed=False, reasoning="call failed: GradeLLMError: "`.
+ The aggregate `GradingReport.aggregate_complete=False` flags the
+ partial report.
+- **Recommended `max_output_tokens` floor: 4096.** Gemini's
+ reasoning style is verbose; smaller ceilings observed truncating
+ mid-string (issue [#155](https://github.com/wjduenow/SignalForge/issues/155)
+ DEC-008, issue [#158](https://github.com/wjduenow/SignalForge/issues/158)).
+ Treat the figure as fixture-scale-dependent: validate with a full
+ run and bump if any pair degrades.
+- **Reference:** [`docs/draft-ops.md` § Gemini provider](draft-ops.md#gemini-provider)
+ · [`docs/grade-ops.md` § Gemini provider](grade-ops.md#gemini-provider)
+ · [`docs/cost-estimate-ops.md`](cost-estimate-ops.md) for Gemini's
+ `count_tokens` integration.
+
+## Prompt caching — what it is and why providers differ
+
+The capability matrix marks Anthropic with a ✅ for prompt caching
+and OpenAI / Gemini with a ❌. That's the most consequential row in
+the table for anyone budgeting a real workload, so it earns its own
+section.
+
+### What prompt caching is (business framing)
+
+Every LLM call bills you for **every input token, every time** —
+even if 90% of the prompt is boilerplate you sent five seconds ago
+on the previous call. Prompt caching is the provider's offer: tell
+me which chunk of the prompt is the *stable prefix*, I'll fingerprint
+it on my side, and on subsequent calls within a TTL window I'll
+charge you a steep discount on those tokens instead of the full
+input rate.
+
+Anthropic's published rates for `claude-sonnet-4-6` (SignalForge's
+default; the figures live in
+[`signalforge/llm/pricing.py`](https://github.com/wjduenow/SignalForge/blob/dev/src/signalforge/llm/pricing.py)):
+
+| Token class | Rate (USD / Mtok) | vs. full input |
+|---|---|---|
+| Full input | $3.00 | baseline |
+| Cache **write** (first call seeds the cache) | $3.75 | 25% premium |
+| Cache **read** (later calls within TTL hit the cache) | $0.30 | 90% discount |
+
+You pay a small premium once to seed the cache, then 10¢ on the
+dollar for every read. Break-even is roughly one follow-up call;
+from the 2nd call onward you're saving money.
+
+### Where this matters in SignalForge
+
+- **Drafter** — one LLM call per `signalforge generate` invocation.
+ The cached prefix is the system prompt + the manifest summary.
+ Inside a single `--select` batch of 20 models, calls 2–20 hit the
+ cache. Modest but real savings.
+- **Grader** — ~48 LLM calls on a typical model (~12 artifacts × 4
+ rubric criteria; see
+ [`grade-ops.md` § One LLM call per artifact × criterion](grade-ops.md)).
+ The cached prefix is the rubric criterion list. Once per run the
+ rubric is "written" to the cache; the other ~47 calls "read" it
+ at 90% off. This is where prompt caching pays for itself
+ loudest.
+
+### Why OpenAI isn't supported
+
+OpenAI's Chat Completions API has an automatic prompt-caching
+feature, but it's deliberately opaque:
+
+1. **No marker, no control surface.** Anthropic exposes an inline
+ `cache_control` marker on the block you want cached; OpenAI's
+ backend pattern-matches recent prompts and applies discounts
+ silently. SignalForge cannot *steer* the cache toward the
+ system + cached-block prefix that matters.
+2. **No public per-MTok rate.** Anthropic publishes a cache-write
+ premium and a cache-read discount, so
+ `signalforge.llm.pricing.PRICES` can model both. OpenAI's
+ discount tier isn't published in the same shape, so
+ `--estimate` cannot project caching savings honestly.
+3. **No `cache_creation_input_tokens` / `cache_read_input_tokens`
+ in the usage response.** Anthropic returns both fields per call,
+ which feeds the dual-zero cache-anomaly WARNING and the audit
+ JSONL's reproducibility hashes. OpenAI's usage shape carries no
+ equivalent — even *measuring* whether a cache hit fired after
+ the fact is awkward.
+
+So `OpenAIProvider.supports_prompt_caching = False` is the honest
+posture: we can't steer the cache, we can't price the cache, and we
+can't audit the cache. Whatever automatic discount OpenAI applies
+on their side, the operator gets — SignalForge just doesn't model
+it. `cache_ttl` in `signalforge.yml` is accepted and silently
+ignored for OpenAI.
+
+### Why Gemini isn't supported (yet)
+
+Gemini ships **context caching** — a real, usable feature — but its
+shape is fundamentally different from Anthropic's inline marker:
+
+1. **Separate API call.** You call `CachedContent.create(...)`
+ before the generation call to upload the chunk to a named cache
+ resource, get back a handle, and reference the handle on every
+ subsequent `generate_content(...)`. Anthropic's `cache_control`
+ marker is a single field inside the existing
+ `messages.create(...)` call.
+2. **Separate pricing dimension.** Gemini bills cache **storage**
+ per hour while the cached resource lives, plus a per-token
+ discount on cached reads. The cost model is "rent the cache
+ slot, get cheaper reads" — fundamentally different from
+ Anthropic's "pay a write premium once, get cheap reads."
+3. **Minimum payload + lifecycle.** Gemini's cached content has a
+ minimum size and an explicit TTL the operator manages; an
+ abandoned cache keeps billing storage until it expires.
+
+Wiring Gemini context caching correctly into SignalForge means a
+separate "warm the cache" code path, a TTL strategy, an
+`LLMResult.usage` shape that includes Gemini's distinct cache
+fields, and a `pricing.py` schema extension carrying the storage
+rate alongside the existing per-MTok rates. None of that was in
+scope for issue
+[#137](https://github.com/wjduenow/SignalForge/issues/137) (which
+landed Gemini as a third provider proving the seam holds). It's a
+tracked follow-up — `GeminiProvider.supports_prompt_caching = False`
+today; `cache_ttl` is accepted and silently ignored.
+
+The asymmetry isn't "Gemini is worse than Anthropic"; it's
+"Anthropic's caching maps onto the existing seam in one config line
+(`cache_ttl: 5m | 1h`), and Gemini's caching needs a code path we
+haven't built yet."
+
+### The practical bottom line
+
+On the grader (the high-call-count surface where caching matters
+most), the math typically nets out:
+
+- **Anthropic** — 1 cache-write rubric + ~47 cache-read rubrics at
+ 10¢-on-the-dollar input tokens.
+- **Gemini `gemini-2.5-flash`** — 48 full-input rubrics, but each
+ input token costs ~10× less than `claude-sonnet-4-6`.
+- **OpenAI `gpt-4o-mini`** — 48 full-input rubrics, input tokens
+ ~20× cheaper than `claude-sonnet-4-6`.
+
+Cheaper-per-token providers usually still come out ahead on
+absolute dollars even without caching — they're just leaving
+optimization on the table that Anthropic doesn't. If Gemini
+context caching lands as a follow-up, Gemini becomes substantially
+cheaper still.
+
+## Choosing a provider
+
+Three dimensions to weigh:
+
+1. **Per-token cost.** At PR-prep prices
+ ([`signalforge/llm/pricing.py`](https://github.com/wjduenow/SignalForge/blob/dev/src/signalforge/llm/pricing.py)),
+ the cheapest grader SKU is `gpt-4o-mini`
+ ($0.15 / $0.60 per Mtok in/out), followed by `gemini-2.0-flash`
+ ($0.10 / $0.40 per Mtok in/out — even cheaper, but a budget
+ model). `claude-sonnet-4-6` at $3 / $15 per Mtok costs ~20× more
+ per token, partly offset by prompt caching on the cached
+ prefix.
+2. **Caching impact.** Anthropic's prompt-cache discount (5m or 1h
+ TTL) is meaningful for the drafter's cached manifest block AND
+ for the grader's cached rubric block when grading multiple
+ artifacts per criterion. Neither OpenAI nor Gemini exposes a
+ comparable discount today — every call pays the full input rate.
+3. **Quality variance.** Gemini's verbose reasoning needs the 4096
+ `max_output_tokens` floor to avoid mid-string truncation
+ (issue #155); OpenAI's tighter `response_format` JSON mode is the
+ simplest route to a clean parse but has shown lower judge
+ evidence quality on some fixtures. Anthropic's judge model
+ carries the longest production exposure (the v0.1–v0.2 e2e
+ smokes all ran Anthropic).
+
+**Practical patterns:**
+
+- **Single-provider Anthropic** (the default). Simplest setup, best
+ caching, highest unit cost. Right answer when you don't want to
+ manage two API keys.
+- **Anthropic drafter + Gemini grader.** Drafter benefits from
+ caching across `--select` siblings; grader fan-out runs at the
+ cheaper per-token rate. The shipped end-to-end smoke fixture
+ exercises this configuration (issue
+ [#155](https://github.com/wjduenow/SignalForge/issues/155)).
+- **OpenAI both stages.** Right answer when you're already
+ standardised on OpenAI billing and don't want to manage an
+ Anthropic key. `gpt-4o` + `gpt-4o-mini` are inexpensive and
+ reliable; no caching discount but no truncation risk either.
+- **Gemini both stages.** Cheapest per-token; needs the 4096
+ `max_output_tokens` floor and a tolerance for occasional
+ `aggregate_complete=False` reports when the safety filter or
+ truncation fires.
+
+## Cost & token accounting
+
+`signalforge generate --estimate` projects per-stage cost before the
+billable run. Each provider supplies its own token counter via
+`LLMProvider.estimate_input_tokens(model, text, *, system="", client=None)`:
+
+- **Anthropic** — live `messages.count_tokens`; `system` is passed
+ as its own kwarg so the system envelope is counted server-side.
+- **OpenAI** — local `tiktoken` (no API round-trip);
+ `tiktoken.encoding_for_model(model)` with `cl100k_base` fallback.
+- **Gemini** — native `models.count_tokens`; `system + text`
+ concatenated into one `contents` entry (Gemini doesn't
+ distinguish a system envelope).
+
+`signalforge.llm.cost.rollup_audit_dir(project_dir) -> CostReport`
+walks `.signalforge/llm_responses.jsonl` and `.signalforge/grade.jsonl`
+after a run and computes per-provider per-model USD against the
+frozen `signalforge.llm.pricing.PRICES` table. The CLI wrapper is
+`scripts/measure_e2e_cost.py`. See
+[`docs/cost-estimate-ops.md`](cost-estimate-ops.md) for the full
+contract, including the `>` degrade when a
+supplementary surface fails.
+
+## Reliability & error handling
+
+### Retry taxonomy (drafter + grader)
+
+Every provider classifies SDK exceptions through
+`LLMProvider.classify_exception(exc) -> ExceptionCategory` (one of
+`AUTH`, `RATE_LIMIT`, `SERVER_ERROR`, `CONNECTION`, `NO_RETRY`).
+`call_llm` runs the same generic retry loop regardless of provider:
+429 × 3, 5xx × 1, connection × 1, 401/403 no-retry-but-hint, 4xx
+no-retry. Each retry emits one `WARNING` with `attempt` / `delay`
+/ `error_class` / `model`. Per-class budgets are configurable per
+stage (`DraftConfig.max_retries_429` /
+`GradeConfig.max_retries_429`).
+
+### Conservative degrade (grader)
+
+A grade pair that exhausts retries, hits a non-clean `finish_reason`,
+or trips its per-pair budget routes through the conservative
+`score=None, passed=False, reasoning=""` degrade —
+never aborts the whole run. The aggregate
+`pass_rate` / `mean_score` are computed over the **scored** subset
+only; `aggregate_complete: bool` flags partial reports. The whole
+run aborts **only** if the audit JSONL writer itself fails. See
+[`docs/grade-ops.md` § Conservative score-and-degrade taxonomy](grade-ops.md).
+
+### Fail-loud (drafter)
+
+The drafter has no equivalent degrade path — a single failing LLM
+call is a hard failure. The retry loop runs; on exhaustion, the
+CLI exits at tier 3 (Anthropic / external dependency failure) with
+a typed error message and no traceback. See
+[`docs/draft-ops.md` § Retry taxonomy](draft-ops.md#retry-taxonomy).
+
+### Prompt-injection envelopes
+
+User-controlled content (model SQL for the drafter, drafted artifact
+text for the grader) is wrapped in named fences:
+
+- Drafter: `...` and
+ `...` (the latter for
+ `meta.signalforge.business_rules`, see
+ [`docs/draft-ops.md` § Custom business-rule tests](draft-ops.md#custom-business-rule-tests-custom_sql)).
+- Grader: `...`.
+
+A payload containing the literal closing tag raises
+`PromptEnvelopeBreachError` / `GradePromptEnvelopeBreachError`
+BEFORE the LLM call is issued — a fail-loud pre-flight scan over
+every payload at orchestrator entry. The defence is a boring
+substring match; no whitespace / case normalisation.
+
+## Audit & reproducibility
+
+Every LLM call lands a structured record on disk. Both writers are
+fail-closed: the call only "succeeds" once the audit byte hits disk
+via `os.write` + `os.fsync`. An audit-write failure aborts the run
+with a typed `LLMResponseAuditWriteError` / `GradeAuditWriteError`
+(CLI tier 3).
+
+| File | Layer | Shape |
+|---|---|---|
+| `.signalforge/audit.jsonl` | safety | One record per `build_llm_request` — what data went to the LLM (columns sent, redactions applied, sampling mode). |
+| `.signalforge/llm_responses.jsonl` | drafter | One record per drafter call — `sent_sql_hash`, `parsed_schema_hash`, `response_text_hash`, `prompt_version`, cache token usage, model id, `signalforge_version`. |
+| `.signalforge/grade.jsonl` | grader | One record per `(artifact × criterion)` pair — `rubric_hash`, `prompt_version_template`, `criterion_prompt_hash`, `response_text_hash`, scored / degraded state. |
+| `.signalforge/grade.json` | grader | End-of-run sidecar; `GradingReport` with per-criterion scores + the `aggregate_complete` flag. |
+| `.signalforge/diff.json` | diff | Sidecar with the kept/dropped/flagged tier table + unified diff + reproducibility hashes. |
+
+The reproducibility hash fields make per-run output bytewise
+verifiable: same input → same hashes → same decisions. See
+[`docs/audits.md`](audits.md) for the per-file schemas and
+correlation patterns.
+
+## Adding a provider
+
+The seam is designed for plug-in extension. A new vendor needs four
+artifacts:
+
+1. **SDK shim** at `src/signalforge/llm/__client.py`. Every
+ `# pyright: ignore` / `# type: ignore` for the vendor SDK is
+ confined here. Expose a `_ClientProtocol` duck-typed at
+ `messages.create` and (optionally) `messages.count_tokens`. AST
+ scan in `tests/test_audit_completeness.py` enforces the
+ confinement.
+2. **`LLMProvider` subclass** at `src/signalforge/llm/providers.py`.
+ Implement `make_client`, `build_create_kwargs`,
+ `build_count_tokens_kwargs`, `extract_text_blocks`,
+ `extract_usage`, `classify_exception`, `is_clean_completion`,
+ `unclean_finish_reason_message`, `estimate_input_tokens`, plus
+ the capability flags. The orchestrator (`call_llm`) gates
+ behaviour on those flags — never name-branch.
+3. **`register_provider("", )`** so
+ `DraftConfig.provider` / `GradeConfig.provider` validators
+ accept the new key. The provider config field is a
+ registry-validated `str`, not a `Literal`, so no churn in two
+ places.
+4. **Pricing SKUs** in `signalforge.llm.pricing._PRICES_MUTABLE` for
+ `--estimate` integration, and a `[]` extra in
+ `pyproject.toml` so users opt in to the SDK weight.
+
+The shipped Anthropic / OpenAI / Gemini providers are the
+worked-examples — each landed as a self-contained slice without
+touching `call_llm`. See `llm-drafter.md` (the rules file) for the
+load-bearing conventions (capability-gated behaviour, server-side
+JSON modes where available, namespace-package SDK considerations).
+
+## Reference
+
+- [`docs/draft-ops.md`](draft-ops.md) — drafter configuration,
+ retry taxonomy, prompt-injection envelopes, per-provider sections.
+- [`docs/grade-ops.md`](grade-ops.md) — grader configuration,
+ conservative degrade taxonomy, per-criterion fan-out,
+ per-provider sections.
+- [`docs/cost-estimate-ops.md`](cost-estimate-ops.md) —
+ `--estimate` semantics, per-provider token counters, pricing
+ table, `EstimateUnknownModelError`.
+- [`docs/audits.md`](audits.md) — fail-closed audit JSONLs and
+ sidecars across every stage.
+- [`docs/safety-ops.md`](safety-ops.md) — what data goes to the
+ LLM (PII redaction, sampling modes).
+- Issues:
+ [#134 epic](https://github.com/wjduenow/SignalForge/issues/134) ·
+ [#135 provider-neutral seam](https://github.com/wjduenow/SignalForge/issues/135) ·
+ [#136 OpenAI](https://github.com/wjduenow/SignalForge/issues/136) ·
+ [#137 Gemini](https://github.com/wjduenow/SignalForge/issues/137) ·
+ [#155 Gemini truncation + per-provider e2e gap](https://github.com/wjduenow/SignalForge/issues/155) ·
+ [#158 Gemini grader `MAX_TOKENS` floor](https://github.com/wjduenow/SignalForge/issues/158).
diff --git a/docs/manifest-loader-ops.md b/docs/manifest-loader-ops.md
index 02af63aa..6b9c6700 100644
--- a/docs/manifest-loader-ops.md
+++ b/docs/manifest-loader-ops.md
@@ -55,6 +55,65 @@ incantation for v9 / v10 / v11.
Schema **v20** (Fusion engine) is tracked as future work and currently
raises `UnsupportedManifestVersionError`.
+## Column types from `catalog.json` (issue #159)
+
+`signalforge.manifest.load(project_dir)` automatically merges column
+types from a sibling `target/catalog.json` (next to `target/manifest.json`)
+into `Column.data_type` on the in-memory `Manifest`. **No CLI flag, no
+config knob — pure sibling auto-discovery.** Run `dbt docs generate`
+in your dbt project to produce `catalog.json` alongside the existing
+`manifest.json` and the LLM drafter will see real warehouse column
+types in its prompt instead of `UNKNOWN` placeholders.
+
+### What it does
+
+| dbt build step | `manifest.json` | `catalog.json` | `Column.data_type` |
+| --------------- | --------------- | -------------- | ------------------ |
+| `dbt parse` | ✓ | absent | `None` (renders as `UNKNOWN` in the drafter prompt) |
+| `dbt docs generate` (after `dbt parse`) | ✓ | ✓ | real warehouse type (e.g. `"INT64"`, `"STRING"`, `"TIMESTAMP"`) |
+
+The drafter's prompt — cached manifest summary AND dynamic data-section
+schema — both render the populated type. Type-aware drafts reduce the
+incidence of type-incoherent `custom_sql` business-rule tests (e.g. an
+`INT64 <> STRING` comparison the warehouse will reject); see
+[`docs/draft-ops.md` § Type-coherence defence](draft-ops.md#type-coherence-defence-issue-159)
+for the parser-side belt-and-braces check.
+
+### Failure modes (all silent except the path-safety gate)
+
+- `catalog.json` absent → no merge; `data_type` fields stay `None`.
+- `catalog.json` unreadable (permission denied) or malformed JSON → no
+ merge; `data_type` fields stay `None`. **No log, no warning, no
+ exception.** The manifest loader is stage-0 deterministic; emitting
+ noise for a stale `catalog.json` is wrong UX.
+- `catalog.json` declares a column NOT in `manifest.json` → ignored
+ (manifest is the source of truth for "what columns exist").
+- `manifest.json` has a column NOT in `catalog.json` → that column's
+ `data_type` stays `None`.
+- Column name casing differs between manifest and catalog (Snowflake
+ uppercases identifiers; BigQuery preserves case; Postgres lowercases)
+ → case-insensitive match via `lower(col_name)`; the merge works
+ across all three warehouses without configuration.
+
+**The one exception — path-containment violation.** If the resolved
+`catalog.json` path escapes the project tree (e.g. a symlink that
+resolves to `/etc/passwd`), the loader raises `PathContainmentError`
+from `signalforge._common.path_safety` — same symlink-hardened gate as
+`manifest.json` itself. This is a security boundary, not a stale-input
+condition, so it deliberately fails loud rather than silently skipping.
+A legitimate `catalog.json` will never trip this.
+
+### Refreshing catalog.json
+
+`catalog.json` is generated by `dbt docs generate`. If your warehouse
+schema changes, re-run that command — SignalForge picks up the new
+types on the next `manifest.load()` call. There is no in-memory cache
+to invalidate; each `load()` rebuilds from disk.
+
+For a regen of the test fixtures in this repo,
+[`tests/fixtures/regenerate.sh`](../tests/fixtures/regenerate.sh) is
+the maintainer-only driver.
+
## Error class quick reference
Public API: `from signalforge.manifest import errors`.
diff --git a/docs/skills.md b/docs/skills.md
new file mode 100644
index 00000000..ae6ebb3a
--- /dev/null
+++ b/docs/skills.md
@@ -0,0 +1,191 @@
+# Claude Code Skill
+
+SignalForge ships a [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills)
+that teaches Claude to drive the `signalforge` CLI end-to-end against a dbt
+project. With the skill installed, a Claude Code session in the project root
+recognises requests like "draft tests for `dim_customers`," "prune my existing
+`schema.yml`," or "run the demo," picks the right `signalforge` subcommand and
+flags, and explains the kept / kept-uncertain / dropped / flagged diff back to
+the user.
+
+The skill is bundled inside the `signalforge-dbt` wheel under
+`src/signalforge/skills/signalforge/` and installed into your project with one
+command (issue [#141](https://github.com/wjduenow/SignalForge/issues/141)).
+
+## Install
+
+```bash
+signalforge install-skill []
+```
+
+Drops the bundled skill into `/.claude/skills/signalforge/SKILL.md`.
+`` defaults to the current working directory, so the common invocation
+from a dbt project root is just `signalforge install-skill`.
+
+| Aspect | Behaviour |
+| --- | --- |
+| Default `` | current working directory (`.`) |
+| Install path | `/.claude/skills/signalforge/SKILL.md` |
+| Overwrite policy | Always replaces every file SignalForge ships (no `--force` flag). **Preserves** every other file in the destination tree — your hand-edited `.claude/` siblings are untouched. |
+| Overwrite signal | On success, stdout prints `Installed SignalForge skill to `; appends `(replaced existing SKILL.md)` when an existing file was overwritten. |
+
+The CLI handler wraps the public `signalforge.skill.install_skill(dest)`
+library entry point at the `cmd_install_skill` boundary and re-raises the
+three `SkillError` subclasses as `CliInstallSkill*Error` wrappers so the
+four-tier exit-code taxonomy stays homogeneous. Exit codes:
+
+| Tier | Exit | Causes |
+| --- | --- | --- |
+| Load | `1` | `CliInstallSkillPathError` (symlink cycle on ``); `CliInstallSkillPackageDataMissingError` (broken wheel install — the bundled skill tree could not be located via `importlib.resources`). |
+| Input | `2` | `CliInstallSkillDestUnsafeError` — `` exists as a regular file, OR the existing `SKILL.md` is a symlink (writing would follow the link). |
+| API | `3` | n/a — install-skill makes no network / warehouse / LLM call. |
+
+Pointer to [docs/cli-ops.md § `signalforge install-skill`](cli-ops.md#signalforge-install-skill-dest)
+for the full flag table and stderr shapes.
+
+## What the skill teaches
+
+The SKILL.md body is a numbered workflow that walks Claude through the full
+SignalForge surface (DEC-021 of
+[`plans/super/141-claude-skill-install.md`](../plans/super/141-claude-skill-install.md)):
+
+1. **Point at a dbt project** — verify `target/manifest.json` exists, name a
+ model to work on.
+2. **Zero-credential demo** — `signalforge init-demo` followed by
+ `signalforge generate --write` against the bundled Austin
+ bikeshare fixture. No warehouse needed; runs entirely from the wheel.
+3. **Real project: draft + prune** — `signalforge generate --write`
+ with the safety posture (schema-only default; `--mode sample` is opt-in;
+ document the cost) and `--estimate` for a pre-flight cost preview.
+4. **Grade tests you already have** — `signalforge prune-existing
+ --schema ` runs the prune step (no LLM call) over an externally
+ authored `schema.yml`, so the warehouse tells you which existing tests
+ add signal.
+5. **Reading the diff** — kept / kept-uncertain / dropped / flagged tiers
+ and the per-artifact "why" cascade (rationale → evidence → fallback).
+6. **Optional: live e2e demonstration** — gated behind explicit user
+ confirmation, env-var checks, and a cost warning. Runs the maintainer
+ `pytest -m e2e --no-cov` flow against the public BigQuery dataset.
+7. **Troubleshooting** — common errors (`ModelNotFoundError`,
+ `WarehouseAuthError`, `LLMCacheTooLargeError`, etc.) with one-line fixes
+ and a pointer to [docs/cli-ops.md](cli-ops.md).
+
+The skill always activates against the live `signalforge` CLI on the user's
+PATH — `signalforge version` (the subcommand) is the first thing it runs to
+confirm the install resolved.
+
+## Two demo paths
+
+The skill body offers two ways to demonstrate SignalForge to a user. Pick
+based on whether the user has warehouse credentials ready.
+
+### Zero-credential demo (default)
+
+`signalforge init-demo` copies the bundled Austin bikeshare demo project out
+of the wheel into a writable directory; `signalforge generate
+--write` then runs the full draft + prune + grade + diff pipeline against
+that fixture.
+
+**No warehouse access required.** The drafter still calls Anthropic (so the
+demo needs `ANTHROPIC_API_KEY`), but the prune step works against the local
+fixture rather than a live warehouse. This is the default path the skill
+recommends — fastest time-to-signal, zero cloud setup. See
+[docs/cli-ops.md § `signalforge init-demo`](cli-ops.md#signalforge-init-demo-dest)
+for the dest-policy and overwrite story.
+
+### Live e2e (opt-in, gated)
+
+The full end-to-end smoke runs `uv run pytest -m e2e --no-cov` against the
+public `bigquery-public-data.austin_bikeshare.bikeshare_trips` dataset. The
+skill body **forces an explicit user confirmation** before triggering this
+path: it checks that `SF_RUN_BQ=1`, `GOOGLE_CLOUD_PROJECT`, and
+`ANTHROPIC_API_KEY` are all set, warns about the LLM + warehouse cost (a
+single run typically lands well under \$0.15 of Anthropic spend plus
+~200–500 MB of BigQuery scan), and only then invokes the gated test. See
+[docs/e2e-smoke-test.md](e2e-smoke-test.md) for the maintainer-facing
+walkthrough of the same flow.
+
+## Parity gate
+
+A pytest gate at `tests/cli/test_skill_cli_parity.py` parses the live CLI
+(every registered subcommand from the `argparse` subparser registry) and
+asserts that each subcommand AND the four canonical demo commands
+(`signalforge init-demo`, `signalforge generate --write`,
+`signalforge prune-existing --schema `,
+`signalforge install-skill`) appear in `SKILL.md`. The gate runs inside the
+canonical `VALIDATE_CMD`
+(`uv run pytest`), so a CLI change that drifts the surface from the skill
+fails validation until `SKILL.md` is updated in the same change.
+
+This is the gate-over-prompt enforcement described in
+[`.claude/rules/skill-parity.md`](https://github.com/wjduenow/SignalForge/blob/dev/.claude/rules/skill-parity.md)
+— the contributor never has to remember to update SKILL.md; the test
+suite makes drift impossible. The gate is mechanical only (subcommand
+names + demo command tokens present); reviewer attention still backs prose
+accuracy.
+
+## Self-grade
+
+The skill prose is graded with [clauditor](https://github.com/wjduenow/clauditor)
+(PyPI distribution `clauditor-eval`) — the LLM-as-judge harness
+SignalForge's own grading layer (`signalforge.grade`) shares its
+methodology with. The pinned score lives at
+`src/signalforge/skills/signalforge/assets/SKILL.eval.json` and is surfaced
+as the `clauditor-graded` shields.io badge on the
+[project README](https://github.com/wjduenow/SignalForge#readme).
+
+**Operating model — pre-release manual, no CI integration.** Per
+[DEC-014 of `plans/super/141-claude-skill-install.md`](https://github.com/wjduenow/SignalForge/blob/dev/plans/super/141-claude-skill-install.md),
+the maintainer regrades before tagging a release; the same commit bumps
+SKILL.md (if it changed), the pinned `assets/SKILL.eval.json`, and the
+README badge. No Anthropic key lives in repo secrets; no per-PR cost.
+
+**Regenerate the grade:**
+
+```bash
+uv run clauditor grade src/signalforge/skills/signalforge/SKILL.md
+```
+
+`clauditor-eval` is in `[dependency-groups].dev`, so `uv sync --dev` picks
+it up. The command reads an `EvalSpec` (the SignalForge-specific
+assertions + grading criteria, scaffolded via
+`uv run clauditor init ` and then hand-tuned by the maintainer),
+runs the configured grading model against the skill's output, and writes
+per-iteration sidecars under `.clauditor/iteration-N/`. The maintainer
+then transcribes the resulting `score`, the current
+`signalforge.__version__`, and an ISO-8601 UTC `graded_at` timestamp into
+`assets/SKILL.eval.json` so the README badge reflects the latest pinned
+score.
+
+Until the first real grade lands, `assets/SKILL.eval.json` carries a
+`status: "pending-first-grade"` placeholder and the README badge reads
+`clauditor: pending`.
+
+## Maintainer-only skills (excluded)
+
+Two skills live at repo-root `.claude/skills/` rather than under `src/`:
+`release-manager` (drives the PyPI release flow) and
+`review-agentskills-spec` (the maintainer's reference for the
+agentskills-spec project). Both are **outside the wheel by construction**
+— Hatch's `tool.hatch.build.targets.wheel.packages = ["src/signalforge"]`
+declaration only ships the `src/` tree, so a `pip install signalforge-dbt`
+user never sees them and `signalforge install-skill` cannot install them.
+
+This intent is documented by a negative assertion in
+`tests/test_wheel_packaging.py::test_wheel_excludes_maintainer_only_claude_skills`,
+which builds the wheel and asserts neither maintainer-only skill name
+appears in the artefact's file list.
+
+## Reference
+
+- [docs/cli-ops.md § `signalforge install-skill`](cli-ops.md#signalforge-install-skill-dest)
+ — full flag table, exit-code mapping, stderr shapes for the
+ `install-skill` subcommand.
+- [docs/e2e-smoke-test.md](e2e-smoke-test.md) — operator walkthrough of
+ the live e2e flow the skill's gated demo path triggers.
+- [`.claude/rules/skill-parity.md`](https://github.com/wjduenow/SignalForge/blob/dev/.claude/rules/skill-parity.md)
+ — contributor rule that documents the SKILL ↔ CLI parity gate.
+- [`plans/super/141-claude-skill-install.md`](https://github.com/wjduenow/SignalForge/blob/dev/plans/super/141-claude-skill-install.md)
+ — design record (DEC-001 … DEC-024), including the seven SKILL.md body
+ sections (DEC-021), the bundled-skill-vs-maintainer-skill split
+ (DEC-022), and this docs entry (DEC-023).
diff --git a/mkdocs.yml b/mkdocs.yml
index 6227c229..e9126beb 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -46,6 +46,7 @@ markdown_extensions:
nav:
- Home: index.md
- CLI Reference: cli-ops.md
+ - Claude Code Skill: skills.md
- Pipeline Stages:
- Manifest Loader: manifest-loader-ops.md
- Warehouse Adapter: warehouse-adapter-ops.md
@@ -55,6 +56,8 @@ nav:
- Prune Engine: prune-ops.md
- Quality Grader: grade-ops.md
- Diff Renderer: diff-ops.md
+ - LLM Providers: llm-providers-ops.md
+ - Cost Estimate: cost-estimate-ops.md
- Audits & Sidecars: audits.md
- End-to-End Smoke Test: e2e-smoke-test.md
- Snowflake E2E Setup: snowflake-e2e-setup.md
diff --git a/plans/super/135-provider-neutral-llm-seam.md b/plans/super/135-provider-neutral-llm-seam.md
new file mode 100644
index 00000000..50e45343
--- /dev/null
+++ b/plans/super/135-provider-neutral-llm-seam.md
@@ -0,0 +1,172 @@
+# Super Plan — #135: provider-neutral LLM seam (abstract `signalforge.llm` beyond Anthropic)
+
+## Meta
+
+- **Ticket:** https://github.com/wjduenow/SignalForge/issues/135
+- **Parent epic:** #134 (pluggable LLM provider for grading — OpenAI/Gemini). Milestone v0.3.
+- **Blocks:** #136 (OpenAI grading), #137 (Gemini grading).
+- **Phase:** devolved (PR #148 → dev)
+- **Branch:** `feature/135-provider-neutral-llm-seam`
+
+## Beads manifest
+
+- **Epic:** `bd_1-scaffolding-j2c`
+- **Tasks** (linear chain; each blocks the next):
+ - `.1` US-001 — Provider foundation (READY)
+ - `.2` US-002 — AnthropicProvider + rename + AST scan
+ - `.3` US-003 — Generic `call_llm`
+ - `.4` US-004 — config provider + CLI migration
+ - `.5` US-005 — no-cache provider neutrality proof
+ - `.6` US-006 — docs + parity
+ - `.7` Quality Gate
+ - `.8` Patterns & Memory
+- **Sessions:** 1 (2026-05-27)
+
+## What / Why
+
+Abstract `signalforge.llm` so an LLM vendor plugs in behind a thin, provider-neutral
+interface — the prerequisite for OpenAI/Gemini grading. Mirrors the warehouse-adapter
+seam (ABC/strategy + factory + per-vendor shim). **Anthropic stays byte-identical as the
+default** — existing draft/grade fixtures and snapshots must not move.
+
+The Anthropic coupling is shared by **both** the drafter (`draft_schema`) and the grader
+(`grade_artifacts`) — both call the free function `call_anthropic(...)` and type their
+`client` kwarg against the public `AnthropicClientProtocol`. So provider support cannot
+live in `grade/` alone; the shared seam must be abstracted first. This ticket is that
+refactor; #136/#137 then wire concrete providers.
+
+## Discovery findings
+
+### The seam today (all in `src/signalforge/llm/`)
+
+- **`client.py:161` `call_anthropic(*, system, cached_block, dynamic_block, model, max_tokens, cache_ttl="5m", prompt_version, max_retries_429=3, max_retries_5xx=1, max_retries_conn=1, client=None) -> LLMResult`** — the single seam. A free function, not a method. Bakes in:
+ - **Retry taxonomy** (`client.py:314-412`): 429×N / 5xx×N / conn×N with `delay = 2**attempt * _rand_uniform(0.75,1.25)`; 4xx-non-auth no-retry → `LLMHelperError`; auth no-retry → `LLMAuthError`. Categorisation keyed to Anthropic exception tuples from `_load_anthropic_exception_classes()`.
+ - **count_tokens pre-send gate** (`client.py:238-305`): counts `system + cached_block`; drops the cache marker below the model min (1024/2048), raises `LLMCacheTooLargeError` above the 8000 cap.
+ - **Prompt caching** (`client.py:214-228`): two content blocks; block-1 carries `{"cache_control":{"type":"ephemeral","ttl":cache_ttl}}`; `anthropic-beta: extended-cache-ttl-2025-04-11` header only when `cache_ttl=="1h"`.
+ - **Cache economics + dual-zero WARNING** (`client.py:429-454`): reads `cache_creation_input_tokens`/`cache_read_input_tokens`; warns only when both are 0 while the marker was active.
+ - Module aliases `_sleep`/`_rand_uniform` (`client.py:57-59`) for deterministic test backoff.
+- **`_client.py`** — confines every Anthropic `# pyright: ignore` + `import anthropic` (lazy). Public `AnthropicClientProtocol` (`:61`, `.messages.create/.count_tokens`); private `_AnthropicMessagesProtocol`, `_AnthropicExceptionClasses`, `_make_anthropic_client(:81)`, `_load_anthropic_exception_classes(:115)` (returns rate_limit/api_status/auth/connection tuples).
+- **`__init__.py` `__all__`** — exports `AnthropicClientProtocol`, `call_anthropic`, `LLMResult`, the `LLM*Error` hierarchy, pricing (`PRICES`, `lookup`, …).
+
+### Call sites (thread `client` for test injection)
+
+- `draft/schema.py:186` `draft_from_request(..., _client: AnthropicClientProtocol | None = None)` → `call_anthropic(... client=_client)`.
+- `grade/engine.py:306` `_grade_one(..., client: AnthropicClientProtocol | None)` → `call_anthropic(... client=client)`; surfaced via `grade_artifacts(..., client=...)`.
+
+### Anthropic-specific config / audit
+
+- `draft/config.py`: `model`, `cheap_model`, `cache_ttl: Literal["5m","1h"]="5m"`, `max_retries_*`. `grade/config.py`: `model`, `cache_ttl="1h"`, `max_retries_*`.
+- Audit cache fields: `draft/audit.py` `LLMResponseEvent.cache_creation_input_tokens/cache_read_input_tokens` (populated `:169`); `grade/models.py` `GradeEvent` same fields (`:310`), `_build_grade_event` (`grade/audit.py:82`), degraded path hardcodes 0.
+
+### Already-neutral — do NOT touch
+
+Judge system prompt, the `` envelope guard, reproducibility blake2b hashes (`grade/prompts.py`).
+
+### Reference seam (warehouse)
+
+`warehouse/base.py` `WarehouseAdapter(abc.ABC)` + `@classmethod from_profile(profile)` lazy-dispatching on `profile.type`; per-vendor shim `adapters/_client.py` confining SDK ignores + `make_real_client` + `map_*_exception`. `tests/llm/_fake.py` `FakeAnthropicClient` (FIFO `expect_count_tokens`/`expect_messages_create` queues) is the test-fake precedent.
+
+## Scoping decisions (Phase 1)
+
+- **SD-1 — Provider boundary: BOTH `grade.provider` AND `draft.provider`.** User chose symmetry over the epic's "drafter out of scope" line. Both config blocks get a `provider` field (default `"anthropic"`); the shared seam is provider-capable and both stages select independently. (The field's *type* is settled by DEC-007 — a **registry-validated `str`**, not a `Literal`; #136/#137 register a provider rather than widening a Literal.)
+- **SD-2 — Generic orchestrator + per-provider strategy.** Keep one retry/backoff/count-tokens/cache loop (the current `call_anthropic` body, generalised). A provider supplies only: client shim, exception→category map, and capability flags (`supports_prompt_caching`, `supports_token_count`). Wiring a new provider = shim + map + enum value (matches the AC literally).
+- **SD-3 — `__client.py` siblings + `llm/providers.py` registry.** Follow `llm-drafter.md` verbatim: rename `_client.py`→`_anthropic_client.py`; future vendors get `_openai_client.py` etc. New `llm/providers.py` holds the neutral protocol + capability descriptor + factory/registry. No new subpackage.
+- **SD-4 — Rename `call_anthropic`→`call_llm`, DROP `call_anthropic`.** Clean breaking rename (pre-1.0). Remove `call_anthropic` from `__all__`; migrate both call sites; update every doc/surface that names it.
+
+## Architecture review (Phase 2)
+
+| Area | Rating | Finding |
+|---|---|---|
+| Neutral-protocol design | pass | Clean orchestrator/strategy split (DEC-001/002). |
+| Byte-identity / backward-compat | concern | Event + `LLMResult` shapes unchanged → fixtures/snapshot byte-identical. Must update `tests/test_audit_completeness.py:329,360` (`_client.py`→`_anthropic_client.py`); migrate ~15 test imports; update 7 CLI monkeypatch sites. |
+| Capability-gated caching | concern | `cache_control` marker + beta header + dual-zero WARNING gated on `supports_prompt_caching`; pre-send count cap gated on `supports_token_count`. Anthropic = both True ⇒ no behaviour change (DEC-008). |
+| Config / surface parity | concern | `provider` on both `DraftConfig`+`GradeConfig`; 5-surface parity across docs + rule files (DEC-007, US-006). |
+| Observability | pass | Logger grep gate uses `rglob` (rename-safe); logs stay generic lazy-format JSON. |
+| Testing strategy | pass | `FakeAnthropicClient` stays; no-cache fake provider proves AC #2/#3 (DEC-011). |
+| Security / Performance | pass | No new external surface; Anthropic path identical. |
+
+No blockers. Concerns are resolved by the decisions below.
+
+## Refinement log (Phase 3 — decisions)
+
+- **DEC-001 — Generic orchestrator + provider strategy (SD-2).** `call_llm` owns the retry loop, backoff math (`2**attempt*_rand_uniform(0.75,1.25)`), WARNING/INFO logging, the min/cap token validation, and `LLMResult` assembly. The provider strategy owns: build create-kwargs, build count-tokens-kwargs, extract text blocks, extract usage, classify exception→category, and capability flags. The orchestrator never touches an Anthropic-shaped dict.
+- **DEC-002 — Neutral value objects.** Introduce `UsageMetrics(input_tokens, output_tokens, cache_creation_input_tokens=0, cache_read_input_tokens=0)` and an `ExceptionCategory` enum (`AUTH`, `RATE_LIMIT`, `SERVER_ERROR`, `CONNECTION`, `NO_RETRY`). `extract_usage`→`UsageMetrics`; `classify_exception`→`ExceptionCategory`. Orchestrator dispatches on the enum, not on SDK exception classes.
+- **DEC-003 — `LLMProvider` ABC + registry.** New `llm/providers.py`: `LLMProvider(abc.ABC)` (abstract `make_client`, `build_create_kwargs`, `build_count_tokens_kwargs`, `extract_text_blocks`, `extract_usage`, `classify_exception`; class-attr/property `name`, `supports_prompt_caching`, `supports_token_count`) + a process-level registry (`register_provider(provider)` / `provider_for(name) -> LLMProvider`; unknown name → typed `LLMError` subclass listing available keys). Mirrors `WarehouseAdapter.from_profile` dispatch, adapted to a name registry so new providers register rather than editing a factory `if`-ladder.
+- **DEC-004 — Module layout (SD-3).** Rename `_client.py`→`_anthropic_client.py` (keeps `AnthropicClientProtocol` public per #44, plus `_AnthropicExceptionClasses`/`_make_anthropic_client`/`_load_anthropic_exception_classes`). `llm/providers.py` holds the ABC + value objects + registry. Future vendors add `_openai_client.py` siblings + a provider class. No new subpackage.
+- **DEC-005 — `call_anthropic`→`call_llm`, drop old name (SD-4).** `call_llm` added to `__all__`; `call_anthropic` removed. Migrate both call sites + every test import + the `test_public_api.py`/`test_schema.py` documented-surface lists.
+- **DEC-006 — Real-client construction pushed into `call_llm` (RF-1).** When `client is None`, `call_llm` resolves the strategy via the registry and calls `strategy.make_client()`. The CLI generate path stops calling `_make_anthropic_client`; it passes `provider=config.provider`. The 7 CLI monkeypatch tests switch to patching `AnthropicProvider.make_client` (or the registry) / injecting a fake client.
+- **DEC-007 — `provider` config field on BOTH configs, registry-validated `str` (SD-1).** `DraftConfig.provider: str = "anthropic"` and `GradeConfig.provider: str = "anthropic"`, each with a validator asserting registry membership (fail-loud on unknown, listing available providers — mirrors the `trusted_models` validate-at-entry fail-loud). **Deliberate deviation from the `Literal`+`extra="forbid"` convention** (`safety-layer.md` DEC-015): a provider registry is a plugin point designed to grow, so #136/#137 register a provider instead of editing a `Literal` in two places, and a test can register a fake provider for AC #3. `call_llm` gains `provider: str = "anthropic"`. Other `extra="forbid"` config fields are unchanged.
+- **DEC-008 — Capability degrade semantics.** `supports_prompt_caching=False` ⇒ no `cache_control` marker, no `extended-cache-ttl` beta header, report 0 cache tokens, skip the dual-zero anomaly WARNING. `supports_token_count=False` ⇒ skip the pre-send count gate entirely (no `LLMCacheTooLargeError` raised pre-send; documented deferral — a provider without token-counting can't enforce the 8000 cap up front). Anthropic sets both `True`, so its control flow + emitted bytes are unchanged.
+- **DEC-009 — Keep `cache_ttl` config + `cache_*_input_tokens` audit fields as-is.** `cache_ttl: Literal["5m","1h"]` stays on both configs (Anthropic-specific; ignored when `supports_prompt_caching=False`). `LLMResponseEvent`/`GradeEvent` keep `cache_creation_input_tokens`/`cache_read_input_tokens` (default 0). Drift detectors + fixtures unchanged → byte-identity holds.
+- **DEC-010 — AST confinement scan renamed, not extended.** `tests/test_audit_completeness.py` `anthropic.Anthropic(...)` confinement updates `_client.py`→`_anthropic_client.py` (lines 329, 360). No new scan in #135; a future provider's SDK-construction confinement (e.g. `openai.OpenAI()`) is that provider ticket's job.
+- **DEC-011 — No-cache fake provider proves AC #2 + #3.** A test-only provider (`supports_prompt_caching=False`, `supports_token_count=False`) registered in the registry, selected via `grade.provider`, driven through `grade_artifacts` → assert: audit JSONL + sidecar round-trip, `cache_*_input_tokens==0`, drift detector + reproducibility blake2b hashes intact, no dual-zero WARNING. The fake IS the "shim + exception map + enum value" wiring, so it doubles as the AC #2 proof.
+- **DEC-012 — Public client-protocol typing.** `AnthropicClientProtocol` stays public + Anthropic-specific (back-compat, #44). `call_llm`'s `client` param is typed `object | None` and handed to the strategy; `draft_schema`/`grade_artifacts` keep `client: AnthropicClientProtocol | None` (Anthropic is the default injection surface) — documented that non-Anthropic providers build their own client and ignore the kwarg.
+
+## Story breakdown (Phase 4)
+
+Ordering: foundation types → Anthropic strategy + rename → generic orchestrator → config/CLI wiring → neutrality proof → docs. Every story's AC includes the canonical `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`.
+
+### US-001 — Provider foundation: value objects + `LLMProvider` ABC + registry
+- **Description:** Create `src/signalforge/llm/providers.py` with the `ExceptionCategory` enum, `UsageMetrics` value object, the `LLMProvider` ABC, and a process-level registry (`register_provider`/`provider_for`). No Anthropic behaviour wired yet.
+- **Traces to:** DEC-001, DEC-002, DEC-003.
+- **AC:** `provider_for("anthropic")` returns a provider (after US-002 registers it; here it raises the typed unknown-provider error listing available keys); unknown name raises an `LLMError` subclass with remediation. `UsageMetrics`/`ExceptionCategory` exported as needed. Validation passes.
+- **Done when:** `providers.py` exists with the ABC + registry + value objects; unit tests cover registry hit/miss.
+- **Files:** `src/signalforge/llm/providers.py` (new); `src/signalforge/llm/errors.py` (add `UnknownProviderError(LLMError)` or reuse `LLMHelperError` — pick fail-loud typed); `src/signalforge/llm/__init__.py` (export new public names); `tests/llm/test_providers.py` (new).
+- **Depends on:** none.
+- **TDD:** registry returns registered provider; unknown key raises typed error with available-keys remediation; `UsageMetrics` defaults cache fields to 0; `ExceptionCategory` has the five members.
+
+### US-002 — `AnthropicProvider` strategy + shim rename + AST scan update
+- **Description:** Rename `_client.py`→`_anthropic_client.py`; implement `AnthropicProvider(LLMProvider)` moving the Anthropic-specific request-build, text/usage extraction, and exception classification (`_extract_text_blocks`, `_extract_usage_field`, `_is_5xx`, `_is_4xx_non_auth`, `_load_anthropic_exception_classes`) behind the ABC methods; register it. Update the AST confinement scan.
+- **Traces to:** DEC-002, DEC-003, DEC-004, DEC-010.
+- **AC:** `AnthropicProvider` reproduces current extraction byte-for-byte against fake responses; `classify_exception` maps RateLimit/APIStatus(5xx)/APIStatus(4xx)/Auth/Connection to the right `ExceptionCategory`; `supports_prompt_caching`/`supports_token_count` both `True`; `provider_for("anthropic")` returns it; `test_audit_completeness.py` confinement points at `_anthropic_client.py`; validation passes.
+- **Done when:** Anthropic logic lives on the provider; `_anthropic_client.py` is the only SDK-ignore home; registry wired.
+- **Files:** `src/signalforge/llm/_client.py`→`_anthropic_client.py` (git mv); `src/signalforge/llm/providers.py` (+`AnthropicProvider`, register); `tests/test_audit_completeness.py:329,360`; `tests/llm/_fake.py` + `tests/llm/test_client_shim.py` imports; `tests/llm/test_providers.py`.
+- **Depends on:** US-001.
+- **TDD:** per-exception classification; usage extraction → `UsageMetrics`; text-block extraction parity; create/count-tokens kwargs match the current Anthropic shape.
+
+### US-003 — Generic `call_llm` orchestrator
+- **Description:** Refactor the `call_anthropic` body into `call_llm(*, system, cached_block, dynamic_block, model, max_tokens, cache_ttl="5m", prompt_version, max_retries_*, provider="anthropic", client=None) -> LLMResult`: resolve strategy from registry; build client via `strategy.make_client()` when `None`; pre-send count gate via strategy gated on `supports_token_count`; retry loop dispatching on `classify_exception`; cache marker/beta + dual-zero WARNING gated on `supports_prompt_caching`; assemble `LLMResult` from strategy extraction. Drop `call_anthropic`; add `call_llm` to `__all__`.
+- **Traces to:** DEC-001, DEC-005, DEC-006, DEC-008.
+- **AC:** All existing retry/cache tests (migrated to `call_llm`) pass; for `provider="anthropic"` the emitted logs + `LLMResult` are byte-identical to before; `call_anthropic` no longer importable; `test_public_api`/`test_schema` documented lists updated. Validation passes.
+- **Done when:** `call_llm` is the single seam; Anthropic path proven byte-identical.
+- **Files:** `src/signalforge/llm/client.py`; `src/signalforge/llm/__init__.py`; `tests/llm/test_client.py`, `tests/llm/test_client_retries.py`, `tests/llm/test_public_api.py`, `tests/draft/test_schema.py` (import + documented-list churn).
+- **Depends on:** US-002.
+- **TDD:** migrate the retry-budget, backoff-determinism (`_sleep`/`_rand_uniform`), cache-marker-drop, cache-too-large, and dual-zero-WARNING tests onto `call_llm`; assert `client is None` builds via the strategy.
+
+### US-004 — `provider` config field + stage threading + CLI client-construction migration
+- **Description:** Add registry-validated `provider: str = "anthropic"` to `DraftConfig` + `GradeConfig`. Thread `provider=config.provider` from `draft_from_request` and `grade._grade_one` into `call_llm`. Remove the `_make_anthropic_client` call from the CLI generate path (client now built inside `call_llm`); migrate the 7 CLI monkeypatch tests.
+- **Traces to:** DEC-006, DEC-007.
+- **AC:** Both configs round-trip `provider`; an unknown provider value fails loud at config load with available-keys remediation; `generate`/grade still work end-to-end with an injected fake client; CLI no longer references `_make_anthropic_client`. Validation passes.
+- **Done when:** Provider selection flows config→`call_llm` for both stages; CLI migrated.
+- **Files:** `src/signalforge/draft/config.py`, `src/signalforge/grade/config.py`, `src/signalforge/draft/schema.py`, `src/signalforge/grade/engine.py`, `src/signalforge/cli/generate.py`; the 7 `tests/cli/test_*` monkeypatch sites; `tests/draft/test_config.py`, `tests/grade/test_config.py`.
+- **Depends on:** US-003.
+- **TDD:** config accepts `anthropic`, rejects `bogus` with typed error; draft/grade pass the configured provider to `call_llm`; CLI generate path patches the registry/provider rather than `_make_anthropic_client`.
+
+### US-005 — No-cache fake provider: provider-neutrality proof (AC #2 + #3)
+- **Description:** Add a test-only provider (`supports_prompt_caching=False`, `supports_token_count=False`), registered in the registry, selected via `grade.provider`, and driven through `grade_artifacts`. Prove the audit/sidecar round-trip with zero cache metrics and intact reproducibility hashes.
+- **Traces to:** DEC-008, DEC-011.
+- **AC:** With the fake provider: `grade_artifacts` writes a valid audit JSONL + sidecar; `cache_*_input_tokens==0`; the grade drift detector validates the produced event; reproducibility blake2b hashes match the Anthropic-path recipe; no dual-zero WARNING emitted; no `cache_control`/beta header built. The fake provider is wired purely as shim + exception map + registry registration (AC #2). Validation passes.
+- **Done when:** AC #2 + #3 are pinned by tests.
+- **Files:** `tests/llm/_fake_provider.py` (new, test-only); `tests/grade/test_provider_neutrality.py` (new); possibly `tests/llm/_fake.py` (a no-cache response fake).
+- **Depends on:** US-004.
+- **TDD:** the whole story is the test (drives the no-cache provider and asserts the round-trip invariants).
+
+### US-006 — Docs + 5-surface parity
+- **Description:** Update operator-facing docs and rule files for the provider seam: the `provider` config knob, the `call_llm` rename, the `__client.py` convention, capability-gated caching.
+- **Traces to:** DEC-004, DEC-005, DEC-007, DEC-008.
+- **AC:** `docs/draft-ops.md` + `docs/grade-ops.md` document `provider`; `.claude/rules/llm-drafter.md` (+ `grade-layer.md` where it names `call_anthropic`/`_client`) updated to the neutral seam + registry; no stale `call_anthropic`/`_client.py` references in user-facing docs. `mkdocs build` (non-strict) clean. Validation passes.
+- **Done when:** All 5 surfaces name `call_llm` + `provider` consistently.
+- **Files:** `docs/draft-ops.md`, `docs/grade-ops.md`, `.claude/rules/llm-drafter.md`, `.claude/rules/grade-layer.md` (only where it names the seam). *(Note: `.claude/` edits are orchestrator-only — see Patterns & Memory note.)*
+- **Depends on:** US-005.
+
+### Quality Gate
+- Run the code reviewer 4× across the full changeset, fixing real bugs each pass; run CodeRabbit if available; full validation green after fixes. Depends on US-001…US-006.
+
+### Patterns & Memory
+- Update `.claude/rules/llm-drafter.md` with the durable provider-seam convention (registry + capability flags + `__client.py`); note the registry-validated-`str` deviation from the `Literal` config convention and why. Depends on Quality Gate. **Worker-writability caveat:** `.claude/rules/` edits are orchestrator-only in Ralph worktrees (see memory `ralph-worker-claude-dir-perms`) — US-006 + P&M `.claude/` edits land via the orchestrator, not a worker.
+
+## Open notes for implementation
+
+- `AnthropicClientProtocol` stays public + Anthropic-named (DEC-012); don't rename it to a "neutral" name — it genuinely describes the Anthropic `.messages` surface.
+- Watch the CLI monkeypatch migration (DEC-006): tests patching `gen_mod._make_anthropic_client` must move to the registry/provider seam; a missed one silently makes a "real client" call in a test.
+- The `git mv _client.py _anthropic_client.py` must keep `# pyright: ignore` confinement intact — pyright is in the validate gate.
diff --git a/plans/super/136-openai-grading-provider.md b/plans/super/136-openai-grading-provider.md
new file mode 100644
index 00000000..4f7a1ddf
--- /dev/null
+++ b/plans/super/136-openai-grading-provider.md
@@ -0,0 +1,356 @@
+# Super Plan — #136: OpenAI model support for grading
+
+## Meta
+
+- **Ticket:** https://github.com/wjduenow/SignalForge/issues/136
+- **Parent epic:** #134 (pluggable LLM provider for grading — OpenAI/Gemini). Milestone v0.3.
+- **Depends on:** #135 (provider-neutral LLM seam) — **merged**, PR #148.
+- **Sibling:** #137 (Gemini grading; mirrors the same shape).
+- **Phase:** published (awaiting approval — PR #152)
+- **Branch:** `feature/136-openai-grading`
+- **PR:** https://github.com/wjduenow/SignalForge/pull/152
+
+## What / Why
+
+Register **OpenAI** as the second LLM provider behind the #135 provider-neutral seam, selectable via `grade.provider: openai` in `signalforge.yml`. Validates that the seam is genuinely vendor-pluggable — Anthropic stays byte-identical (the regression floor), and a real-world non-caching vendor wires in cleanly through the same `LLMProvider` ABC + registry that #135 established. Closes the v0.3 epic's "OpenAI grading" deliverable; #137 (Gemini) ships next as the third provider with the same pattern.
+
+The seam already validates the abstract case: `tests/grade/test_provider_neutrality.py` registers `FakeNoCacheProvider` (both capability flags `False`) and proves `grade_artifacts` end-to-end. **OpenAI is the same shape with a real SDK** — `supports_prompt_caching=False`, `supports_token_count=False` (no Anthropic-equivalent count_tokens API; OpenAI offers a one-shot completions call). The work is mechanical SDK plumbing + a confinement shim + tests, with one genuine design choice — how `make_client()` reconciles OpenAI's `client.chat.completions.create(...)` with the orchestrator's hard-coded `client.messages.create(...)` protocol.
+
+## Discovery findings
+
+### The seam after #135 (all in `src/signalforge/llm/`)
+
+- **`providers.py`** — `LLMProvider` ABC + `register_provider(provider)` / `provider_for(name)` registry + `AnthropicProvider` (registered at import time). Capability flags: `name`, `supports_prompt_caching`, `supports_token_count`. Six abstract methods: `make_client`, `build_create_kwargs`, `build_count_tokens_kwargs`, `extract_text_blocks`, `extract_usage`, `classify_exception`.
+- **`client.py` `call_llm(*, system, cached_block, dynamic_block, model, max_tokens, cache_ttl="5m", prompt_version, max_retries_*, provider="anthropic", client=None) -> LLMResult`** — generic orchestrator. Retry loop dispatches on `ExceptionCategory` (AUTH / RATE_LIMIT / SERVER_ERROR / CONNECTION / NO_RETRY). Pre-send token-count gate is **gated on `supports_token_count`** — skipped entirely when `False`. Cache marker + dual-zero WARNING gated on `supports_prompt_caching`.
+- **`_anthropic_client.py`** — sole home of every Anthropic `# pyright: ignore`. Lazy SDK import inside `_make_anthropic_client` and `_load_anthropic_exception_classes`. Confinement enforced by `tests/test_audit_completeness.py` Scan 3 (`anthropic.Anthropic(...)` constructions allowed only here).
+- **`pricing.py`** — `_PRICES_MUTABLE` dict of `ModelPricing(input_per_mtok, output_per_mtok, cache_write_5m_per_mtok, cache_read_per_mtok)`. Three Anthropic SKUs (sonnet-4-6, opus-4-7, haiku-4-5). `PRICE_TABLE_VERSION = "2026-05-11"`. Consumed by `cli/_estimate.py:457,473`.
+
+### Grade / draft consumption sites
+
+- `src/signalforge/grade/engine.py:306-310` — `call_llm(system=_SYSTEM_PROMPT, cached_block=rubric_block, dynamic_block=dynamic_block, ..., provider=config.provider, client=client)`.
+- `src/signalforge/grade/config.py:127` — `provider: str = "anthropic"` with the `_provider_registered` validator (lines 209–230) calling `provider_for(v)`.
+- `src/signalforge/draft/config.py:113` — same shape, parity-mirrored.
+- `src/signalforge/draft/schema.py:197` — `call_llm(..., provider=config.provider, ...)`.
+
+### `--estimate` cost-preview path (`src/signalforge/cli/_estimate.py`)
+
+- **Anthropic-coupled today.** Threads a single `anthropic_client: AnthropicClientProtocol` through `_count_draft_tokens` (calls `anthropic_client.messages.count_tokens(...)`) and the grader-side equivalent.
+- **`pricing_grade = _pricing.lookup(grade_config.model)`** (line 473) — raises `EstimateUnknownModelError` on unknown model.
+- **Scope answer:** SD-3 says **fully wire OpenAI to `--estimate`** — generalise the estimate path's token-counting to be provider-aware (Anthropic = `messages.count_tokens`; OpenAI = `tiktoken` local), add OpenAI pricing entries, keep Anthropic byte-identical.
+
+### Provider-neutrality test infrastructure (already in place)
+
+- `tests/llm/_fake.py` — `FakeAnthropicClient` with `expect_count_tokens` / `expect_messages_create` FIFO queues + `assert_all_expectations_met()`. This is the shape to mirror as `FakeOpenAIClient`.
+- `tests/llm/_fake_provider.py` — `FakeNoCacheProvider` (synthetic no-cache provider) + `FakeNoCacheClient` with `create_calls` inspector + a `count_tokens` that raises if invoked. Already proves the no-cache path works end-to-end.
+- `tests/grade/test_provider_neutrality.py` — three tests: AC #1 registry validation, AC #2 GradeConfig validator accepts a registered provider, AC #3 `grade_artifacts` drives the no-cache provider end-to-end and verifies cache_*=0 in audit JSONL + 16-hex blake2b-8 reproducibility hashes + sidecar round-trip + **no dual-zero WARNING**.
+- AST confinement precedents: `tests/test_audit_completeness.py` Scan 3 for `anthropic.Anthropic(...)`; `tests/warehouse/test_snowflake_client_confinement.py` for the line-based `# type: ignore` confinement (Snowflake-shaped).
+
+### `openai` SDK availability
+
+- **Not a declared dependency.** `pyproject.toml:19` pins `anthropic>=0.50,<2.0`; no `openai` entry. (`openai 1.3.7` happens to be in this env but isn't required by SignalForge.) Need a new optional extra `[openai]` mirroring `[snowflake]`, with a lazy SDK import inside `_openai_client.py` (the same pattern as Snowflake — `tests/warehouse/test_snowflake_client_confinement.py` enforces it).
+
+## Scoping decisions (Phase 1)
+
+- **SD-1 — OpenAI API surface: Chat Completions.** `client.chat.completions.create(...)`. Stable, universal, simplest. Read text from `response.choices[0].message.content`. Pin `openai>=1.40` (covers stable Chat Completions + structured outputs). Responses API deferred.
+- **SD-2 — No cross-validation of provider/model.** Keep `GradeConfig.model` a free string. Model-naming evolves fast (gpt-4o → gpt-4.1 → …); a hard allowlist rots. Registry validates the provider name only; an obviously-wrong model errors at API call time with a typed `LLMError`.
+- **SD-3 — Fully wire OpenAI to `--estimate`.** Generalise the estimate path's token-counting to be provider-aware: Anthropic keeps `messages.count_tokens`; OpenAI uses `tiktoken` (local BPE counter, no API call). Add OpenAI pricing SKUs. Anthropic estimate bytes stay identical.
+- **SD-4 — Default judge model: `gpt-4o`.** Used in docs examples + the gated live test. `GradeConfig.model` default stays `claude-sonnet-4-6` — operators choosing OpenAI explicitly set `grade.model: gpt-4o`.
+
+## Architecture review (Phase 2)
+
+| Area | Rating | Finding |
+|---|---|---|
+| **SDK confinement** | concern | Every `# pyright: ignore` / `import openai` must live in `_openai_client.py`. Extend Scan 3 in `tests/test_audit_completeness.py` to also exclude `_openai_client.py` for `openai.OpenAI(...)` constructions. Mirrors Anthropic precedent exactly. **Resolution:** add the new AST scan + add `openai` to the per-SDK exclusion lists. |
+| **`.messages` adapter shape** | concern | OpenAI SDK exposes `client.chat.completions.create(...)`, NOT `client.messages.create(...)`. The orchestrator hard-calls `llm_client.messages.create(**kwargs)`. **Resolution:** `OpenAIProvider.make_client()` returns a thin adapter object whose `.messages.create(**kwargs)` delegates to the underlying `openai.OpenAI().chat.completions.create(**kwargs)`. `messages.count_tokens` is never called (`supports_token_count=False`); the adapter raises `NotImplementedError` on that path defensively. |
+| **`build_count_tokens_kwargs` ABC contract** | pass | A provider with `supports_token_count=False` never sees the method called by the orchestrator. Precedent: `FakeNoCacheProvider.build_count_tokens_kwargs` raises `NotImplementedError`. Match that. |
+| **Anthropic byte-identity** | concern | The estimate refactor (SD-3) threads a provider strategy through `_count_draft_tokens` + the grader-side equivalent. **Anthropic estimate output must stay byte-identical.** Resolution: extract the SDK-call into a per-strategy `count_input_tokens(client, ...) -> int` method; Anthropic impl is the existing `client.messages.count_tokens(...)` call verbatim. Pin a snapshot test on Anthropic estimate stdout before refactor + verify after. |
+| **`tiktoken` dependency** | concern | Adding a local-tokeniser dependency for OpenAI estimate. `tiktoken` is OpenAI-published, MIT, no native build (wheels for cpython 3.11–3.13). **Resolution:** add to the `[openai]` optional extra, lazy-import inside `_openai_client.py`. `OpenAIProvider.count_input_tokens` uses tiktoken's `encoding_for_model` with a graceful fallback to `cl100k_base` for unknown model ids. |
+| **Pricing table churn** | pass | `_PRICES_MUTABLE` gains OpenAI SKUs (at minimum `gpt-4o`); `PRICE_TABLE_VERSION` bumps. Additive change. Cache fields set to `0.0` (OpenAI has no equivalent cache discount). |
+| **Drafter provider symmetry** | concern | #135 gave BOTH `grade.provider` and `draft.provider` a `str` field. Once `OpenAIProvider` is registered, both stages naturally accept `provider: openai`. **Decision needed (refinement):** scope #136 to "grade only" with `draft.provider: openai` documented as untested-but-permitted, OR explicitly cover both stages. The work is identical; the doc message differs. |
+| **GradeEvent / drift detector** | pass | `GradeEvent.cache_creation_input_tokens` / `cache_read_input_tokens` default to 0 — already proven by `FakeNoCacheProvider` round-trip in `tests/grade/test_drift_detector.py`. No schema bump. |
+| **Reproducibility hashes** | pass | `rubric_hash`, `prompt_version_template`, `criterion_prompt_hash`, `response_text_hash` are LLM-content-agnostic. OpenAI responses produce a different `response_text_hash` (different judge model output) but the same 16-hex blake2b-8 shape. |
+| **Exception taxonomy** | pass | OpenAI SDK exceptions map cleanly: `AuthenticationError`/`PermissionDeniedError` → AUTH; `RateLimitError` → RATE_LIMIT; `APIConnectionError` → CONNECTION; `APIStatusError` with 5xx → SERVER_ERROR; 4xx-non-auth + anything else → NO_RETRY. Mirrors `AnthropicProvider.classify_exception`. |
+| **JSON parser tolerance** | pass | `parse_grade_response` already routes through `extract_json_payload` (issue #144) which strips prose preambles. No prefill needed (OpenAI Chat Completions doesn't support assistant-turn prefill either — same constraint as `claude-sonnet-4-6`). Optional refinement: set OpenAI's `response_format={"type":"json_object"}` to enforce JSON server-side. |
+| **Live gated test** | pass | Add `@pytest.mark.openai` marker; gate on `SF_RUN_OPENAI=1` + `OPENAI_API_KEY`; register in `pyproject.toml` `[tool.pytest.ini_options].markers` + add to `addopts -m 'not ...'` exclusion. Mirrors the `anthropic` marker precedent. |
+| **`--estimate` parity test** | concern | Need a unit-level estimate test with `grade.provider: openai` driving a `FakeOpenAIClient` + faked-or-real tiktoken count, asserting the report renders correctly. |
+| **Observability / logger gate** | pass | Lazy-format JSON logger gate (`tests/llm/test_logger_grep_gate.py`) already scans `src/signalforge/llm`. New `_openai_client.py` falls under the gate automatically. No new logger calls planned in the shim — logging stays in `client.py`. |
+| **Documentation surfaces** | concern | `docs/grade-ops.md` needs an OpenAI section (config snippet, no-cache caveat, model id guidance, env var). `docs/cost-estimate-ops.md` (or wherever `--estimate` ops live) needs the tiktoken note. `CLAUDE.md` "Related projects" doesn't need a change. `.claude/rules/llm-drafter.md` adds a sub-section on the OpenAI shim + Chat Completions adapter pattern as the precedent for #137. **Resolution:** dedicated docs story. |
+| **CHANGELOG** | pass | Add a `0.3.0.dev` entry under "Added" for OpenAI grading. |
+
+**Blockers:** none. **Concerns:** 6 listed — all routed to refinement or absorbed into specific stories. No architectural blockers.
+
+## Refinement log
+
+### Phase 1 scoping decisions (operator-facing)
+
+- **DEC-001 — OpenAI API surface: Chat Completions.** `client.chat.completions.create(...)` is the stable, universal surface; read text from `response.choices[0].message.content`. Pin `openai>=1.40`. Responses API deferred — no operator-visible feature in v0.3 needs it. From SD-1.
+- **DEC-002 — No cross-validation of provider/model.** `GradeConfig.model` / `DraftConfig.model` stay free strings. The provider registry validates the provider name; the model id is checked at API call time. Model naming evolves too fast (gpt-4o → gpt-4.1 → next) for a hard allowlist to be worth maintaining. From SD-2.
+- **DEC-003 — `--estimate` fully wired for OpenAI.** Generalise the estimate-path token-counting through a new `LLMProvider.estimate_input_tokens(model, text) -> int` ABC method. Anthropic impl calls `client.messages.count_tokens(...)` (preserves byte-identity); OpenAI impl uses `tiktoken` (local BPE, no API call). Add OpenAI pricing SKUs. From SD-3.
+- **DEC-004 — Default judge model: `gpt-4o`.** Used in docs examples + gated live tests. `GradeConfig.model` / `DraftConfig.model` keep their `claude-sonnet-4-6` default — operators selecting OpenAI explicitly set the model. From SD-4.
+
+### Phase 3 refinement decisions
+
+- **DEC-005 — Scope both grade AND draft explicitly.** `OpenAIProvider` is global once registered (provider field on both configs is symmetric since #135). Ship both stages with tests, docs sections, and live smokes. The work is mechanical; asymmetric documentation would imply a non-existent guard. The two stages share one provider class, one shim, one set of pricing SKUs — the *test/docs surface* doubles, not the implementation.
+- **DEC-006 — Server-enforce JSON via `response_format={"type":"json_object"}`.** `OpenAIProvider.build_create_kwargs` attaches the JSON-mode flag. Belt-and-braces with the existing tolerant `extract_json_payload` parser: server-side enforcement eliminates the prose-preamble drift class (mirrors issue #144's fix for `claude-sonnet-4-6`), and the parser remains the fallback if a future model strips the flag. The grade system prompt already names "JSON" so OpenAI's prompt-requirement check passes.
+- **DEC-007 — Ship four OpenAI SKUs in `pricing.py`.** `gpt-4o` (default judge per DEC-004), `gpt-4o-mini` (budget tier), `gpt-4.1` (newer flagship), `gpt-4-turbo` (back-compat). Each carries `input_per_mtok` + `output_per_mtok`; cache fields are `0.0` (OpenAI has no equivalent cache discount). Bump `PRICE_TABLE_VERSION` to the ship date.
+- **DEC-008 — Live gated smoke covers `grade_artifacts`, `draft_schema`, AND `--estimate`.** Three `@pytest.mark.openai` tests gated on `SF_RUN_OPENAI=1` + `OPENAI_API_KEY`: one drives end-to-end grading against the real API; one drives end-to-end drafting (honors DEC-005's both-stages scope at live level too); one runs `signalforge generate --estimate` with `grade.provider: openai` and asserts the report renders. Mirrors the maintainer-only `anthropic` marker precedent and #137's three-live-test breadth.
+
+### Phase 2 architecture-concern resolutions
+
+- **DEC-009 — `OpenAIProvider.make_client()` returns a thin `.messages`-shaped adapter.** OpenAI SDK exposes `client.chat.completions.create(...)`; the orchestrator hard-calls `client.messages.create(**kwargs)`. The adapter pattern: `_OpenAIClientAdapter` has a `.messages` namespace whose `.create(**kwargs)` delegates to the underlying `openai.OpenAI().chat.completions.create(**kwargs)`. `.messages.count_tokens` raises `NotImplementedError` defensively (orchestrator never calls it for a `supports_token_count=False` provider).
+- **DEC-010 — `_openai_client.py` is the sole home of every OpenAI SDK ignore; add a new 9th AST scan.** Scan 3 in `tests/test_audit_completeness.py` is Anthropic-specific (`anthropic.Anthropic(...)`); adding the OpenAI confinement requires a **new** AST scan, not an extension — bumping the project tally from 8 → 9 (and to 10 once #137's Gemini scan lands; whichever vendor merges first owns the 8 → 9 bump and the second owns 9 → 10). The new scan reuses the existing `_QualifiedNameCallFinder` helper per `testing-signal.md` § "AST single-construction-seam scans must catch all three bypass patterns" (bare / import-alias / module-attribute). Excludes `_openai_client.py`; sanity check asserts ≥1 legitimate `openai.OpenAI(...)` construction lives in the shim. Companion per-file confinement test `tests/llm/test_openai_client_confinement.py` mirrors the Snowflake-shaped `# type: ignore` line scan.
+- **DEC-011 — `OpenAIProvider.build_count_tokens_kwargs` raises `NotImplementedError`.** Matches `FakeNoCacheProvider.build_count_tokens_kwargs` precedent. The orchestrator never invokes it (`supports_token_count=False`), but the ABC requires the method present; raising is the honest behaviour.
+- **DEC-012 — `tiktoken` lives in the `[openai]` extra, lazy-imported in the shim; dual-listed across all three dev slots.** Mirrors Snowflake's `[snowflake]` precedent verbatim per `python-build.md` § "uv-managed dev environment": `openai>=1.40,<3.0` AND `tiktoken>=0.7,<1.0` appear in **three** places in lockstep — `[project.optional-dependencies].openai` (operator install: `pip install signalforge-dbt[openai]`), `[project.optional-dependencies].dev` (pip back-compat for `pip install -e ".[dev]"`), and `[dependency-groups].dev` (uv-native, what CI uses). Missing any one slot drifts the install surfaces. `uv.lock` refreshes in the same commit. `_count_openai_tokens(model, text)` uses `tiktoken.encoding_for_model(model)` with a `cl100k_base` fallback for unknown ids.
+- **DEC-013 — Anthropic estimate byte-identity is the floor.** Before the estimate refactor, capture a golden snapshot of `signalforge generate --estimate` stdout for an Anthropic-config fixture. After the refactor (DEC-003 — strategy-driven token counting), the snapshot must reproduce byte-for-byte. Pin via `tests/cli/test_estimate.py`.
+- **DEC-014 — `_load_openai_exception_classes()` returns empty tuples on `ImportError`.** Mirrors `_load_anthropic_exception_classes`'s `pragma: no cover` branch exactly. If a base install ships without the `[openai]` extra, `import openai` raises and the loader returns a frozen `_OpenAIExceptionClasses` with empty tuples in every category. `OpenAIProvider.classify_exception` then routes every exception to `NO_RETRY` cleanly — the operator never gets `provider: openai` to resolve a real call (the registry validator at config load would fail first, since the registration also runs lazily), but import-time behaviour is graceful. **Refusal / content-filter symmetry note:** OpenAI returns refusals as model-generated text (e.g. "I cannot help with that") rather than via a typed exception. The grade parser's tolerant JSON extraction (issue #144) treats unparseable refusal text as `GradeOutputError(violation_type="json_parse")` → standard degrade. No Gemini-style `safety_filter → typed degrade` DEC is needed; the existing pipeline handles it.
+
+## Detailed breakdown
+
+Stories follow the natural ordering: dependency wiring → shim → provider strategy → fakes/tests → pricing → estimate refactor → live smokes → docs → QG → P&M. The canonical validation command (`uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`) is implicit in every AC.
+
+### US-001 — `_openai_client.py` shim + dependency + AST confinement
+
+**Description:** Create the single shim where every `openai` SDK type ignore lives, add the optional `[openai]` extra (openai + tiktoken) in lockstep across the three pyproject slots, and add a **new 9th AST scan** confining `openai.OpenAI(...)` constructions to the shim.
+
+**Traces to:** DEC-001, DEC-009, DEC-010, DEC-012, DEC-014.
+
+**Files:**
+- `src/signalforge/llm/_openai_client.py` (new) — `OpenAIClientProtocol`, `_OpenAIMessagesAdapter`, `_OpenAIClientAdapter`, `_make_openai_client(api_key=None)`, `_load_openai_exception_classes()` (DEC-014 empty-tuple fallback on `ImportError`), `_OpenAIExceptionClasses` frozen dataclass, `_count_openai_tokens(model, text)`. All `# pyright: ignore` / `# type: ignore` confined here.
+- `pyproject.toml` — **three-slot dual listing** per DEC-012: add `openai = ["openai>=1.40,<3.0", "tiktoken>=0.7,<1.0"]` under `[project.optional-dependencies]`; append both packages to `[project.optional-dependencies].dev` AND `[dependency-groups].dev`. Regenerate `uv.lock`.
+- `tests/test_audit_completeness.py` — **add a new 9th AST scan** (NOT an extension of Scan 3 — Scan 3 is Anthropic-specific) reusing `_QualifiedNameCallFinder` to detect `openai.OpenAI(...)` constructions, excluding `_openai_client.py`. Add the three-pattern planted-violation regression test (bare / import-alias / module-attribute) per `testing-signal.md` § "AST single-construction-seam scans must catch all three bypass patterns." Sanity test asserts ≥1 legitimate `openai.OpenAI(...)` in the shim.
+- `tests/llm/test_openai_client_confinement.py` (new) — line-based scan rejecting `openai`-mentioning `# type: ignore` / `# pyright: ignore` outside the shim (mirrors `tests/warehouse/test_snowflake_client_confinement.py`).
+
+**TDD:** Write the 9th scan + planted-violation test + per-file confinement test first; all should fail (no shim) → red. Add the shim with one legitimate `openai.OpenAI(...)` construction → green. Then plant each of the three bypass patterns (bare, import-alias, module-attribute) and re-run to confirm the scan catches each; revert.
+
+**Acceptance criteria:**
+- `uv sync --dev` installs both `openai` and `tiktoken` (dev group includes the extra).
+- `_make_openai_client(api_key=None)` lazy-imports the SDK and returns a `_OpenAIClientAdapter`.
+- `_OpenAIClientAdapter.messages.create(**kwargs)` delegates to `chat.completions.create(**kwargs)`; `.messages.count_tokens(...)` raises `NotImplementedError`.
+- `_count_openai_tokens("gpt-4o", "hello world")` returns a positive int; an unknown model id falls back to `cl100k_base` without raising.
+- AST Scan 3 still passes; a planted `openai.OpenAI(...)` outside the shim fails the scan.
+- Line-based confinement test asserts every `openai`-tagged `# type: ignore` lives only in `_openai_client.py`.
+
+**Done when:** Above ACs all pass; canonical validation command is green.
+
+**Depends on:** none.
+
+### US-002 — `OpenAIProvider` + registration + config-validator coverage
+
+**Description:** Add `OpenAIProvider(LLMProvider)` to `providers.py`, register at import time, and pin that both `GradeConfig` and `DraftConfig` accept `provider="openai"` after registration.
+
+**Traces to:** DEC-001, DEC-005, DEC-006, DEC-009, DEC-011.
+
+**Files:**
+- `src/signalforge/llm/providers.py` — add `OpenAIProvider` class (mirrors `AnthropicProvider` shape) with `name="openai"`, `supports_prompt_caching=False`, `supports_token_count=False`. Six ABC method impls: `make_client()` → `_make_openai_client()`; `build_create_kwargs()` → returns `{"model", "max_tokens", "messages": [{"role":"system",...},{"role":"user","content": cached_block+dynamic_block}], "response_format":{"type":"json_object"}}` (cache_marker_active / cache_ttl ignored); `build_count_tokens_kwargs()` raises `NotImplementedError`; `extract_text_blocks()` reads `response.choices[0].message.content`; `extract_usage()` reads `response.usage.{prompt_tokens, completion_tokens}` mapped to `UsageMetrics(input_tokens, output_tokens, cache_creation_input_tokens=0, cache_read_input_tokens=0)`; `classify_exception()` maps SDK exceptions via `_load_openai_exception_classes()` to `ExceptionCategory`. `register_provider(OpenAIProvider())` at module end.
+- `src/signalforge/llm/__init__.py` — export `OpenAIProvider` in `__all__`.
+- `tests/llm/test_providers.py` — extend (or add) tests: `provider_for("openai")` returns an `OpenAIProvider`; `UnknownProviderError("xyz")` message lists both "anthropic" and "openai"; each ABC method has a focused unit test against synthetic inputs/exceptions.
+- `tests/grade/test_config.py` + `tests/draft/test_config.py` — pin that `GradeConfig(provider="openai", model="gpt-4o")` validates; `DraftConfig(provider="openai", model="gpt-4o")` validates.
+
+**TDD:** For each ABC method, write the unit test first (e.g. `classify_exception(openai.RateLimitError(...))` returns `ExceptionCategory.RATE_LIMIT`); fill in impl until green. Cover all five `ExceptionCategory` branches (AUTH / RATE_LIMIT / SERVER_ERROR / CONNECTION / NO_RETRY) — each maps from a real `openai.*` exception class.
+
+**Acceptance criteria:**
+- `provider_for("openai")` returns an `OpenAIProvider` instance with `supports_prompt_caching=False`, `supports_token_count=False`.
+- `OpenAIProvider().build_create_kwargs(...)` returns a dict containing `model`, `max_tokens`, `messages` (a list with a system role + a user role), and `response_format={"type":"json_object"}`. No `cache_control` marker anywhere.
+- `OpenAIProvider().build_count_tokens_kwargs(...)` raises `NotImplementedError`.
+- `OpenAIProvider().classify_exception(...)` returns the correct `ExceptionCategory` for at least one concrete SDK exception per category.
+- `GradeConfig(provider="openai", model="gpt-4o")` and `DraftConfig(provider="openai", model="gpt-4o")` validate without error.
+- `provider_for("xyz")` raises `UnknownProviderError` listing `("anthropic", "openai")` (order-insensitive).
+
+**Done when:** Above ACs pass; validation green.
+
+**Depends on:** US-001.
+
+### US-003 — `FakeOpenAIClient` + grade end-to-end provider-neutrality test
+
+**Description:** Build the test fake mirroring `FakeAnthropicClient`'s `expect_*` API and add an end-to-end `grade_artifacts(provider="openai")` integration test that proves cache_*=0, reproducibility hashes, and no dual-zero WARNING — the OpenAI analogue of the existing `FakeNoCacheProvider` proof.
+
+**Traces to:** DEC-001, DEC-005, DEC-006, DEC-009, DEC-011.
+
+**Files:**
+- `tests/llm/_fake_openai.py` (new) — `FakeOpenAIUsage(prompt_tokens, completion_tokens)`, `FakeOpenAIMessage(content, role="assistant")`, `FakeOpenAIChoice(message, index=0, finish_reason="stop")`, `FakeOpenAICompletion(choices, usage, model, id, object="chat.completion")`. `_MessagesAdapter` with FIFO `_create_queue: list[_CreateExpectation]` + `create_calls: list[dict]` inspector. `FakeOpenAIClient` exposes `.messages` (delegating to `chat.completions` for parity with real SDK adapter); `expect_messages_create(matching, returns)` + `assert_all_expectations_met()`.
+- `tests/grade/test_provider_neutrality_openai.py` (new) — three tests mirroring the no-cache provider neutrality suite: (1) `provider_for("openai")` resolves and capability flags are False/False; (2) `GradeConfig(provider="openai", model="gpt-4o")` validates; (3) `grade_artifacts(..., provider="openai", client=FakeOpenAIClient())` drives the engine end-to-end against canned JSON judge responses and asserts: JSONL `cache_creation_input_tokens == 0` and `cache_read_input_tokens == 0`, 16-hex blake2b-8 reproducibility hashes, sidecar round-trips, no dual-zero cache-anomaly WARNING in caplog.
+
+**TDD:** Write the end-to-end test first; it fails because no `FakeOpenAIClient` exists. Build the fake until the test passes. Then plant edge cases (Exception in `returns`, mismatched `matching`) and confirm the fake's `assert_all_expectations_met()` catches under-consumption.
+
+**Acceptance criteria:**
+- `FakeOpenAIClient` exposes `.messages.create(**kwargs)` consuming one matching expectation from the FIFO queue; raises `AssertionError` on no-match.
+- The end-to-end test passes: `grade_artifacts(provider="openai", client=FakeOpenAIClient())` produces a valid `GradingReport`, JSONL audit, and sidecar.
+- `caplog` contains no `"cache marker no-op"` WARNING in the OpenAI path.
+- `assert_all_expectations_met()` after the run reports zero un-consumed expectations.
+
+**Done when:** Above ACs pass; validation green.
+
+**Depends on:** US-002.
+
+### US-004 — Pricing entries (`gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4-turbo`)
+
+**Description:** Add four OpenAI SKUs to `_PRICES_MUTABLE` in `pricing.py`; bump `PRICE_TABLE_VERSION`.
+
+**Traces to:** DEC-003, DEC-004, DEC-007.
+
+**Files:**
+- `src/signalforge/llm/pricing.py` — extend `_PRICES_MUTABLE` with the four SKUs (input/output per-Mtok USD from OpenAI's public price page at PR-prep time; cache fields = 0.0). Bump `PRICE_TABLE_VERSION` to today (e.g. `"2026-05-27"`).
+- `tests/llm/test_pricing.py` — assert `lookup("gpt-4o")`, `lookup("gpt-4o-mini")`, `lookup("gpt-4.1")`, `lookup("gpt-4-turbo")` each return a non-zero `input_per_mtok` and `output_per_mtok` and zero cache fields. Assert `lookup("gpt-9-unicorn")` still raises `EstimateUnknownModelError`.
+
+**TDD:** Pricing-lookup tests first (red); add SKU entries (green); pin the version bump.
+
+**Acceptance criteria:**
+- All four OpenAI SKUs resolve via `lookup()` with non-zero input/output rates and zero cache rates.
+- `PRICE_TABLE_VERSION` bumped.
+- Unknown model still raises.
+
+**Done when:** Above ACs pass; validation green.
+
+**Depends on:** none (parallel-safe).
+
+### US-005 — `--estimate` provider-aware token counting
+
+**Description:** Generalise the estimate path's token-counting through a new `LLMProvider.estimate_input_tokens(model, text) -> int` abstract method. Anthropic impl preserves byte-identity (calls existing SDK `count_tokens`); OpenAI impl uses tiktoken; `FakeNoCacheProvider` impl returns a constant. Refactor `cli/_estimate.py` to thread the strategy.
+
+**Traces to:** DEC-003, DEC-007, DEC-012, DEC-013.
+
+**Files:**
+- `src/signalforge/llm/providers.py` — add `LLMProvider.estimate_input_tokens(model, text) -> int` abstract method. Implement on `AnthropicProvider` (delegates to its SDK; reuses the client construction path used by `_estimate`); implement on `OpenAIProvider` (delegates to `_count_openai_tokens`).
+- `src/signalforge/cli/_estimate.py` — refactor `_count_draft_tokens` and the grader-side equivalent to dispatch through `provider_for(config.provider).estimate_input_tokens(model, text)`. Remove the hard-coded `anthropic_client.messages.count_tokens(...)` callsite; thread the resolved client (or `None` for clients the strategy builds itself) through the strategy.
+- `tests/llm/_fake_provider.py` — add `FakeNoCacheProvider.estimate_input_tokens(model, text) -> int` returning a constant (e.g. `len(text.split())`) so existing neutrality tests still pass.
+- `tests/cli/test_estimate.py` — (a) pin Anthropic byte-identity: capture a golden snapshot of estimate stdout for an Anthropic-config fixture BEFORE the refactor in the same commit (via a new fixture) and assert it after; (b) add a test driving `--estimate` with `grade.provider: openai` + `grade.model: gpt-4o` (and `draft.provider: openai` + `draft.model: gpt-4o`) against `FakeOpenAIClient`, asserting the report renders with non-zero token counts and a non-zero USD estimate.
+
+**TDD:** Capture Anthropic golden first; refactor; verify identity. Then write the OpenAI estimate test; implement until green.
+
+**Acceptance criteria:**
+- `LLMProvider.estimate_input_tokens(model, text) -> int` is an abstract method on the ABC.
+- `AnthropicProvider.estimate_input_tokens` reproduces the pre-refactor token count for the same input.
+- `OpenAIProvider.estimate_input_tokens` returns a positive int for `gpt-4o`.
+- Anthropic estimate stdout snapshot is byte-identical before and after the refactor (pinned by `tests/cli/test_estimate.py`).
+- `signalforge generate --estimate` with `grade.provider: openai` produces an `EstimateReport` with non-zero grader token counts and non-zero USD figures.
+
+**Done when:** Above ACs pass; validation green; no `--cov-fail-under` regression.
+
+**Depends on:** US-002, US-004.
+
+### US-006 — Live gated smoke tests (`grade_artifacts` + `draft_schema` + `--estimate`)
+
+**Description:** Add the `openai` pytest marker, register three gated tests against the real OpenAI API (grader + drafter + estimate, per DEC-005 + DEC-008), document the env-var gate. Mirrors the `anthropic` marker precedent.
+
+**Traces to:** DEC-001, DEC-004, DEC-005, DEC-008.
+
+**Files:**
+- `pyproject.toml` — register `"openai: real-API smoke test (requires OPENAI_API_KEY; excluded from default CI)"` under `[tool.pytest.ini_options].markers`; extend `addopts -m 'not ...'` exclusion to include `not openai`.
+- `tests/grade/test_smoke_real_api_openai.py` (new) — `pytestmark = pytest.mark.openai`; env-gate `SF_RUN_OPENAI=1` + `OPENAI_API_KEY`. Drives `grade_artifacts(..., provider="openai", config=GradeConfig(model="gpt-4o", ...), client=None)` and asserts shape-only (positive scores, valid JSONL, no dual-zero WARNING).
+- `tests/draft/test_smoke_real_api_openai.py` (new) — `pytestmark = pytest.mark.openai`; same env gates. Drives `draft_schema(..., provider="openai", config=DraftConfig(model="gpt-4o", ...), client=None)` against a small in-test manifest fixture; asserts `CandidateSchema` validates + `LLMResponseEvent` JSONL is written with `cache_*_input_tokens == 0`. Honours DEC-005's "scope both stages" commitment that US-003's grade-only neutrality test alone doesn't cover live-side.
+- `tests/cli/test_e2e_estimate_openai.py` (new) — `pytestmark = pytest.mark.openai`; same env gates. Runs `signalforge generate --estimate ...` with `grade.provider: openai` + `grade.model: gpt-4o`; asserts the rendered report includes a non-zero grader USD estimate, exit code 0, no traceback.
+- `CONTRIBUTING.md` (or `docs/cost-estimate-ops.md`) — document the `SF_RUN_OPENAI=1` + `OPENAI_API_KEY` gating env vars next to the existing Anthropic equivalents.
+
+**TDD:** Stub the three test files with the env-skip plumbing first; ensure the default suite still passes (marker is excluded). Run `uv run pytest -m openai --no-cov` manually with credentials to validate against the live API once.
+
+**Acceptance criteria:**
+- `uv run pytest` excludes the new tests by default (marker not in default set).
+- `uv run pytest -m openai --no-cov` with `SF_RUN_OPENAI=1` + `OPENAI_API_KEY` runs all three tests; without those env vars, each skips with a clear reason naming the missing var.
+- Grade live smoke against `gpt-4o` produces a valid `GradingReport` (shape assertions only — no value pinning).
+- Draft live smoke against `gpt-4o` produces a `CandidateSchema` that validates and an `LLMResponseEvent` JSONL row with zero cache tokens.
+- Live `--estimate` produces non-zero token counts and a non-zero USD estimate; exit 0.
+
+**Done when:** Above ACs pass; default validation still green (gated tests excluded); maintainer has run the live smokes once.
+
+**Depends on:** US-002, US-005.
+
+### US-007 — Documentation surfaces
+
+**Description:** Update every documentation surface that names the available providers / `--estimate` flow / shim convention.
+
+**Traces to:** DEC-001 through DEC-014 (collectively).
+
+**Files:**
+- `docs/grade-ops.md` — add an "OpenAI provider" section: config snippet (`grade.provider: openai`, `grade.model: gpt-4o`), `OPENAI_API_KEY` env var, no-prompt-cache caveat, link to live smoke gating.
+- `docs/draft-ops.md` — add equivalent section for `draft.provider: openai`.
+- `docs/cost-estimate-ops.md` (or wherever `--estimate` ops live; create if absent) — add tiktoken note + the `[openai]` extra requirement.
+- `.claude/rules/llm-drafter.md` — extend the "Provider-neutral seam" section with the OpenAIProvider shim notes (Chat Completions adapter pattern, `response_format=json_object`, capability flags False/False). Becomes the canonical precedent for #137 (Gemini).
+- `CHANGELOG.md` — under `0.3.0.dev` "Added": `OpenAI as a grading + drafting provider (#136). Set grade.provider: openai or draft.provider: openai in signalforge.yml; requires the [openai] install extra and OPENAI_API_KEY.`
+- `README.md` — if the README enumerates supported providers, extend the list.
+
+**TDD:** N/A (docs only).
+
+**Acceptance criteria:**
+- `docs/grade-ops.md` carries an OpenAI section with a copy-pasteable config example.
+- `docs/draft-ops.md` carries the equivalent.
+- The estimate ops doc names tiktoken + the `[openai]` extra.
+- `.claude/rules/llm-drafter.md` carries the OpenAIProvider shim sub-section.
+- `CHANGELOG.md` has the new entry.
+- `uv run --only-group docs mkdocs build` succeeds (the `docs-build` CI job mirrors this).
+
+**Done when:** Above ACs pass; the `docs-build` job is green locally.
+
+**Depends on:** US-001, US-002, US-005 (so the docs describe a working surface).
+
+### US-008 — Quality Gate
+
+**Description:** Multi-pass code review across the full changeset, CodeRabbit if available, full validation including gated markers.
+
+**Files:** wherever the prior stories' bugs land.
+
+**Acceptance criteria:**
+- `/code-review` run 4 times; every real bug surfaced is fixed (false positives recorded with rationale).
+- CodeRabbit review run if accessible.
+- `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest` passes.
+- `uv run pytest -m anthropic --no-cov` passes (proves Anthropic byte-identity in the estimate refactor end-to-end).
+- `uv run pytest -m openai --no-cov` passes locally with credentials.
+- `uv run pytest -m wheel_smoke --no-cov` passes (new `[openai]` extra doesn't break wheel build).
+- Coverage stays at or above the current threshold.
+
+**Done when:** All gates green.
+
+**Depends on:** US-001 through US-007.
+
+### US-009 — Patterns & Memory
+
+**Description:** Capture durable lessons in `.claude/rules/llm-drafter.md` (the canonical precedent for #137) and add memory entries for any non-obvious traps surfaced during implementation.
+
+**Files:**
+- `.claude/rules/llm-drafter.md` — refine the OpenAI shim sub-section if implementation surfaced anything unexpected (likely candidates: the `.messages` adapter wrap pattern, tiktoken model-id fallback strategy, `response_format=json_object` interaction with the tolerant JSON parser).
+- `~/.claude/projects/-home-wesd-Projects-SignalForge/memory/` — one memory file per non-obvious trap, indexed in `MEMORY.md`.
+
+**Acceptance criteria:**
+- `.claude/rules/llm-drafter.md` has a concrete OpenAI sub-section a future contributor can mirror for #137.
+- Memory entries (if any) follow the user/feedback/project/reference taxonomy and link related entries via `[[name]]`.
+
+**Done when:** Above ACs pass.
+
+**Depends on:** US-008.
+
+## Worker-writability routing
+
+Per `~/.claude/projects/-home-wesd-Projects-SignalForge/memory/ralph-worker-claude-dir-perms.md`: **Ralph workers cannot Write under `.claude/` in worktrees** — only the orchestrator can. The story split honours this:
+
+- US-001 through US-007 + US-008 (Quality Gate) touch only worker-writable paths (`src/`, `tests/`, `pyproject.toml`, `docs/`, `CHANGELOG.md`, `README.md`, `uv.lock`).
+- **US-009 (Patterns & Memory) is orchestrator-only** because it edits `.claude/rules/llm-drafter.md` (and potentially `.claude/rules/grade-layer.md`). If a worker is dispatched against US-009 the bead fails with a write-denied error; route it to the orchestrator.
+
+This is the same routing convention `#137`'s plan codifies; mirroring it here so the rule lands durably for both #136 and #137.
+
+## Open notes for implementation
+
+Pragmatic verification items that depend on the installed SDK version at implementation time — flag during US-001 / US-002 / US-005, not codified as DECs because the SDK surface evolves faster than the plan:
+
+- **Verify `openai` SDK exception class names + status-code attrs against the installed version.** DEC-009's exception → `ExceptionCategory` mapping (`openai.AuthenticationError`/`PermissionDeniedError` → AUTH; `RateLimitError` → RATE_LIMIT; `APIConnectionError` → CONNECTION; `APIStatusError` 5xx → SERVER_ERROR; 4xx-non-auth + everything else → NO_RETRY) is the shape; the precise class names (`InternalServerError` vs `APIStatusError`-with-status-code; `Timeout` vs `APITimeoutError`) may need a tiny adjustment for `openai>=1.40`. The unit tests in US-002 drive this — write the tests against the installed SDK, then implement to pass.
+- **Confirm the `.messages.create` façade adapts cleanly to `chat.completions.create`.** US-001 / US-002: the orchestrator hard-calls `llm_client.messages.create(**kwargs)`. `_OpenAIClientAdapter.messages.create(**kwargs)` delegates to `self._raw.chat.completions.create(**kwargs)`. The kwargs dict shape is OpenAI-native (`model`, `max_tokens`, `messages` list of `{role, content}` dicts, `response_format`). Verify there's no per-call mutation needed; if `extra_headers` is passed (it shouldn't be — capability-gated off), drop it at the adapter rather than at the provider.
+- **`tiktoken` model-id fallback table.** `tiktoken.encoding_for_model("gpt-4o")` works for the four planned SKUs at SDK version pin time; if a model id isn't recognised, fall through to `tiktoken.get_encoding("cl100k_base")`. Don't raise on unknown — `--estimate` is a calibration signal, not a billing guarantee (mirrors the planner-estimate caveats in `warehouse-adapters.md` § "estimate_query_bytes graduation"). Log one INFO line per unknown-model fallback so the operator knows the count is approximate.
+- **`response_format={"type":"json_object"}` requires "json" in the prompt.** The grade system prompt already names JSON; verify by reading `signalforge.grade.prompts._SYSTEM_PROMPT` during US-002. The drafter system prompt likewise. If either ever drops the word "json", the OpenAI request will fail server-side with a `BadRequestError` — pin a unit test asserting both prompts contain `"json"` (case-insensitive) to catch future drift.
+- **`pricing.lookup` returns zero cache fields for the four OpenAI SKUs.** US-004: assert this in `tests/llm/test_pricing.py`. `cli/_estimate.py:489` lines (cache cost math) should produce 0.0 contributions without raising — verify the multiplication doesn't break on a zero `cache_write_5m_per_mtok`.
+- **Anthropic byte-identity snapshot — capture in the SAME COMMIT as the refactor.** US-005: the `tests/cli/test_estimate.py` golden file must be added in the same PR commit that introduces the strategy method, or git history can't prove byte-identity. Capture stdout pre-refactor on the feature branch's first commit; refactor on the second; the test compares against the captured golden. If the snapshot changes during the refactor, the refactor is wrong.
+
+## Beads manifest
+
+- **Epic:** `bd_1-scaffolding-4tw` — `#136 epic: OpenAI grading provider` (P2, external-ref `gh-136`)
+- **Tasks** (dep edges per plan's "Depends on:" lines; all P2):
+ - `.1` US-001 — `_openai_client.py` shim + `[openai]` extra + 9th AST scan — **READY** (no deps)
+ - `.2` US-002 — `OpenAIProvider` + registration + config-validator coverage — blocked by `.1`
+ - `.3` US-003 — `FakeOpenAIClient` + grade end-to-end provider-neutrality test — blocked by `.2`
+ - `.4` US-004 — Pricing entries (gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4-turbo) — **READY** (parallel-safe; no deps)
+ - `.5` US-005 — `--estimate` provider-aware token counting — blocked by `.2`, `.4`
+ - `.6` US-006 — Live gated smoke tests (grade + draft + `--estimate`) — blocked by `.2`, `.5`
+ - `.7` US-007 — Documentation surfaces — blocked by `.1`, `.2`, `.5`
+ - `.8` Quality Gate — code-review ×4 + CodeRabbit + full validation + `wheel_smoke` — blocked by `.1`–`.7`
+ - `.9` Patterns & Memory (orchestrator-only — edits `.claude/rules/`) — blocked by `.8`
+- **Cross-epic gate (downstream):** `bd_1-scaffolding-41a` (sentinel for #137 US-007) **`DEPENDS ON .9`**. When this epic completes, US-009 closes → sentinel becomes close-eligible → operator closes sentinel after PR #152 merges to `dev` → #137 US-007 unblocks. See #137 plan DEC-019.
+- **Parallel-safe entry points at devolve time:** `.1` (shim/extra/AST) and `.4` (pricing). They touch disjoint files — `.1` edits `_openai_client.py` / `pyproject.toml` / `test_audit_completeness.py`; `.4` edits `pricing.py` / `test_pricing.py` — so `ralph-serialize-shared-registry-beads` does NOT apply. Run them concurrently.
+- **Sessions:** 2 (initial plan; devolve + #137 cross-review revisions)
+
diff --git a/plans/super/137-gemini-grading.md b/plans/super/137-gemini-grading.md
new file mode 100644
index 00000000..eea31f68
--- /dev/null
+++ b/plans/super/137-gemini-grading.md
@@ -0,0 +1,817 @@
+# Super Plan — #137: Gemini model support for grading
+
+## Meta
+
+- **Ticket:** https://github.com/wjduenow/SignalForge/issues/137
+- **Parent epic:** #134 (pluggable LLM provider for grading — OpenAI/Gemini). Milestone v0.3.
+- **Depends on:**
+ - **#135** (provider-neutral LLM seam) — **merged** (`32b298f`, PR #148 → dev).
+ - **#136** (OpenAI grading) — **plan approved and now in implementation**; #136 lands on `dev` first. #137 **sequences after #136 merged**: #136 ships the `LLMProvider.estimate_input_tokens` ABC extension, the `pricing.py` per-provider SKU pattern, the `--estimate` strategy refactor (with an Anthropic byte-identity snapshot as the floor), AND the 9th AST scan (`openai.OpenAI(...)` confinement). #137 piggybacks on that shape with a Gemini-native implementation and adds the **10th** AST scan (DEC-019).
+- **Phase:** devolved (PR #151 → dev)
+- **Branch:** `feature/137-gemini-grading`
+- **Worktree:** `../worktrees/SignalForge/137-gemini-grading`
+
+## Beads manifest
+
+- **Epic:** `bd_1-scaffolding-txe`
+- **Tasks** (linear chain except where noted parallel-safe; all P2):
+ - `.1` US-001 — `_gemini_client.py` shim + new AST confinement scan — **READY**
+ - `.2` US-002 — `GeminiProvider(LLMProvider)` + registration (incl. `response_mime_type="application/json"`) — blocked by `.1`
+ - `.3` US-003 — `pyproject.toml` `[gemini]` extra + dev-group sync — blocked by `.1`
+ - `.4` US-004 — `FakeGeminiClient` + offline provider unit tests — blocked by `.2`
+ - `.5` US-005 — Provider-neutrality end-to-end tests (draft + grade, fake-driven) — blocked by `.4`
+ - `.6` US-006 — Gemini pricing SKUs in `pricing.py` — **READY** (parallel-safe; no deps)
+ - `.7` US-007 — `GeminiProvider.estimate_input_tokens` + `--estimate` integration — blocked by `.2`, `.6`, **and `bd_1-scaffolding-41a` (sentinel: #136 PR #152 merged to dev — close on merge to unblock; mechanical encoding of DEC-019)**
+ - `.8` US-008 — Live tests (`@pytest.mark.gemini`, raw + draft + grade) + CONTRIBUTING update — blocked by `.2`, `.5`
+ - `.9` US-009 — Operator-facing docs + CHANGELOG — blocked by `.2`
+ - `.10` Quality Gate (code-review ×4 + CodeRabbit + canonical validate + `wheel_smoke` + `anthropic` + `gemini` markers) — blocked by `.1`–`.9`
+ - `.11` Patterns & Memory **(orchestrator-only — edits `.claude/rules/`)** — blocked by `.10`
+- **Sessions:** 3 (2026-05-27 — initial plan; 2026-05-27 — extension after #136 plan comparison; 2026-05-28 — devolve)
+
+## What / Why
+
+Add **Google Gemini** as a selectable LLM provider for both the grader and the drafter,
+via `grade.provider: gemini` / `llm.provider: gemini`. Anthropic stays the default; no
+existing draft/grade fixtures or snapshots move. v0.3 ships Gemini **without prompt
+caching** — every call shipping the full system+rubric prompt — to keep the first cut
+simple and the request shape uniform; explicit Gemini context caching is a follow-up.
+
+This is the second concrete provider the #135 seam was designed for. The seam itself is
+unchanged — the work is one new shim, one new provider class, an `extra=` entry, and
+tests/docs. The plan deliberately mirrors #135's shape so #136 (OpenAI) can copy this
+plan and substitute the vendor.
+
+## Discovery findings
+
+### The seam #137 plugs into (all in `src/signalforge/llm/`)
+
+- `providers.py` — `LLMProvider` ABC + process-level registry (`register_provider` /
+ `provider_for`); `AnthropicProvider` registered at module scope (line 362). Capability
+ flags drive every Anthropic-specific branch in the orchestrator.
+- `_anthropic_client.py` — the per-vendor shim pattern: `ClientProtocol`,
+ `_make__client`, `_load__exception_classes`. **Every `# pyright: ignore`
+ for the SDK is confined here** (DEC-012 of #5; renamed by #135 DEC-004). The convention
+ in `.claude/rules/llm-drafter.md` is explicit: a new vendor gets `__client.py`.
+- `client.py` `call_llm` — generic orchestrator. Capability-gated branches we'll rely on:
+ `supports_prompt_caching=False` ⇒ no `cache_control` marker, no beta header, cache
+ tokens reported as 0, no dual-zero WARNING (DEC-008 of #135). `supports_token_count=False`
+ ⇒ skip the pre-send count gate entirely.
+- `models.py::LLMResult` — `cache_*_input_tokens` default 0; the no-cache path is
+ first-class on the result type.
+
+### Config surface (already provider-aware via #135)
+
+- `DraftConfig.provider: str = "anthropic"` (`src/signalforge/draft/config.py:113`) and
+ `GradeConfig.provider: str = "anthropic"` (`src/signalforge/grade/config.py:127`). Both
+ `@field_validator("provider")` call `provider_for(v)` and propagate `UnknownProviderError`
+ raw (it's an `LLMError`, not `ValueError`/`TypeError`/`AssertionError`, so Pydantic
+ doesn't wrap it). Registering `GeminiProvider` makes `provider: gemini` validate.
+- `cache_ttl: Literal["5m","1h"]` stays on both configs — Anthropic-specific, ignored
+ when `supports_prompt_caching=False` (DEC-009 of #135). No churn there.
+
+### Test fakes + the no-cache neutrality proof (DEC-011 of #135)
+
+- `tests/llm/_fake.py::FakeAnthropicClient` — the `expect_*` API to mirror.
+- `tests/llm/_fake_provider.py::FakeNoCacheProvider` — already proves the seam handles
+ `False/False` capability flags through `grade_artifacts` end-to-end (audit JSONL,
+ sidecar, drift detectors, blake2b-8 reproducibility hashes intact). The Gemini provider
+ reuses this proven path; #137's neutrality test is `FakeGeminiClient`-driven (not
+ `FakeNoCacheProvider`-driven) to exercise the real Gemini request shape + safety-filter
+ branch.
+- `tests/grade/test_provider_neutrality.py` — the test pattern to mirror for the new
+ Gemini neutrality tests.
+
+### SDK choice — `google-genai` (the actively-maintained one)
+
+The ticket flags this: prefer `google-genai` (new unified SDK, supersedes
+`google-generativeai`). Module surface: `from google import genai; client = genai.Client(api_key=...)`;
+calls via `client.models.generate_content(model=..., contents=..., config=...)`;
+exceptions in `google.genai.errors` (`APIError` / `ClientError` / `ServerError`).
+Safety-filter responses surface as a candidate with `finish_reason` ∈ {`SAFETY`,
+`RECITATION`, `OTHER`, …} and no `text` parts — the shim must detect this and route to
+a typed `LLMError` (DEC-005 below).
+
+### `pricing.py` + `cli/_estimate.py` — the seam #136 generalises, #137 piggybacks on
+
+- `src/signalforge/llm/pricing.py` ships three Anthropic SKUs today
+ (`claude-sonnet-4-6`, `claude-opus-4-7`, `claude-haiku-4-5`); `PRICE_TABLE_VERSION =
+ "2026-05-11"` *at discovery time* (US-006 of this plan bumps it as it adds the three
+ Gemini SKUs; #136 lands four OpenAI SKUs in parallel and bumps it again — the current
+ on-disk value is whatever the most recent of those two landed). `lookup(model)` raises
+ `EstimateUnknownModelError` for any non-Anthropic id, so `--estimate` is silently
+ un-usable for `grade.provider: gemini` until SKUs land.
+- `src/signalforge/cli/_estimate.py` is **Anthropic-coupled**: it threads a single
+ `anthropic_client: AnthropicClientProtocol` through `_count_draft_tokens` (line 309)
+ and the grader-side equivalent (line 348), both hard-calling
+ `anthropic_client.messages.count_tokens(...)`.
+- **#136 (DEC-003, DEC-013, US-005) generalises this** by adding
+ `LLMProvider.estimate_input_tokens(model, text) -> int` to the ABC, threading the
+ resolved provider strategy through `cli/_estimate.py`, and pinning an Anthropic
+ byte-identity snapshot as the floor. OpenAI implements via `tiktoken` (local BPE).
+- **#137 piggybacks** on that ABC extension with a Gemini-native implementation via
+ `client.models.count_tokens(model=, contents=)` — the google-genai SDK exposes a real
+ count_tokens method, so no `tiktoken`-equivalent local-tokeniser dep is needed (the
+ shim wraps the SDK call; cost is one extra API round-trip per estimate call, identical
+ in shape to the Anthropic path). See DEC-016 + US-007.
+
+### Snowflake `[snowflake]` extra — the pattern to mirror (`pyproject.toml`)
+
+`snowflake-connector-python>=3,<4` appears in **both** `[project.optional-dependencies].snowflake`
+(operator install: `pip install signalforge-dbt[snowflake]`) **and** `[dependency-groups].dev`
+(so offline tests can construct real `snowflake.connector.errors.*` instances for the
+exception mapper without needing a live warehouse). The same dual-listing is required for
+Gemini — see `warehouse-adapters.md` § "Snowflake test harness" `_sfe()` lazy-import
+gotcha (full-suite ordering deletes `snowflake.connector` from `sys.modules`; lazy import
+inside each test).
+
+### Already-neutral — do NOT touch
+
+- Grade prompts (`` envelope, rubric criterion list, blake2b-8 reproducibility
+ hashes in `grade/prompts.py`) are provider-neutral by design (#7 DEC-008/010/019). The
+ `` envelope is the only prompt-injection defence for judge-prompt content and
+ applies identically regardless of provider.
+- `LLMResult` / `GradeEvent` / `LLMResponseEvent` shapes — already accommodate
+ `cache_*_input_tokens = 0` via #135 DEC-009. No drift-detector or fixture moves.
+- `tests/llm/test_prompt_cache_stability.py` — pins the Anthropic cached-block bytes;
+ unaffected (Gemini takes a different code path through `call_llm`).
+- `tests/test_audit_completeness.py` scans 1–7 — unchanged. Scan **8** (fail-closed
+ writers) and the Anthropic-construction scan stay. **#136 lands the 9th** scan
+ (`openai.OpenAI(...)` confinement) first; #137 adds the **10th** scan for
+ `genai.Client(...)` confinement to `_gemini_client.py`.
+
+## Scoping decisions (Phase 1 — answered)
+
+- **Token counting:** `supports_token_count = False`. Skip the pre-send 8000-token cap
+ gate. Simplest path; matches `FakeNoCacheProvider` precedent. The cap exists primarily
+ to bound the Anthropic cache block — without caching, the marginal value doesn't
+ justify a count-tokens round-trip per call. Documented deferral.
+- **Provider/model coherence:** No validation. `GradeConfig.model` / `DraftConfig.model`
+ stay free-form `str`. Model-name allowlists rot the moment Google ships a new family;
+ the documented Gemini model id in ops docs is the soft guidance.
+- **Safety-filter / blocked response:** Raise typed `LLMResponseFormatError` (an
+ `LLMError`) naming the `finish_reason` in the message. `grade_artifacts` wraps to
+ `GradeLLMError` and degrades the pair with `reasoning="call failed: GradeLLMError"`
+ (DEC-015 of #7). Explicit, meaningful — not a JSON-parse failure masquerading as a
+ response-shape bug.
+- **Scope:** Cover **both** drafter (`llm.provider: gemini`) and grader
+ (`grade.provider: gemini`). The provider is shared seam infrastructure — once
+ registered, both paths use it automatically. Cost is one extra test file per side
+ (mostly mechanical) for a measurable broadening of operator value.
+
+## Architecture review (Phase 2)
+
+| Area | Rating | Notes |
+|---|---|---|
+| **SDK confinement / supply-chain** | pass | `google-genai` import lazy + confined to `_gemini_client.py` (and `_load_gemini_exception_classes`); AST scan #9 enforces. Mirrors `_anthropic_client.py` exactly. |
+| **Performance** | concern → accepted | No caching ⇒ every grade call ships full system+rubric prompt. For default 4 criteria × ~12 artifacts = ~48 sequential calls, this is the dominant cost. Documented as cost guidance in `docs/grade-ops.md`; explicit Gemini caching deferred. |
+| **Capability degrade** | pass | Both flags `False`. Identical path to `FakeNoCacheProvider` which #135 already proves end-to-end. No new orchestrator branches. |
+| **Safety filter / no-content** | pass | Detected in `extract_text_blocks`; routes via `LLMResponseFormatError` → `GradeLLMError` → degrade. Pinned by a dedicated test driving `FakeGeminiClient.expect_create(returns=)`. |
+| **Exception taxonomy** | pass | Five categories cover Gemini's `google.genai.errors` surface (auth via 401/403 on `ClientError`; 429 → RATE_LIMIT; 5xx via `ServerError` → SERVER_ERROR; connection-flavoured → CONNECTION; default NO_RETRY). Mapper unit-tested offline against genuine SDK exception instances. |
+| **Config / registry validation** | pass | `provider="gemini"` validates the moment `register_provider(GeminiProvider())` runs at module import (`signalforge.llm.providers`). `UnknownProviderError` lists registered providers; no Pydantic wrap. |
+| **Reproducibility hashes** | pass | `rubric_hash`, `prompt_version_template`, `criterion_prompt_hash`, `response_text_hash`, `args_hash` are provider-neutral. Cache-token fields default 0 — already round-tripped by drift detectors. |
+| **Testing strategy** | pass | Hand-rolled `FakeGeminiClient` + `expect_*` for offline behaviour; offline exception-map tests use genuine `google.genai.errors.*` instances (SDK is a dev dep); `@pytest.mark.gemini` for live (gated by `SF_RUN_GEMINI=1` + `GOOGLE_API_KEY`). Live tests run `--no-cov`. |
+| **Observability** | pass | No new logging beyond what `call_llm` already emits (and most of that is gated off by capability flags). Cleanup-boundary fail-soft N/A — no session state. |
+| **`--estimate` integration** | concern → addressed | The ABC extension (`estimate_input_tokens`) ships in #136; #137 implements it via Gemini's native `client.models.count_tokens` and adds 3 Gemini SKUs to `pricing.py`. Documented as DEC-016/017 + US-006/007. **Sequencing: merge after #136** so the ABC + estimate refactor are in place (DEC-019). |
+| **Server-side JSON enforcement** | concern → addressed | `extract_json_payload` (issue #144) already tolerates a prose preamble, but server-side enforcement eliminates the drift class entirely. `GeminiProvider.build_create_kwargs` sets `GenerateContentConfig(response_mime_type="application/json")`. Mirrors #136 DEC-006 (`response_format={"type":"json_object"}`). DEC-018. |
+| **Pricing-table churn** | pass | `_PRICES_MUTABLE` gains 3 Gemini SKUs (`gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash`) with cache fields = 0.0; `PRICE_TABLE_VERSION` bumps. Additive; no Anthropic SKU moves. DEC-017. |
+| **Wheel-build smoke** | pass | New `[gemini]` extra changes packaging metadata. QG runs `uv run pytest -m wheel_smoke --no-cov` per the maintainer marker convention (`python-build.md` § "wheel_smoke maintainer-gate"). |
+| **Docs / 5-surface parity** | concern → addressed | Provider list mention in `docs/{draft,grade}-ops.md` ("today only `anthropic` is registered" → "`anthropic`, `openai`, `gemini`" once #136 lands first); cost-guidance bullet about no-caching; `docs/cost-estimate-ops.md` mention of the Gemini estimate path; CONTRIBUTING line for `uv run pytest -m gemini --no-cov`; README provider list; **CHANGELOG entry under `[Unreleased]`**. `.claude/rules/llm-drafter.md` + `grade-layer.md` updates handled by orchestrator-only Patterns & Memory story (Ralph workers can't write `.claude/`, per memory). |
+| **Worker-writability** | pass | All shipped code + tests + `docs/` + `README` + `pyproject.toml` + `CHANGELOG.md` are worker-writable. The two `.claude/rules/` updates land in the orchestrator-handled P&M story. |
+
+No blockers. One concern (performance/cost) accepted with explicit docs; one concern (5-surface parity) addressed by the story split.
+
+## Refinement log (Phase 3 — decisions)
+
+- **DEC-001 — Per-vendor shim confinement (mirrors DEC-012 of #5 / DEC-004 of #135).**
+ `src/signalforge/llm/_gemini_client.py` is the sole module that imports
+ `google.genai` / `google.genai.errors`. Exposes `GeminiClientProtocol`
+ (`@runtime_checkable`, duck-typed at the surface `GeminiProvider` consumes),
+ `_make_gemini_client(api_key=None) -> GeminiClientProtocol`, and
+ `_load_gemini_exception_classes() -> _GeminiExceptionClasses` (lazy import in the
+ function body — same shape as `_load_anthropic_exception_classes`). Every
+ `# pyright: ignore[...]` and `# type: ignore[...]` for the Gemini SDK lives here.
+
+- **DEC-002 — SDK choice: `google-genai`.** The newer unified SDK (`from google import
+ genai`). The legacy `google-generativeai` is no longer actively maintained. Pinned
+ loosely as `google-genai>=0.5,<1` in `[gemini]` and `dev` until v1 stabilises;
+ bump bounds with each maintainer-driven SDK upgrade (mirrors the `snowflake-connector-python>=3,<4`
+ pattern).
+
+- **DEC-003 — Capability flags `False / False`.** `GeminiProvider.supports_prompt_caching
+ = False` and `supports_token_count = False`. Both branches in `call_llm` degrade exactly
+ as the `FakeNoCacheProvider` proves: no cache marker, no beta header, cache tokens
+ reported as 0, no dual-zero WARNING, no pre-send count gate, no `LLMCacheTooLargeError`
+ pre-send. `cache_marker_active` evaluates `False` regardless (both flags must be `True`
+ per the QG lesson in #135). `build_count_tokens_kwargs` raises `NotImplementedError`
+ with an explicit "unreachable when supports_token_count=False" message (matches
+ `FakeNoCacheProvider`).
+
+- **DEC-004 — Request shape: system_instruction + single user turn.** `build_create_kwargs`
+ maps `system` → `config.system_instruction`; concatenates `cached_block + "\n\n" +
+ dynamic_block` into one user-role `contents` entry. No cache control. Returned dict
+ follows the SDK's `models.generate_content(model=, contents=, config=)` call shape (the
+ shim's `GeminiClientProtocol.models.generate_content` consumes it).
+
+ > The orchestrator passes the dict via `client.messages.create(**kwargs)` today — for
+ > Gemini, the protocol surface `GeminiClientProtocol.messages.create` is the **shim's
+ > façade** over `client.models.generate_content`. The shim adapts the call shape so
+ > `call_llm` stays vendor-agnostic. See US-001 / US-002 for the precise façade.
+
+- **DEC-005 — Safety-filter / no-content → `LLMResponseFormatError`.** `extract_text_blocks`
+ inspects `response.candidates`. When no candidate yields a non-empty text part (blocked
+ by safety filter, recitation, length, or any other non-`STOP` finish reason that
+ produces no content), it raises
+ `LLMResponseFormatError(f"Gemini response produced no text (finish_reason={fr!r}).")`.
+ An `LLMError` subclass propagates out of `call_llm` (extraction runs AFTER the retry
+ loop), so the grade engine wraps it as `GradeLLMError` and degrades the pair with
+ `reasoning="call failed: GradeLLMError"`. The drafter path surfaces it directly to the
+ CLI's exit-code tier 2.
+
+ **Refusal / content-filter symmetry with OpenAI (cross-ref #136 DEC-014).** OpenAI
+ surfaces refusals as **model-generated text** (e.g. "I cannot help with that") which
+ the tolerant JSON parser routes through `GradeOutputError(violation_type="json_parse")`
+ → standard degrade — so #136 deliberately ships **no** safety-filter typed-degrade DEC.
+ Gemini is the opposite: structural blocks produce **no candidate / no text at all**, so
+ the same parser-degrade path can't catch them; the typed-error branch is load-bearing
+ here. The two providers' divergent refusal surfaces are why #136 doesn't need this DEC
+ and #137 does. If a future Gemini SDK release starts returning refusal-as-text in some
+ cases, the OpenAI parser-degrade path catches it automatically — keep the typed branch
+ for the structural case it uniquely handles.
+
+- **DEC-006 — Exception → `ExceptionCategory` taxonomy.** Loaded lazily in the shim:
+ - `google.genai.errors.ClientError` with `code == 401` or `403` → `AUTH`
+ - `google.genai.errors.ClientError` with `code == 429` → `RATE_LIMIT`
+ - `google.genai.errors.ServerError` (5xx family) → `SERVER_ERROR`
+ - Connection-flavoured: `httpx.ConnectError` / `httpx.TimeoutException` (or the SDK's
+ wrapped equivalent — verified against the real `google-genai` exception tree at
+ implementation) → `CONNECTION`
+ - Anything else → `NO_RETRY`
+
+ Mirrors `AnthropicProvider.classify_exception`. The retry-budget knobs
+ (`max_retries_429`, `max_retries_5xx`, `max_retries_conn`) on `GradeConfig`/`DraftConfig`
+ apply unchanged.
+
+- **DEC-007 — No provider/model coherence check.** `GradeConfig.model` and
+ `DraftConfig.model` stay free-form `str`. Document the recommended Gemini model id in
+ `docs/grade-ops.md` § Configuration and `docs/draft-ops.md` § Configuration. An
+ operator setting `provider: gemini` + `model: claude-sonnet-4-6` fails at the first
+ API call with a typed `LLMError` from the mapper — late, but not silently wrong.
+
+- **DEC-008 — API-key resolution via `_make_gemini_client(api_key=None)`.**
+ `genai.Client(api_key=api_key)`. When `api_key is None`, the SDK reads
+ `GOOGLE_API_KEY` (or `GEMINI_API_KEY`, depending on SDK version — verified at
+ implementation). Explicit `api_key=` overrides. No SignalForge-specific env var. Tests
+ that need a real key set `GOOGLE_API_KEY=...` and gate behind `SF_RUN_GEMINI=1`.
+
+- **DEC-009 — New AST confinement scan: `genai.Client(...)` only in `_gemini_client.py`.**
+ Extend `tests/test_audit_completeness.py` with a `_QualifiedNameCallFinder` mirror
+ matching `Call(func=Attribute(value=Name(id="genai"), attr="Client"))` (and the
+ three bypass patterns from `testing-signal.md`: bare via `from google.genai import
+ Client`, import-alias `from google.genai import Client as C`, attribute via
+ `from google import genai; genai.Client(...)`). The 7th-AST-scan helper already
+ generalises; reuse it. Sanity test asserts ≥1 construction in
+ `_gemini_client.py`. **Tally: #137 bumps 9 → 10** (#136 lands the 9th scan for
+ `openai.OpenAI(...)` first per DEC-019). The docstring "AST scans" tally in
+ `safety-layer.md` is the surface that needs updating to **10**; the scan-7
+ discovery count counts a different thing — per-stage `errors.py` modules — and is
+ unaffected.
+
+- **DEC-010 — `pyproject.toml` `[gemini]` extra + dual dev-group listing.** `google-genai`
+ appears in **three** places, in lockstep (Snowflake precedent):
+ - `[project.optional-dependencies].gemini = ["google-genai>=0.5,<1"]` (operator
+ install: `pip install signalforge-dbt[gemini]`).
+ - `[project.optional-dependencies].dev` (pip back-compat for dev install).
+ - `[dependency-groups].dev` (uv-native; CI uses this).
+
+ `uv.lock` refreshes in the same commit. Default install stays Gemini-free — the base
+ package depends only on `anthropic` (the default provider).
+
+- **DEC-011 — `tests/llm/_fake_gemini.py::FakeGeminiClient` with `expect_*` API.**
+ Mirrors `FakeAnthropicClient` shape: `expect_generate_content(matching, returns)`
+ (and `expect_messages_create` as the shim-façade alias the orchestrator actually
+ calls — the orchestrator hits `client.messages.create`; the shim adapts to
+ `client.models.generate_content` under the hood, so the fake's `.messages.create`
+ is the load-bearing entry point). Inspector property `create_calls` for assertion
+ on `extra_headers` (must be absent: no cache beta) and `cache_control` (must not
+ appear on any content block). `assert_all_expectations_met()` matches the
+ precedent. Supports queuing exceptions for the retry-loop tests.
+
+- **DEC-012 — `@pytest.mark.gemini` + `SF_RUN_GEMINI=1` + `GOOGLE_API_KEY` env-gate
+ for live tests.** Marker registered in `pyproject.toml`'s
+ `[tool.pytest.ini_options].markers`, added to the default `addopts` exclusion list
+ (`-m 'not ... and not gemini'`). Belt-and-suspenders: `_skip_reason()` helper that
+ surfaces a clear skip when env vars are missing under a maintainer `pytest -m gemini`
+ run. Three live tests (mirrors Snowflake): `tests/llm/test_gemini_live.py` (raw
+ `call_llm`), `tests/draft/test_gemini_draft_live.py` (drafter via `draft_schema`),
+ `tests/grade/test_gemini_grade_live.py` (grader via `grade_artifacts`). Marker-runs
+ use `--no-cov` (matches the `bigquery` / `cli_subprocess` / `wheel_smoke` /
+ `snowflake` precedent in `testing-signal.md`).
+
+- **DEC-013 — Cost guidance + 5-surface parity in docs.** Add a paragraph to
+ `docs/grade-ops.md` § "Cost guidance" and the equivalent section in
+ `docs/draft-ops.md`: "**Gemini (v0.3) ships without prompt caching.** Every call
+ transmits the full system + rubric (grade) / system + cached-block (draft); there is
+ no Anthropic-style discount on the cached prefix. For a default 4-criterion grade run
+ over a 12-column model (~48 calls), budget accordingly. Explicit Gemini context
+ caching is tracked as a follow-up." Update the "today only `anthropic` is registered"
+ text in both ops docs to "today `anthropic` and `gemini` are registered." Update
+ `README.md` provider list (if any) likewise.
+
+- **DEC-014 — Both drafter and grader covered.** US-005 ships `FakeGeminiClient`-driven
+ end-to-end tests for both `draft_schema` (via `tests/draft/test_gemini_neutrality.py`)
+ and `grade_artifacts` (via `tests/grade/test_gemini_neutrality.py`). US-006 ships the
+ corresponding live tests behind `@pytest.mark.gemini`. Drafter coverage is light by
+ design — the drafter has only one LLM call per model; the value is proving the request
+ shape + audit JSONL round-trip survive the shared seam, which the test does.
+
+- **DEC-015 — `_GeminiExceptionClasses` empty-tuple fallback when SDK absent.**
+ `_load_gemini_exception_classes()` returns a frozen dataclass with empty tuples for
+ every category when `import google.genai` raises `ImportError` (exact mirror of
+ `_load_anthropic_exception_classes`'s `pragma: no cover` branch). Lets
+ `classify_exception` route every exception to `NO_RETRY` cleanly under a base install
+ without the `[gemini]` extra — the operator just never gets `provider: gemini` to
+ resolve a real call, but import-time behaviour is graceful.
+
+- **DEC-016 — `GeminiProvider.estimate_input_tokens` via native `count_tokens`.**
+ google-genai exposes `client.models.count_tokens(model=, contents=)` as a real,
+ server-side token counter — no `tiktoken`-equivalent local-tokeniser dependency
+ needed (cleaner than #136's OpenAI path, which leans on `tiktoken` because OpenAI
+ has no first-party count endpoint on Chat Completions). The shim wraps the call;
+ `GeminiProvider.estimate_input_tokens(model, text)` delegates via the shim. Cost:
+ one extra API round-trip per `--estimate` call (identical in shape to Anthropic).
+ **Depends on the ABC method shipping with #136** (DEC-019).
+
+- **DEC-017 — 3 Gemini pricing SKUs + `PRICE_TABLE_VERSION` bump.** Add
+ `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash` to `_PRICES_MUTABLE` in
+ `src/signalforge/llm/pricing.py`. Each carries `input_per_mtok` + `output_per_mtok`
+ per Google's public price page at PR-prep time; cache fields = 0.0 (no Anthropic-
+ equivalent discount). Bump `PRICE_TABLE_VERSION` to the ship date. Mirrors #136
+ DEC-007 (which adds 4 OpenAI SKUs in the same dict). Additive — no Anthropic SKU
+ moves; Anthropic-config `--estimate` byte-identity unaffected.
+
+- **DEC-018 — Server-side JSON enforcement via `response_mime_type="application/json"`.**
+ `GeminiProvider.build_create_kwargs` constructs a `GenerateContentConfig` with
+ `response_mime_type="application/json"` (and `system_instruction=system`). Belt-and-
+ braces with the existing tolerant `extract_json_payload` parser (issue #144):
+ server-side enforcement eliminates the prose-preamble drift class, the parser
+ remains the fallback if a future model strips the flag. The grade system prompt
+ already names "JSON" so any prompt-requirement check passes. Mirrors #136 DEC-006
+ exactly — same defence, different vendor flag. Deliberately NOT setting
+ `response_schema` for v0.3: the parser is the canonical structural gate, and a full
+ Pydantic-derived schema adds surface for marginal benefit.
+
+- **DEC-019 — Sequence after #136 (implementation in progress; gate is mechanical).**
+ **#136 is being implemented first** — `feature/136-openai-grading` is the active
+ epic; #137 merges on top of it so the `LLMProvider.estimate_input_tokens` ABC
+ extension, the per-provider pricing pattern, the `--estimate` strategy refactor,
+ AND the 9th AST scan (`openai.OpenAI(...)`) are all in place when US-007 lands.
+ Rebase #137 on `dev` (or directly on `feature/136-openai-grading` if needed) after
+ #136 merges rather than racing edits on `providers.py` / `pricing.py` /
+ `cli/_estimate.py` / `tests/test_audit_completeness.py`.
+
+ **Mechanical gate (sentinel bead).** The cross-epic blocker is encoded in bd as
+ `bd_1-scaffolding-41a` ("#136 OpenAI grading PR #152 merged to dev"). US-007
+ (`.7`) `DEPENDS ON` the sentinel; `bd ready` does NOT surface US-007 until the
+ sentinel is closed. **Close the sentinel the moment #136 merges to `dev`** and
+ US-007 unblocks automatically. This avoids the historical pattern of relying on a
+ human to read DEC-019 before picking up a `bd ready` bead.
+
+## Story breakdown (Phase 4)
+
+Each story includes its trace to DECs, acceptance criteria, "Done when," files, and TDD
+notes. The canonical validation command is the project's:
+`uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`
+(per CLAUDE.md). Live `@pytest.mark.gemini` runs are maintainer-only post-merge and not
+part of the validation gate.
+
+### US-001 — `_gemini_client.py` shim + AST confinement scan extension
+
+**Traces to:** DEC-001, DEC-008, DEC-009, DEC-015.
+
+**Description.** Create the per-vendor shim that confines every `google.genai` import +
+SDK ignore. Add the **10th** AST audit-completeness scan asserting `genai.Client(...)`
+construction only happens here (#136 lands the 9th scan for OpenAI first, per DEC-019).
+
+**Files.**
+- `src/signalforge/llm/_gemini_client.py` (new): `GeminiClientProtocol` (with nested
+ `_GeminiModelsProtocol` for `models.generate_content` and the shim's `messages.create`
+ façade), `_make_gemini_client(api_key=None) -> GeminiClientProtocol`,
+ `_load_gemini_exception_classes() -> _GeminiExceptionClasses`,
+ `_GeminiExceptionClasses` frozen dataclass.
+- `tests/test_audit_completeness.py` (edit): add the 10th scan + the planted-violation
+ regression test covering bare / import-alias / module-attribute bypass patterns
+ (`testing-signal.md` § "AST single-construction-seam scans"). Bump the module
+ docstring's "AST scans" tally from 9 → 10.
+- `tests/llm/test_gemini_client_confinement.py` (new): asserts every
+ `google.genai`-typed import and `# pyright: ignore` for the SDK lives only in
+ `_gemini_client.py` (mirrors `tests/warehouse/test_snowflake_client_confinement.py`).
+
+**TDD.** Write the planted-violation tests first; write the scan; assert it fires; write
+the shim; assert it passes the confinement test.
+
+**Acceptance criteria.**
+- `from signalforge.llm._gemini_client import GeminiClientProtocol, _make_gemini_client`
+ works in a fresh `uv sync --dev` env (SDK installed via `dev` group).
+- `tests/test_audit_completeness.py` 10th scan rejects a planted
+ `genai.Client(...)` in any module other than `_gemini_client.py`.
+- `tests/llm/test_gemini_client_confinement.py` passes.
+- Canonical validation command passes.
+
+**Done when.** Shim file exists, AST scan fires on plant + passes on the real tree,
+confinement test passes, no `google.genai` symbol appears in `git grep` outside
+`_gemini_client.py` / `_load_gemini_exception_classes`.
+
+### US-002 — `GeminiProvider(LLMProvider)` + registration
+
+**Traces to:** DEC-001, DEC-003, DEC-004, DEC-005, DEC-006, DEC-013 (capability flags
+drive docs wording), DEC-018 (server-side JSON enforcement).
+
+**Description.** Implement the `LLMProvider` subclass and register it at module import.
+
+**Files.**
+- `src/signalforge/llm/providers.py` (edit): add `GeminiProvider` after
+ `AnthropicProvider`; call `register_provider(GeminiProvider())` at module scope
+ (line below the existing Anthropic registration). Implement all six abstract methods
+ + the two capability-flag class attrs. `build_create_kwargs` constructs the call
+ with `GenerateContentConfig(system_instruction=system, response_mime_type="application/json")`
+ per DEC-004 + DEC-018; cached_block + dynamic_block concatenated into one
+ user-role contents entry. No `cache_control` anywhere.
+- `src/signalforge/llm/__init__.py` (edit): re-export `GeminiProvider` alongside
+ `AnthropicProvider`.
+
+**TDD.** Tests under US-004 drive the behaviour; this story implements to pass them.
+Pure-logic methods (`build_create_kwargs`, `extract_text_blocks` including the
+safety-filter branch, `extract_usage`, `classify_exception`) get unit tests in US-004.
+
+**Acceptance criteria.**
+- `signalforge.llm.providers.provider_for("gemini")` returns a `GeminiProvider`
+ instance after `signalforge.llm` import.
+- `provider.supports_prompt_caching is False` and `provider.supports_token_count is False`.
+- `provider.build_count_tokens_kwargs(...)` raises `NotImplementedError` with the
+ unreachable-when-supports_token_count=False message (matches `FakeNoCacheProvider`).
+- `provider.build_create_kwargs(...)` returns a dict with no `cache_control` block
+ anywhere and no `extra_headers` key (capability-gated, DEC-008 of #135 / DEC-003 here).
+- The kwargs dict carries the JSON-mime config (asserted by inspecting the dict
+ string-representation OR the `config` arg's `response_mime_type` field; DEC-018).
+- Canonical validation command passes.
+
+**Done when.** Provider registered, all abstract methods implemented, capability flags
+both `False`, JSON-mime enforcement wired, `__init__` exports updated, US-004 tests green.
+
+### US-003 — `pyproject.toml` `[gemini]` extra + dev-group sync
+
+**Traces to:** DEC-010.
+
+**Description.** Wire the optional dependency in lockstep across all three locations;
+refresh the uv lock.
+
+**Files.**
+- `pyproject.toml` (edit): add `gemini = ["google-genai>=0.5,<1"]` under
+ `[project.optional-dependencies]`; append `"google-genai>=0.5,<1"` to BOTH
+ `[project.optional-dependencies].dev` and `[dependency-groups].dev`.
+- `uv.lock` (regenerated): `uv lock` commits the resolution.
+
+**TDD.** N/A (pure config). The validation gate `uv sync --dev` is the test.
+
+**Acceptance criteria.**
+- `pip install signalforge-dbt[gemini]` would resolve to `google-genai`. (Verified
+ locally by `uv pip install --dry-run -e ".[gemini]"`.)
+- `uv sync --dev` installs `google-genai`.
+- `uv.lock` round-trip is clean (no spurious churn).
+- Canonical validation command passes.
+
+**Done when.** Three pyproject entries land, uv.lock refreshed, `uv sync --dev` succeeds
+in a clean checkout.
+
+### US-004 — `FakeGeminiClient` + offline provider unit tests
+
+**Traces to:** DEC-005, DEC-006, DEC-011, DEC-015.
+
+**Description.** Hand-rolled fake mirroring `FakeAnthropicClient`'s `expect_*` API,
+plus the offline test suite for every `GeminiProvider` method including the safety-filter
+branch and the full exception-mapper taxonomy (against genuine
+`google.genai.errors.*` instances). Lazy SDK import inside each test (Snowflake `_sfe()`
+pattern from `warehouse-adapters.md` — avoids the full-suite `sys.modules` deletion
+gotcha).
+
+**Files.**
+- `tests/llm/_fake_gemini.py` (new): `FakeGeminiClient` + `FakeGeminiMessages`,
+ `expect_messages_create(matching, returns)`, `create_calls` inspector property,
+ `assert_all_expectations_met()`, support dataclasses (`FakeGeminiCandidate`,
+ `FakeGeminiContent`, `FakeGeminiPart`, `FakeGeminiUsageMetadata`, `FakeGeminiResponse`).
+- `tests/llm/test_gemini_provider.py` (new): unit tests for
+ `build_create_kwargs` (system_instruction shape, no cache_control, no extra_headers),
+ `extract_text_blocks` (happy path, safety-blocked → `LLMResponseFormatError`,
+ finish_reason quoted in message), `extract_usage` (cache fields zero), `make_client`
+ (calls `_make_gemini_client`), `classify_exception` for each ExceptionCategory.
+- `tests/llm/test_gemini_exception_mapping.py` (new): drives `classify_exception`
+ against genuine `google.genai.errors.*` instances; lazy import inside each test.
+
+**TDD.** Write the test list first (one assertion per `ExceptionCategory`, one per
+finish_reason branch, one per request-shape invariant); implement to pass.
+
+**Acceptance criteria.**
+- Every `ExceptionCategory` has at least one test mapping a real
+ `google.genai.errors.*` instance to it.
+- The safety-blocked test asserts the raised `LLMResponseFormatError`'s message names
+ the finish_reason verbatim (case-sensitive).
+- `build_create_kwargs` test asserts the kwargs dict has no `cache_control` substring
+ anywhere AND no `extra_headers` key.
+- `FakeGeminiClient.assert_all_expectations_met()` is invoked at the end of every test
+ that queued expectations.
+- Canonical validation command passes.
+
+**Done when.** Fake + three new test files exist, all asserting behaviour pinned by DECs.
+
+### US-005 — Provider-neutrality end-to-end tests (draft + grade)
+
+**Traces to:** DEC-011, DEC-014, DEC-003 (audit/sidecar round-trip with zero cache
+tokens), DEC-005 (safety-blocked → grade degrade).
+
+**Description.** Drive `draft_schema` AND `grade_artifacts` end-to-end with
+`provider="gemini"` using `FakeGeminiClient` injection. Mirrors
+`tests/grade/test_provider_neutrality.py` (the `FakeNoCacheProvider` proof) but with the
+real Gemini provider + request shape exercised. Asserts:
+
+- Audit JSONL records exist with `cache_creation_input_tokens == cache_read_input_tokens == 0`.
+- All blake2b-8 reproducibility hashes (rubric, prompt_version_template,
+ criterion_prompt_hash, response_text_hash, args_hash) are populated.
+- Drift detectors (`Strict(extra="forbid")` mirrors) accept the produced JSONL/sidecar.
+- No cache-anomaly WARNING surfaces (gated off by `supports_prompt_caching=False`).
+- A safety-blocked grade response degrades the pair to
+ `GradingResult(score=None, passed=False, reasoning="call failed: GradeLLMError")` —
+ not a crash, not a `GradeOutputError`.
+
+**Files.**
+- `tests/grade/test_gemini_neutrality.py` (new): grade end-to-end + safety-blocked degrade.
+- `tests/draft/test_gemini_neutrality.py` (new): drafter end-to-end (one schema draft
+ call via `draft_schema`, asserts `LLMResponseEvent` JSONL round-trip).
+
+**TDD.** Mirror `tests/grade/test_provider_neutrality.py` test names + `_isolate_registry`
+fixture; substitute `FakeGeminiClient` injection + real `GeminiProvider`.
+
+**Acceptance criteria.** Every assertion above passes. Canonical validation command
+passes.
+
+**Done when.** Both test files exist; each test asserts the listed invariants; runs
+green in `uv run pytest`.
+
+### US-006 — Gemini pricing SKUs in `pricing.py`
+
+**Traces to:** DEC-017.
+
+**Description.** Add three Gemini SKUs to `_PRICES_MUTABLE` in `pricing.py`; bump
+`PRICE_TABLE_VERSION`. Mirrors #136 US-004's OpenAI-SKU additions; parallel-safe.
+
+**Files.**
+- `src/signalforge/llm/pricing.py` (edit): extend `_PRICES_MUTABLE` with `gemini-2.5-pro`,
+ `gemini-2.5-flash`, `gemini-2.0-flash`. Each carries `input_per_mtok` +
+ `output_per_mtok` (USD per 1M tokens) from Google's public price page at PR-prep
+ time; `cache_write_5m_per_mtok = 0.0`; `cache_read_per_mtok = 0.0`. Bump
+ `PRICE_TABLE_VERSION` to the ship date (e.g. `"2026-05-27"`).
+- `tests/llm/test_pricing.py` (edit): assert `lookup("gemini-2.5-pro")`,
+ `lookup("gemini-2.5-flash")`, `lookup("gemini-2.0-flash")` each return a non-zero
+ `input_per_mtok` + `output_per_mtok` and zero cache fields. Assert
+ `lookup("gemini-9-unicorn")` still raises `EstimateUnknownModelError`. Assert the
+ three existing Anthropic SKUs round-trip unchanged.
+
+**TDD.** Pricing-lookup tests first (red); add SKU entries (green); pin the version bump.
+
+**Acceptance criteria.**
+- All three Gemini SKUs resolve via `lookup()` with non-zero input/output rates and
+ zero cache rates.
+- `PRICE_TABLE_VERSION` bumped (asserted via byte-equal string match against the new
+ ship date).
+- Unknown Gemini model still raises `EstimateUnknownModelError`.
+- The three Anthropic SKUs are byte-identical (their `ModelPricing` instances unchanged).
+
+**Done when.** Pricing extended, tests green, validation passes.
+
+**Depends on:** none (parallel-safe with US-001 … US-005).
+
+### US-007 — `GeminiProvider.estimate_input_tokens` + `--estimate` integration
+
+**Traces to:** DEC-016, DEC-017, DEC-019.
+
+**Description.** Implement Gemini's side of the `LLMProvider.estimate_input_tokens`
+ABC method shipped by #136. Uses google-genai's native `client.models.count_tokens`
+(no `tiktoken`-equivalent local dep) — cleaner story than OpenAI's path because
+Gemini has a first-party count endpoint. Pin a fake-driven `--estimate` test that
+drives the path end-to-end.
+
+**Files.**
+- `src/signalforge/llm/_gemini_client.py` (edit): add a thin wrapper around
+ `client.models.count_tokens(model=, contents=)` (e.g. `_count_gemini_tokens(client,
+ model, text) -> int`) — confined to the shim per DEC-001.
+- `src/signalforge/llm/providers.py` (edit): implement
+ `GeminiProvider.estimate_input_tokens(model, text) -> int` delegating to the shim
+ helper. The orchestrator-side strategy threading lives in #136's US-005.
+- `tests/llm/test_gemini_provider.py` (edit): add a test driving
+ `GeminiProvider.estimate_input_tokens` against `FakeGeminiClient` queued with a
+ `count_tokens` response (extend `FakeGeminiClient` from US-004 with
+ `expect_count_tokens(matching, returns)` if not already covered).
+- `tests/cli/test_estimate.py` (edit): add a fake-driven test running
+ `signalforge generate --estimate` with `grade.provider: gemini` + `grade.model:
+ gemini-2.5-flash` (and the drafter-side equivalent), asserting the report renders
+ with non-zero token counts + non-zero USD figures. Mirrors #136 US-005's OpenAI
+ fake-driven estimate test.
+
+**TDD.** Provider unit test first (`GeminiProvider.estimate_input_tokens` returns
+the count_tokens response's `total_tokens` field). Then the CLI integration test
+driving `--estimate` end-to-end. Anthropic byte-identity is #136's gate (this story
+inherits the snapshot test landed there).
+
+**Acceptance criteria.**
+- `GeminiProvider.estimate_input_tokens("gemini-2.5-flash", "hello world")` returns
+ a positive int (against a `FakeGeminiClient` queued with the expected response).
+- `signalforge generate --estimate` with `grade.provider: gemini` produces an
+ `EstimateReport` with non-zero grader token counts and non-zero USD figures.
+- Anthropic byte-identity snapshot (from #136) remains green — no estimate-path
+ regression from the Gemini wiring.
+
+**Done when.** Above ACs pass; validation green; no `--cov-fail-under` regression.
+
+**Depends on:** US-002 (provider class exists), US-006 (pricing SKUs exist), and
+**#136 merged** (provides the ABC extension + the cli/_estimate.py strategy
+refactor). If #136 is not yet merged at devolve time, this story is blocked.
+
+### US-008 — Live tests + CONTRIBUTING update
+
+**Traces to:** DEC-012, DEC-016.
+
+**Description.** Maintainer-gated live tests against the real Gemini API. Registers
+`gemini` marker; threads `_skip_reason()` env-var gate. Covers `call_llm`, `draft_schema`,
+`grade_artifacts`, AND `--estimate` (the last one is the live counterpart to US-007's
+fake-driven CLI test).
+
+**Files.**
+- `pyproject.toml` (edit): register `gemini` marker; add `and not gemini` to default
+ `addopts`.
+- `tests/llm/test_gemini_live.py` (new): one `@pytest.mark.gemini` test calling
+ `call_llm(provider="gemini", ...)` directly; asserts non-empty `text_blocks`,
+ `cache_*_input_tokens == 0`, `input_tokens > 0`.
+- `tests/draft/test_gemini_draft_live.py` (new): `@pytest.mark.gemini` `draft_schema`
+ against a small in-test manifest fixture; asserts `CandidateSchema` validates +
+ `LLMResponseEvent` JSONL written.
+- `tests/grade/test_gemini_grade_live.py` (new): `@pytest.mark.gemini` `grade_artifacts`
+ against a 1-criterion rubric over a 1-artifact candidate; asserts one
+ `GradingResult` with `score is not None` and `aggregate_complete is True`.
+- `tests/cli/test_e2e_estimate_gemini.py` (new): `@pytest.mark.gemini`
+ `signalforge generate --estimate ...` with `grade.provider: gemini` + `grade.model:
+ gemini-2.5-flash`; asserts the rendered report includes non-zero grader USD
+ estimate, exit code 0, no traceback. Mirrors #136 US-006's OpenAI live-estimate test.
+- `CONTRIBUTING.md` (edit): add `uv run pytest -m gemini --no-cov` to the maintainer
+ marker-run list, alongside the existing `snowflake` / `anthropic` / `openai` lines.
+ Note required env vars: `SF_RUN_GEMINI=1 GOOGLE_API_KEY=...`.
+
+**TDD.** Live tests are integration smokes; structure assertions are deliberately
+modest (no LLM-output-byte assertions; engineered determinism via 1-criterion rubric +
+the same `not_null`-on-clean-column trick `testing-signal.md` § "Engineered determinism"
+documents is not needed here — we're proving the wire, not the output quality).
+
+**Acceptance criteria.**
+- Default `pytest` does NOT collect `@pytest.mark.gemini` tests.
+- `pytest -m gemini --no-cov` with no env vars surfaces four clear `pytest.skip(reason)`
+ outputs (one per test) naming the missing var.
+- Each live test, with env vars set, exits 0 against the real API.
+
+**Done when.** Marker registered + excluded; four live tests exist with the env-gate;
+CONTRIBUTING line landed.
+
+### US-009 — Operator-facing docs + CHANGELOG
+
+**Traces to:** DEC-007 (recommended model id), DEC-010 (`[gemini]` install), DEC-013
+(cost guidance + provider list), DEC-016/017 (estimate path + pricing).
+
+**Description.** Worker-writable docs only. `.claude/rules/*` updates live in P&M
+(orchestrator-only, per `skill-parity.md` + memory). Update the operator surface:
+
+**Files.**
+- `docs/grade-ops.md` (edit):
+ - In `signalforge.yml` `grade:` block example, add a comment showing `provider: gemini`
+ + recommended model id alternative (`gemini-2.5-flash` for judge work).
+ - Update the registered-providers wording to enumerate `anthropic`, `openai` (from
+ #136), and `gemini`.
+ - Add a "Gemini cost note (v0.3)" paragraph to § Cost guidance with the DEC-013 text.
+ - Note the `[gemini]` install (`pip install signalforge-dbt[gemini]`).
+- `docs/draft-ops.md` (edit): equivalent updates for the drafter `llm:` block (provider
+ list, install hint, cost note).
+- `docs/cost-estimate-ops.md` (edit, or create if absent following #136 US-007): name
+ Gemini's `client.models.count_tokens` as the estimate-path source — no
+ `tiktoken`-equivalent local dep, one extra round-trip per estimate call. Reference
+ the three Gemini SKUs in the pricing table.
+- `README.md` (edit): if the README lists supported providers, add Gemini.
+- `CHANGELOG.md` (edit): under `[Unreleased]` "Added": `Gemini as a grading + drafting
+ provider (#137). Set grade.provider: gemini or llm.provider: gemini in
+ signalforge.yml; requires the [gemini] install extra and GOOGLE_API_KEY. v0.3 ships
+ without prompt caching; explicit Gemini context caching is a follow-up.`
+
+**TDD.** N/A (docs).
+
+**Acceptance criteria.** Each ops doc names `gemini` as a registered provider AND ships
+a cost-guidance paragraph naming the no-caching deferral. The install hint appears in
+both ops docs. CHANGELOG carries the new entry under `[Unreleased]`. Canonical
+validation command passes (the docs gate runs `mkdocs build` on PR via the `docs-build`
+job per `docs-publishing.md`).
+
+**Done when.** Docs edits land, CHANGELOG entry landed, mkdocs build is clean.
+
+### Quality Gate
+
+Run `/code-review` (or equivalent) **4 times** across the full changeset, fixing every
+real bug found each pass. Run CodeRabbit on the PR. Canonical validation command must
+pass after every round of fixes. **Additionally:**
+
+- `uv run pytest -m anthropic --no-cov` passes (proves Anthropic byte-identity in the
+ `--estimate` strategy refactor end-to-end — inherits #136's snapshot).
+- `uv run pytest -m gemini --no-cov` passes locally with credentials (live smoke).
+- `uv run pytest -m wheel_smoke --no-cov` passes (new `[gemini]` extra doesn't break
+ wheel build — per `python-build.md` § "wheel_smoke maintainer-gate"; the test asserts
+ the canonical demo file set still appears under the expected wheel path with the new
+ optional-dep declaration).
+- Coverage stays at or above the current threshold.
+
+**Depends on:** US-001 … US-009.
+
+### Patterns & Memory (orchestrator-only)
+
+**Files.**
+- `.claude/rules/llm-drafter.md` (edit):
+ - Update "Provider-neutral seam — generic orchestrator + per-provider strategy (#135)"
+ section to note Gemini as the **third** concrete provider (OpenAI #136 + Gemini #137)
+ and the `_gemini_client.py` confinement; bump "AST audit-completeness scans" from
+ five to six (#136 adds the 9th scan; #137 adds the 10th — both surface as new
+ AST-scan items in the rule file's tally).
+ - Add a paragraph: **"v0.3 Gemini ships no-cache."** Both capability flags `False`;
+ request shape collapses `system + cached_block + dynamic_block` into
+ `system_instruction + single user turn`; safety-filter no-content responses surface
+ as `LLMResponseFormatError` → grade degrade. Explicit Gemini context caching is a
+ follow-up.
+- `.claude/rules/grade-layer.md` (edit): one sentence in the degrade taxonomy noting
+ Gemini safety-filter responses route through the same `GradeLLMError` degrade as
+ Anthropic retry-exhaustion — the contract is provider-neutral.
+
+**Done when.** Both rule files updated, the lesson is durably captured for #136 (OpenAI)
+to copy this plan and substitute the vendor.
+
+## Worker-writability routing
+
+Per `~/.claude/projects/-home-wesd-Projects-SignalForge/memory/ralph-worker-claude-dir-perms.md`: **Ralph workers cannot Write under `.claude/` in worktrees** — only the orchestrator can. The story split honours this:
+
+- US-001 through US-008 + US-009 (docs + CHANGELOG) + Quality Gate touch only worker-writable paths (`src/`, `tests/`, `pyproject.toml`, `docs/`, `CHANGELOG.md`, `README.md`, `uv.lock`, `CONTRIBUTING.md`).
+- **Patterns & Memory is orchestrator-only** because it edits `.claude/rules/llm-drafter.md` and `.claude/rules/grade-layer.md`. The beads-manifest line already labels the task "(orchestrator: …)"; if a worker is dispatched against P&M the bead fails with a write-denied error — route it to the orchestrator at devolve time.
+
+The OpenAI plan (#136) codifies the same routing; mirroring it here so the rule lands durably for both #136 and #137. Future provider plans (#138+ if a fourth vendor ever ships) should copy this section verbatim.
+
+## Open notes for implementation
+
+- **Verify the exact `google.genai.errors` exception class names + status-code attrs
+ against the installed SDK version at implementation time.** DEC-006's mapping is the
+ shape; the precise SDK-class names (`ClientError` vs `APIError`, status-code attr name
+ `code` vs `status_code`) may need a tiny adjustment. The offline exception-mapper test
+ drives this — write the test against the installed SDK, then implement to pass.
+- **`response_mime_type="application/json"` requires NO keyword in the prompt.** Unlike
+ OpenAI's `response_format={"type":"json_object"}` (which fails server-side unless
+ "json" appears in the prompt — see #136's Open notes), Gemini's structured-output
+ enforcement is purely a request flag. Don't add a defensive "JSON" sentence to the
+ grade or draft system prompts on Gemini's behalf — the existing prompts already name
+ JSON for the OpenAI path, and that's exclusively an OpenAI requirement. If a future
+ refactor splits the system prompts per-provider, this is the only Gemini-specific
+ prompt note to preserve.
+- **`pricing.lookup` returns zero cache fields for the three Gemini SKUs.** US-006:
+ assert this in `tests/llm/test_pricing.py`. `cli/_estimate.py` cache-cost math (the
+ `cache_write_5m_per_mtok` / `cache_read_per_mtok` multiplications) should produce 0.0
+ contributions without raising — verify the multiplication path doesn't break on a zero
+ `cache_write_5m_per_mtok`. Mirrors the symmetric verification item from #136.
+- **`messages.create` façade in `GeminiClientProtocol`.** The orchestrator calls
+ `llm_client.messages.create(**kwargs)`. The shim adapts this to
+ `client.models.generate_content(...)` internally — the protocol exposes
+ `.messages.create` so the orchestrator stays vendor-neutral. The fake mirrors the
+ façade. Confirm during US-001 that this is the cleanest adaptation; if the protocol
+ needs an extra method (e.g. count_tokens, even though we never call it), add it as
+ `NotImplementedError` stub for protocol-completeness.
+- **`extract_text_blocks` finish-reason enumeration.** Google's `FinishReason` enum
+ ships values like `STOP`, `MAX_TOKENS`, `SAFETY`, `RECITATION`, `OTHER`,
+ `MALFORMED_FUNCTION_CALL`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `SPII`. Treat anything
+ other than `STOP` (or `STOP` with empty text) as the no-content branch — the message
+ quotes the reason verbatim so the operator sees exactly which filter fired.
+- **No new `WarehouseError`-style sub-hierarchy.** Reuse `LLMResponseFormatError` /
+ `LLMHelperError` / `LLMAuthError` / etc. The taxonomy is provider-neutral by design.
+ No new entries in the `_EXCEPTION_TO_EXIT_CODE` table (per `cli-layer.md` § 7th AST
+ scan).
+- **The 10th AST scan re-uses `_QualifiedNameCallFinder`.** Don't roll a new visitor;
+ the existing helper handles all three bypass patterns (`testing-signal.md` § "AST
+ single-construction-seam scans must catch all three bypass patterns"). #136's 9th
+ scan (`openai.OpenAI(...)`) is the precedent — copy its shape.
+- **`count_tokens` response field name — verify against installed `google-genai` SDK.**
+ DEC-016: `GeminiProvider.estimate_input_tokens` reads the count from the
+ `models.count_tokens(...)` response. The field is `.total_tokens` on current SDK
+ versions; pin a unit test under US-007 that drives `FakeGeminiClient` with a queued
+ count_tokens response and asserts the extraction works. If the SDK renames the field
+ (e.g. `.total_token_count`), the test fails loud at implementation time — adjust
+ the shim, not the plan. Parallel to #136's tiktoken-fallback note.
+- **Anthropic byte-identity snapshot is owned by #136.** US-007 inherits the snapshot
+ test at `tests/cli/test_estimate.py` that #136 lands. Do NOT re-capture or move the
+ snapshot during #137 implementation — if it changes when wiring Gemini's
+ `estimate_input_tokens`, the wiring is wrong (a Gemini estimate path must not move
+ Anthropic estimate bytes).
diff --git a/plans/super/141-claude-skill-install.md b/plans/super/141-claude-skill-install.md
new file mode 100644
index 00000000..223b664d
--- /dev/null
+++ b/plans/super/141-claude-skill-install.md
@@ -0,0 +1,753 @@
+# 141: SignalForge Claude Code skill + `install-skill` command
+
+## Meta
+
+- **Ticket:** [GH #141](https://github.com/wjduenow/SignalForge/issues/141)
+- **Branch:** `feature/141-claude-skill-install`
+- **Worktree:** `../worktrees/SignalForge/141-claude-skill-install`
+- **Phase:** implemented
+- **PR:** [#166](https://github.com/wjduenow/SignalForge/pull/166)
+- **Epic:** `bd_1-scaffolding-ezn`
+- **Sessions:**
+ - 2026-05-29 — Phase 1 discovery (parallel research, 4 scoping decisions locked)
+ - 2026-05-29 — Phase 2 architecture review (no blockers, 2 concerns surfaced)
+ - 2026-05-29 — Phase 3 refinement (24 DECs locked), Phase 4 detailing (11 stories), Phase 5 published as draft PR #166
+ - 2026-05-30 — Phase 6 approved, Phase 7 devolved to beads (epic `bd_1-scaffolding-ezn`, 11 tasks)
+ - 2026-05-30 — Ralph run end-to-end: all 11 stories landed (US-001 → US-011); QG (US-010) fixed 8 review findings including a stale-rebase __version__ downgrade, a SKILL.md `--force` line that didn't exist on the CLI (caught by Reviews 3+4 independently), and a symlinked-ancestor-dir bypass; gate extended with a flag-validity scan; PR #166 marked ready for review
+
+## Beads manifest
+
+- **Epic:** `bd_1-scaffolding-ezn` — "141: SignalForge skill + install-skill"
+- **Tasks:**
+ - `bd_1-scaffolding-ezn.1` — US-001 — Bootstrap skills tree + wheel packaging (no deps; READY)
+ - `bd_1-scaffolding-ezn.2` — US-002 — Public `signalforge.skill` lib + errors (deps: .1)
+ - `bd_1-scaffolding-ezn.3` — US-003 — CLI `install-skill` subcommand (deps: .2)
+ - `bd_1-scaffolding-ezn.4` — US-004 — SKILL ↔ CLI parity gate (deps: .1, .3, .7)
+ - `bd_1-scaffolding-ezn.5` — US-005 — 5-surface parity for install-skill (deps: .3, .6)
+ - `bd_1-scaffolding-ezn.6` — US-006 — Docs (skills.md + nav + cli-ops + README) (deps: .3)
+ - `bd_1-scaffolding-ezn.7` — US-007 — Author SKILL.md prose (deps: .1, .3)
+ - `bd_1-scaffolding-ezn.8` — US-008 — Clauditor self-grade + README badge (deps: .7)
+ - `bd_1-scaffolding-ezn.9` — US-009 — skill-parity.md + cli-layer.md update [ORCHESTRATOR-ONLY] (deps: .3, .4)
+ - `bd_1-scaffolding-ezn.10` — Quality Gate — code-review ×4 + CodeRabbit (deps: .1…9)
+ - `bd_1-scaffolding-ezn.11` — Patterns & Memory (deps: .10)
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/141-claude-skill-install`
+- **Branch:** `feature/141-claude-skill-install`
+
+## Ticket summary
+
+Ship a user-facing **Claude Code skill** for SignalForge, bundled in the wheel, plus an
+**install command** that drops it into a target project's `.claude/skills/` — mirroring
+clauditor's `src/clauditor/skills/` + `clauditor setup` pattern. The skill teaches Claude
+how to drive the `signalforge` CLI against a user's dbt project so adoption is "install the
+package, run the skill," not "read the docs and assemble commands by hand."
+
+**AC-1** `pip install signalforge-dbt` then `signalforge install-skill` drops a working
+`SKILL.md` into `.claude/skills/signalforge/`, and a fresh Claude Code session activates it
+on a relevant prompt.
+
+**AC-2** `wheel_smoke` asserts the skill ships in the wheel.
+
+**AC-3** Install command honours the four-tier exit codes + no-traceback floor; registered
+in the exit-code AST scan.
+
+**AC-4** README + docs link the skill; `mkdocs build` stays clean.
+
+**AC-5** On request, the skill runs the zero-credential `init-demo` → `generate` demo
+end-to-end; the live `pytest -m e2e` path is gated behind explicit user confirmation +
+env-var checks (clean skip when unset, cost warning before running).
+
+**AC-6** The skill's CLI surface is enforced by a parity gate running inside the canonical
+`VALIDATE_CMD` (`uv run pytest`): adding/changing a subcommand or demo command without
+updating `SKILL.md` fails the test — so `/ralph-run` keeps the skill current automatically,
+without relying on the model remembering.
+
+## Discovery
+
+### Codebase findings (key seams)
+
+- **CLI subcommand template — `init-demo` is the closest precedent.** Both `add_parser` and
+ `cmd_init_demo` live in `src/signalforge/cli/init_demo.py`; registered from
+ `src/signalforge/cli/__init__.py:84`. The new module is `src/signalforge/cli/install_skill.py`
+ following the same shape verbatim.
+- **Library-surface wrap pattern.** `signalforge.demo.copy_demo(dest, *, force=False) -> Path`
+ is the public lib; `Demo*Error` hierarchy lives in `signalforge.demo.errors`. The CLI handler
+ wraps lib errors into `CliInitDemo*Error` (`src/signalforge/cli/errors.py`). New
+ `signalforge.skill` subpackage mirrors this: `install_skill(dest, *, force) -> Path` + a
+ `Skill*Error` hierarchy + CLI-side `CliInstallSkill*Error` wrappers.
+- **Exit-code registry.** `_EXCEPTION_TO_EXIT_CODE` in `src/signalforge/cli/_helpers.py` is
+ the single source of truth. `init-demo`'s registrations (DemoPathError → 1,
+ DemoDestExistsError → 2, CliInitDemoFixtureMissingError → 1, etc.) are the template.
+- **Wheel packaging.** `[tool.hatch.build.targets.wheel]` in `pyproject.toml` carries
+ `packages = ["src/signalforge"]` + `include = ["src/signalforge/_demo"]`. Add a sibling
+ `include` entry for the skills tree. (See decision on path name below.)
+- **`wheel_smoke` precedent.** `tests/test_wheel_packaging.py::_EXPECTED_DEMO_FILES` is a
+ 7-file tuple asserted present in the built `.whl`. The skills equivalent asserts
+ `SKILL.md` + `SKILL.eval.json` (if grading) + any `assets/` files appear under the
+ expected wheel path.
+- **AST audit-completeness scan #7.** Already a depth-1∪depth-2 glob over
+ `src/signalforge/*/errors.py`; new `signalforge/skill/errors.py` lands automatically.
+ Update the count test (`test_scan_7_discovers_every_per_stage_errors_module` — bump
+ 12 → 13) AND add `SkillError` to `_EXCEPTION_MAPPING_EXCLUDED_BASES` if the hierarchy
+ spans tiers 1+2 (mirrors `DemoError`/`IngestError`).
+- **5-surface parity test precedent.** `tests/cli/test_5_surface_parity_init_demo.py`
+ pins canonical tokens (subcommand name, key flags) across argparse help / handler docstring
+ / `docs/cli-ops.md` / plan / test name. The skill ticket needs the same shape for the
+ `install-skill` flags.
+- **Skill ↔ CLI parity gate (NEW, separate test).** Distinct from the 5-surface parity gate:
+ this one parses the live argparse subparser registry from `signalforge.cli._build_parser()`
+ and asserts every registered subcommand name + the demo-flow commands appear verbatim in
+ `src/signalforge/skills/signalforge/SKILL.md`. Mirrors the mechanical surface-scan idea.
+- **Subprocess smoke pattern.** `tests/cli/test_subprocess_smoke.py` runs `signalforge
+ install-skill --help` under `@pytest.mark.cli_subprocess` (default-deselected); asserts
+ `returncode == 0`, subcommand-unique tokens in stdout, no traceback on stderr.
+- **MkDocs nav.** `mkdocs.yml` has a flat `nav:` with a "CLI Reference: cli-ops.md" + a
+ "Pipeline Stages" subsection. A "Claude Code Skill: skills.md" entry at the top level
+ (after CLI Reference) fits naturally.
+- **README quick-start.** `README.md:78-100` has `## Quick start` → `Install` subsection.
+ The skill pointer fits as a follow-up sentence after `pip install`.
+
+### Agent-skills spec (what the SKILL.md must contain)
+
+From `.claude/skills/review-agentskills-spec/SKILL.md` + the `release-manager` example:
+
+- **Frontmatter:** `name` (matches parent dir), `description` (activation triggers — "use
+ when X, Y, or Z"), `compatibility` (hard requirements: dbt project, manifest.json present,
+ optional warehouse profile + API keys), `disable-model-invocation` (omit; this skill
+ reasons about the diff), `allowed-tools` (scoped Bash patterns).
+- **`allowed-tools` scope** (zero-credential default; live e2e gated behind confirmation):
+ - `Bash(signalforge *)` — every CLI invocation
+ - `Bash(uv run signalforge *)` — uv-run variant
+ - `Bash(cat *)`, `Bash(ls *)`, `Bash(grep *)` — inspecting fixtures + diff output
+ - `Read`, `Write`, `Edit` — for the user's dbt project files only
+ - (Conditional, behind explicit user opt-in) `Bash(uv run pytest -m e2e*)` for the live
+ smoke; the skill body forces a confirmation gate before invoking
+- **Body shape:** `# /signalforge — drafts and prunes dbt tests with an LLM` + numbered
+ workflow sections covering (1) point at a dbt project; (2) zero-cred demo via
+ `init-demo` → `generate`; (3) `prune-existing` for tests the user already has; (4)
+ reading the kept/kept-uncertain/dropped/flagged diff + per-artifact "why"; (5) safety
+ posture (schema-only default; sample is opt-in); (6) optional live e2e (gated).
+
+### Convention/rule constraints (filtered)
+
+Most-load-bearing per `.claude/rules/`:
+
+- **`cli-layer.md`:** `add_parser`/`cmd_install_skill`; four-tier exit codes (0/1/2/3);
+ library-surface wrap pattern (lib seam + thin CLI handler); typed-error registration in
+ `_EXCEPTION_TO_EXIT_CODE`; no-traceback floor; single-boundary `try/except Exception`;
+ path canonicalisation at orchestrator via `canonicalise_user_path(raw, project_dir)` (with
+ caveat: install-skill has NO project_dir requirement — the user runs it before they have
+ one configured, like `init-demo`); subprocess `--help` smoke under `cli_subprocess`;
+ 5-surface parity for new flags.
+- **`python-build.md`:** Explicit `include = ["src/signalforge/skills"]`; `wheel_smoke` test
+ pins the skill file set (dotfile-inclusion fragility noted — SKILL.md is not a dotfile so
+ this is straightforward, but `assets/` recursion needs verification in the smoke test).
+- **`docs-publishing.md`:** New `docs/skills.md` requires a `nav:` entry in `mkdocs.yml` in
+ the same commit. No new docs deps.
+- **`testing-signal.md`:** No `assert True`-shaped tests; strict markers (already set); AST
+ source-scan gates if any new "must-call X" gate is added; marker-gated subprocess pattern.
+- **`manifest-readers.md`:** Three symlink/containment traps apply to install-skill's
+ destination-path validation.
+- **`skill-parity.md` (anticipatory rule — file NOT YET on disk, lives in CLAUDE.md context
+ only).** Specifies the parity gate contract: skill lives at
+ `src/signalforge/skills/signalforge/SKILL.md` (worker-writable, NEVER `.claude/`); gate
+ parses live CLI subparser registry; runs inside `VALIDATE_CMD`. The rule file is part of
+ this ticket — Deliverable 7 ("parity-surface rule entry") ships it.
+- **`safety-layer.md`:** NOT APPLICABLE. install-skill is a deterministic file copy with no
+ LLM/warehouse/audit seam.
+
+## Scoping decisions (Phase 1 close)
+
+- **S-1 Destination policy:** Always overwrite SKILL.md (+ the files we ship); preserve any
+ sibling files the user has added under `.claude/skills/signalforge/`. **No `--force` flag**
+ in v0.1. Friendlier for upgrade-in-place ("re-run install-skill, get the new SKILL.md").
+ We still refuse if SKILL.md *itself* is a symlink — writing follows the link, which is
+ the same defence init-demo's `copy_demo` already implements for `--force`-against-symlink.
+- **S-2 e2e demo paths:** Both. Zero-credential `init-demo` → `generate` is the always-on
+ default. Live `pytest -m e2e` is opt-in — the skill body forces an explicit user
+ confirmation, checks `SF_RUN_BQ` / `GOOGLE_CLOUD_PROJECT` / `ANTHROPIC_API_KEY`, and warns
+ about warehouse + LLM cost before invoking. `allowed-tools` scopes the live path
+ conditionally.
+- **S-3 Self-grade badge:** Include in v0.1. Run `clauditor grade` against the SKILL.md,
+ pin the score in `assets/SKILL.eval.json` (sibling of SKILL.md), and surface a shields.io
+ badge from the README. (The CI-vs-local-pinning question is Phase 3 refinement.)
+- **S-4 Skill src path:** `src/signalforge/skills/signalforge/SKILL.md` — plural `skills/`
+ parent allows a future sibling skill (e.g., `skills/signalforge-grade/`) without
+ restructuring; matches the install destination shape exactly; matches the anticipatory
+ `skill-parity.md` rule verbatim.
+
+## Architecture review
+
+| Area | Rating | Findings |
+|------|--------|----------|
+| **Security** | pass | Mirror `signalforge.demo.copy_demo`'s symlink-cycle trap (`resolve(strict=True)` first, fall back to `strict=False` on `FileNotFoundError`/`NotADirectoryError`, catch both `RuntimeError` (≤3.12) and `OSError(errno.ELOOP)` (≥3.13)). Per S-1 we never `rmtree`, so the `--force`-against-symlink-dest hazard collapses; we still refuse to overwrite if `/.claude/skills/signalforge/SKILL.md` is a symlink (writing follows the link). Path canonicalisation rolled inline like `copy_demo` (NOT via `canonicalise_user_path`, which requires a project_dir — install-skill is the second "creates the project context" entry point alongside `init-demo`, and its module docstring will document this verbatim, citing the `copy_demo` precedent). |
+| **API design** | concern | Default `` is `.` (CWD), so the install path becomes `/.claude/skills/signalforge/SKILL.md` — operator runs the command from the dbt project root. Mirrors `init-demo`'s `./signalforge-demo/` ergonomics. Lib seam: `install_skill(dest: Path \| str = ".") -> Path` returns the absolute SKILL.md path. **Concern:** if `/.claude/skills/signalforge/` exists with an unmodelled file alongside SKILL.md, do we report what we preserved? Lock the answer in Phase 3. |
+| **Packaging / wheel_smoke** | pass | `include = ["src/signalforge/skills"]` ships the tree recursively (confirmed by the `_demo` precedent — every nested file lands in the wheel without additional globs). `wheel_smoke` extends with a sibling `_EXPECTED_SKILL_FILES` tuple naming SKILL.md + SKILL.eval.json (+ any v0.1 assets). Dotfile-fragility note in `python-build.md` doesn't apply (SKILL.md is not a dotfile). |
+| **Observability** | pass | One INFO log line at success: `{"installed": "", "preserved_siblings": [...]}` (lazy-format JSON; raw paths are user-owned, no PII concerns). No DEBUG/WARNING/audit JSONL — install-skill is a deterministic file copy. |
+| **Testing strategy** | pass | (1) lib seam unit tests (`tests/skill/test_install.py`) — happy / overwrite-existing / preserve-siblings / SkillDestUnsafeError / SkillPackageDataMissingError. (2) CLI handler tests (`tests/cli/test_install_skill.py`) — main(argv) paths exercising each exit code. (3) Subprocess `--help` smoke under `cli_subprocess` marker. (4) `wheel_smoke` extension. (5) Skill ↔ CLI parity gate (NEW — scope locked in Phase 3). (6) 5-surface parity for `install-skill` itself (no flags in v0.1, so canonical tokens reduce to the subcommand name). |
+| **Docs** | pass | New `docs/skills.md` catalog + `mkdocs.yml` nav entry (one line under "CLI Reference"). README "Quick start" gains one sentence after `pip install signalforge-dbt` pointing at `signalforge install-skill`. The shields.io self-grade badge surfaces at the README top per `clauditor`'s precedent. |
+| **AST scan #7 (typed-error registry)** | pass | New `signalforge/skill/errors.py` is the **13th** per-stage `errors.py` (current count: 12). Bump `test_scan_7_discovers_every_per_stage_errors_module` count + add `SkillError` to `_EXCEPTION_MAPPING_EXCLUDED_BASES` (its concretes will span tier 1 + tier 2, mirroring `DemoError`/`IngestError`). |
+| **Worker-writability** | pass | All shipped artefacts land under worker-writable paths: SKILL.md + assets under `src/signalforge/skills/`; parity gate under `tests/`; rule file under `.claude/rules/skill-parity.md` (orchestrator-only edit). Per `ralph-worker-claude-dir-perms.md` memory, the orchestrator (not a worker) makes the one `.claude/rules/` edit. |
+| **Worktree / branch** | pass | Worktree created at `/home/wesd/Projects/worktrees/SignalForge/141-claude-skill-install` on `feature/141-claude-skill-install` off `dev`. |
+
+No blockers. Two concerns surface as Phase 3 refinement questions: (1) clauditor self-grade
+operating model (CI vs pinned-at-release), (2) Skill ↔ CLI parity gate token scope.
+
+## Refinement log
+
+### Decisions
+
+- **DEC-001 — Skill source path.** Package-data tree at
+ `src/signalforge/skills/signalforge/SKILL.md` (plural `skills/` parent allows future
+ sibling skills; matches `.claude/skills//SKILL.md` install destination shape;
+ matches the anticipatory `skill-parity.md` rule verbatim). NO `__init__.py` under
+ `skills/` or `skills/signalforge/` — the directory is package-data, NOT a Python
+ package. Mirrors `src/signalforge/_demo/` exactly.
+
+- **DEC-002 — Python lib subpackage name.** The runtime code lives at
+ `src/signalforge/skill/` (singular) — a real Python package with `__init__.py`,
+ `errors.py`, and the public `install_skill(...)` function. Singular name mirrors
+ `signalforge.demo`; the package-data tree's plural name is the install destination
+ convention, not the lib name. Two distinct paths, one each side of the seam.
+
+- **DEC-003 — Destination policy.** `install_skill(dest, *, ...)` always overwrites
+ every file SignalForge ships (SKILL.md + SKILL.eval.json + any `assets/*` we
+ enumerate from the bundled tree); never touches any other file in the dest dir. No
+ `--force` flag in v0.1. Friendlier for upgrade-in-place; eliminates the
+ `--force`-against-symlink-dest hazard that `copy_demo` defends against because we
+ never `rmtree`.
+
+- **DEC-004 — Default destination.** Positional `` defaults to `"."` (CWD). The
+ effective install path is `/.claude/skills/signalforge/SKILL.md`. Mirrors
+ `init-demo`'s default-to-CWD ergonomics. The operator runs from the dbt project root.
+
+- **DEC-005 — Symlink defence (mirror `copy_demo` verbatim).** `install_skill` resolves
+ `` via `.resolve(strict=True)` first; falls back to `.resolve(strict=False)` on
+ `FileNotFoundError` / `NotADirectoryError` (common — dest dir may not exist yet);
+ catches `RuntimeError` (Python ≤3.12) AND `OSError(errno.ELOOP)` (Python ≥3.13) on
+ cycle detection. Wraps cycle failures as `SkillDestPathError` (tier 1). Additionally:
+ if `/.claude/skills/signalforge/SKILL.md` exists AND is a symlink, raise
+ `SkillDestUnsafeError` (tier 2) — writing would follow the link to an arbitrary
+ destination.
+
+- **DEC-006 — Path canonicalisation lives in the lib, not via `canonicalise_user_path`.**
+ `canonicalise_user_path` enforces a `project_dir` containment boundary. `install-skill`
+ is the second "creates the project context" entry point (alongside `init-demo`) where
+ no project_dir applies. The lib seam rolls its own resolution mirroring
+ `signalforge.demo.copy_demo`; the module docstring documents the precedent verbatim.
+
+- **DEC-007 — Package-data lookup.** Mirror `copy_demo` verbatim:
+ `files("signalforge").joinpath("skills").joinpath("signalforge")` wrapped in
+ `as_file(...)` for zipapp/zipimport safety. Failure to find the bundled tree raises
+ `SkillPackageDataMissingError` (tier 1) — signals a corrupted install.
+
+- **DEC-008 — Error hierarchy.**
+ - Lib (`signalforge.skill.errors`):
+ - `SkillError(Exception)` — abstract base; `extra="forbid"` is N/A (not a Pydantic
+ model); `__str__` renders `message` + optional `↳ Remediation:` line per
+ `manifest-readers.md` § "Errors carry remediation."
+ - `SkillDestPathError(SkillError)` — tier 1; symlink cycle / containment failure.
+ - `SkillDestUnsafeError(SkillError)` — tier 2; dest is a file (not dir), SKILL.md is
+ a symlink, dest permission denied at write time.
+ - `SkillPackageDataMissingError(SkillError)` — tier 1; bundled SKILL.md absent.
+ - CLI (`signalforge.cli.errors`):
+ - `CliInstallSkillPathError(CliError)` — tier 1; wraps `SkillDestPathError`.
+ - `CliInstallSkillDestUnsafeError(CliError)` — tier 2; wraps `SkillDestUnsafeError`.
+ - `CliInstallSkillPackageDataMissingError(CliError)` — tier 1; wraps
+ `SkillPackageDataMissingError`.
+ - **Concretes span tiers 1 + 2**, so `SkillError` joins `DemoError` / `IngestError`
+ pattern: register only in `_EXCEPTION_MAPPING_EXCLUDED_BASES`, never in the
+ `_EXCEPTION_TO_EXIT_CODE` table.
+
+- **DEC-009 — AST scan #7.** Bump
+ `test_scan_7_discovers_every_per_stage_errors_module` count 12 → 13 in lockstep
+ with `signalforge/skill/errors.py` landing. Add `SkillError` to
+ `_EXCEPTION_MAPPING_EXCLUDED_BASES` (frozenset). Register every concrete CLI wrapper
+ (`CliInstallSkillPathError` / `CliInstallSkillDestUnsafeError` /
+ `CliInstallSkillPackageDataMissingError`) AND every lib concrete (`SkillDestPathError`
+ / `SkillDestUnsafeError` / `SkillPackageDataMissingError`) in
+ `_EXCEPTION_TO_EXIT_CODE` (defence-in-depth: both layers in the table even though MRO
+ walk would resolve the lib raise via the CLI wrapper).
+
+- **DEC-010 — Wheel packaging.** Extend `[tool.hatch.build.targets.wheel].include` to
+ `["src/signalforge/_demo", "src/signalforge/skills"]`. The directory-level include
+ is transitive — every nested file (SKILL.md, SKILL.eval.json, assets/*) ships
+ recursively. Confirmed by the `_demo` precedent (recursively ships nested
+ `models/staging/*.sql`, `target/*.json`).
+
+- **DEC-011 — `wheel_smoke` extension.** Add `_EXPECTED_SKILL_FILES` tuple alongside
+ `_EXPECTED_DEMO_FILES` in `tests/test_wheel_packaging.py`. v0.1 set:
+ `("signalforge/skills/signalforge/SKILL.md",
+ "signalforge/skills/signalforge/assets/SKILL.eval.json")`. Run via
+ `uv run pytest -m wheel_smoke --no-cov`. Also add a NEGATIVE assertion: no
+ `.claude/skills/*` paths appear in the built wheel (defence against accidentally
+ including maintainer-only `release-manager` / `review-agentskills-spec` — they live
+ at repo-root `.claude/skills/`, outside `src/`, so they're already excluded, but
+ the negative assertion documents intent).
+
+- **DEC-012 — e2e demo paths (both, with live gated).** Zero-credential default:
+ `signalforge init-demo /tmp/signalforge-demo` → `signalforge generate
+ models/staging/stg_bikeshare_trips.sql --write` (schema-only mode by default) →
+ walk through the kept / kept-uncertain / dropped / flagged diff. Live e2e (opt-in):
+ the skill body forces an explicit user confirmation ("This will run paid LLM +
+ warehouse queries — proceed?"), checks `SF_RUN_BQ`, `GOOGLE_CLOUD_PROJECT`,
+ `ANTHROPIC_API_KEY` (clean skip-with-reason when absent), then invokes
+ `uv run pytest -m e2e --no-cov`. Cost warning before invocation.
+
+- **DEC-013 — `allowed-tools` scope.** Comma-separated:
+ `Bash(signalforge *), Bash(uv run signalforge *), Bash(uv run pytest -m e2e*),
+ Bash(cat *), Bash(ls *), Bash(grep *), Bash(head *), Bash(tail *),
+ Read, Write, Edit`. The `pytest -m e2e*` scope is required for the live-gated path
+ per DEC-012; the skill body's confirmation gate is the user-facing defence.
+
+- **DEC-014 — Self-grade operating model.** Pre-release manual run.
+ Maintainer runs `clauditor grade src/signalforge/skills/signalforge/SKILL.md` locally
+ before tagging a release; captures the score; pins it in
+ `src/signalforge/skills/signalforge/assets/SKILL.eval.json`. README badge surfaces the
+ pinned score via shields.io. Same commit updates SKILL.md + eval.json + README
+ badge. No CI integration, no Anthropic key in repo secrets, no per-PR cost. Adds
+ `clauditor` to `[dependency-groups].dev` if not already present.
+
+- **DEC-015 — Parity gate scope.** New test
+ `tests/cli/test_skill_cli_parity.py` scans for three categories of tokens, all of which
+ must appear verbatim in `src/signalforge/skills/signalforge/SKILL.md`:
+ 1. Every subcommand name from the live argparse parser (auto-grows). Source:
+ `signalforge.cli._build_parser()` → walk `parser._subparsers._group_actions[0].choices`.
+ Current v0.2 set: `generate`, `lint`, `prune-existing`, `init-demo`, `install-skill`,
+ `version`.
+ 2. The four canonical demo command lines: `signalforge init-demo`,
+ `signalforge generate --write`, `signalforge prune-existing --schema
+ `, `signalforge install-skill`. Plain substring match — no regex, no whitespace
+ normalisation (mirrors envelope-breach guard pattern from `business-rule-tests.md`).
+ 3. The install-skill bootstrap line itself (`signalforge install-skill`).
+ The gate is mechanical, not semantic — semantic freshness lives in the clauditor self-grade.
+
+- **DEC-016 — Parity gate is a NEW test, not an extension of 5-surface parity.** The
+ 5-surface parity tests in `tests/cli/test_5_surface_parity_*.py` pin canonical tokens for
+ ONE subcommand across five surfaces (help/docstring/ops/plan/test). The skill parity
+ gate scans the FULL CLI surface against ONE skill body. Different shape, different
+ failure modes; keeping them as separate tests preserves the locality of each gate's
+ failure message.
+
+- **DEC-017 — Overwrite UX.** Single INFO line on success:
+ `Installed SignalForge skill to `. When an existing SKILL.md was overwritten,
+ append `(replaced existing SKILL.md)`. No diff, no backup file. The operator can
+ `git diff` if they had the file under version control. Lazy-format JSON; not via
+ `_LOGGER` (the CLI writes to stdout for success messages, stderr for errors).
+
+- **DEC-018 — `cli-layer.md` parity-surface entry.** Add a paragraph under the
+ "Multi-surface parity for behaviour changes" section noting that the bundled skill is
+ the Nth parity surface — a change to the CLI subcommand/flag surface updates
+ `src/signalforge/skills/signalforge/SKILL.md` in the same commit, and the
+ `tests/cli/test_skill_cli_parity.py` gate enforces it. Adds a "6th surface" entry to
+ the list (currently: help/docstring/ops/test/DEC).
+
+- **DEC-019 — `skill-parity.md` rule file.** The orchestrator (NOT a worker) writes
+ `.claude/rules/skill-parity.md` in this PR per the
+ `ralph-worker-claude-dir-perms.md` memory — workers cannot Write under `.claude/` in
+ worktrees. The content is the contract written verbatim in DEC-013…DEC-018 above plus
+ a pointer back to this plan + cli-layer.md.
+
+- **DEC-020 — SKILL.md frontmatter.**
+ ```yaml
+ ---
+ name: signalforge
+ description: Use when the user wants to draft, prune, or grade dbt tests / docs with an LLM, has a dbt project (manifest.json + sql models), or asks about SignalForge. Drives the `signalforge` CLI end-to-end: drafts candidate tests, runs them against warehouse samples, drops the noise, and explains every kept/dropped artifact.
+ compatibility: "Requires: signalforge installed (pip install signalforge-dbt). For the zero-credential demo: no warehouse needed. For real dbt projects: dbt-core + a populated manifest.json. For live e2e: a configured warehouse profile (BigQuery v0.1) + ANTHROPIC_API_KEY."
+ metadata:
+ signalforge-version: "0.X.Y"
+ allowed-tools: Bash(signalforge *), Bash(uv run signalforge *), Bash(uv run pytest -m e2e*), Bash(cat *), Bash(ls *), Bash(grep *), Bash(head *), Bash(tail *), Read, Write, Edit
+ ---
+ ```
+ No `disable-model-invocation` — the skill reasons about the per-artifact "why" output
+ to help the operator interpret the diff. `signalforge-version` is updated by the
+ release-manager skill in lockstep with the wheel version.
+
+- **DEC-021 — SKILL.md body sections.** Numbered workflow:
+ 1. **Point at a dbt project** — verify `manifest.json` exists, name a model.
+ 2. **Zero-credential demo** — `init-demo` → `generate --write` walkthrough.
+ 3. **Real project: draft + prune** — `generate --write` with the safety
+ posture (schema-only default; `--mode sample` is opt-in; document the cost).
+ 4. **Grade tests you already have** — `prune-existing --schema `.
+ 5. **Reading the diff** — kept / kept-uncertain / dropped / flagged tiers + the
+ per-artifact "why" cascade.
+ 6. **Optional: live e2e demonstration** — gated behind explicit user confirmation,
+ env-var checks, cost warning.
+ 7. **Troubleshooting** — common errors (`ModelNotFoundError`, `WarehouseAuthError`,
+ `LLMCacheTooLargeError`) with one-line fixes; pointer to `docs/cli-ops.md`.
+
+- **DEC-022 — Maintainer-only skill exclusion.** `release-manager` and
+ `review-agentskills-spec` live at repo-root `.claude/skills/`, which is outside `src/`
+ — they're never in the wheel by construction. install-skill enumerates from
+ `files("signalforge").joinpath("skills")` (the package-data tree only), so there's
+ no code path that could install them. The wheel_smoke negative assertion (DEC-011)
+ documents this intent.
+
+- **DEC-023 — Docs entry.** New `docs/skills.md` page describing the bundled skill +
+ install command + the two demo paths (zero-cred and live-gated). `mkdocs.yml` `nav:`
+ gains `- Claude Code Skill: skills.md` under "CLI Reference". README "Quick start"
+ gets a one-sentence pointer after the `pip install` block. The README self-grade
+ badge surfaces the clauditor score (DEC-014).
+
+- **DEC-024 — 5-surface parity for the `install-skill` subcommand itself.** Canonical
+ tokens (v0.1, no flags): `"install-skill"`. The test mirrors
+ `test_5_surface_parity_init_demo.py` shape across (1) argparse help, (2) handler
+ docstring, (3) `docs/cli-ops.md`, (4) this plan, (5) test docstring. The
+ SKILL ↔ CLI parity gate (DEC-015) is orthogonal — that one scans the *full* CLI
+ surface against SKILL.md; this one pins one subcommand across five surfaces.
+
+### Session notes
+
+- 2026-05-29 — Phase 1 discovery: parallel research locked the four scoping decisions
+ (dest policy, e2e paths, self-grade inclusion, src path); architecture review pass
+ surfaced two refinement concerns (self-grade ops, parity gate scope, overwrite UX);
+ Phase 3 closed all 24 decisions. Plan now at `detailing` phase, ready for story
+ generation.
+
+## Detailed breakdown
+
+The 11 stories below follow the natural architecture order: package-data + wheel
+packaging → public lib seam → CLI handler → enforcement gates → docs/grade → rules
+ledger → quality gate → memory.
+
+**Acceptance check repeated for every story:**
+`uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`
+(the canonical `VALIDATE_CMD`).
+
+---
+
+### US-001 — Bootstrap `src/signalforge/skills/signalforge/` tree + wheel packaging
+
+Lay down the package-data skeleton (empty-but-shaped SKILL.md + SKILL.eval.json
+placeholder under `assets/`), wire wheel packaging, and extend `wheel_smoke` to gate
+the file set. Content of SKILL.md stays a placeholder (`# SignalForge skill — draft`)
+until US-007 fills it in; this story owns the *shape*.
+
+**Traces to:** DEC-001, DEC-010, DEC-011, DEC-022.
+
+**Files:**
+- `src/signalforge/skills/signalforge/SKILL.md` — placeholder body; full content lands
+ in US-007.
+- `src/signalforge/skills/signalforge/assets/SKILL.eval.json` — placeholder JSON
+ (`{"score": null, "version": "0.0.0", "graded_at": null}`); pinned in US-008.
+- `pyproject.toml` — extend `[tool.hatch.build.targets.wheel].include` to
+ `["src/signalforge/_demo", "src/signalforge/skills"]`.
+- `tests/test_wheel_packaging.py` — add `_EXPECTED_SKILL_FILES` tuple + assertion;
+ add negative assertion that no `.claude/skills/*` paths appear in the wheel.
+
+**Done when:** `uv build && unzip -l dist/*.whl | grep signalforge/skills/` shows
+SKILL.md + assets/SKILL.eval.json; `uv run pytest -m wheel_smoke --no-cov` passes
+including the negative `.claude/skills/*` assertion; full `VALIDATE_CMD` passes.
+
+**TDD:** Not pure TDD — the wheel_smoke test IS the test for this story. Write the
+expected file tuple + negative assertion FIRST (red), then update pyproject.toml
+include + create the placeholder files (green).
+
+**Depends on:** none.
+
+---
+
+### US-002 — Public `signalforge.skill` lib module + typed errors
+
+Create the `signalforge.skill` Python package with `install_skill(dest) -> Path` and
+the four-class typed-error hierarchy. Mirror `copy_demo`'s symlink/cycle defence
+verbatim; mirror its `importlib.resources` lookup; never `rmtree`. AST scan #7 picks
+up the new `errors.py` automatically (depth-1 glob).
+
+**Traces to:** DEC-002, DEC-003, DEC-005, DEC-006, DEC-007, DEC-008, DEC-009.
+
+**Files:**
+- `src/signalforge/skill/__init__.py` — exports `install_skill`, the three lib errors,
+ and `SkillError` base. `__all__` is the public contract.
+- `src/signalforge/skill/errors.py` — `SkillError` base + three concretes.
+- `tests/skill/test_install.py` — unit tests (see TDD below).
+- `tests/test_audit_completeness.py` — bump `test_scan_7_discovers_every_per_stage_errors_module`
+ count 12 → 13; add `SkillError` to `_EXCEPTION_MAPPING_EXCLUDED_BASES`.
+
+**Done when:** `install_skill(tmp_path)` returns the absolute SKILL.md path under
+`/.claude/skills/signalforge/`; preserves any sibling files; symlink-cycle
+dest raises `SkillDestPathError`; symlinked-SKILL.md dest raises
+`SkillDestUnsafeError`; patched-away source raises `SkillPackageDataMissingError`;
+AST scan #7 passes; full `VALIDATE_CMD` passes.
+
+**TDD:** Write these tests FIRST:
+1. `test_install_skill_to_fresh_dir_writes_skill_md` — happy path; assert returned
+ path is absolute and exists.
+2. `test_install_skill_overwrites_existing_skill_md_unchanged_otherwise` — pre-create
+ `.claude/skills/signalforge/SKILL.md` with `"OLD"` + a sibling `notes.txt`;
+ `install_skill` returns; assert SKILL.md content changed AND notes.txt untouched.
+3. `test_install_skill_refuses_when_skill_md_is_symlink` — pre-create the dest
+ tree with SKILL.md as a symlink; assert `SkillDestUnsafeError`.
+4. `test_install_skill_with_cyclic_symlink_dest_raises_dest_path_error` — create
+ a symlink cycle as dest; assert `SkillDestPathError`.
+5. `test_install_skill_missing_package_data_raises` — monkeypatch
+ `importlib.resources.files` to return a non-dir; assert
+ `SkillPackageDataMissingError`.
+6. `test_install_skill_dest_is_file_raises_unsafe` — pass an existing regular file
+ as dest; assert `SkillDestUnsafeError`.
+
+**Depends on:** US-001 (placeholder SKILL.md must exist in the source tree).
+
+---
+
+### US-003 — CLI `install-skill` subcommand + handler + exit-code mapping + subprocess smoke
+
+Wire the subcommand into the argparse registry; add the three `CliInstallSkill*Error`
+wrappers; register every typed error in `_EXCEPTION_TO_EXIT_CODE`; ship the
+subprocess `--help` smoke under `cli_subprocess`.
+
+**Traces to:** DEC-002, DEC-003, DEC-004, DEC-008, DEC-009, DEC-017, DEC-024.
+
+**Files:**
+- `src/signalforge/cli/install_skill.py` — `add_parser(subparsers)` + `cmd_install_skill(args) -> int`.
+- `src/signalforge/cli/__init__.py` — register via
+ `install_skill_cmd.add_parser(subparsers)` in `_build_parser()`.
+- `src/signalforge/cli/errors.py` — three `CliInstallSkill*Error` wrapper classes.
+- `src/signalforge/cli/_helpers.py` — register six new entries in
+ `_EXCEPTION_TO_EXIT_CODE` (three lib + three CLI wrappers per DEC-009).
+- `tests/cli/test_install_skill.py` — main([…]) tests for each exit-code path; assert
+ no traceback on stderr.
+- `tests/cli/test_subprocess_smoke.py` — add `test_signalforge_install_skill_help_via_subprocess`
+ under `@pytest.mark.cli_subprocess`.
+
+**Done when:**
+- `signalforge install-skill ` returns 0, writes file, INFO line on stdout per
+ DEC-017.
+- `signalforge install-skill ` returns 2, prints
+ `ERROR: ` + remediation, no traceback.
+- `signalforge install-skill ` returns 1, no traceback.
+- `uv run pytest -m cli_subprocess --no-cov` passes the new `--help` smoke.
+- Full `VALIDATE_CMD` passes.
+
+**TDD:** Write these tests FIRST:
+1. `test_install_skill_success_returns_zero_writes_file_prints_info` — happy path.
+2. `test_install_skill_overwrite_appends_replaced_notice` — pre-create old SKILL.md;
+ assert stdout contains `(replaced existing SKILL.md)` per DEC-017.
+3. `test_install_skill_dest_is_file_returns_two_no_traceback` — tier 2.
+4. `test_install_skill_dest_with_symlink_cycle_returns_one_no_traceback` — tier 1.
+5. `test_install_skill_missing_package_data_returns_one_no_traceback` —
+ monkeypatched.
+6. `test_install_skill_default_dest_is_cwd` — `chdir(tmp_path)`, run
+ `main(["install-skill"])`, assert file lands at `tmp_path/.claude/skills/signalforge/SKILL.md`.
+
+**Depends on:** US-002.
+
+---
+
+### US-004 — SKILL ↔ CLI parity gate
+
+The mechanical enforcement test that closes the
+"forgot-to-update-SKILL.md-when-changing-the-CLI" loop. Lives under `tests/` so
+workers can update it.
+
+**Traces to:** DEC-015, DEC-016, DEC-019.
+
+**Files:**
+- `tests/cli/test_skill_cli_parity.py` — NEW test file.
+
+**Done when:**
+- Test reads `src/signalforge/skills/signalforge/SKILL.md` once.
+- Walks `signalforge.cli._build_parser()._subparsers._group_actions[0].choices` to
+ enumerate every registered subcommand; asserts each name appears as a substring of
+ the SKILL.md body.
+- Asserts the four canonical demo command lines (per DEC-015) appear verbatim.
+- Asserts the install-skill bootstrap line (`signalforge install-skill`) appears.
+- Failure prints which subcommand / demo command / bootstrap line was missing.
+- Planted-violation self-check: a separate test inside the same file edits a copy of
+ SKILL.md in `tmp_path` to remove `"generate"`, asserts the gate raises
+ `AssertionError` — proves the gate can fail.
+
+**TDD:** Write the planted-violation self-check FIRST (it's a red test for a gate
+that doesn't exist yet → write the gate to make it green).
+
+**Depends on:** US-001 (SKILL.md placeholder), US-003 (install-skill subcommand
+registered). The SKILL.md placeholder from US-001 needs to be expanded enough to
+contain the canonical tokens this test scans for — coordinated with US-007 which
+writes the prose; US-004 may temporarily fail until US-007 lands. Sequence US-004 to
+either land AFTER US-007 or to be merged together; document dependency.
+
+---
+
+### US-005 — 5-surface parity test for `install-skill`
+
+Mirror `test_5_surface_parity_init_demo.py` for the new subcommand. v0.1 canonical
+tokens: `"install-skill"` (no flags yet, so the surface is minimal).
+
+**Traces to:** DEC-024.
+
+**Files:**
+- `tests/cli/test_5_surface_parity_install_skill.py` — NEW test mirroring the
+ `init_demo` precedent.
+
+**Done when:** test asserts `"install-skill"` appears in all five surfaces: (1)
+argparse help (rendered from `add_parser`), (2) `cmd_install_skill` docstring, (3)
+`docs/cli-ops.md` § Subcommands, (4) this plan
+(`plans/super/141-claude-skill-install.md`), (5) the test docstring itself. Failure
+names which surface lacks the token.
+
+**Depends on:** US-003 (subcommand exists), US-007 (`docs/cli-ops.md` updated). Mirror
+US-004's coordination — may need to land alongside US-007.
+
+---
+
+### US-006 — Docs: `docs/skills.md`, `mkdocs.yml` nav, `docs/cli-ops.md`, README pointer
+
+Single docs story covering all four surfaces. Authoritative content for the skill
+catalog page; updates README quick-start with the one-line pointer; extends
+`docs/cli-ops.md` with the `install-skill` subcommand entry (Flag reference / Exit
+codes / Stderr shapes).
+
+**Traces to:** DEC-021, DEC-023.
+
+**Files:**
+- `docs/skills.md` — NEW. Describes the bundled skill, what it teaches, the install
+ command, and both demo paths (zero-cred + live-gated). Pointer to clauditor
+ self-grade.
+- `mkdocs.yml` — add `- Claude Code Skill: skills.md` under "CLI Reference".
+- `docs/cli-ops.md` — add `install-skill` entry to Subcommands section; map to exit
+ codes; show stderr shapes for each tier-2/1 error.
+- `README.md` — one-sentence pointer after `pip install signalforge-dbt`:
+ `Run \`signalforge install-skill\` to drop the Claude Code skill into your project.`
+
+**Done when:** `uv run --only-group docs mkdocs build` is clean; new nav entry
+renders; README quick-start shows the pointer; `docs/cli-ops.md` § install-skill
+matches the actual handler help text.
+
+**Depends on:** US-003 (subcommand exists so help text + cli-ops entry can be
+generated against the real handler).
+
+---
+
+### US-007 — Author the SKILL.md prose (the actual user-facing workflow)
+
+Fill in the placeholder from US-001 with the real workflow per DEC-020 (frontmatter)
++ DEC-021 (body sections). This is the prose-heavy story; expect iteration with the
+clauditor self-grade in US-008.
+
+**Traces to:** DEC-012, DEC-013, DEC-020, DEC-021.
+
+**Files:**
+- `src/signalforge/skills/signalforge/SKILL.md` — replace placeholder with full body.
+
+**Done when:**
+- Frontmatter matches DEC-020 verbatim.
+- All seven body sections from DEC-021 present.
+- Both demo paths (zero-cred + live-gated) include the exact CLI invocations.
+- Live-gated section enforces the user confirmation + env-var check + cost warning.
+- SKILL ↔ CLI parity gate (US-004) passes against the new content.
+- 5-surface parity (US-005) passes.
+
+**Depends on:** US-001 (placeholder exists), US-003 (install-skill subcommand
+registered so SKILL.md can reference it accurately).
+
+---
+
+### US-008 — Clauditor self-grade + README badge
+
+Add `clauditor` to dev-deps if absent; run grading; pin the score in
+`assets/SKILL.eval.json`; surface the shields.io badge on the README.
+
+**Traces to:** DEC-014.
+
+**Files:**
+- `pyproject.toml` — add `clauditor` to `[dependency-groups].dev` if not present.
+- `src/signalforge/skills/signalforge/assets/SKILL.eval.json` — replace placeholder
+ with real graded JSON.
+- `README.md` — add shields.io badge near the top (alongside any existing
+ badges).
+- `docs/skills.md` — add a "Self-grade" subsection pointing at the pinned score and
+ the regeneration command.
+
+**Done when:** `clauditor grade src/signalforge/skills/signalforge/SKILL.md` runs
+clean against the SKILL.md from US-007; the JSON has a numeric `score`, a non-null
+`graded_at` ISO-8601 UTC timestamp, and a `signalforge-version` matching
+`signalforge.__version__`; README badge URL points at the pinned score; full
+`VALIDATE_CMD` passes.
+
+**Depends on:** US-007 (SKILL.md prose stable). Run AFTER US-007 lands so the score
+reflects the real content.
+
+---
+
+### US-009 — Skill-parity rule file + cli-layer.md update (orchestrator)
+
+The rule files under `.claude/rules/` are orthogonal to worker-writable code per
+`ralph-worker-claude-dir-perms.md` memory — the orchestrator (this conversation OR
+the maintainer in a closing PR commit) writes them, not a Ralph worker. Worker
+implementations of US-001…US-008 reference these rules; this story lands them
+durably.
+
+**Traces to:** DEC-018, DEC-019.
+
+**Files:**
+- `.claude/rules/skill-parity.md` — NEW; written by the orchestrator.
+- `.claude/rules/cli-layer.md` — add a paragraph under "Multi-surface parity for
+ behaviour changes" naming the bundled skill as a parity surface; cross-link to
+ skill-parity.md.
+
+**Done when:** both files present, lint-clean, cross-referenced; ralph workers can
+read them. No test gates this directly (rule files are read by humans + the model);
+absence is caught at code-review time.
+
+**Depends on:** US-003, US-004 (the contracts these rules document must exist).
+
+---
+
+### US-010 — Quality Gate
+
+Run `code-review` x4 across the full diff; address each pass's findings; run
+CodeRabbit if available; ensure `VALIDATE_CMD` is green; gated marker runs
+(`wheel_smoke`, `cli_subprocess`) clean.
+
+**Traces to:** ALL prior decisions.
+
+**Done when:** four code-review passes complete with all real findings resolved;
+CodeRabbit review posted + addressed; `VALIDATE_CMD` green; `uv run pytest -m
+wheel_smoke --no-cov` green; `uv run pytest -m cli_subprocess --no-cov` green.
+
+**Depends on:** US-001 … US-009.
+
+---
+
+### US-011 — Patterns & Memory
+
+Capture durable lessons from this work. Likely additions:
+- "Skill-shaped lib seam mirrors init-demo verbatim" — pattern for any future "ship a
+ user-facing artifact into the user's project" subcommand.
+- "Two-name convention: `skills/` (plural) for the package-data tree matching the
+ install destination; `skill/` (singular) for the Python lib module matching
+ `signalforge.demo`."
+- "Parity gate over prompt — the model can't be relied on to update SKILL.md from
+ context; the pytest gate is the durable enforcement."
+- Memory file under `~/.claude/projects/-home-wesd-Projects-SignalForge/memory/`
+ + MEMORY.md pointer per the harness memory protocol.
+
+**Traces to:** Lessons learned from US-001 … US-010.
+
+**Done when:** new memory files written; MEMORY.md updated with one-line pointers;
+`.claude/rules/` changes (if any) reviewed.
+
+**Depends on:** US-010.
+
+## Risks & non-goals
+
+**Non-goals:**
+- No CI integration for clauditor grading (manual pre-release per DEC-014).
+- No multi-skill install (v0.1 ships exactly one skill; the `skills/` plural parent
+ anticipates v0.2+).
+- No `--force` flag (per DEC-003).
+- No `.bak` file on overwrite (per DEC-017).
+- No diff-on-overwrite output (per DEC-017).
+
+**Risks:**
+- **R-1: SKILL.md prose churn drives badge churn.** Every SKILL.md edit triggers a
+ new clauditor grade + eval.json + README badge update (3-file commit). Mitigation:
+ group SKILL.md edits into PRs where possible; document the regen command in
+ `docs/skills.md`.
+- **R-2: SKILL ↔ CLI parity gate false negatives.** A subcommand could be added with
+ a name that's also a common English word (e.g. if someone adds a `signalforge run`)
+ — the substring scan would pass even if the SKILL.md doesn't actually teach the
+ command. Acceptable for v0.1; the clauditor self-grade catches semantic gaps.
+- **R-3: Anticipatory rule file (skill-parity.md) drift.** The rule file references
+ contracts that other rules also reference. If we update one and forget the other,
+ the rules drift. Mitigation: keep skill-parity.md short and link out to
+ cli-layer.md / python-build.md rather than restating their contracts.
diff --git a/plans/super/155-gemini-truncation-e2e-gap.md b/plans/super/155-gemini-truncation-e2e-gap.md
new file mode 100644
index 00000000..d1de40df
--- /dev/null
+++ b/plans/super/155-gemini-truncation-e2e-gap.md
@@ -0,0 +1,200 @@
+# #155 — Gemini MAX_TOKENS truncation + per-provider full-pipeline e2e gap
+
+## Meta
+
+- **Issue:** [#155](https://github.com/wjduenow/SignalForge/issues/155)
+- **Branch:** `feature/155-gemini-truncation-e2e-gap`
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/155-gemini-truncation-e2e-gap`
+- **Phase:** `devolved` (epic + 10 tasks live in bd; ready set: US-001, US-003, US-004)
+- **Parent epic:** [#134](https://github.com/wjduenow/SignalForge/issues/134) (pluggable LLM provider for grading)
+- **Sibling refs:** plans/super/{135,136,137}-*.md, plans/super/10-e2e-bigquery-smoke.md
+- **Sessions:** 2026-05-28 (first)
+
+## What & Why
+
+Three findings surfaced by live validation of the #134 epic, all rooted in the same structural gap (no full-pipeline e2e for non-Anthropic providers).
+
+1. **Bug (load-bearing).** `GeminiProvider.extract_text_blocks` (`src/signalforge/llm/providers.py:867-897`) only raises `LLMResponseFormatError` when **zero** text parts are collected. A `finish_reason="MAX_TOKENS"` response that produces a partial (truncated mid-string) text part silently returns, the truncated JSON reaches `parse_grade_response`, the grade engine wraps the resulting `GradeOutputError(violation_type="json_parse")` as a degraded result with `reasoning="call failed: GradeOutputError"` — masking the actionable typed degrade (`"call failed: GradeLLMError"`) that `llm-drafter.md` § "Gemini provider shape" DEC-005 of #137 contracts. The same class of bug exists latently in OpenAI (`finish_reason="length"`) and Anthropic (`stop_reason="max_tokens"`); the fix is provider-neutral.
+
+2. **Flake (tactical).** `tests/grade/test_gemini_grade_live.py:142` sets `max_output_tokens=512`; Gemini 2.5-flash's verbose `reasoning` field routinely exceeds that on the smoke fixture's 5 pairs, hitting MAX_TOKENS. Verified passing at 2048.
+
+3. **E2E gap (structural).** No live full-pipeline `signalforge generate` test exists for OpenAI or Gemini — only the BigQuery + Anthropic e2e (`tests/cli/test_e2e_bigquery_smoke.py`). The three grade-only live smokes exercise `grade_artifacts()` in isolation; they never see drafter, prune, diff, or sidecar seams with a non-Anthropic provider. Finding 1 is a worked example of drift the in-isolation test surfaced only because the rendered output failed parse — a full-pipeline smoke would have hit it the same way plus all surrounding contracts.
+
+## Discovery (summary)
+
+- **Bug location:** `src/signalforge/llm/providers.py:867-897` — early `if blocks:` return at line 888-889 swallows partial text. `finish_reason` is at `candidates[0].finish_reason.name`.
+- **Existing safety-filter contract pin:** `tests/grade/test_gemini_neutrality.py:381` already asserts `bad.reasoning == "call failed: GradeLLMError"` — the fix makes MAX_TOKENS land on the same assertion.
+- **E2E template:** `tests/cli/test_e2e_bigquery_smoke.py` (~49 LLM calls/run, 7 invariants). `tests/cli/test_e2e_snowflake_smoke.py:225-241` shows the `textwrap.dedent` `signalforge.yml` overlay pattern.
+- **Markers:** `pyproject.toml` already registers `openai` + `gemini` and excludes them from default `addopts`.
+- **Austin fixture Anthropic-isms:** Only `llm.model: claude-sonnet-4-6`. No provider key on grade. Models SQL is provider-agnostic.
+
+## Architecture Review
+
+| Area | Rating | Finding |
+|---|---|---|
+| Provider seam design | concern | Use new ABC method `is_clean_completion(response) -> bool` (Option B). Centralizes the rule, future-proofs vendors, no AST impact. → DEC-005 |
+| Cost / cadence | pass | Full live suite ≈ **$1.38/run** at pricing-table `2026-05-28` (measured 2026-05-29; superseded the original $0.30 estimate — see DEC-010). Pre-release-only cadence in CONTRIBUTING. → DEC-010 |
+| Test-fixture reusability | pass | Austin's `llm.model` pin is the only Anthropic-ism. Per-test `signalforge.yml` overlay (no `GradeConfig` defaults change). → DEC-009, DEC-012 |
+| Helpers refactor | pass | One new `_e2e_helpers.apply_provider_override(project_dir, *, grade_provider, grade_model, grade_max_output_tokens)`. ~15 lines. → DEC-012 |
+| Parametrize vs duplicate | pass | Keep 3 separate e2e files; failure ergonomics + cost transparency win. → DEC-011 |
+| Regression risk (Finding 1) | **green / mechanical** | No literal-string pin on the old `"call failed: GradeOutputError"`. Drift detectors validate type not value. Empty `response_text_hash` sentinel already standard. |
+| Retry classification | pass | `LLMResponseFormatError` raised post-call at `client.py:477`, outside the retry try/except → non-retryable as designed; no wasted retries on truncation. |
+| AST scan / confinement | pass | No new vendor SDK constructions; scans 3/9/10 untouched. No logger lazy-format gate violation. |
+| Audit-log fixture parity | pass | No committed JSONL/JSON fixture pins the old reasoning string. |
+
+## Decisions
+
+| ID | Decision | Rationale |
+|---|---|---|
+| **DEC-001** | Fix scope = all three providers (Anthropic + OpenAI + Gemini), not Gemini-only. | The rule's intent is provider-neutral typed degrade. OpenAI's `length` and Anthropic's `max_tokens` are latent versions of the same bug. Fixing one without the others guarantees a #155b. |
+| **DEC-002** | Raise predicate = any non-clean-STOP finish_reason. | Future-proof against new finish_reason values the vendors add. "Allowlist of bad reasons" needs maintenance every SDK bump; "anything not in the explicit clean set" doesn't. |
+| **DEC-003** | E2E scope = 2 new sibling files + parametrize BQ smoke over `grade.provider ∈ [anthropic, openai, gemini]`. | Sibling files give per-provider failure ergonomics; BQ parametrize covers the cross-provider diff-sidecar rendering contract the in-isolation grade smokes miss. ~$1.38/full-suite run at pricing-table `2026-05-28` (measured 2026-05-29; the original $0.30 estimate this DEC was authored against was off by ~4.6× — see DEC-010 + `plans/super/157-e2e-cost-and-parallel.md` § "Measured baseline (2026-05-29)"). |
+| **DEC-004** | ADR lives at `plans/super/155-*.md` (this doc). | Per-issue convention every other plan uses. Cross-link to 137-gemini-grading.md. |
+| **DEC-005** | Provider seam = new abstract method `LLMProvider.is_clean_completion(response) -> bool` called by `call_llm` before `extract_text_blocks` (Option B). Each provider declares `_CLEAN_STOP_REASONS: frozenset[str]`. | Centralizes the rule in the orchestrator (where cross-provider invariants live), forces every future provider to declare its clean-set (can't silently forget), zero AST/confinement impact. |
+| **DEC-006** | Anthropic's `stop_reason="tool_use"` is **unclean** in v0.3 (clean set = `{end_turn, stop_sequence}`). | Codebase doesn't use tools today; `tool_use` would signal system-prompt drift or unexpected LLM behaviour. When tool-use intentionally lands, the clean set expands deliberately. |
+| **DEC-007** | Error-message text = provider-specific via override `LLMProvider.unclean_finish_reason_message(response) -> str`. Default in ABC; each concrete overrides to surface its vendor-native field name. | Operator-facing diagnostic stays vendor-accurate (`stop_reason` for Anthropic, `finish_reason` for OpenAI/Gemini). |
+| **DEC-008** | Per-provider `max_output_tokens` floor table in `docs/grade-ops.md` + `docs/draft-ops.md`: Anthropic **1024**, OpenAI **1024**, Gemini **2048**. Documented as recommended floor for grading workloads, not enforced cap. | Honest floors from observed data. Gemini's verbose `reasoning` provably needs ≥1024; 2048 verified safe in #155 probe. **Reframed by #158 (2026-05-28):** the 2048 "verified safe" claim was scoped to the 5-pair in-isolation probe (`tests/grade/test_gemini_grade_live.py`). The first full-pipeline e2e run against Gemini surfaced 5–6/108 pairs still degrading at 2048 on the Austin bikeshare fixture — Gemini's per-pair `reasoning` is high-variance enough that the in-isolation floor is not the full-fixture floor. The docs table now reads **4096** for Gemini, framed as "fixture-scale-dependent" rather than a single safe number; this DEC stays as the historical record of the 2048 figure's provenance. The full-pipeline correction lives in `plans/super/` issue [#158](https://github.com/wjduenow/SignalForge/issues/158); the generalised lesson lives in memory `in-isolation-smoke-misses-pipeline-drift`. |
+| **DEC-009** | Gemini e2e sibling's `max_output_tokens=2048` lives in the test's `signalforge.yml` overlay, NOT a bumped `GradeConfig`/`DraftConfig` production default. | Tested-by-construction; no production-config change. Avoids over-budgeting Anthropic/OpenAI default calls. |
+| **DEC-010** | Live-suite cadence = pre-release only, documented in `CONTRIBUTING.md`. NO `make e2e-live-all` wrapper, NO per-PR CI integration. | **~$1.38/full-suite run** at pricing-table `2026-05-28` (measured 2026-05-29 against the Austin bikeshare fixture; one run, `-n 3` xdist concurrency, 893s wall-clock; ~108 grade calls/test vs the ~48-call estimate this DEC was originally authored against — calibration signal, not a billing guarantee). At ~2-3 pre-release audits/month that lands at roughly **$2.76-$4.14/month** for a one-maintainer project. Env-var gating is already explicit; a shell wrapper adds surface area without changing the contract. The original $0.30/run figure (and the derived $0.60-1.00/month band) were stale once the per-test artifact count was measured; see `plans/super/157-e2e-cost-and-parallel.md` § "Measured baseline (2026-05-29)" for the per-provider breakdown. |
+| **DEC-011** | Keep 3 separate e2e test files (`test_e2e_bigquery_smoke.py`, `test_e2e_openai_smoke.py`, `test_e2e_gemini_smoke.py`), do NOT collapse into one parametrized test. Parametrize is internal to BQ over `grade.provider`. | Per-file failure messages name the broken provider; per-file cost is transparent in CI logs; per-file marker gating aligns with the existing `@pytest.mark.openai` / `@pytest.mark.gemini` convention. |
+| **DEC-012** | Add `_e2e_helpers.apply_provider_override(project_dir, *, grade_provider=None, grade_model=None, grade_max_output_tokens=None) -> None`. Reads `/signalforge.yml`, overlays the `grade:` block deltas, writes back. Non-destructive: unset knobs left alone. | Surgical edits to per-run temp copy; never modifies the committed fixture. Mirrors the Snowflake `textwrap.dedent` precedent at one level of abstraction. |
+
+## Refinement Log
+
+Session 1 (2026-05-28): 12 DECs captured. All architecture-review concerns resolved. Open issues: none. Ready for detailing.
+
+## Stories (right-sized for Ralph)
+
+Ordering: refactor → tests → impl → docs (per `cli-layer.md` § 5-surface parity and `testing-signal.md` § TDD).
+
+### US-001 — `LLMProvider.is_clean_completion` ABC + 3 concrete impls + `call_llm` wire-in + happy-path tests
+**Traces to:** DEC-001, DEC-002, DEC-005, DEC-006, DEC-007
+**Description:** Add abstract method `is_clean_completion(response: object) -> bool` and `unclean_finish_reason_message(response: object) -> str` to `LLMProvider` ABC. Implement on all three concretes with per-provider `_CLEAN_STOP_REASONS` frozensets. Wire into `call_llm` AFTER `messages.create` returns and BEFORE `extract_text_blocks`. Raise `LLMResponseFormatError(strategy.unclean_finish_reason_message(response))` when `is_clean_completion` is `False`.
+**TDD:** Write the 3 happy-path tests FIRST (one per provider, asserts `is_clean_completion(clean_response) is True`), confirm they fail (method doesn't exist), then implement.
+**Files:**
+- `src/signalforge/llm/providers.py` — add ABC methods + 3 concrete impls (~60 lines net).
+- `src/signalforge/llm/client.py:~477` — add 2-line gate before `extract_text_blocks` call.
+- `tests/llm/test_anthropic_provider_via_fake.py` (or sibling) — happy-path `is_clean_completion(end_turn) is True` test.
+- `tests/llm/test_openai_provider_via_fake.py` — happy-path `is_clean_completion(stop) is True` test.
+- `tests/llm/test_gemini_provider_via_fake.py` — happy-path `is_clean_completion(STOP) is True` test.
+**Done when:** All four `uv run` checks pass (ruff/format/pyright/pytest). No new `_LOGGER.\w+\(f"` violations. AST scans 3/9/10 pass.
+**Depends on:** none
+
+### US-002 — Per-provider unclean-path tests + `llm-drafter.md` DEC-005 clarification
+**Traces to:** DEC-001, DEC-002, DEC-005, DEC-006, DEC-007
+**Description:** Write fake-driven tests pinning the unclean-path contract for each provider. Verify `tests/grade/test_gemini_neutrality.py:381`'s existing `assert bad.reasoning == "call failed: GradeLLMError"` still passes (it should — the path now fires earlier but lands at the same degrade). Update `.claude/rules/llm-drafter.md` § "Gemini provider shape" DEC-005 to reflect the new `is_clean_completion` factoring + extend to all three providers.
+**TDD:** Tests first. Each asserts `pytest.raises(LLMResponseFormatError)` when provider receives a response with non-clean finish_reason (Anthropic `max_tokens`, OpenAI `length`, Gemini `MAX_TOKENS`, all with partial text present).
+**Files:**
+- `tests/llm/test_anthropic_provider_via_fake.py` — unclean test (Anthropic `max_tokens` with partial text → raise).
+- `tests/llm/test_openai_provider_via_fake.py` — unclean test (OpenAI `length` with partial text → raise).
+- `tests/llm/test_gemini_provider_via_fake.py` — unclean test (Gemini `MAX_TOKENS` with partial text → raise).
+- `tests/llm/test_client.py` (or sibling) — integration test: `call_llm` raises `LLMResponseFormatError` on unclean finish_reason.
+- `.claude/rules/llm-drafter.md` — update § Gemini DEC-005 + add brief § for the analogous Anthropic/OpenAI behaviour.
+**Done when:** All four `uv run` checks pass. `test_gemini_neutrality.py:381` continues to pass without modification.
+**Depends on:** US-001
+
+### US-003 — Bump `test_gemini_grade_live.py` fixture + add per-provider `max_output_tokens` floor docs
+**Traces to:** DEC-008
+**Description:** Change `tests/grade/test_gemini_grade_live.py:142` from `max_output_tokens=512` to `max_output_tokens=2048`. Add a "Per-provider `max_output_tokens` recommended floors" table to `docs/grade-ops.md` and `docs/draft-ops.md` (Anthropic 1024 / OpenAI 1024 / Gemini 2048).
+**Files:**
+- `tests/grade/test_gemini_grade_live.py:142` — `512 → 2048`.
+- `docs/grade-ops.md` — add 6-line floor table under "Cost guidance" or "Configuration" section.
+- `docs/draft-ops.md` — add same 6-line floor table.
+**Done when:** All four `uv run` checks pass. `mkdocs build` (non-strict) emits no new warnings for the touched files.
+**Depends on:** none (independent of US-001/US-002)
+
+### US-004 — Add `_e2e_helpers.apply_provider_override` helper
+**Traces to:** DEC-012
+**Description:** Add `apply_provider_override(project_dir: Path, *, grade_provider: str | None = None, grade_model: str | None = None, grade_max_output_tokens: int | None = None) -> None` to `tests/cli/_e2e_helpers.py`. Reads the existing `signalforge.yml`, applies the `grade:` block overlay, writes back. Non-destructive (unset knobs left alone). Refactor `tests/cli/test_e2e_bigquery_smoke.py` to use it for its baseline-Anthropic config (no behaviour change; proves the helper).
+**TDD:** Tests first. Unit test the helper directly in `tests/cli/test_e2e_helpers.py` (does it exist? if not, create it). Assert: overlay preserves untouched keys, applies new keys, raises if `signalforge.yml` is missing.
+**Files:**
+- `tests/cli/_e2e_helpers.py` — add helper (~15 lines).
+- `tests/cli/test_e2e_helpers.py` — add helper unit tests.
+- `tests/cli/test_e2e_bigquery_smoke.py` — refactor to use the helper (no behaviour change).
+**Done when:** All four `uv run` checks pass. `uv run pytest tests/cli/test_e2e_helpers.py` passes (no markers required).
+**Depends on:** none
+
+### US-005 — `test_e2e_openai_smoke.py` (new live e2e)
+**Traces to:** DEC-003, DEC-009, DEC-010, DEC-011, DEC-012
+**Description:** New full-pipeline `signalforge generate` e2e against BigQuery + OpenAI. Gated `@pytest.mark.e2e` + `@pytest.mark.openai`. Three-env-var skip gate: `SF_RUN_OPENAI=1`, `OPENAI_API_KEY`, `GOOGLE_CLOUD_PROJECT`. Uses Austin bikeshare fixture + `_e2e_helpers.apply_provider_override(project_dir, grade_provider="openai", grade_model="gpt-4o")`. Asserts the BQ smoke's 7 invariants (exit 0, sidecar exists, kept/dropped/flagged counts, always-passes drop, `aggregate_complete=True`, no traceback).
+**Files:**
+- `tests/cli/test_e2e_openai_smoke.py` (new) — ~100 lines mirroring BQ smoke.
+**Done when:** All four `uv run` checks pass. Maintainer-only verification: `SF_RUN_BQ=1 SF_RUN_OPENAI=1 OPENAI_API_KEY=… ANTHROPIC_API_KEY=… GOOGLE_CLOUD_PROJECT=… uv run pytest -m openai --no-cov tests/cli/test_e2e_openai_smoke.py` passes against live APIs.
+**Depends on:** US-004
+
+### US-006 — `test_e2e_gemini_smoke.py` (new live e2e, with `max_output_tokens=2048` overlay per DEC-008/009)
+**Traces to:** DEC-003, DEC-008, DEC-009, DEC-010, DEC-011, DEC-012
+**Description:** New full-pipeline e2e against BigQuery + Gemini. Gated `@pytest.mark.e2e` + `@pytest.mark.gemini`. Three-env-var skip gate: `SF_RUN_GEMINI=1`, `GOOGLE_API_KEY`, `GOOGLE_CLOUD_PROJECT`. Uses `apply_provider_override(project_dir, grade_provider="gemini", grade_model="gemini-2.5-flash", grade_max_output_tokens=2048)`. Same 7 assertions as BQ smoke.
+**Files:**
+- `tests/cli/test_e2e_gemini_smoke.py` (new) — ~100 lines mirroring BQ smoke.
+**Done when:** Same as US-005 with Gemini env vars.
+**Depends on:** US-004 (and benefits from US-001/US-002 being in: if a Gemini call hits MAX_TOKENS despite the 2048 cap, the fixed `is_clean_completion` surfaces it as `GradeLLMError` cleanly rather than `GradeOutputError`).
+
+### US-007 — Parametrize `test_e2e_bigquery_smoke.py` over `grade.provider`
+**Traces to:** DEC-003, DEC-011, DEC-012
+**Description:** Add `@pytest.mark.parametrize("grade_provider", ["anthropic", "openai", "gemini"])` to the BQ smoke. For `openai`/`gemini` variants, gate via `_skip_reason()` on the appropriate env vars AND apply the provider overlay via `_e2e_helpers.apply_provider_override`. Drafter stays Anthropic for fixture stability.
+**Files:**
+- `tests/cli/test_e2e_bigquery_smoke.py` — add parametrize decorator + env-gate logic per parameter + overlay call.
+**Done when:** All four `uv run` checks pass. Maintainer-only: three variants run independently (`-k anthropic` / `-k openai` / `-k gemini`).
+**Depends on:** US-004
+
+### US-008 — `CONTRIBUTING.md` update — live-suite cadence + full env-var block
+**Traces to:** DEC-010
+**Description:** Add a "Live e2e suite (pre-release only)" subsection to `CONTRIBUTING.md` listing all 5 paid runs and the full env-var block to invoke them. Stress the "pre-release cadence, not per-PR" intent.
+**Files:**
+- `CONTRIBUTING.md` — ~15 lines added.
+**Done when:** `mkdocs build` (non-strict) clean; the new env-var block matches the actual gates in US-005/US-006/US-007.
+**Depends on:** US-005, US-006, US-007 (ensure the documented invocation matches the actually-shipped marker set)
+
+### US-009 — Quality Gate (code review × 4 + CodeRabbit + canonical `uv run` quad)
+**Traces to:** (all)
+**Description:** Run the project's code-review skill 4 times across the full diff, fixing real bugs each pass. Run CodeRabbit if available. Final pass: `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest` must be all-green.
+**Files:** (varies; whatever the reviewers find)
+**Done when:** All reviewer passes report no real bugs; canonical validation green.
+**Depends on:** US-001 through US-008
+
+### US-010 — Patterns & Memory (priority 99)
+**Traces to:** (all)
+**Description:** Update `.claude/rules/` and memory with new patterns learned in this ticket. Specifically:
+- `.claude/rules/llm-drafter.md` § "Provider-neutral seam" — document the new `is_clean_completion` / `unclean_finish_reason_message` ABC methods and the per-provider `_CLEAN_STOP_REASONS` convention. Mention this is the post-#155 generalisation of the original #137 DEC-005 contract.
+- `.claude/rules/testing-signal.md` § "End-to-end gated tests" — add subsection noting that `apply_provider_override` is the canonical helper for per-test provider overlays.
+- Memory: file `fake-driven-tests-miss-finish-reason-drift.md` — recap the #155 lesson that fake-driven byte-identity tests pin rendered output but not call-shape / response-shape semantics; live tests catch this class of bug.
+**Files:**
+- `.claude/rules/llm-drafter.md`
+- `.claude/rules/testing-signal.md`
+- `~/.claude/projects/-home-wesd-Projects-SignalForge/memory/fake-driven-tests-miss-finish-reason-drift.md` + `MEMORY.md` pointer.
+**Done when:** Memory file present + linked from MEMORY.md; rule files updated; canonical validation green.
+**Depends on:** US-009
+
+## Beads Manifest
+
+Created 2026-05-28. Epic + 10 tasks, 16 dependency links wired. Ready set on creation: US-001, US-003, US-004 (parallel-safe).
+
+| Bead ID | Story | Status | Depends on |
+|---|---|---|---|
+| `bd_1-scaffolding-eu0` | Epic | open | — |
+| `bd_1-scaffolding-eu0.2` | US-001 ABC + concretes + wire-in | **ready** | — |
+| `bd_1-scaffolding-eu0.3` | US-002 unclean-path tests + rule edit | blocked | US-001 |
+| `bd_1-scaffolding-eu0.4` | US-003 bump fixture + docs floor table | **ready** | — |
+| `bd_1-scaffolding-eu0.5` | US-004 `apply_provider_override` helper | **ready** | — |
+| `bd_1-scaffolding-eu0.6` | US-005 `test_e2e_openai_smoke.py` | blocked | US-004 |
+| `bd_1-scaffolding-eu0.7` | US-006 `test_e2e_gemini_smoke.py` | blocked | US-004 |
+| `bd_1-scaffolding-eu0.8` | US-007 parametrize BQ smoke | blocked | US-004 |
+| `bd_1-scaffolding-eu0.9` | US-008 `CONTRIBUTING.md` cadence | blocked | US-005,6,7 |
+| `bd_1-scaffolding-eu0.10` | US-009 Quality Gate | blocked | US-001..008 |
+| `bd_1-scaffolding-eu0.11` | US-010 Patterns & Memory | blocked | US-009 |
+
+### Serialization callouts (per memory: `ralph-serialize-shared-registry-beads`)
+- **US-005 / US-006 / US-007 all touch `tests/cli/_e2e_helpers.py`** (the helper US-004 added) AND `tests/cli/test_e2e_bigquery_smoke.py` (US-007 parametrizes it; US-004 refactored it). Even though they're listed as "ready after US-004 completes," they should NOT be claimed concurrently — serialise them one-at-a-time to avoid merge conflicts on the shared file.
+- **US-002 edits `.claude/rules/llm-drafter.md`** — per memory `ralph-worker-claude-dir-perms`, this MUST be done by the orchestrator (me) directly, NOT a Ralph worker. The bead description flags this.
+- **US-010 also edits `.claude/rules/`** — same orchestrator-only constraint.
+
+## References
+
+- `.claude/rules/llm-drafter.md` § "Gemini provider shape (#137)" DEC-005 — the contract being violated and clarified.
+- `.claude/rules/grade-layer.md` § "Conservative score-and-degrade taxonomy (DEC-002, DEC-015)" — confirms `LLMResponseFormatError` → `GradeLLMError` degrade path.
+- `.claude/rules/testing-signal.md` § "End-to-end gated tests (issue #10)" — belt-and-suspenders gating pattern.
+- `.claude/rules/cli-layer.md` § "Multi-surface parity for behaviour changes" — 5-surface checklist.
+- `plans/super/137-gemini-grading.md` — the original Gemini provider plan (DEC-005 source).
+- `plans/super/10-e2e-bigquery-smoke.md` — the e2e template plan.
+- `plans/super/135-provider-neutral-llm-seam.md` — the `LLMProvider` ABC origin.
diff --git a/plans/super/157-e2e-cost-and-parallel.md b/plans/super/157-e2e-cost-and-parallel.md
new file mode 100644
index 00000000..b978c7e0
--- /dev/null
+++ b/plans/super/157-e2e-cost-and-parallel.md
@@ -0,0 +1,478 @@
+# 157 — E2E suite: real-measured cost docs + parallelization
+
+## Meta
+
+- **Ticket:** [#157](https://github.com/wjduenow/SignalForge/issues/157)
+- **Branch:** `feature/157-e2e-cost-parallel`
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/157-e2e-cost-parallel`
+- **Phase:** in-flight (US-001..US-005 closed; US-006..US-008 pending)
+- **Created:** 2026-05-29
+- **Sessions:** 1
+
+## Ticket summary
+
+The first full live-e2e run after #155 took **39 min wall-clock for 6 tests** vs the ~3-6 min and ~$0.30/run estimate baked into the plan + docs. Two asks:
+
+1. **Update cost+duration docs to real-measured.** Three surfaces drift: `plans/super/155-gemini-truncation-e2e-gap.md` DEC-010 ("~$0.30/full-suite run"), `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" (inherits that figure), and `docs/grade-ops.md` Cost guidance § ("$0.18/model on Sonnet 4.6" — built from a stale "~12 artifacts × 4 criteria = 48 calls" assumption when the Austin fixture is actually ~29 artifacts × 4 criteria = 108-116 calls per test).
+2. **Evaluate parallelizing the e2e suite for wall-clock speed.** The 6 tests are mutually independent; `pytest-xdist` at the test level could cut ~39 min → ~13-18 min. Grade engine stays sequential (per `grade-layer.md` DEC-004/DEC-027); parallelism is purely at the test-node level.
+
+Out of scope (separately filed): the Gemini-grader's `max_output_tokens=2048` floor was already raised to 4096 in #158/PR #160 — independent.
+
+## Discovery (Phase 1)
+
+### Codebase scout findings (Subagent B)
+
+**E2E test surface — exactly 6 nodes collected by `pytest -m e2e`:**
+
+| File | Test func | Parametrize | Gate env vars |
+|---|---|---|---|
+| `tests/cli/test_e2e_bigquery_smoke.py` | `test_e2e_signalforge_generate_against_austin_bikeshare` | `grade_provider ∈ {anthropic, openai, gemini}` | All variants: `SF_RUN_BQ=1`, `ANTHROPIC_API_KEY`, `GOOGLE_CLOUD_PROJECT`. `openai`: +`SF_RUN_OPENAI=1`+`OPENAI_API_KEY`. `gemini`: +`SF_RUN_GEMINI=1`+`GOOGLE_API_KEY`. |
+| `tests/cli/test_e2e_business_rules.py` | `test_e2e_custom_sql_business_rules_end_to_end` | — | `SF_RUN_BQ=1`, `ANTHROPIC_API_KEY`, `GOOGLE_CLOUD_PROJECT` |
+| `tests/cli/test_e2e_openai_smoke.py` | sibling smoke | — | drafter Anthropic + grader OpenAI (5 env vars per `testing-signal.md`) |
+| `tests/cli/test_e2e_gemini_smoke.py` | sibling smoke | — | drafter Anthropic + grader Gemini (5 env vars) |
+
+Six nodes total. (`test_e2e_snowflake_smoke.py` is gated by `snowflake`, not `e2e`; `test_e2e_estimate_openai.py` uses its own marker.)
+
+**Shared helpers — parallel-safe:** `tests/cli/_e2e_helpers.py` provides `copy_fixture_to_tmp(tmp_path)`, `apply_provider_override(...)`, `read_prune_decisions(...)`, `read_diff_report(...)`, `inject_model_business_rules(...)`. Every helper either reads committed fixtures (read-only) or writes under `tmp_path` — no shared mutable state, no env mutation. `apply_provider_override` is the per-test grader-swap seam (`testing-signal.md` § "Per-test provider overlay").
+
+**Audit JSONL carries everything pricing needs:**
+- `LLMResponseEvent` (drafter, `.signalforge/llm_responses.jsonl`) — `model`, `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`.
+- `GradeEvent` (grader, `.signalforge/grade.jsonl`) — same five fields (cache fields default 0 for OpenAI/Gemini).
+
+**Pricing table already exists.** `src/signalforge/llm/pricing.py` has a frozen `PRICES: MappingProxyType` indexed by model id, `PRICE_TABLE_VERSION = "2026-05-28"`, `lookup(model) -> ModelPricing`. Active SKUs cover everything the suite uses (Anthropic Sonnet 4.6, OpenAI gpt-4o, Gemini 2.5 Flash). **Implication:** computing real measured USD is `sum(input * input_price + output * output_price + cache_* * cache_*_price) / 1e6` over both JSONLs — no new pricing surface needed.
+
+**`pytest-xdist` is NOT a dependency.** No `-n` in `addopts`. No serial-only flag. No architectural barrier in `[tool.pytest.ini_options]`.
+
+### Convention checker findings (Subagent C)
+
+Constraints from `.claude/rules/*.md` that bear on this plan:
+
+- **`testing-signal.md` § "End-to-end gated tests"** — the "belt-and-suspenders gating" rule (marker + runtime `_skip_reason()`); the `tmp_path` isolation rule; the "per-test provider overlay via `apply_provider_override`" seam. xdist must NOT break any of those. The 5-env-var gate for full-pipeline e2e (drafter API key + grader API key + their `SF_RUN_*` opt-ins + `GOOGLE_CLOUD_PROJECT`) is the contract.
+- **`grade-layer.md` DEC-004 / DEC-027** — grade engine MUST stay sequential per-`(criterion, artifact)`. Parallelism is at the pytest node level, never inside a grade run.
+- **`testing-signal.md` § "Engineered determinism"** — assertions must remain deterministic across runs. Parallel execution doesn't change determinism (each test owns its `tmp_path`), but a maintainer running with `-n auto` must still get the same kept/dropped counts.
+- **`ci-supply-chain.md`** — every long-lived branch trigger needs lockstep updates. `pytest-xdist` going into `[dependency-groups].dev` flows through `uv sync --dev` automatically; no workflow changes needed *unless* CI starts opting into `-n`.
+- **`python-build.md`** — `[dependency-groups].dev` + `[project.optional-dependencies].dev` mirror each other; new dep lands in both.
+- **`cli-layer.md` § "5-surface parity"** — N/A here; no CLI behaviour change.
+
+**No `workflow-project.md` exists** — using baseline scoping questions only.
+
+### Existing doc surfaces to update (paths + headings)
+
+1. `plans/super/155-gemini-truncation-e2e-gap.md` — DEC-010 ("~$0.30/full-suite run"), plus the "Cost / cadence" row in the architecture-review table.
+2. `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" (around line 96-130).
+3. `docs/grade-ops.md` § Cost guidance — the "$0.18/model on Sonnet 4.6" reference figure + the per-provider floor table.
+
+### Scoping decisions
+
+- **DEC-Q1 — Scope:** Both asks ship together in one plan/PR. Single coherent change.
+- **DEC-Q2 — Cost source:** Ship a re-runnable rollup helper (walks `.signalforge/*.jsonl` × `signalforge.llm.pricing`) AND ship the measured baseline computed by it. Helper makes future re-measurement boring; baseline gives the docs a concrete number today.
+- **DEC-Q3 — Xdist shape:** Add `pytest-xdist` to `[dependency-groups].dev` + mirror; document `uv run pytest -m e2e -n 3 --no-cov` as the recommended maintainer invocation in CONTRIBUTING. **No `addopts` change** — default behaviour stays sequential.
+- **DEC-Q4 — Measurement:** Maintainer re-runs the live suite as part of implementation (one story explicitly for this). Helper exists before the run so figures aren't hand-reconstructed.
+
+## Architecture Review (Phase 2)
+
+No blockers; three concerns to resolve in refinement. Reviewed the proposed shape (rollup helper as `signalforge.llm.pricing.rollup_audit_dir(...)` library function + thin `scripts/measure_e2e_cost.py` wrapper, `pytest-xdist` as opt-in dev dep, live re-run as part of impl).
+
+| Review area | Rating | Findings |
+|---|---|---|
+| **Security** | pass | Helper reads only token-count + model-id fields, never echoes `evidence`/`reasoning`. Path safety must route through `signalforge._common.path_safety.canonicalise_path` (the convention for any user-supplied path; `manifest-readers.md`). No credentials surface. Three parallel BigQuery temp-tables on the maintainer's billing project at Austin scale (≪100M rows after sample) are well inside slot quotas. |
+| **Performance / cost** | concern | **Anthropic 50 RPM is the tight gate.** With drafter (Anthropic, singleton) + three parallel Anthropic graders the suite can hit ~50 calls in one epoch and trigger the `WARNING: rate limit` retry path (`llm-drafter.md` DEC-005). Gemini 60 RPM and OpenAI 500 RPM are comfortable at `-n 3`. **Wall-clock bound:** test-level parallelism is capped by the *longest* test (the BQ smokes at ~8 min each). With 3 BQ variants + 3 standalone smokes, `-n 3` can in principle pack three ~8 min tests into one ~8 min wave + three shorter tests into another, so the floor is ~16-18 min vs 39 serial. Real speedup needs measurement — DEC-Q4 covers that. **Recommendation:** document `-n 3` with the rate-limit caveat + monitoring guidance, allow maintainer to downgrade to `-n 2` if Anthropic retries spike. |
+| **Data model / API** | pass | The rollup return shape ships as `@dataclass(frozen=True) CostReport` (NOT Pydantic), so it sidesteps the `extra="ignore"` + drift-detector contract — it's a pure compute output, never serialised to a JSONL/sidecar that downstream consumers read back. No `audit_schema_version` bump: the helper consumes existing `LLMResponseEvent` / `GradeEvent` fields only (`input_tokens` / `output_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens` / `model`). |
+| **Observability / fail-soft** | concern | Helper needs ~3 typed errors: `CostRollupAuditMissingError` (neither JSONL present), `CostRollupMalformedRecordError(line_num, reason)` (bad JSONL line), `CostRollupUnknownModelError(model_id)` (pricing-table miss). Each carries `default_remediation` per the `manifest-readers.md` rule. **Decision:** does it need its own subpackage `signalforge.llm.cost` with `errors.py`, or extend `signalforge.llm.pricing` with the new functions + typed errors? The latter avoids growing scan-7's "exactly 11 errors.py modules" count (`cli-layer.md`). |
+| **Testing strategy** | pass | Helper is unit-testable against `tests/fixtures/draft/llm_response_*.json` + `tests/fixtures/grade/grade_event_v1.jsonl` + the frozen `PRICES` table. Add deterministic micro-fixtures for the rollup arithmetic. **pytest-xdist interaction with maintainer-only markers:** `cli_subprocess` (5 tests in one file → no parallel collision risk on the installed wheel), `wheel_smoke` (one test, builds wheel into a temp dir → no shared-state risk). Recommendation: only `e2e` gets the `-n 3` recommendation; serial stays the default for `cli_subprocess` / `wheel_smoke` invocations. |
+| **CONTRIBUTING / docs** | concern | `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" at line 96 IS the section to update (line 106 has the `$0.30` figure). **Latent doc gap to fix in this plan:** `test_e2e_business_rules.py` IS marked `@pytest.mark.e2e` but is NOT listed in CONTRIBUTING's enumeration (lines 119-148 list only the four BQ/OpenAI/Gemini/Snowflake files). Plan must add the business_rules entry to the list. `docs/grade-ops.md` § Cost guidance needs per-provider USD rows (Anthropic + OpenAI + Gemini) — current text has only the Sonnet figure. `plans/super/155-…md` DEC-010 update is the small change. `docs/cost-estimate-ops.md` documents the `--estimate` preview, separate concern, no cross-reference needed. |
+
+### Concerns to resolve in Refinement
+
+- **C1.** Concurrency level — recommend `-n 3`, `-n 2`, or no specific number?
+- **C2.** Helper home — `signalforge.llm.pricing` extended in-place, or new `signalforge.llm.cost` subpackage?
+- **C3.** Doc-gap scope — fix the missing `business_rules` enumeration entry as part of this plan, or file separately?
+
+## Refinement Log (Phase 3)
+
+### Decisions
+
+- **DEC-001 — `-n 3` is the recommended xdist concurrency, with documented Anthropic rate-limit caveat.**
+ - *Rationale:* Anthropic 50 RPM is the tight gate; OpenAI 500 RPM + Gemini 60 RPM are comfortable. `-n 3` gives a target wall-clock of ~13-18 min vs ~39 serial; if `_LOGGER.warning("…rate limit…")` events spike in stderr during a real run, the maintainer downgrades to `-n 2`. CONTRIBUTING documents the tuning knob explicitly.
+
+- **DEC-002 — Cost rollup ships as a new `signalforge.llm.cost` subpackage with its own `errors.py`.**
+ - *Rationale:* Aligns with the per-stage `errors.py` convention. The 3 typed errors all map to CLI tier 2 (input-validation); the `CostError` base also gets a dual-registration at tier 2 (the safety-net pattern from `cli-layer.md` — same shape as `ManifestError` → tier 1). Scan-7's glob currently walks `src/signalforge/*/errors.py` (depth 1) — extending it to also discover `src/signalforge/*/*/errors.py` is a one-line shape change + a bump of the expected-paths list from 11 → 12. This is the first sub-stage `errors.py`; the convention generalises cleanly for future sub-packages.
+
+- **DEC-003 — `test_e2e_business_rules.py` enumeration doc gap is fixed in the same CONTRIBUTING update.**
+ - *Rationale:* Same surface, same touch, logical to bundle. Avoids a follow-up ticket for a one-line list addition.
+
+- **DEC-004 — Return shape is `@dataclass(frozen=True) CostReport`, not Pydantic.**
+ - *Rationale:* The rollup output is a pure compute result, never serialised to a JSONL/sidecar that downstream consumers read back. A frozen dataclass sidesteps the `extra="ignore"` + drift-detector contract that `manifest-readers.md` mandates for any read-back Pydantic model. Carries `per_provider: Mapping[str, ProviderRollup]` (also frozen dataclass) + `total_usd: float` + `pricing_table_version: str` (stamps `signalforge.llm.pricing.PRICE_TABLE_VERSION`).
+
+- **DEC-005 — Helper is read-only; no fail-closed writer; symlink-hardened path canonicalisation at entry.**
+ - *Rationale:* Routes the supplied `project_dir` through `signalforge._common.path_safety.canonicalise_path` per `manifest-readers.md` § "Symlink-hardened path resolution". Hardcodes the `.signalforge/` subdir relative to canonicalised `project_dir` (matches the convention in `signalforge.grade` and `signalforge.draft`). No new fail-closed writer to register in scan-8.
+
+- **DEC-006 — `scripts/measure_e2e_cost.py` is repo-only, NOT shipped in wheel.**
+ - *Rationale:* Per `python-build.md`, the wheel's `[tool.hatch.build.targets.wheel]` explicitly lists what ships. The maintainer audit script lives alongside future regen scripts and never appears on a user's `pip install signalforge-dbt`. First entry in a `scripts/` directory; sets the precedent.
+
+- **DEC-007 — `pytest-xdist` lands in `[dependency-groups].dev` AND `[project.optional-dependencies].dev`, mirrored.**
+ - *Rationale:* `python-build.md` § "uv-managed dev environment" mandates the two lists stay in sync for `uv sync --dev` + `pip install -e ".[dev]"` parity. No `addopts` change — opt-in invocation only.
+
+- **DEC-008 — Maintainer live re-run is its own story.** Bead is marked maintainer-only at devolve; closes when measured baseline lands in this plan's refinement log.
+
+- **DEC-009 — Durable convention captured in `testing-signal.md` § "End-to-end gated tests"** — the "parallel-safe via per-test tmp_path + apply_provider_override" rule plus the rate-limit-caveat invocation pattern. Patterns & Memory story owns this.
+
+### Measured baseline (2026-05-29)
+
+Live `uv run pytest -m e2e -n 3 --no-cov --capture=no -v` against the maintainer's billing project (`duenow-nest`). Rollup computed by `scripts/measure_e2e_cost.py` over each test's preserved `tmp_path/.signalforge/` JSONLs at `/tmp/pytest-of-wesd/pytest-629/`. **`PRICE_TABLE_VERSION = "2026-05-28"`** (from `signalforge.llm.pricing`).
+
+**Wall-clock:** `14:53` (893.35s) at `-n 3` vs the ticket-cited `39:23` serial baseline → **~2.6× speedup**. Test scheduling: 3 xdist workers, each running 2 tests sequentially. Per-test wall-clock is not cleanly extractable from xdist parallel output; the gw2 worker's first test (Gemini-grader sibling, inferred from progress output — xdist doesn't surface per-test wall-clock cleanly) completed in ~9 min, and each worker finished both tests within 14:53.
+
+**Rate-limit retries:** **zero** observed in stderr. Grep for `rate limit` / `429` / `retry` over the full log returned no matches. The Anthropic-50-RPM concern documented in CONTRIBUTING did not materialise at `-n 3` against the Austin fixture — the per-test grade-call cadence is more spread out than worst-case calculation suggested. **Concurrency: `-n 3` was safe; no downgrade needed.**
+
+**Per-test USD (locked by JSONL token rollup × `PRICES` table):**
+
+| Test (pytest tmp basename) | Drafter | Grader | Calls | USD |
+|---|---|---|---|---|
+| BQ smoke `[anthropic]` (gw0 #0) | Anthropic | Anthropic | 105 | **$0.3822** |
+| BQ smoke `[openai]` *or* `openai_smoke` (gw0 #1) | Anthropic | OpenAI gpt-4o | 1 + 108 | **$0.2550** |
+| `business_rules` (gw1 #0) ⚠ | Anthropic | Anthropic | 89 | **$0.3218** |
+| BQ smoke `[gemini]` *or* `gemini_smoke` (gw1 #1) | Anthropic | Gemini 2.5-flash | 1 + 108 | **$0.0881** |
+| Sibling smoke (gemini grader, gw2 #0) | Anthropic | Gemini 2.5-flash | 1 + 104 | **$0.0843** |
+| Sibling smoke (openai grader, gw2 #1) | Anthropic | OpenAI gpt-4o | 1 + 108 | **$0.2476** |
+
+Within each `(openai|gemini)` row pair, identifying which is the BQ parametrize variant vs the standalone sibling is not load-bearing for cost (both ran the full pipeline; both produced kept/dropped diffs). Their costs differ by `±$0.01` driven by Anthropic-side prompt-cache hit/miss state across the run.
+
+Sums agree to rounding: per-test sum = $1.3790; per-provider sum = $1.3788; headline = **$1.38**.
+
+**Per-provider aggregate:**
+
+| Provider | Calls | Input tokens | Output tokens | Cache-write tokens | Cache-read tokens | Subtotal USD |
+|---|---|---|---|---|---|---|
+| **Anthropic** (drafter on all 6; grader on 2) | 198 | 85,460 | 38,387 | 9,412 | 3,866 | **$0.8686** |
+| **OpenAI** (gpt-4o grader on 2) | 216 | 85,686 | 20,891 | 0 | 0 | **$0.4231** |
+| **Gemini** (2.5-flash grader on 2) | 212 | 84,248 | 24,734 | 0 | 0 | **$0.0871** |
+
+**Grand total: `$1.3789 per full-suite run`** at pricing-table `2026-05-28`. This is **~4.6× the stale `$0.30/full-suite` figure** that lived in CONTRIBUTING / `plans/super/155-…md` DEC-010 / `docs/grade-ops.md`. The drift was driven by a larger-than-estimated artifact count on the Austin fixture (~108 grade calls/test, not the ~48 the original plan estimated — already noted in the ticket text).
+
+**Pre-existing flake observed (NOT a US-005 blocker):** `test_e2e_business_rules.py::test_e2e_business_rules_drafts_prunes_custom_sql` FAILED with an `AssertionError` at line 207 — the test expects at least one `custom_sql` PruneDecision to be `kept` with `reason="kept"` (the "same start/end station" business rule should be violated by real A→B bikeshare trips), but the drafter produced a tautological SQL that pruned to `('dropped', 'always-passes')`. This is LLM-determinism on the drafter side, not a bug in US-001…US-004 or in `-n 3` parallelization. The test still ran to completion and emitted all four `.signalforge/` JSONLs; cost rollup is unaffected. Filed as [#163](https://github.com/wjduenow/SignalForge/issues/163) — the drafter ignored both injected business rules and hallucinated a third (`WHERE duration_minutes <= 0`); investigation directions live in the issue.
+
+**Framing for US-006 (cited verbatim in the doc updates):** "calibration signal, not a billing guarantee" per `warehouse-adapters.md` precedent — the figures above are a single 2026-05-29 measurement at `PRICE_TABLE_VERSION=2026-05-28`; vendor pricing rotates and the Austin-fixture artifact count is workload-specific.
+
+### Session notes
+
+- Codebase Scout confirmed token-cost fields are already on both `LLMResponseEvent` (drafter) and `GradeEvent` (grader); no `audit_schema_version` bump needed. Pricing table at `signalforge.llm.pricing.PRICES` already covers every SKU the suite uses.
+- Caught the Subagent claim that CONTRIBUTING's "Live e2e suite" section doesn't exist (line 96 verifies it does, with the $0.30 figure at line 106). Doc-update story works from the existing section.
+- Latent fix bundled in: CONTRIBUTING's e2e enumeration omits `test_e2e_business_rules.py` (which IS `@pytest.mark.e2e`-marked). DEC-003 covers.
+
+## Detailed Breakdown (Phase 4)
+
+Eight stories. Six implementation + Quality Gate + Patterns & Memory. Each AC ends with the canonical validation command (`uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`). US-005 is maintainer-only; the rest are Ralph-eligible.
+
+---
+
+### US-001 — `signalforge.llm.cost` subpackage skeleton + errors
+
+**Description.** Create the new subpackage with `__init__.py` re-exports, an `errors.py` carrying `CostError(LLMError)` base + 3 concretes, and a stub `_rollup.py` whose public function signature exists but raises `NotImplementedError`. Wire the 3 concretes into `signalforge.cli._helpers._EXCEPTION_TO_EXIT_CODE` at tier 2; dual-register `CostError` at tier 2 (single-tier safety net per `cli-layer.md`). Extend scan-7 to walk nested sub-stage `errors.py` files; bump the expected-paths list 11 → 12 to include `llm/cost/errors.py`. Add `CostError` to `_EXCEPTION_MAPPING_EXCLUDED_BASES`.
+
+**Traces to:** DEC-002.
+
+**Files:**
+- `src/signalforge/llm/cost/__init__.py` — re-exports `rollup_audit_dir`, `CostReport`, `ProviderRollup`, `CostError`, `CostRollupAuditMissingError`, `CostRollupMalformedRecordError`, `CostRollupUnknownModelError`.
+- `src/signalforge/llm/cost/errors.py` — typed-error hierarchy. Each concrete carries `default_remediation`; messages render user-supplied strings via the `_format_value` repr-safe helper (the standard from `manifest-readers.md`).
+- `src/signalforge/llm/cost/_rollup.py` — stub `def rollup_audit_dir(project_dir: Path | str, *, audit_dir: str = ".signalforge") -> CostReport: raise NotImplementedError` + `CostReport` / `ProviderRollup` frozen-dataclass definitions (real shape, so the imports/tests in US-002 can pin against them).
+- `src/signalforge/cli/_helpers.py` — register 3 concretes at tier 2; dual-register `CostError` at tier 2; add `CostError` to `_EXCEPTION_MAPPING_EXCLUDED_BASES`.
+- `tests/test_audit_completeness.py` — extend scan-7's glob to also walk `_SIGNALFORGE_DIR.glob("*/*/errors.py")`; bump expected-paths list 11 → 12 (add `llm/cost/errors.py`); bump the `test_scan_7_discovers_every_per_stage_errors_module` count assertion.
+- `tests/llm/cost/__init__.py` — empty (test dir bootstrap, no `tests/__init__.py` per `testing-signal.md`).
+- `tests/llm/cost/test_errors.py` — assert: every concrete inherits `CostError`; each carries non-empty `default_remediation`; each appears in `_EXCEPTION_TO_EXIT_CODE` mapped to tier 2; `CostError` base maps to tier 2; `CostError` is in `_EXCEPTION_MAPPING_EXCLUDED_BASES`.
+
+**Done when:** Subpackage importable; scan-7 + AST scan-7-mapping tests green; canonical validation passes.
+
+**Acceptance criteria:**
+- `from signalforge.llm.cost import rollup_audit_dir, CostReport, ProviderRollup, CostError, CostRollupAuditMissingError, CostRollupMalformedRecordError, CostRollupUnknownModelError` succeeds.
+- Calling `rollup_audit_dir(...)` raises `NotImplementedError` (stub).
+- Scan-7 (`test_every_typed_error_is_in_exit_code_mapping_table`) passes with 12 modules discovered.
+- `test_scan_7_discovers_every_per_stage_errors_module` count assertion bumped 11 → 12; expected-paths list adds `llm/cost/errors.py`.
+- Each of `CostRollupAuditMissingError` / `CostRollupMalformedRecordError` / `CostRollupUnknownModelError` maps to exit code **2**.
+- `CostError` is in `_EXCEPTION_MAPPING_EXCLUDED_BASES` AND has a dual-registration table entry at tier 2.
+- Canonical validation passes: `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`.
+
+**Depends on:** none.
+
+**TDD:**
+- Test: importing the subpackage exposes the seven public names.
+- Test: `CostError` is a subclass of `LLMError` (preserves the hierarchy).
+- Test: each concrete carries a non-empty `default_remediation` string.
+- Test: each concrete's `__str__` renders `message + ↳ Remediation: …` (the `manifest-readers.md` rendering contract).
+- Test: scan-7 sees `llm/cost/errors.py` and all four classes are mapped.
+
+---
+
+### US-002 — Rollup engine (TDD)
+
+**Description.** Implement `rollup_audit_dir(project_dir, *, audit_dir=".signalforge") -> CostReport`. Walks `project_dir/audit_dir/llm_responses.jsonl` + `project_dir/audit_dir/grade.jsonl`, deserialises each line via the existing `LLMResponseEvent` / `GradeEvent` models, multiplies the four token fields against `signalforge.llm.pricing.lookup(model).*`, and returns a `CostReport` carrying per-provider per-model rollups + grand total. Project-dir canonicalised at entry via `_common.path_safety.canonicalise_path`. Missing both JSONLs → `CostRollupAuditMissingError`; missing one → degraded with the other (operator-friendly). Bad JSONL line → `CostRollupMalformedRecordError(line_num, reason)`. Unknown model id → `CostRollupUnknownModelError(model_id)`.
+
+**Traces to:** DEC-002, DEC-004, DEC-005.
+
+**Files:**
+- `src/signalforge/llm/cost/_rollup.py` — full implementation (replace the stub from US-001). `CostReport` + `ProviderRollup` shapes finalised:
+ ```python
+ @dataclass(frozen=True)
+ class ProviderRollup:
+ provider: str # "anthropic" / "openai" / "gemini"
+ per_model: Mapping[str, ModelRollup] # model id -> token + USD
+ subtotal_usd: float
+
+ @dataclass(frozen=True)
+ class ModelRollup:
+ model: str
+ input_tokens: int
+ output_tokens: int
+ cache_creation_input_tokens: int
+ cache_read_input_tokens: int
+ total_usd: float
+ call_count: int
+
+ @dataclass(frozen=True)
+ class CostReport:
+ per_provider: Mapping[str, ProviderRollup]
+ total_usd: float
+ pricing_table_version: str # = signalforge.llm.pricing.PRICE_TABLE_VERSION at run time
+ audit_files_consumed: tuple[str, ...] # ("llm_responses.jsonl", "grade.jsonl") subset
+ ```
+- `tests/llm/cost/test_rollup.py` — TDD-first. Uses the committed fixtures from `tests/fixtures/draft/` + `tests/fixtures/grade/` plus a small handcrafted fixture for the multi-provider mixed case.
+- `tests/llm/cost/test_path_safety.py` — symlink-loop + outside-project rejection tests.
+
+**Done when:** All TDD cases below pass; no `NotImplementedError` left.
+
+**Acceptance criteria:**
+- Computes correct per-provider per-model USD from a known-input fixture (assertion is on a hand-computed value — small fixtures, easy arithmetic).
+- Handles three provider mixes: Anthropic-only (with cache fields populated), OpenAI-only (cache fields = 0), Gemini-only (cache fields = 0).
+- Aggregates correctly when both audit files contain records.
+- Raises `CostRollupAuditMissingError` when both JSONLs absent.
+- Returns a degraded `CostReport` (with `audit_files_consumed` reflecting the subset) when only one JSONL present.
+- Raises `CostRollupMalformedRecordError(line_num=N, reason="")` on a corrupt JSONL line.
+- Raises `CostRollupUnknownModelError(model_id=X)` when a record references a model absent from `PRICES`.
+- Rejects path outside `project_dir` (symlink containment) via `PathContainmentError` → wrapped as `CostRollupAuditMissingError` (no new path-error class).
+- `CostReport.pricing_table_version == signalforge.llm.pricing.PRICE_TABLE_VERSION`.
+- Canonical validation passes.
+
+**Depends on:** US-001.
+
+**TDD (test cases listed before implementation):**
+- `test_rollup_empty_project_raises_missing_audit_error`
+- `test_rollup_only_llm_responses_returns_degraded_report` — `audit_files_consumed == ("llm_responses.jsonl",)`.
+- `test_rollup_only_grade_returns_degraded_report` — `audit_files_consumed == ("grade.jsonl",)`.
+- `test_rollup_both_jsonls_returns_full_report`
+- `test_rollup_anthropic_uses_cache_pricing` — cached input tokens × cache_read_price; uncached × input_price.
+- `test_rollup_openai_zero_cache_pricing` — confirms OpenAI's `cache_write_price_per_million == 0.0`.
+- `test_rollup_gemini_zero_cache_pricing`
+- `test_rollup_mixed_provider_aggregates_correctly` — Anthropic drafter + Gemini grader in one project.
+- `test_rollup_malformed_jsonl_line_raises_typed_error` — `line_num` + `reason` populated.
+- `test_rollup_unknown_model_raises_typed_error`
+- `test_rollup_pins_pricing_table_version`
+- `test_rollup_call_count_matches_jsonl_line_count`
+- `test_rollup_rejects_audit_path_outside_project_dir` — symlink to `/etc/passwd`-shaped attempt.
+- `test_rollup_rejects_symlink_loop_in_project_dir`
+- `test_rollup_grand_total_equals_sum_of_provider_subtotals` — invariant check.
+
+---
+
+### US-003 — `scripts/measure_e2e_cost.py` wrapper
+
+**Description.** Thin script that argparse-parses a `project_dir` arg, calls `rollup_audit_dir(...)`, and pretty-prints per-provider per-model + grand total to stdout. Maps typed errors to non-zero exits matching the CLI taxonomy (exit 2 for any `CostError`). Mirrors `cli-layer.md`'s "no traceback ever leaks" rule via one boundary `try/except Exception`. Not shipped in wheel (verified by `wheel_smoke` test extension).
+
+**Traces to:** DEC-006.
+
+**Files:**
+- `scripts/measure_e2e_cost.py` — first entry in a `scripts/` directory. Shebang `#!/usr/bin/env python3`. Self-contained: imports from `signalforge.llm.cost`, no other repo modules. Argparse: `--project-dir` (required), `--audit-dir` (default `.signalforge`), `--format {text,json}` (default `text`).
+- `tests/scripts/test_measure_e2e_cost.py` — subprocess smoke (NOT gated; runs against a tiny committed fixture). Asserts exit 0 on happy path, exit 2 on missing-audit path, no traceback on stderr.
+- `tests/test_wheel_packaging.py` (or wherever the `wheel_smoke` marker test lives) — assert `scripts/` is NOT inside the built wheel. Mirrors `python-build.md`'s `wheel_smoke` shape.
+
+**Done when:** Script runs end-to-end against the committed fixture; wheel smoke confirms `scripts/` excluded; canonical validation passes.
+
+**Acceptance criteria:**
+- `python scripts/measure_e2e_cost.py --project-dir ` exits 0 and prints a per-provider table + grand total.
+- `--format=json` emits machine-readable JSON with the same data.
+- Missing both JSONLs exits 2 with the typed error's remediation rendered to stderr.
+- Stderr never contains "Traceback" (the `cli-layer.md` floor applies even though this isn't a registered CLI subcommand).
+- `uv run pytest -m wheel_smoke --no-cov` confirms `scripts/` is NOT inside `dist/*.whl`.
+- Canonical validation passes.
+
+**Depends on:** US-002.
+
+**TDD:** Light. One subprocess test per exit code (0 / 2-for-missing / 2-for-unknown-model); one wheel-smoke assertion that `scripts/` is excluded.
+
+---
+
+### US-004 — `pytest-xdist` dev dep + CONTRIBUTING parallel-invocation doc
+
+**Description.** Add `pytest-xdist` to both `[dependency-groups].dev` and `[project.optional-dependencies].dev` (mirror per `python-build.md`). No `addopts` change. Rewrite `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" to document the parallel invocation `uv run pytest -m e2e -n 3 --no-cov` with the Anthropic 50-RPM caveat + downgrade-to-`-n 2` guidance. Add the missing `test_e2e_business_rules.py` entry to the e2e enumeration. Add a note that `cli_subprocess` / `wheel_smoke` markers stay serial. No measured-cost figures in this story — those land in US-006 after US-005.
+
+**Traces to:** DEC-001, DEC-003, DEC-007.
+
+**Files:**
+- `pyproject.toml` — add `pytest-xdist` (version-pin to a recent stable, e.g. `pytest-xdist>=3.6,<4`) to both lists.
+- `uv.lock` — regenerated by `uv sync --dev`.
+- `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" — restructure to:
+ - List 5 e2e files now (BQ smoke, OpenAI smoke, Gemini smoke, Snowflake smoke, **business_rules**).
+ - Document `pytest -m e2e -n 3 --no-cov` as recommended.
+ - Document the Anthropic rate-limit caveat + monitoring hint (`grep "rate limit" pytest-stderr.log`).
+ - Document the downgrade path (`-n 2` or `-n 1`).
+ - Document that `cli_subprocess` and `wheel_smoke` markers stay serial (no `-n` flag).
+ - Cross-reference `scripts/measure_e2e_cost.py` for post-run cost rollup.
+
+**Done when:** `pytest-xdist` importable in dev shell; CONTRIBUTING reads coherently; canonical validation passes.
+
+**Acceptance criteria:**
+- `python -c "import xdist"` succeeds in a `uv sync --dev` shell.
+- `uv run pytest -m e2e -n 3 --collect-only` exits cleanly (does NOT need the live env vars — `--collect-only` just verifies xdist can plan the run).
+- CONTRIBUTING.md enumeration lists 5 e2e files (incl. business_rules).
+- CONTRIBUTING.md mentions `-n 3` AND the rate-limit caveat AND the downgrade path AND `scripts/measure_e2e_cost.py`.
+- A test that grep-asserts the CONTRIBUTING.md surface contains all five enumerated test files passes (parity gate, mirrors the `cli-layer.md` 5-surface pattern).
+- Canonical validation passes.
+
+**Depends on:** US-003 (CONTRIBUTING references the script).
+
+**TDD:** A parity test under `tests/` greps the CONTRIBUTING.md surface for each of the 5 e2e file basenames + the `-n 3` invocation + the `pytest-xdist` rate-limit caveat phrasing. (Defensive — exists to catch a future regression that drops one entry; covers the `business_rules` doc-gap and prevents it from recurring.)
+
+---
+
+### US-005 — Maintainer live re-run + measured baseline capture
+
+**Description.** **Maintainer-only.** Maintainer runs `uv run pytest -m e2e -n 3 --no-cov` (or `-n 2` / `-n 1` if Anthropic retries spike during a dry-run) against their billing project. After the run, for each test, points `scripts/measure_e2e_cost.py` at the test's `tmp_path` `.signalforge/` dir (recoverable from `/tmp/pytest-of-/pytest-current/` or via a `tmp_path` retention flag). Aggregates results: per-test wall-clock + per-test USD + per-provider USD + grand total + measured wall-clock for the `-n 3` parallel run vs an `-n 1` serial baseline for at least one comparison data point. Pastes the numbers into this plan's refinement log under a new "Measured baseline (YYYY-MM-DD)" subsection.
+
+**Traces to:** DEC-008.
+
+**Files:**
+- `plans/super/157-e2e-cost-and-parallel.md` § Refinement Log — new "Measured baseline" subsection. Block format: a wall-clock table per test, a USD-rollup table per provider, the `-n 3` vs `-n 1` comparison data point, the pricing-table version stamp.
+
+**Done when:** Measured baseline lands in the plan doc; bead is closed by the maintainer with a notes link pointing to the run's `pytest-stderr.log`.
+
+**Acceptance criteria:**
+- Plan doc carries: per-test wall-clock seconds, per-test USD breakdown, per-provider grand total, the `-n 3` vs serial wall-clock comparison, the `PRICE_TABLE_VERSION` stamp.
+- Any rate-limit retries observed are noted (or "none observed").
+- Notes record the actual concurrency the maintainer ran (`-n 1` / `-n 2` / `-n 3`).
+- Canonical validation passes (the doc edit is a pure markdown change).
+
+**Depends on:** US-003, US-004.
+
+**TDD:** N/A (manual measurement).
+
+**Operational notes for maintainer (paste into bead description at devolve):**
+- Capture `/tmp/pytest-of-$USER/pytest-current/` BEFORE the next test invocation overwrites it.
+- For comparable concurrency measurement, run `pytest -m e2e -n 1 --no-cov` immediately after the `-n 3` run on the same project to get a serial wall-clock data point. Optional but useful.
+- Sanity-check totals against Anthropic's billing dashboard if available.
+
+---
+
+### US-006 — Lift measured baseline into the 3 doc surfaces
+
+**Description.** Once US-005 lands a measured baseline in the plan's refinement log, lift the numbers into the three user-facing doc surfaces: `plans/super/155-gemini-truncation-e2e-gap.md` DEC-010 + the architecture-review "Cost / cadence" row; `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" (replace the `$0.30` figure); `docs/grade-ops.md` § Cost guidance (add per-provider rows). Frame numbers per the `warehouse-adapters.md` precedent: "calibration signal, not a billing guarantee" + date-stamp with `PRICE_TABLE_VERSION`.
+
+**Traces to:** all of DEC-Q1, DEC-Q2, DEC-001, DEC-008.
+
+**Files:**
+- `plans/super/155-gemini-truncation-e2e-gap.md` — update DEC-010's `$0.30/full-suite run` figure + the architecture-review table's "Cost / cadence" row.
+- `CONTRIBUTING.md` § "Live e2e suite (pre-release only)" — replace `≈ $0.30 per full-suite run` (line 106) with the measured figure + date-stamp + pricing-table version.
+- `docs/grade-ops.md` § Cost guidance — replace the single Sonnet 4.6 line with a per-provider table (Anthropic, OpenAI, Gemini) showing input/output prices × the measured per-test calls. Add the "calibration signal, not billing guarantee" framing.
+
+**Done when:** All 3 surfaces reflect the measured baseline + the framing caveat; canonical validation passes.
+
+**Acceptance criteria:**
+- No surface still contains `$0.30` as the suite-cost reference.
+- `docs/grade-ops.md` § Cost guidance has a 3-row provider table (Anthropic, OpenAI, Gemini).
+- Each surface date-stamps the measurement and includes `PRICE_TABLE_VERSION`.
+- Each surface includes the "calibration signal, not a billing guarantee" framing.
+- A parity test (extend US-004's grep gate) asserts the three surfaces all quote the same headline number (gate against future drift).
+- Canonical validation passes.
+
+**Depends on:** US-005.
+
+**TDD:** Extend the US-004 parity gate to assert the same dollar figure appears across the three surfaces (gate-over-prompt per `testing-signal.md` § "Gate-over-prompt").
+
+---
+
+### US-Quality-Gate — Code review × 4 + CodeRabbit + canonical validation
+
+**Description.** Run the code reviewer 4 times across the full changeset, fixing all real bugs found each pass. Run CodeRabbit review if available. Canonical validation must pass green after all fixes. **No traceback / no lazy-format f-string-logger regression** floors carry across.
+
+**Done when:** 4 code-review passes show no remaining real bugs; CodeRabbit review (if available) clean or addressed; canonical validation green.
+
+**Acceptance criteria:**
+- Each of 4 code-review passes is logged in the bead notes with the resulting fix commits.
+- All `.claude/rules/` constraints identified in Discovery (Subagent C) are honoured by the final diff.
+- Canonical validation passes: `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`.
+- Logger grep gate (`tests/llm/test_logger_grep_gate.py`) green — extending to the new `signalforge.llm.cost` if/when it logs.
+
+**Depends on:** US-001 … US-006 (all implementation stories).
+
+---
+
+### US-Patterns-and-Memory — Durable convention capture
+
+**Description.** Update `.claude/rules/testing-signal.md` § "End-to-end gated tests" with the durable parallel-safe convention: tests using `apply_provider_override` + `tmp_path` isolation + `copy_fixture_to_tmp` are xdist-safe; document the Anthropic rate-limit caveat + `-n 3` recommendation. Add a brief mention of `signalforge.llm.cost.rollup_audit_dir` as the post-run cost-audit surface. Memory entries for any non-obvious lessons learned (e.g. "scan-7 expanded to walk nested errors.py").
+
+**Traces to:** DEC-009.
+
+**Files:**
+- `.claude/rules/testing-signal.md` — extend the e2e § with the parallel-safety convention + `-n 3` invocation + rate-limit caveat + post-run cost-rollup pointer.
+- Memory entry (one of, if learned anything non-obvious): "scan-7 generalises to nested `errors.py`" — but only if the implementation revealed something not derivable from CLAUDE.md.
+
+**Done when:** Rule file updated; canonical validation passes; new memory written (if applicable) with the standard frontmatter shape.
+
+**Acceptance criteria:**
+- `testing-signal.md` § "End-to-end gated tests" carries the parallel-safety convention.
+- Convention names `apply_provider_override` + `tmp_path` + `copy_fixture_to_tmp` as the load-bearing isolation primitives.
+- Mentions the rollup helper and CONTRIBUTING.md as the cross-references.
+- Canonical validation passes.
+
+**Depends on:** US-Quality-Gate (runs last, captures lessons across the whole set).
+
+---
+
+### Right-sizing check
+
+| Story | Files | Risk | Ralph-shaped? |
+|---|---|---|---|
+| US-001 | ~5 new + 1 edit | low (mechanical) | ✓ |
+| US-002 | 1 new (full impl) + ~2 tests | medium (pricing arithmetic) | ✓ |
+| US-003 | 1 new script + 2 tests | low | ✓ |
+| US-004 | 1 config + 1 doc + 1 test | low | ✓ |
+| US-005 | manual run + 1 doc edit | n/a (maintainer-only) | ✗ — gate at devolve |
+| US-006 | 3 doc edits + 1 test ext. | low | ✓ |
+| US-QG | review across diff | n/a | ✓ |
+| US-Patterns | 1 rule edit (+ memory) | n/a | ✓ |
+
+All Ralph-eligible stories fit in one context window. US-005 is the explicit maintainer hand-off; bead description names the maintainer at devolve.
+
+### Rules compliance audit
+
+- ✓ `cli-layer.md` — tier-2 mapping, dual registration, scan-7 extension, no-traceback floor.
+- ✓ `manifest-readers.md` — `extra="forbid"` not applicable (using frozen dataclass per DEC-004); typed errors carry `default_remediation`; symlink-hardened path canonicalisation.
+- ✓ `testing-signal.md` — deterministic fixtures, no `assert True`-shaped tests, planted-violation regression for scan-7 extension.
+- ✓ `python-build.md` — dual-list dev dep (DEC-007); `scripts/` excluded from wheel (DEC-006 + US-003 wheel_smoke).
+- ✓ `ci-supply-chain.md` — no workflow changes; `uv sync --dev` flows through automatically.
+- ✓ `docs-publishing.md` — `docs/grade-ops.md` edit propagates to the published site via `mkdocs.yml` nav (already configured).
+- ✓ `llm-drafter.md` / `grade-layer.md` — grade engine stays sequential per DEC-004/DEC-027; parallelism is only at the test-node level.
+- ✓ `safety-layer.md` — no audit-event construction outside its blessed module (helper is read-only).
+- N/A `prune-engine.md`, `diff-renderer.md`, `warehouse-adapters.md`, `ingest-layer.md`, `business-rule-tests.md`, `skill-parity.md` — no touch.
+
+## Beads Manifest (Phase 7)
+
+Created 2026-05-29 via `bd create` from the worktree at
+`/home/wesd/Projects/worktrees/SignalForge/157-e2e-cost-parallel`.
+
+- **Epic:** `bd_1-scaffolding-e1a` — 157: E2E cost docs + parallelization
+- **Children (8):**
+ - `bd_1-scaffolding-e1a.1` — US-001: subpackage skeleton + errors *(ready — no deps)*
+ - `bd_1-scaffolding-e1a.2` — US-002: rollup engine (TDD) *(blocked by .1)*
+ - `bd_1-scaffolding-e1a.3` — US-003: scripts/measure_e2e_cost.py *(blocked by .2)*
+ - `bd_1-scaffolding-e1a.4` — US-004: pytest-xdist + CONTRIBUTING *(blocked by .3)*
+ - `bd_1-scaffolding-e1a.5` — US-005: **maintainer live re-run** *(blocked by .3, .4; assignee: wjduenow)*
+ - `bd_1-scaffolding-e1a.6` — US-006: lift measured baseline into 3 docs *(blocked by .5)*
+ - `bd_1-scaffolding-e1a.7` — Quality Gate *(blocked by .1–.6)*
+ - `bd_1-scaffolding-e1a.8` — Patterns & Memory *(blocked by .7)*
+
+`bd ready` immediately after devolve: only US-001 (.1) is unblocked, as expected from the dependency graph.
+
+**Next steps:**
+1. Run Ralph: `/ralph-run` (will pick up `bd_1-scaffolding-e1a.1` first).
+2. Ralph will stop at US-005 (maintainer-only); maintainer runs the live suite manually, pastes measured baseline into this plan's refinement log, then closes `.5`.
+3. Ralph resumes US-006 → Quality Gate → Patterns & Memory.
+4. When done: `/closeout`.
diff --git a/plans/super/159-drafter-column-types.md b/plans/super/159-drafter-column-types.md
new file mode 100644
index 00000000..96b4a6e0
--- /dev/null
+++ b/plans/super/159-drafter-column-types.md
@@ -0,0 +1,317 @@
+# 159 — Drafter column-type awareness (test_e2e_business_rules flake)
+
+## Meta
+
+- **Ticket:** https://github.com/wjduenow/SignalForge/issues/159
+- **Title:** test_e2e_business_rules flake: drafter emits custom_sql with column-type mismatch
+- **Phase:** devolved
+- **PR:** https://github.com/wjduenow/SignalForge/pull/161
+- **Branch:** `feature/159-drafter-column-types`
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/159-drafter-column-types`
+- **Started:** 2026-05-28
+
+## Summary
+
+The `test_e2e_business_rules_drafts_prunes_custom_sql` e2e test flakes because the LLM drafter generated a `custom_sql` business-rule test comparing an INT64 column to a STRING column. BigQuery rejected the query; the prune engine correctly routed to `kept-without-evidence` per the conservative-bias contract; the test asserts `kept` with positive evidence and fails.
+
+Root cause: **the Austin fixture's `manifest.json` has `data_type: null` for every column.** The full drafter / safety / prompt-rendering pipeline already supports column types — both the cached manifest summary (`prompts.py:347`) and the dynamic data section (`safety/request.py:161`) read `Column.data_type` and render it into the LLM prompt — but the input is empty for this fixture, so the LLM sees no type information and guesses.
+
+## Discovery — key findings
+
+1. **`Column.data_type` is already wired end-to-end in production code.** The drafter does NOT need to "learn" types. The cached block renders `- {name} ({data_type or "UNKNOWN"}): {description}` at `src/signalforge/draft/prompts.py:347`. The dynamic block's data section receives `request.schema` as `tuple[(name, type_str)]` from the safety layer at `src/signalforge/safety/request.py:161`, where `type_str = column.data_type or ""`.
+
+2. **`dbt parse` does NOT populate `data_type`.** Only `dbt docs generate` (which produces `catalog.json`) carries column types. The Austin fixture was generated by `dbt parse` against bikeshare; its `data_type` fields are null. **Real users running `dbt docs generate` would already get types** — this is partially a fixture problem.
+
+3. **No parser-level type-mismatch check.** `_validate_anchor_contract` in `src/signalforge/draft/parser.py:87–191` is purely structural — column-set membership + test-column linkage. There is no defence catching a type-mismatched `custom_sql` post-LLM-response. Prompts are advisory; if we depend on the LLM honoring types, we want dual-defence.
+
+4. **Cache rotation risk is bounded.** The cache-stability golden (`tests/llm/test_prompt_cache_stability.py:71` pins `_EXPECTED_PROMPT_VERSION = "c9e7ee1f6f465933"`) uses the `fct_orders` fixture, NOT bikeshare. Populating the Austin fixture's `data_type` fields does NOT touch the golden. Template-text changes WOULD rotate the version.
+
+5. **`Column.data_type` is already in the public Pydantic model** (`src/signalforge/manifest/models.py:97`) as `str | None = None`, drift-detected. No model surgery needed.
+
+6. **Same fixture serves multiple e2e tests.** `tests/fixtures/dbt_project_austin/target/manifest.json` is used by both `test_e2e_bigquery_smoke.py` and `test_e2e_business_rules.py`. Populating `data_type` in this manifest will affect the dynamic block bytes for BOTH; only the cache-stability test pins a golden, and it uses a different fixture.
+
+7. **The warehouse adapter knows types but doesn't surface them to the manifest layer.** `BigQueryAdapter._flush_column_stats_batch` (lines 839–912 in `adapters/bigquery.py`) populates `ColumnStats.data_type: str` from `INFORMATION_SCHEMA`. No path connects this back to `Manifest.Column.data_type` for drafter consumption.
+
+## Resolution-option tree
+
+| Option | Description | Cost | Helps real users? | Touches |
+|---|---|---|---|---|
+| **A. Fixture-only type populate** | Edit `tests/fixtures/dbt_project_austin/target/manifest.json` to add real `data_type` values | XS | No (fixture-only) | One JSON file |
+| **B. Merge `catalog.json` into manifest read** | When `/target/catalog.json` exists, merge its column `type` fields into `Column.data_type` during manifest load | M | **Yes** — any user running `dbt docs generate` benefits | `manifest/loader.py`, `manifest/models.py`, drift detector, docs |
+| **C. Warehouse-adapter type stitch** | At draft time, call `adapter.get_column_types(table)` and overlay onto `Model.columns` before rendering | L | **Yes** — works even without `dbt docs generate` | New adapter method per warehouse, drafter overlay seam, audit implications |
+| **D. Parser-level type-mismatch defence** | Extend `_validate_anchor_contract` to reject `custom_sql` whose SQL is type-incoherent given known column types | M | **Yes (defensive)** — dual-defence per `llm-drafter.md` | `parser.py`, anchor-contract test surface |
+| **E. Type-safe rule swap** | Edit the test's injected business rules to ones that don't risk cross-type comparison | XS | No (test-only) | `test_e2e_business_rules.py` |
+| **F. Accept kept-without-evidence** | Relax `has_kept_with_evidence` assertion | XS | No | One assertion line |
+
+**Combined paths** (not mutually exclusive):
+- **A** alone — closes the immediate flake; ships no product value; cheap.
+- **A + B** — closes the flake AND ships real product value for `dbt docs generate` users.
+- **A + B + D** — A+B plus a defence so a future fixture without types degrades to `kept-without-evidence` consistently (not a hard fail).
+- **C** — addresses the "user doesn't have `dbt docs generate`" gap; bigger surface; v0.3-y.
+
+## Scoping (Phase 1 → 2)
+
+User locked the following:
+
+- **Scope:** A + B + D (fixture populate + catalog.json merge + sqlglot parser defence)
+- **Catalog discovery:** Sibling lookup against `/catalog.json`; no new config knob
+- **Parser defence depth:** Full sqlglot AST type-checking (not regex-only)
+- **Test assertion:** Keep `has_kept_with_evidence` as-is; engineered determinism
+
+## Architecture review (Phase 2)
+
+Three parallel subagent reviews completed. Consolidated table:
+
+| Area | Rating | Disposition |
+|---|---|---|
+| sqlglot already in dep tree (transitive via `fakesnow`, dev-only) | PASS | Promoting to runtime is a genuine add for PyPI users, not a marker flip |
+| sqlglot BigQuery type inference catches INT64 vs STRING | PASS | `annotate_types` + `COERCES_TO` table; verified |
+| sqlglot performance | PASS | Sub-50ms per draft; negligible |
+| catalog.json sibling lookup + path safety | PASS | Reuse `_common.path_safety.canonicalise_path` |
+| Stale catalog risk (extra/missing columns) | **CONCERN** | Silent merge, never block; conservative-bias matches the rule |
+| Case-sensitivity of catalog column names (Snowflake uppercases) | **CONCERN** | Need case-insensitive match policy |
+| `_PROMPT_VERSION` cache rotation | PASS | NO rotation — version is template-hash; per-project content variation already allowed today |
+| Cache-stability golden (`fct_orders` fixture) | PASS | Fixture already has `data_type` populated; unaffected by catalog merge |
+| Error class: extend `LLMOutputAnchorContractError.violations` vs new subclass | PASS | Extend existing; saves 5-surface parity rework |
+| Parser integration shape (collect-all invariant) | PASS | Append to existing `violations` tuple |
+| Skip-when-uncertain policy for sqlglot check | **CONCERN** | Need explicit rules (CAST / COALESCE / function / unknown type → skip) |
+| Drift detector | PASS | `Column.data_type` already exists & drift-detected |
+| 5-surface parity for catalog merge | PASS | Code / rules / docs / test / DEC list |
+| 5-surface parity for parser defence | PASS | Code / rules / docs / test / DEC list |
+| New CLI flag / config knob | PASS | None added |
+| Python 3.13 ELOOP path safety on catalog read | PASS | `_canonicalise_path` already handles both `RuntimeError` and `OSError(ELOOP)` |
+
+**Three concerns to resolve in refinement; no blockers.**
+
+### Critical facts surfaced
+
+1. **sqlglot v30.2.1 is in `uv.lock` via `fakesnow` (dev extra).** A direct runtime pin in `[project].dependencies` is a real dependency addition; PyPI users of `signalforge-dbt` will pick up sqlglot transitively from then on. sqlglot is MIT-licensed, pure-Python, ~15MB — acceptable but deliberate.
+2. **No `_PROMPT_VERSION` rotation needed.** Per `llm-drafter.md`, the version is `blake2b(_SYSTEM_PROMPT + _MANIFEST_SUMMARY_TEMPLATE + DATA_SECTION_TEMPLATES_JSON)` — template-only. Per-project rendered bytes have always varied (different manifests → different output); only template edits rotate the version. Populating `data_type` falls under per-project variation.
+3. **Cache-stability golden uses `fct_orders` fixture**, which already has populated `data_type` fields (`NUMERIC`, `STRING`, `TIMESTAMP`, `INT64`). The golden is unaffected by anything we do to the Austin fixture or by adding catalog.json merging.
+4. **Reviewer disagreement resolved:** One reviewer claimed catalog.json merge requires `_PROMPT_VERSION` rotation; this is incorrect (conflated template-vs-content). The codebase's posture is template-hash only.
+
+
+## Refinement log (Phase 3) — locked decisions
+
+- **DEC-001 — Scope.** Implement A + B + D: populate `data_type` in the Austin fixture (closes the flake), merge `target/catalog.json` types into `Column.data_type` during manifest load (product value for `dbt docs generate` users), add sqlglot-based type-coherence defence in `_validate_anchor_contract` for `custom_sql` (dual-defence per `llm-drafter.md`).
+
+- **DEC-002 — Catalog discovery.** Sibling lookup at `/catalog.json`. No new config knob, no CLI flag. Mirrors how dbt itself locates the file. Path is canonicalised via `_common.path_safety.canonicalise_path` (`manifest-readers.md` § Symlink-hardened path resolution).
+
+- **DEC-003 — Parser defence depth.** Use sqlglot AST type-checking via `optimizer.annotate_types` with a schema map built from `model.columns_list`. The BigQuery dialect's `COERCES_TO` table catches INT64 vs STRING; numeric-family coercions (INT64↔FLOAT64, NUMERIC↔BIGNUMERIC) stay accepted as legitimate.
+
+- **DEC-004 — Test assertion.** `test_e2e_business_rules_drafts_prunes_custom_sql::has_kept_with_evidence` stays as-is. Engineering the input (types in manifest) makes the assertion mathematically reachable per `testing-signal.md` § Engineered determinism.
+
+- **DEC-005 — sqlglot dependency.** Promote sqlglot to a direct runtime pin in `[project].dependencies` as `sqlglot>=30,<31`. Currently a dev-only transitive (via `fakesnow`); type-defence correctness is load-bearing and cannot depend on transitive resolution from a dev-only package. Mirror entry in `[project.optional-dependencies].dev` per `python-build.md` § uv-managed dev environment.
+
+- **DEC-006 — Skip-when-uncertain policy.** The sqlglot type check flags ONLY direct `Column Column` comparison nodes where:
+ 1. Both operands are bare `Column` AST nodes (NOT `Cast`, `SafeCast`, `Coalesce`, `IfNull`, function calls, subqueries, literals, NULL, window functions)
+ 2. Both columns appear in the schema map with non-None `data_type`
+ 3. The two types are not in the `COERCES_TO`-compatible set for the dialect
+ Every other shape skips silently. `sqlglot.errors.ParseError` from `parse_one` also skips silently (invalid SQL is caught downstream by the warehouse adapter). False-positive avoidance trumps marginal recall.
+
+- **DEC-007 — Catalog column matching.** Case-insensitive lookup via `lower(col_name)` key. Snowflake's catalog.json uppercases identifiers; BigQuery preserves case. The lower-fold is a strict superset for both warehouses. No logging — manifest layer is stage-0 (`manifest-readers.md` § No logging in stage-0).
+
+- **DEC-008 — sqlglot import confinement.** Convention only (documented in `.claude/rules/llm-drafter.md`): sqlglot imports live ONLY in `signalforge.draft.parser`. No AST scan in v0.1 (one consumer; the bigger surface that justified AST scans was the 4+ vendor-SDK pattern). Revisit if a second module reaches for sqlglot.
+
+- **DEC-009 — No `_PROMPT_VERSION` rotation.** `_PROMPT_VERSION = blake2b(_SYSTEM_PROMPT + _MANIFEST_SUMMARY_TEMPLATE + DATA_SECTION_TEMPLATES_JSON)` is a TEMPLATE hash. Per-project rendered bytes have always varied (different manifests → different output); only template-text edits rotate the version. Populating `data_type` from catalog.json is per-project content variation. The cache-stability golden uses `fct_orders` (which already has populated types); golden is unaffected.
+
+- **DEC-010 — Stale catalog handling.** When catalog.json exists but is stale relative to manifest.json: (a) catalog columns NOT in manifest are silently ignored (never add phantom columns to `Model.columns`); (b) manifest columns NOT in catalog keep `data_type = None` (no change vs. today); (c) `json.JSONDecodeError` / `OSError` reading catalog → silent skip, manifest loads with all `data_type = None`. Conservative-bias: never block load on catalog mismatches. No typed `CatalogError`.
+
+- **DEC-011 — Error class reuse.** Type-mismatch violations append to the existing `LLMOutputAnchorContractError.violations` tuple. No new error subclass. Saves a CLI exit-code-table entry, a 7th-AST-scan exclusion update, and a 5-surface parity sweep. The violation message names the column, the conflicting types, and the operator (`"custom_sql test references column 'a' (INT64) and 'b' (STRING) in '<>' comparison — types incompatible"`).
+
+- **DEC-012 — Parser API.** `_validate_anchor_contract` gains two keyword-only parameters: `model_columns_by_type: Mapping[str, str | None] | None = None` (column-name → `data_type` or None) and `dialect_name: str = "bigquery"` (string, not the typed `Dialect` value object — avoids cross-stage import). When `model_columns_by_type is None` OR every column's type is None, the type-coherence arm is a no-op. Threaded through `parse_draft_response` from `draft_from_request`; orchestrator builds the map from `model.columns_list`.
+
+- **DEC-013 — Dialect threading.** `dialect_name` is sourced from `safety_policy.warehouse_dialect_name` if it exists; otherwise hardcoded to `"bigquery"` at the orchestrator call site with a TODO referencing v0.2 multi-warehouse work. v0.1 supports BigQuery only at the warehouse layer; Snowflake/Postgres are skeletons.
+
+## Detailed breakdown (Phase 4)
+
+Architecture ordering — stage-0 reader → drafter → parser → fixtures → docs → quality. Each story is sized to one Ralph context window.
+
+---
+
+### US-001 — Manifest loader: catalog.json sibling reader & type merge
+
+**Description:** Extend `signalforge.manifest.loader` to load a sibling `target/catalog.json` (when present) and merge its column types into `Column.data_type` on the in-memory `Manifest`. Silent-skip on missing/malformed catalog. Case-insensitive column matching.
+
+**Traces to:** DEC-001, DEC-002, DEC-007, DEC-010
+
+**TDD test cases (write first, then implement):**
+- `test_load_merges_catalog_types_into_columns` — happy path: load fixture with manifest + catalog; assert `column.data_type` matches catalog type.
+- `test_load_catalog_missing_is_silent` — no catalog.json; load succeeds; all `data_type` stay `None`; no log emitted.
+- `test_load_catalog_malformed_json_is_silent` — corrupt catalog.json; manifest still loads; types stay `None`.
+- `test_load_catalog_oserror_is_silent` — catalog.json present but unreadable (mode 0o000 in tmp_path); manifest still loads.
+- `test_load_catalog_column_case_insensitive_match` — manifest `user_id`, catalog `USER_ID`; type merges correctly.
+- `test_load_catalog_phantom_column_ignored` — catalog declares column not in manifest; not added to `Model.columns`.
+- `test_load_catalog_missing_column_stays_null` — manifest has column with no catalog entry; `data_type` is `None`.
+- `test_load_catalog_path_canonicalised` — catalog.json resolved through `_canonicalise_path`; symlink outside project rejected via `PathContainmentError`.
+
+**Acceptance Criteria:**
+- `signalforge.manifest.load()` reads `.parent / "catalog.json"` when present.
+- Per-column merge uses `Column.model_copy(update={"data_type": catalog_type})` (frozen-model pattern from `loader.py:37`).
+- Case-insensitive match keyed on `lower(col_name)`.
+- Missing / malformed / unreadable catalog: silent no-op.
+- No logging in the loader (stage-0 invariant).
+- Canonical validation passes: `uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest tests/manifest/`.
+
+**Done when:** All eight TDD cases pass; `uv run pytest tests/manifest/test_loader.py -v` green; no log output from any catalog code path.
+
+**Files:**
+- `src/signalforge/manifest/loader.py` — add `_load_catalog_overlay` helper + integrate post-Manifest construction at the existing post-validate seam (~line 333).
+- `tests/manifest/test_loader.py` — add new test block at end.
+- `tests/fixtures/manifest/catalog_*.json` — new fixtures (canonical-shape + case-mismatch + malformed).
+
+**Depends on:** none
+
+---
+
+### US-002 — Drafter parser: sqlglot type-coherence defence
+
+**Description:** Add a sqlglot-based type-coherence check to `_validate_anchor_contract` in `signalforge.draft.parser`. For each `custom_sql` test, parse the SQL with `sqlglot.parse_one(dialect="bigquery")`, annotate types against a schema map built from the model, and append violations for direct `Column Column` comparisons where the two known types are incompatible. Skip silently on every other shape per DEC-006.
+
+**Traces to:** DEC-001, DEC-003, DEC-005, DEC-006, DEC-008, DEC-011, DEC-012, DEC-013
+
+**TDD test cases (write first, then implement):**
+
+Planted positives (must add a violation):
+- `test_custom_sql_int64_vs_string_comparison_is_rejected` — `WHERE int_col <> str_col`; flags violation naming both columns + types + operator.
+- `test_custom_sql_int64_vs_string_equality_is_rejected` — same with `=`.
+- `test_custom_sql_int64_vs_date_comparison_is_rejected` — INT64 vs DATE.
+
+Planted negatives (must NOT add a violation):
+- `test_custom_sql_int64_vs_float64_accepted_numeric_coercion` — legitimate cross-numeric.
+- `test_custom_sql_numeric_vs_bignumeric_accepted` — same family.
+- `test_custom_sql_cast_around_string_skipped` — `WHERE CAST(int_col AS STRING) <> str_col`; skipped.
+- `test_custom_sql_coalesce_skipped` — `WHERE COALESCE(int_col, 0) <> other_col`; skipped.
+- `test_custom_sql_safe_cast_skipped` — BigQuery `SAFE_CAST` shape; skipped.
+- `test_custom_sql_null_comparison_skipped` — `WHERE col IS NOT NULL`; skipped.
+- `test_custom_sql_literal_compare_skipped` — `WHERE int_col <> 0`; literal side, skipped (already covered by other checks).
+- `test_custom_sql_function_call_skipped` — `WHERE LENGTH(str_col) > 0`; skipped.
+- `test_custom_sql_subquery_skipped` — `WHERE col IN (SELECT id FROM other)`; skipped.
+
+Robustness:
+- `test_custom_sql_unparseable_sql_is_silent` — `parse_one` raises `ParseError`; no violation appended; structural anchor-contract checks still run.
+- `test_custom_sql_unknown_column_type_skipped` — both columns have `data_type = None`; no violation (existing degrade preserved).
+- `test_custom_sql_partial_unknown_skipped` — one column has type, other is None; skip.
+- `test_custom_sql_model_columns_by_type_none_skips_arm_entirely` — passing `None` as `model_columns_by_type` makes the type-coherence arm a no-op (structural checks still run).
+- `test_validate_anchor_contract_collects_type_and_structural_violations` — collect-all invariant: a candidate with BOTH a hallucinated column AND a type mismatch produces BOTH violations.
+
+**Acceptance Criteria:**
+- `_validate_anchor_contract(candidate, model_columns, *, model_columns_by_type=None, dialect_name="bigquery", exclude_tests=frozenset())` is the new signature.
+- New private helper `_check_custom_sql_type_coherence(sql, model_columns_by_type, dialect_name)` encapsulates sqlglot use.
+- sqlglot imports live ONLY in `parser.py` (DEC-008 convention).
+- Skip-when-uncertain policy from DEC-006 is implemented; **every "skipped" shape has a dedicated test**.
+- Collect-all invariant preserved: type violations append to the same `violations` list as structural checks; never short-circuit.
+- `parse_draft_response` signature extended to accept `model_columns_by_type` and `dialect_name`; callers updated.
+- `draft_from_request` (in `signalforge.draft.schema`) builds the type map from `model.columns_list` and threads through.
+- Canonical validation: `uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest tests/draft/`.
+
+**Done when:** All 17 TDD cases pass; full test suite green; no new pyright errors; no sqlglot reference outside `signalforge.draft.parser`.
+
+**Files:**
+- `pyproject.toml` — add `sqlglot>=30,<31` to `[project].dependencies` AND `[project.optional-dependencies].dev` (mirror per `python-build.md`).
+- `src/signalforge/draft/parser.py` — `_check_custom_sql_type_coherence` helper, extended `_validate_anchor_contract` and `parse_draft_response` signatures, sqlglot import at module top.
+- `src/signalforge/draft/schema.py` — `draft_from_request` builds + threads `model_columns_by_type` and `dialect_name`.
+- `tests/draft/test_parser.py` — new test block.
+- `uv.lock` — regenerate via `uv lock`.
+
+**Depends on:** US-001 (manifest layer needs to surface `data_type` for the schema map to carry real types; otherwise the parser defence has no input on which to act).
+
+---
+
+### US-003 — Austin fixture: populate data_type + add catalog.json
+
+**Description:** Populate `data_type` fields in `tests/fixtures/dbt_project_austin/target/manifest.json` (for the columns that the e2e tests touch) AND author a sibling `target/catalog.json` carrying the same types. The manifest populate alone closes the flake; the catalog companion exercises the US-001 read path end-to-end on a real e2e.
+
+**Traces to:** DEC-001 (sub-option A), DEC-004
+
+**Acceptance Criteria:**
+- `tests/fixtures/dbt_project_austin/target/manifest.json` — each column on `stg_bikeshare_trips` carries a real BigQuery `data_type` ("INT64" for `trip_id` / `bike_id` / `duration_minutes`; "STRING" for `subscriber_type` / `start_station_id` / `end_station_id`; "TIMESTAMP" for `start_time`). Source of truth: `bigquery-public-data.austin_bikeshare.bikeshare_trips` real schema.
+- `tests/fixtures/dbt_project_austin/target/catalog.json` — minimal dbt-canonical-shape catalog with `nodes["model.signalforge_test_austin.stg_bikeshare_trips"].columns[*].type` populated identically.
+- `test_e2e_business_rules_drafts_prunes_custom_sql` runs to green with `has_kept_with_evidence` (when gated `-m e2e` markers are run): the LLM, now seeing types, emits a type-coherent `custom_sql` (or, if it still emits a mismatch, the parser defence from US-002 catches it and drives the always-passes drop / kept-with-evidence balance per the fixture's other rules).
+- `test_e2e_bigquery_smoke` (the sibling smoke that shares the fixture) still passes — populating types does not perturb the always-passes column path.
+- Canonical validation green; default (non-e2e) pytest run unchanged.
+
+**Done when:** Fixture committed; default `uv run pytest` green; this story's own commit changes ONLY fixture/demo data files (`tests/fixtures/dbt_project_austin/target/{manifest,catalog}.json` plus their `src/signalforge/_demo/target/` mirrors per the `tests/test_demo_fixture_parity.py` gate — no source code). The cumulative diff against the base branch will of course include the upstream US-001 + US-002 changes via the dependency chain — that is expected and is NOT what this criterion scopes.
+
+**Files:**
+- `tests/fixtures/dbt_project_austin/target/manifest.json` — edit
+- `tests/fixtures/dbt_project_austin/target/catalog.json` — new
+- `src/signalforge/_demo/target/manifest.json` — edit (DEC-008 of #47 demo-fixture parity gate; mirror of the test fixture)
+- `src/signalforge/_demo/target/catalog.json` — new (same)
+
+**Depends on:** US-001 (loader must already read catalog.json for the new fixture to exercise it end-to-end), US-002 (parser defence must coexist with type-populated drafts so the gated e2e behaviour is well-defined).
+
+---
+
+### US-004 — Rules + docs updates (5-surface parity)
+
+**Description:** Document the two new behaviours in the rules + ops docs surfaces, per `cli-layer.md` § "Multi-surface parity for behaviour changes".
+
+**Traces to:** DEC-001, DEC-002, DEC-005, DEC-006, DEC-007, DEC-008, DEC-009, DEC-010, DEC-011, DEC-012
+
+**Acceptance Criteria:**
+- `.claude/rules/manifest-readers.md` — new section "Catalog.json sibling merge (issue #159)" documenting: sibling-lookup contract, case-insensitive matching, silent degradation on missing/malformed, no-logging stage-0 invariant.
+- `.claude/rules/llm-drafter.md` — under "Whole-draft fail-loud anchor contract", new sub-section "Sqlglot type-coherence check (issue #159)" documenting: skip-when-uncertain policy (DEC-006 cases enumerated), sqlglot-import-confinement convention (DEC-008), no `_PROMPT_VERSION` rotation rationale (DEC-009), `LLMOutputAnchorContractError` reuse (DEC-011).
+- `docs/manifest-loader-ops.md` (if absent, use the closest existing doc — check `docs/`) — add operator-facing "Column types: catalog.json sourcing" section explaining the contract.
+- `docs/draft-ops.md` (if absent, append to the closest drafter doc) — add operator-facing "Type-coherence defence" section.
+- `CHANGELOG.md` — entry under the unreleased section: "drafter: reject type-incoherent `custom_sql` (column-type mismatch) at parse time (#159)" and "manifest: merge `target/catalog.json` types into `Column.data_type` (#159)".
+
+**Done when:** All 5 surface files reflect the new behaviour; `git grep "#159"` shows hits in each.
+
+**Files:**
+- `.claude/rules/manifest-readers.md`
+- `.claude/rules/llm-drafter.md`
+- `docs/manifest-loader-ops.md` or closest
+- `docs/draft-ops.md` or closest
+- `CHANGELOG.md`
+
+**Depends on:** US-001, US-002, US-003 (cite real code)
+
+---
+
+### US-005 (Quality Gate) — code review x4 + CodeRabbit + canonical validation
+
+**Description:** Run the code reviewer four times across the full changeset, fixing every real bug each pass. Run CodeRabbit if configured. Run the canonical validation command end-to-end. The gate fails until every reviewer-flagged correctness/security/contract issue is fixed.
+
+**Acceptance Criteria:**
+- 4 passes of `/code-review --fix` (or equivalent) at increasing depth; every real-bug finding fixed before next pass.
+- `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest` all green.
+- If a `@pytest.mark.e2e` run is feasible, gated e2e suite executes green (`SF_RUN_BQ=1 GOOGLE_CLOUD_PROJECT= ANTHROPIC_API_KEY=sk-... uv run pytest -m e2e --no-cov`).
+- All 5-surface parity items from US-004 verified by grep.
+
+**Done when:** Validation command green on a clean machine; PR has CodeRabbit / reviewer comments addressed.
+
+**Depends on:** US-001, US-002, US-003, US-004
+
+---
+
+### US-006 (Patterns & Memory) — capture conventions learned
+
+**Description:** Update memory + rules with conventions discovered during the work. Specifically: (a) the cache-rotation-vs-content-variation distinction (which two reviewers disagreed on — note for future); (b) catalog.json discovery as a precedent for any future "dbt-adjacent target/ file" reads.
+
+**Acceptance Criteria:**
+- Memory entry on the cache-rotation distinction added (`_PROMPT_VERSION` rotates on template-text edits, NOT per-project rendered-byte variation).
+- `.claude/rules/manifest-readers.md` cross-link added to `llm-drafter.md` § Cached-block scope so the next implementer doesn't conflate the two.
+
+**Done when:** Memory file written; rule cross-link in place; this story is the last to close.
+
+**Depends on:** US-005
+
+---
+
+## Beads manifest (Phase 7)
+
+- **Epic:** `bd_1-scaffolding-crh` — #159: drafter column-type awareness
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/159-drafter-column-types`
+- **Branch:** `feature/159-drafter-column-types`
+
+| Story | Bead ID | Depends on |
+|---|---|---|
+| US-001 — manifest catalog.json sibling reader | `bd_1-scaffolding-crh.1` | — |
+| US-002 — drafter parser sqlglot defence | `bd_1-scaffolding-crh.2` | — |
+| US-003 — Austin fixture: data_type + catalog.json | `bd_1-scaffolding-crh.3` | .1, .2 |
+| US-004 — 5-surface rules + docs | `bd_1-scaffolding-crh.4` | .1, .2, .3 |
+| US-005 — Quality Gate | `bd_1-scaffolding-crh.5` | .4 |
+| US-006 — Patterns & Memory | `bd_1-scaffolding-crh.6` | .5 |
+
+US-001 and US-002 start in parallel; the rest serialize.
diff --git a/plans/super/163-drafter-business-rules-fidelity.md b/plans/super/163-drafter-business-rules-fidelity.md
new file mode 100644
index 00000000..e460f84c
--- /dev/null
+++ b/plans/super/163-drafter-business-rules-fidelity.md
@@ -0,0 +1,237 @@
+# 163 — Drafter business-rules fidelity
+
+## Meta
+
+- **Ticket:** [#163](https://github.com/wjduenow/SignalForge/issues/163) — `test_e2e_business_rules: drafter ignores meta.signalforge.business_rules and hallucinates an unrelated rule`
+- **Branch:** `feature/163-drafter-business-rules`
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/163-drafter-business-rules`
+- **Phase:** `complete` (PR [#164](https://github.com/wjduenow/SignalForge/pull/164); beads epic `bd_1-scaffolding-74b` — all stories closed)
+- **Sessions:** 1 (2026-05-29)
+
+## Symptom
+
+Live e2e run (2026-05-29, drafter=`claude-sonnet-4-6`, Austin bikeshare fixture). The `tests/cli/test_e2e_business_rules.py::test_e2e_business_rules_drafts_prunes_custom_sql` injects **two** rules into `meta.signalforge.business_rules`:
+
+1. **Tautology** (always-passes): `duration_minutes must always be greater than or equal to itself …`
+2. **Engineered failing rows** (kept): `every trip must start and end at the same station (a row violates this rule when start_station_id <> end_station_id)`
+
+The drafter emitted **one** `custom_sql` test matching **neither** rule:
+```sql
+SELECT trip_id, duration_minutes FROM {{ this }} WHERE duration_minutes <= 0
+```
+
+Pipeline completed cleanly (exit 0, audits intact, cost rollup unaffected). The failure is the drafter's instruction-following on `business_rules` — not anything SignalForge's own code paths control directly.
+
+## Discovery findings
+
+### Current rendering shape (verified in code)
+
+- `src/signalforge/draft/prompts.py:557-579` — `_render_business_rules_section(model)` renders rules as a plain bulleted list under `## BUSINESS RULES` in the **dynamic block**:
+ ```text
+ ## BUSINESS RULES
+
+ Operator-supplied business rules for this model. Draft one custom_sql test per rule, translating each into a failing-rows SELECT (a non-empty result means the rule was violated):
+
+ - (model) duration_minutes must always be greater than or equal to itself …
+ - (model) every trip must start and end at the same station …
+ ```
+- `src/signalforge/draft/prompts.py:104-115` — the **only** business-rules instruction in the cached system prompt (`_CUSTOM_SQL_SCOPE_INSTRUCTION`):
+ > "If a BUSINESS RULES section appears in the data block below, draft one `custom_sql` test per stated rule, translating the natural-language rule into a failing-rows SELECT. When no business rules are supplied, you MAY still infer `custom_sql` tests …"
+- `_PROMPT_VERSION` (`src/signalforge/draft/prompts.py:298-308`) is a `blake2b-8` hash of `_SYSTEM_PROMPT + _MANIFEST_SUMMARY_TEMPLATE + JSON(_DATA_SECTION_TEMPLATES)`. System-prompt changes rotate it; dynamic-block changes do not.
+- Parser anchor contract (`src/signalforge/draft/parser.py`) currently has **no rule-to-test cardinality check**. A run with 2 rules and 0 custom_sql tests passes validation silently.
+- Test injection helper: `tests/cli/_e2e_helpers.py:137-188` `inject_model_business_rules` writes both `config.meta.signalforge.business_rules` AND `meta.signalforge.business_rules` (belt-and-braces).
+- Existing unit tests for rendering: `tests/draft/test_prompts.py:410-426` only check that the literal rule strings + `"## BUSINESS RULES"` appear — no cardinality / numbering / envelope shape pinned.
+
+### Convention-checker constraints (the load-bearing watch-outs)
+
+1. **`business_rules` stay in the dynamic block, not the cached system prompt** (`business-rule-tests.md` DEC-001). Moving per-rule text into the cached prompt would invalidate Anthropic prompt-cache for every model that has rules. — **load-bearing**.
+2. **`_PROMPT_VERSION` rotates if and only if the cached system prompt template changes** (`llm-drafter.md` § "Cached-block scope"). If we tighten the SCOPE instruction text (Lever A), bump the version AND regenerate `tests/llm/test_prompt_cache_stability.py` golden in lockstep.
+3. **Prompt-injection envelope guard** (`llm-drafter.md` DEC-007). If we wrap rules in `` tags, `_render_dynamic_block` must raise `PromptEnvelopeBreachError` when any rule contains the closing tag. Mirrors the `` precedent.
+4. **Conservative-bias / no new `DropReason`** (`prune-engine.md`). A parser-side rejection of "too few `custom_sql` tests" should surface as `LLMOutputAnchorContractError` (collect-all violations), NOT a new prune drop-reason — the prune stage never sees the rejected candidate.
+5. **Gate-over-prompt** (`testing-signal.md`). The fix must be verifiable at unit-test time without an LLM in the loop — a unit test that injects a 2-rule payload and a hand-rolled bad candidate must reject; a 2-rule payload + matching candidate must accept.
+6. **Fail-closed audit invariant**: bad-JSON / parse-fail responses still write no audit row. A new parser rejection path must run BEFORE the audit write (current ordering already satisfies this).
+7. **Tolerant JSON extraction is the only JSON-only guarantee on `claude-sonnet-4-6`** (`llm-drafter.md` § "Tolerant JSON extraction" / issue #144). Assistant-turn prefill is API 400. Don't propose prefill-based hardening.
+8. **Provider-neutral seam** (`llm-drafter.md` DEC-006 of #135). System-prompt / dynamic-block / parser changes apply uniformly across Anthropic, OpenAI, Gemini — no branching on provider.
+9. **Exit-code lockstep** (`cli-layer.md` 7th AST scan). If we introduce a new error class, register it in `_EXCEPTION_TO_EXIT_CODE`. The simplest path — re-use `LLMOutputAnchorContractError` — needs no new entry.
+10. **Skill-parity + 5-surface graduation** (`skill-parity.md` / `cli-layer.md`). No CLI flag is being added; no SKILL.md / `docs/cli-ops.md` parity work expected. `docs/draft-ops.md` + `business-rule-tests.md` itself ARE in scope.
+
+### Domain-expert lever analysis (5 levers evaluated)
+
+| Lever | Cost | False-pos risk | Gate? | LOC |
+|---|---|---|---|---|
+| **A. System-prompt restructure** (strengthen "MUST emit one per rule"; remove "MAY infer" when rules present) | rotates `_PROMPT_VERSION` + snapshot | moderate (model may over-emit) | prompt-only | ~20 |
+| **B. Dynamic-block hardening** (number rules, wrap each in `…` + envelope-breach guard) | none (dynamic block) | low | prompt-only | ~10 |
+| **C1. Parser count-only gate** (reject when `custom_sql_count < business_rule_count`) | none (parser-side) | very low | **full gate** | ~30 |
+| **C2. Parser rule-ID gate** (require each test attribute itself to a rule via new `rule_id` field) | schema bump + prompt rotation | very low | full gate | ~80 |
+| **D. Coverage warning only** | none | none | no enforcement | ~15 |
+| **E. Two-step drafting** (one call for built-ins, one for `custom_sql` per rule) | **2× LLM cost** | moderate | prompt-focused | ~100 |
+
+**Domain expert recommendation:** Combine **B + C1** as the minimal high-confidence fix; consider escalating to A only if real-world compliance stays low after B+C1 lands.
+
+## Scoping decisions (Phase 1)
+
+- **Q1 → Lever B + C1.** Dynamic-block hardening (numbered `…` envelopes + breach guard) AND parser cardinality gate (`LLMOutputAnchorContractError` violation when count < N). No system-prompt restructure, no `_PROMPT_VERSION` rotation.
+- **Q2 → at-least-one-per-rule.** Gate rejects only when `custom_sql_count < len(business_rules)`. Excess is allowed (legitimate multi-test decomposition of a complex rule).
+- **Q3 → unit-level only.** Hand-rolled candidates in `tests/draft/test_parser.py` for the gate; rely on the existing `tests/cli/test_e2e_business_rules.py` as the live-pipeline cert.
+- **Q4 → thread through `parse_draft_response`.** Pass `business_rules: tuple[str, ...]` from `draft_from_request` → `parse_draft_response` → `_validate_anchor_contract`. Mirrors `model_columns_by_type` threading from #159 — single source of truth, no re-read drift risk.
+
+## Architecture review
+
+| Area | Rating | Note |
+|---|---|---|
+| Security | pass (with envelope guard) | `` envelope mirrors ``; closing-tag substring scan rejects rules that would break the fence. Defence-in-depth — operator content already passes the safety layer's ANSI strip earlier. |
+| Performance | pass | ≤1KB added to dynamic block per typical N=2–5 rules; zero cached-block impact; O(N_tests) parser gate. |
+| Data model | pass | No `CandidateSchema` / `CandidateTestCustomSQL` field changes. No `audit_schema_version` bump. |
+| API design | pass | `parse_draft_response` gains keyword-only `business_rules: tuple[str, ...] = ()` (non-breaking; all 36 existing parser-test call sites work unchanged). |
+| Observability | pass | Re-uses existing multi-violation `LLMOutputAnchorContractError` stderr shape (`cli-layer.md` DEC-008). No new `_LOGGER` calls. |
+| Testing | pass | Unit-level cardinality gate verifiable with hand-rolled candidates (no LLM); existing e2e is the live cert. |
+| Cache stability | pass | No `_PROMPT_VERSION` rotation; cached-block + golden snapshot untouched. |
+| Provider neutrality | pass | Parser gate is provider-independent; dynamic block stays provider-neutral. |
+| Exit-code lockstep | pass | No new error class. Re-uses tier-2 `LLMOutputAnchorContractError` and tier-2 `PromptEnvelopeBreachError` (parameterised). |
+| 5-surface parity | pass | No CLI flag / no SKILL.md change. Docs touches: `business-rule-tests.md` cardinality + envelope; `llm-drafter.md` parameterised breach pattern. |
+
+**Blockers: 0. Concerns: 0 (after refinement Q5–Q7).**
+
+## Refinement log
+
+### Decisions
+
+- **DEC-001 — Fix shape: Lever B + C1.** Dynamic-block hardening (numbered `…` envelopes) + parser cardinality gate (`LLMOutputAnchorContractError` violation when count < N). System-prompt restructure (Lever A) is held in reserve if Sonnet 4.6 compliance remains low after this fix lands. **Rationale:** gate-over-prompt per `testing-signal.md`; no `_PROMPT_VERSION` rotation; minimal LOC; verifiable without LLM.
+- **DEC-002 — Cardinality is at-least-one-per-rule.** `count >= len(business_rules)` accepts. Excess allowed (the LLM may legitimately split a complex rule into two SELECTs). Exact equality conflates over- and under-coverage; per-scope (model vs. column) requires a `rule_id` field we explicitly didn't add.
+- **DEC-003 — Unit-level parser tests + reuse existing e2e.** Hand-rolled candidates exercise the new gate without LLM cost. `tests/cli/test_e2e_business_rules.py` is already the live reproduction and certifies the round-trip after the fix.
+- **DEC-004 — Thread `business_rules` through `parse_draft_response` (mirror #159).** Keyword-only `business_rules: tuple[str, ...] = ()` on `parse_draft_response` and `_validate_anchor_contract`. Built in `draft_from_request` (`schema.py`) from `_read_business_rules(model)`. Single source of truth; mirrors `model_columns_by_type` threading from issue #159 verbatim.
+- **DEC-005 — Reuse `PromptEnvelopeBreachError` with parameterised envelope.** Extend `__init__` with `envelope: str = "MODEL_SQL"` and `rule_index: int | None = None` kwargs (both default-safe; existing call site untouched). Message renders `` or `` per envelope. No taxonomy growth, no new exit-code entry.
+- **DEC-006 — Violation message names rules verbatim.** When the parser gate fires, the violation lists every declared business rule (prefixed `(model)` or `(column X)` per the renderer's existing prefix). Since cardinality is at-least-one-per-rule (DEC-002), we can't identify a specific missing rule — the operator gets the full declared set + the actual `custom_sql` count. Message shape pinned by test.
+- **DEC-007 — No INFO breadcrumb on rule injection.** The `LLMResponseEvent` audit row already carries `response_text_hash` + the prompt that triggered it. Adding an INFO line per render is noise in default-quiet runs.
+- **DEC-008 — `exclude_tests=("custom_sql",)` short-circuits both surfaces.** When the operator forbids `custom_sql`, `_render_business_rules_section` returns `""` (don't tell the LLM to draft rules it can't emit) AND `_validate_anchor_contract` skips the cardinality gate (no rules in scope). Mirrors how `_render_system_prompt(exclude_tests)` already drops `custom_sql` from the catalogue.
+- **DEC-009 — Envelope format.** Per rule: opening tag `` on its own line; rule text indented 2 spaces on the next line(s); closing tag `` on its own line. IDs start at 1. Section header `## BUSINESS RULES` + lead-in prose unchanged. Pinned by test.
+
+## Stories
+
+Each story is right-sized for one Ralph context window. Acceptance criteria trace to DECs; the canonical validation command (`uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest`) is the floor for every story.
+
+### US-001 — Dynamic-block envelope hardening + parameterised breach guard
+
+**Traces to:** DEC-001, DEC-005, DEC-008, DEC-009.
+
+**Description:** Replace the bulleted `## BUSINESS RULES` list in `_render_business_rules_section` with numbered `…` envelopes. Extend `PromptEnvelopeBreachError` to be envelope-parameterised and add a pre-render breach guard for the new envelope.
+
+**Acceptance criteria:**
+
+1. `_render_business_rules_section(model)` emits `\n \n` per rule (N starts at 1, body indented 2 spaces). Section header `## BUSINESS RULES` and lead-in prose unchanged.
+2. The rule body still carries the existing scope prefix (`(model)` / `(column X)`) verbatim.
+3. `_render_business_rules_section` short-circuits to `""` when `custom_sql` is in `DraftConfig.exclude_tests` (passed in from the renderer via the existing thread).
+4. `PromptEnvelopeBreachError.__init__` accepts new keyword-only args: `envelope: str = "MODEL_SQL"`, `rule_index: int | None = None`. Existing single call site in `prompts.py` keeps working unchanged.
+5. Rendered message: when `envelope="MODEL_SQL"`, byte-equal to the current `` message; when `envelope="BUSINESS_RULE"`, mentions the rule index (`"… in rule #2 of model.x.y …"`).
+6. `_render_business_rules_section` scans each rule for the literal `` substring (boring substring match, no whitespace normalisation per `llm-drafter.md` DEC-007/grade-layer.md envelope-breach precedent) and raises `PromptEnvelopeBreachError(model.unique_id, envelope="BUSINESS_RULE", rule_index=i)`.
+7. `uv run pytest tests/draft/test_prompts.py` passes; `uv run pytest tests/llm/test_prompt_cache_stability.py` passes **with no `_PROMPT_VERSION` change** (this is the load-bearing check that we didn't accidentally touch the cached system prompt).
+8. Canonical validation command passes.
+
+**Done when:** new envelope shape renders, breach guard fires loudly on poisoned rules, and `_PROMPT_VERSION` is byte-identical to its pre-fix value.
+
+**Files:**
+
+- `src/signalforge/draft/prompts.py` — rewrite `_render_business_rules_section`; thread `exclude_tests: tuple[str, ...]` (or read from the existing config seam); add breach scan.
+- `src/signalforge/draft/errors.py` — parameterise `PromptEnvelopeBreachError.__init__`.
+- `tests/draft/test_prompts.py` — update `test_business_rules_render_into_dynamic_block` for the new shape; add `test_business_rules_section_short_circuits_when_custom_sql_excluded`; add `test_business_rules_envelope_breach_guard_fires_on_closing_tag`; add `test_business_rules_envelope_breach_message_includes_rule_index`.
+- `tests/draft/test_errors.py` *(if exists; otherwise add to nearest)* — pin parameterised `PromptEnvelopeBreachError` message for both envelopes; assert existing `MODEL_SQL` byte-equal.
+
+**Depends on:** none.
+
+**TDD:** yes — write the envelope-shape test, breach-guard test, and parameterised-error tests FIRST. Implement until all pass.
+
+### US-002 — Parser cardinality gate + business_rules threading
+
+**Traces to:** DEC-001, DEC-002, DEC-003, DEC-004, DEC-006, DEC-008.
+
+**Description:** Add a keyword-only `business_rules: tuple[str, ...] = ()` to `parse_draft_response` and `_validate_anchor_contract`. Thread it from `draft_from_request` (the orchestrator already builds the rules tuple via `_read_business_rules(model)`). In `_validate_anchor_contract`, when `business_rules` is non-empty AND `custom_sql` is NOT in `exclude_tests`, append one violation if `custom_sql_count < len(business_rules)`.
+
+**Acceptance criteria:**
+
+1. `parse_draft_response` signature gains keyword-only `business_rules: tuple[str, ...] = ()` (default keeps every existing call site working). Public-API surface confirmed via `tests/draft/test_public_api.py` (or equivalent).
+2. `_validate_anchor_contract` signature gains the same kwarg; existing `model_columns_by_type` threading is the precedent — match its placement and pyright-narrowing style.
+3. `draft_from_request` (in `signalforge.draft.schema`) builds `business_rules = tuple(_read_business_rules(model))` AFTER the existing `model_columns_by_type` build and threads it through `parse_draft_response(...)`. (`_read_business_rules` already exists in `prompts.py`; expose / re-import as needed.)
+4. Gate logic: when `business_rules` non-empty AND `"custom_sql" not in exclude_tests`, count `custom_sql` tests across `candidate.tests` + every `column.tests` and append one violation if `count < len(business_rules)`.
+5. Violation message: `Expected ≥{N} custom_sql test(s) (one per declared business rule), got {actual}. Declared rules: {comma-separated quoted rule strings with their (model)/(column X) prefixes}.` Pinned by test.
+6. Gate is a no-op when `business_rules = ()` (preserves all 36 existing parser-test call sites; backward compat).
+7. Gate is a no-op when `"custom_sql"` is in `exclude_tests` (DEC-008).
+8. Unit tests cover: under-coverage rejection (2 rules + 1 custom_sql → violation present); coverage match (2 rules + 2 → accept); over-coverage allowed (2 rules + 3 → accept); empty rules + zero custom_sql → accept; empty rules + custom_sql present → accept (inferred-fallback path preserved); custom_sql in exclude_tests + non-empty rules → no gate violation; column-level custom_sql tests counted; model-level custom_sql tests counted; mixed counted.
+9. Multi-violation collect-all preserved: a candidate with a hallucinated column AND a cardinality miss produces BOTH violations in one `LLMOutputAnchorContractError`.
+10. Canonical validation command passes.
+
+**Done when:** the parser gate rejects an under-coverage response loudly with a verbose message, the inferred-fallback path stays open, and `tests/cli/test_e2e_business_rules.py` (the existing e2e) continues to be the live cert.
+
+**Files:**
+
+- `src/signalforge/draft/parser.py` — add kwarg + gate logic to `_validate_anchor_contract`; add kwarg to `parse_draft_response`.
+- `src/signalforge/draft/schema.py` — build `business_rules` tuple from `_read_business_rules(model)`; thread through `parse_draft_response(...)`.
+- `src/signalforge/draft/prompts.py` — confirm `_read_business_rules` is importable from `schema.py` (or re-export); no behavioural change.
+- `tests/draft/test_parser.py` — 8+ new tests per AC #8.
+
+**Depends on:** US-001 (the envelope-shape change is the operator-facing half; landing the parser gate without it would surface the rejection without giving the LLM the clearer input format — together they're the complete fix).
+
+**TDD:** yes — write the 8 gate-behaviour tests FIRST against the unchanged parser (they should fail); implement until all pass.
+
+### US-003 — Quality Gate (code review x4 + CodeRabbit)
+
+**Traces to:** the project's standard Quality-Gate convention.
+
+**Description:** Run the `/code-review` skill four times across the full diff, fix every real bug found each pass. Run CodeRabbit if available. The canonical validation command must pass after all fixes.
+
+**Acceptance criteria:**
+
+1. Four `/code-review` passes complete; all real findings landed as fixes (not deferred).
+2. CodeRabbit review requested on the draft PR (when bot is configured); maintainer-deemed real findings landed.
+3. `uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest` passes locally.
+4. `uv run pytest tests/llm/test_prompt_cache_stability.py` passes with `_PROMPT_VERSION` unchanged from pre-fix.
+5. No `Traceback` in any stderr from CLI subprocess smoke tests.
+
+**Done when:** all four review passes are green, validation is green, the diff is the size we said it would be.
+
+**Files:** any touched by US-001/US-002 (no scope expansion in this story).
+
+**Depends on:** US-001 + US-002.
+
+### US-004 — Patterns & Memory (docs + rules update)
+
+**Traces to:** DEC-005, DEC-006, DEC-008, DEC-009; `business-rule-tests.md` conventions.
+
+**Description:** Roll the durable conventions from this fix into the rule files + ops docs.
+
+**Acceptance criteria:**
+
+1. `.claude/rules/business-rule-tests.md` § "Two input paths, both in the dynamic prompt block" gains a sub-bullet documenting:
+ - The numbered `…` envelope (DEC-009).
+ - The at-least-one-per-rule cardinality contract enforced at the parser (DEC-002, DEC-006).
+ - The `exclude_tests=("custom_sql",)` short-circuit on both surfaces (DEC-008).
+2. `.claude/rules/llm-drafter.md` § "`` prompt-injection envelope" gains a note that `PromptEnvelopeBreachError` is now envelope-parameterised (the second envelope `` shipped in #163) — and that future envelopes follow the same pattern (extend with a new `envelope=` arg, never a new error class).
+3. `docs/draft-ops.md` (or wherever the operator-facing business-rules story lives) carries a short "Cardinality contract" subsection.
+4. The plan doc's `Beads manifest` section is populated post-devolve (Phase 7).
+5. Canonical validation command passes.
+
+**Done when:** the rule files + ops doc reflect the new conventions, future contributors can find the pattern without re-reading the plan.
+
+**Files:**
+
+- `.claude/rules/business-rule-tests.md`
+- `.claude/rules/llm-drafter.md`
+- `docs/draft-ops.md` *(if it documents the business-rules path; check during the story)*
+- `plans/super/163-drafter-business-rules-fidelity.md` — Beads manifest section.
+
+**Depends on:** US-003.
+
+## Beads manifest
+
+- **Epic:** `bd_1-scaffolding-74b` — #163: drafter business-rules fidelity
+- **Worktree:** `/home/wesd/Projects/worktrees/SignalForge/163-drafter-business-rules`
+- **Branch:** `feature/163-drafter-business-rules`
+- **External ref:** `gh-163`
+
+| Story | Bead ID | Depends on | Status | Commit |
+|---|---|---|---|---|
+| US-001 — Dynamic-block envelope hardening + parameterised breach guard | `bd_1-scaffolding-74b.1` | — | ✅ closed | `cfd3510` (merged via `0d32311`) |
+| US-002 — Parser cardinality gate + business_rules threading | `bd_1-scaffolding-74b.2` | US-001 | ✅ closed | `2bbe092` (merged via `9731a1f`) |
+| US-003 — Quality Gate (code-review x4 + 5 invariant tests landed) | `bd_1-scaffolding-74b.3` | US-001, US-002 | ✅ closed | `213e777` (inline) |
+| US-004 — Patterns & Memory (rule files + ops docs) | `bd_1-scaffolding-74b.4` | US-003 | ✅ closed | this commit |
+
+**Run summary:** Ralph autonomous run on 2026-05-30. 2 worker beads + 2 inline beads. Final validation: 2662 tests passed, 97.72% coverage. `_PROMPT_VERSION` unchanged at `c9e7ee1f6f465933` (load-bearing cache-stability gate held throughout).
diff --git a/pyproject.toml b/pyproject.toml
index 5ac70528..eff368bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,16 +17,31 @@ dependencies = [
"pydantic>=2.5,<3",
# anthropic <2.0: lock the major so a 1.x release doesn't block fresh installs without forcing a SignalForge cut.
"anthropic>=0.50,<2.0",
+ # sqlglot pinned to a single major: powers the drafter parser's
+ # type-coherence defence for `custom_sql` (#159 / DEC-005). Previously
+ # a transitive via `fakesnow` (dev-only); type-defence correctness is
+ # load-bearing so a direct runtime pin is required.
+ "sqlglot>=30,<31",
]
[project.optional-dependencies]
# Retained for `pip install -e ".[dev]"` back-compat. CI runs uv (`uv sync --dev`)
# which consumes `[dependency-groups].dev` below; the two lists are kept in sync.
# pyright pinned exactly: tool bumps are tested via deliberate maintainer upgrades, not CI surprise from a new release.
-dev = ["ruff", "pyright==1.1.409", "pytest", "dbt-core>=1.8,<2", "types-PyYAML>=6,<7", "pytest-cov>=5.0", "snowflake-connector-python>=3,<4", "fakesnow>=0.9"]
+dev = ["ruff", "pyright==1.1.409", "pytest", "dbt-core>=1.8,<2", "types-PyYAML>=6,<7", "pytest-cov>=5.0", "pytest-xdist>=3.6,<4", "snowflake-connector-python>=3,<4", "fakesnow>=0.9", "openai>=1.40,<3.0", "tiktoken>=0.7,<1.0", "google-genai>=0.5,<1", "sqlglot>=30,<31"]
# Optional warehouse extra: pulls the Snowflake SDK only when the operator
# installs `signalforge-dbt[snowflake]`. The base install stays BigQuery-only.
snowflake = ["snowflake-connector-python>=3,<4"]
+# Optional LLM provider extra: pulls the OpenAI SDK + tiktoken local
+# tokeniser only when the operator installs `signalforge-dbt[openai]`. The
+# base install stays Anthropic-only. tiktoken powers the local pre-send
+# token count for the `--estimate` path (OpenAI has no server-side
+# count_tokens API; supports_token_count=False on OpenAIProvider). See
+# .claude/rules/llm-drafter.md for the three-slot lockstep convention.
+openai = ["openai>=1.40,<3.0", "tiktoken>=0.7,<1.0"]
+# Optional LLM-provider extra: pulls the Google Gen AI SDK only when the operator
+# installs `signalforge-dbt[gemini]`. The base install stays Anthropic-only.
+gemini = ["google-genai>=0.5,<1"]
[dependency-groups]
# uv-native dependency groups (PEP 735).
@@ -49,9 +64,22 @@ dev = [
"dbt-core>=1.8,<2",
"types-PyYAML>=6,<7",
"pytest-cov>=5.0",
+ "pytest-xdist>=3.6,<4",
"build>=1.2,<2",
"snowflake-connector-python>=3,<4",
"fakesnow>=0.9",
+ "openai>=1.40,<3.0",
+ "tiktoken>=0.7,<1.0",
+ "google-genai>=0.5,<1",
+ "sqlglot>=30,<31",
+ # clauditor-eval powers the pre-release SKILL.md self-grade run per
+ # DEC-014 of plans/super/141-claude-skill-install.md. PyPI dist name is
+ # `clauditor-eval`; it provides the `clauditor` CLI entry point. The
+ # maintainer runs `uv run clauditor grade
+ # src/signalforge/skills/signalforge/SKILL.md` and pins the score in
+ # `src/signalforge/skills/signalforge/assets/SKILL.eval.json`. No CI
+ # integration — manual pre-release only.
+ "clauditor-eval>=0.1,<1",
{include-group = "docs"},
]
@@ -67,13 +95,21 @@ path = "src/signalforge/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/signalforge"]
-# `include` is defence-in-depth for the demo tree under `src/signalforge/_demo/`
+# `include` is defence-in-depth for non-`.py` data trees under `src/signalforge/`
# — Hatchling's default `packages` glob picks `.py` reliably but its behaviour
# on non-`.py` data files (and dotfiles like `.gitignore` per DEC-006 of
# `plans/super/47-init-demo.md`) is not contractually guaranteed across releases.
-# The wheel_smoke marker (`tests/test_wheel_packaging.py`) gates the demo file
-# set in the built artifact; this directive is the production-side guarantee.
-include = ["src/signalforge/_demo"]
+# The wheel_smoke marker (`tests/test_wheel_packaging.py`) gates the on-disk
+# file set in the built artifact for both trees; this directive is the
+# production-side guarantee.
+# - `_demo/` — bundled demo project (DEC-002 of plans/super/47-init-demo.md).
+# - `skills/` — bundled SignalForge Claude Code skill that the `install-skill`
+# CLI copies into `~/.claude/skills/` (DEC-010 of
+# plans/super/141-claude-skill-install.md). Maintainer-only
+# skills under repo-root `.claude/skills/` (release-manager,
+# review-agentskills-spec) are deliberately NOT listed here —
+# see DEC-022 + the negative-assertion test in test_wheel_packaging.py.
+include = ["src/signalforge/_demo", "src/signalforge/skills"]
[tool.ruff]
line-length = 100
@@ -96,7 +132,7 @@ testpaths = ["tests"]
# `--import-mode=importlib` lets us share basenames across test dirs (e.g.
# tests/manifest/test_errors.py and tests/warehouse/test_errors.py) without
# adding tests/__init__.py — keeps `testing-signal.md`'s no-init rule intact.
-addopts = "-ra --strict-markers --import-mode=importlib -m 'not bigquery and not anthropic and not cli_subprocess and not e2e and not wheel_smoke and not snowflake' --cov=signalforge --cov-report=xml --cov-report=term-missing --cov-fail-under=80"
+addopts = "-ra --strict-markers --import-mode=importlib -m 'not bigquery and not anthropic and not cli_subprocess and not e2e and not wheel_smoke and not snowflake and not openai and not gemini' --cov=signalforge --cov-report=xml --cov-report=term-missing --cov-fail-under=80"
minversion = "7.0"
strict_markers = true
markers = [
@@ -112,4 +148,6 @@ markers = [
"e2e: end-to-end smoke test against real Anthropic + real BigQuery (gated by SF_RUN_BQ=1, ANTHROPIC_API_KEY, GOOGLE_CLOUD_PROJECT; skipped by default)",
"wheel_smoke: maintainer-only; builds the wheel via `python -m build --wheel` and inspects the artifact for the demo file set (run with --no-cov)",
"snowflake: maintainer-only; offline compiled-SQL validation via fakesnow AND the gated live EXPLAIN-estimate certification (run with --no-cov; live tests self-skip without SF_RUN_SNOWFLAKE + connection env vars)",
+ "openai: real-API smoke test against OpenAI (requires SF_RUN_OPENAI=1 + OPENAI_API_KEY; excluded from default CI)",
+ "gemini: real-API smoke test against Gemini (requires SF_RUN_GEMINI=1 + GOOGLE_API_KEY; excluded from default CI)",
]
diff --git a/scripts/measure_e2e_cost.py b/scripts/measure_e2e_cost.py
new file mode 100755
index 00000000..74ef8f57
--- /dev/null
+++ b/scripts/measure_e2e_cost.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+"""Maintainer-only audit-cost rollup wrapper.
+
+Thin argparse entrypoint around
+:func:`signalforge.llm.cost.rollup_audit_dir`. Established by US-003 of
+``plans/super/157-e2e-cost-and-parallel.md`` so the maintainer can
+re-measure the live e2e cost figure (the "~$0.30/full-suite run"
+documented in CHANGELOG / runbook) from real audit JSONLs rather than
+reasoning about it.
+
+The script is NOT a ``signalforge`` subcommand — it does not register in
+:mod:`signalforge.cli` and does not ship in the built wheel
+(``tests/test_wheel_packaging.py`` gates that exclusion). It lives
+under repo-root ``scripts/`` and is invoked via
+``python scripts/measure_e2e_cost.py …``.
+
+Usage::
+
+ python scripts/measure_e2e_cost.py --project-dir /path/to/proj
+ python scripts/measure_e2e_cost.py --project-dir /path/to/proj --format json
+ python scripts/measure_e2e_cost.py --project-dir /path/to/proj --audit-dir .signalforge
+
+Exit codes mirror the CLI taxonomy in ``.claude/rules/cli-layer.md``:
+
+* ``0`` — success.
+* ``2`` — any :class:`signalforge.llm.cost.CostError` subclass (input /
+ state validation: audit dir missing, malformed JSONL, unknown model).
+* ``1`` — any other unexpected ``Exception`` (panic-path equivalent).
+
+The boundary ``try / except`` in :func:`main` is the single sink. No
+``Traceback`` ever leaks to stderr — the no-traceback floor from
+``cli-layer.md`` § "No traceback ever leaks" applies here too.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from signalforge.llm.cost import (
+ CostError,
+ CostReport,
+ ModelRollup,
+ ProviderRollup,
+ rollup_audit_dir,
+)
+
+
+def _model_to_jsonable(rollup: ModelRollup) -> dict[str, object]:
+ """Convert one :class:`ModelRollup` to a plain JSON-serialisable dict.
+
+ Walking the fields explicitly (instead of :func:`dataclasses.asdict`)
+ sidesteps the fact that ``asdict`` invokes :func:`copy.deepcopy` on
+ every container, and :class:`types.MappingProxyType` — used by the
+ rollup shapes for read-only mappings — is not pickle / deepcopy
+ safe.
+ """
+ return {
+ "model": rollup.model,
+ "input_tokens": rollup.input_tokens,
+ "output_tokens": rollup.output_tokens,
+ "cache_creation_input_tokens": rollup.cache_creation_input_tokens,
+ "cache_read_input_tokens": rollup.cache_read_input_tokens,
+ "total_usd": rollup.total_usd,
+ "call_count": rollup.call_count,
+ }
+
+
+def _provider_to_jsonable(rollup: ProviderRollup) -> dict[str, object]:
+ """Convert one :class:`ProviderRollup` to a JSON-serialisable dict.
+
+ ``per_model`` is rebuilt as a plain dict keyed alphabetically so two
+ runs over the same inputs produce byte-identical JSON (mirrors
+ Architectural Commitment #5, "explainable diffs").
+ """
+ return {
+ "provider": rollup.provider,
+ "per_model": {
+ model_id: _model_to_jsonable(rollup.per_model[model_id])
+ for model_id in sorted(rollup.per_model)
+ },
+ "subtotal_usd": rollup.subtotal_usd,
+ }
+
+
+def _report_to_jsonable(report: CostReport) -> dict[str, object]:
+ """Convert a :class:`CostReport` to a plain JSON-serialisable dict.
+
+ Keys sorted at every level so two runs with the same inputs produce
+ byte-identical JSON.
+ """
+ return {
+ "per_provider": {
+ provider_name: _provider_to_jsonable(report.per_provider[provider_name])
+ for provider_name in sorted(report.per_provider)
+ },
+ "total_usd": report.total_usd,
+ "pricing_table_version": report.pricing_table_version,
+ "audit_files_consumed": list(report.audit_files_consumed),
+ }
+
+
+def _print_json(report: CostReport) -> None:
+ """Emit the report as indented JSON on stdout."""
+ json.dump(_report_to_jsonable(report), sys.stdout, indent=2)
+ sys.stdout.write("\n")
+
+
+def _print_text(report: CostReport) -> None:
+ """Emit a human-readable per-provider per-model table on stdout.
+
+ No external table library — plain ``print`` with column alignment.
+ Layout:
+
+ * One block per provider (alphabetical), header line +
+ per-model rows + provider subtotal.
+ * Trailing ``TOTAL: $X.XXXX (pricing table YYYY-MM-DD; audit
+ files: ...)`` line.
+ """
+ header = (
+ f"{'model':<40} {'calls':>6} {'input':>10} {'output':>10} "
+ f"{'cache_w':>10} {'cache_r':>10} {'usd':>12}"
+ )
+ if not report.per_provider:
+ # Edge case: no provider rolled up at all. Still emit the TOTAL
+ # line so downstream tooling sees the canonical footer; the
+ # rollup helper would have raised CostRollupAuditMissingError
+ # before reaching here if BOTH JSONLs were absent, so this path
+ # is reachable only if both files exist but contain no records.
+ print("(no priced records found in the audit JSONLs)")
+ for provider_name in sorted(report.per_provider):
+ provider = report.per_provider[provider_name]
+ print(f"\nprovider: {provider_name}")
+ print(header)
+ print("-" * len(header))
+ for model_id in sorted(provider.per_model):
+ m = provider.per_model[model_id]
+ print(
+ f"{m.model:<40} {m.call_count:>6} {m.input_tokens:>10} "
+ f"{m.output_tokens:>10} {m.cache_creation_input_tokens:>10} "
+ f"{m.cache_read_input_tokens:>10} ${m.total_usd:>11.4f}"
+ )
+ print(
+ f"{' subtotal':<40} {'':>6} {'':>10} {'':>10} {'':>10} {'':>10} "
+ f"${provider.subtotal_usd:>11.4f}"
+ )
+
+ audit_list = ", ".join(report.audit_files_consumed)
+ print(
+ f"\nTOTAL: ${report.total_usd:.4f} "
+ f"(pricing table {report.pricing_table_version}; audit files: {audit_list})"
+ )
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ """Construct the argparse surface.
+
+ Kept in a helper so tests can introspect / re-parse without invoking
+ :func:`main` (mirrors the ``cli-layer.md`` pattern for the public
+ CLI's ``add_parser`` helpers).
+ """
+ parser = argparse.ArgumentParser(
+ prog="measure_e2e_cost.py",
+ description=(
+ "Roll up per-provider per-model USD cost from the SignalForge "
+ "audit JSONLs under //."
+ ),
+ )
+ parser.add_argument(
+ "--project-dir",
+ type=Path,
+ required=True,
+ help="Path to the SignalForge project root whose audit JSONLs will be rolled up.",
+ )
+ parser.add_argument(
+ "--audit-dir",
+ type=str,
+ default=".signalforge",
+ help=("Audit subdirectory name under --project-dir (default: %(default)s)."),
+ )
+ parser.add_argument(
+ "--format",
+ choices=("text", "json"),
+ default="text",
+ help="Output shape (default: %(default)s).",
+ )
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Entry point. Returns the process exit code.
+
+ The single boundary ``try / except`` is the only place errors are
+ caught — no inner stage wraps its own ``except``. Mirrors
+ ``cli-layer.md`` § "No traceback ever leaks": every routed exit
+ code is one of ``{0, 1, 2}`` and stderr never carries a
+ ``Traceback`` line on the failure paths.
+ """
+ parser = _build_parser()
+ args = parser.parse_args(argv)
+ try:
+ report = rollup_audit_dir(args.project_dir, audit_dir=args.audit_dir)
+ if args.format == "json":
+ _print_json(report)
+ else:
+ _print_text(report)
+ return 0
+ except CostError as exc:
+ # ``LLMError.__str__`` renders ``message\n ↳ Remediation: …``
+ # so the operator sees a single, readable two-line message on
+ # stderr without any traceback noise.
+ print(str(exc), file=sys.stderr)
+ return 2
+ except Exception as exc: # noqa: BLE001 — panic-path single sink
+ # Tier-1 / panic-path equivalent of cli-layer.md § "No traceback
+ # ever leaks". ``type(exc).__name__`` keeps the operator pointed
+ # at the failing class without leaking a full traceback.
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/signalforge/__init__.py b/src/signalforge/__init__.py
index a4781bac..66b0e5a1 100644
--- a/src/signalforge/__init__.py
+++ b/src/signalforge/__init__.py
@@ -1,3 +1,3 @@
"""SignalForge: LLM-drafted, warehouse-pruned dbt artifacts."""
-__version__ = "0.3.0"
+__version__ = "0.5.0"
diff --git a/src/signalforge/_demo/target/catalog.json b/src/signalforge/_demo/target/catalog.json
new file mode 100644
index 00000000..795fd919
--- /dev/null
+++ b/src/signalforge/_demo/target/catalog.json
@@ -0,0 +1,70 @@
+{
+ "metadata": {
+ "dbt_schema_version": "https://schemas.getdbt.com/dbt/catalog/v1.json",
+ "dbt_version": "1.8.9",
+ "generated_at": null,
+ "invocation_id": null,
+ "env": {}
+ },
+ "nodes": {
+ "model.signalforge_test_austin.stg_bikeshare_trips": {
+ "metadata": {
+ "type": "BASE TABLE",
+ "schema": "austin_bikeshare",
+ "name": "bikeshare_trips",
+ "database": "bigquery-public-data",
+ "comment": null,
+ "owner": null
+ },
+ "columns": {
+ "trip_id": {
+ "type": "STRING",
+ "index": 1,
+ "name": "trip_id",
+ "comment": null
+ },
+ "subscriber_type": {
+ "type": "STRING",
+ "index": 2,
+ "name": "subscriber_type",
+ "comment": null
+ },
+ "bike_id": {
+ "type": "STRING",
+ "index": 3,
+ "name": "bike_id",
+ "comment": null
+ },
+ "start_time": {
+ "type": "TIMESTAMP",
+ "index": 4,
+ "name": "start_time",
+ "comment": null
+ },
+ "start_station_id": {
+ "type": "INT64",
+ "index": 5,
+ "name": "start_station_id",
+ "comment": null
+ },
+ "end_station_id": {
+ "type": "STRING",
+ "index": 6,
+ "name": "end_station_id",
+ "comment": null
+ },
+ "duration_minutes": {
+ "type": "INT64",
+ "index": 7,
+ "name": "duration_minutes",
+ "comment": null
+ }
+ },
+ "stats": {},
+ "unique_id": "model.signalforge_test_austin.stg_bikeshare_trips"
+ }
+ },
+ "sources": {},
+ "errors": null,
+ "info": null
+}
diff --git a/src/signalforge/_demo/target/manifest.json b/src/signalforge/_demo/target/manifest.json
index 51aad295..963659fc 100644
--- a/src/signalforge/_demo/target/manifest.json
+++ b/src/signalforge/_demo/target/manifest.json
@@ -69,7 +69,7 @@
"name": "trip_id",
"description": "Unique identifier assigned to each bikeshare trip by the city's bikeshare system. Acts as the natural primary key for this table; no two rows in the source share a `trip_id`. Stored as a STRING because the underlying identifier is alphanumeric in some city installations even though it looks numeric in this dataset.",
"meta": {},
- "data_type": null,
+ "data_type": "STRING",
"constraints": [],
"quote": null,
"tags": []
@@ -78,7 +78,7 @@
"name": "subscriber_type",
"description": "Membership classification of the rider who took the trip — typical values include `local monthly`, `walk-up`, `single trip`, `student membership`, `weekender`, etc. Useful for segmenting demand by user category. Free-form STRING (not enumerated in the source schema), so downstream consumers should expect long-tail values.",
"meta": {},
- "data_type": null,
+ "data_type": "STRING",
"constraints": [],
"quote": null,
"tags": []
@@ -87,7 +87,7 @@
"name": "bike_id",
"description": "Identifier of the physical bike used for this trip. Maps a single bike across many trips so utilisation per bike can be computed. Note the underscore in `bike_id` (the source column is `bike_id`, NOT `bikeid`); the underscore matters for joins and for any downstream model that references this column by name.",
"meta": {},
- "data_type": null,
+ "data_type": "STRING",
"constraints": [],
"quote": null,
"tags": []
@@ -96,7 +96,7 @@
"name": "start_time",
"description": "Timestamp marking when the trip began (the rider docked-out the bike). Stored as TIMESTAMP in UTC. This is the primary time dimension for the model and is reliably non-null in the source data — every trip has a recorded start time even when other fields are sparse.",
"meta": {},
- "data_type": null,
+ "data_type": "TIMESTAMP",
"constraints": [],
"quote": null,
"tags": []
@@ -105,7 +105,7 @@
"name": "start_station_id",
"description": "Numeric identifier of the bikeshare station where the trip began. Joins to `austin_bikeshare.bikeshare_stations.station_id` to resolve station name, latitude, longitude, council district. Some legacy trips have NULL here when the station was deleted from the registry but the trip record was preserved.",
"meta": {},
- "data_type": null,
+ "data_type": "INT64",
"constraints": [],
"quote": null,
"tags": []
@@ -114,7 +114,7 @@
"name": "end_station_id",
"description": "Numeric identifier of the station where the trip ended (rider docked-in the bike). Same join semantics as `start_station_id`. NULL is possible for trips that ended outside the station network or whose end-station record was later deleted from the registry.",
"meta": {},
- "data_type": null,
+ "data_type": "STRING",
"constraints": [],
"quote": null,
"tags": []
@@ -123,7 +123,7 @@
"name": "duration_minutes",
"description": "Trip length in whole minutes, computed by the source system as `end_time - start_time`. INTEGER. Most trips fall under 60 minutes; the long tail above 1440 (24 hours) typically indicates abandoned bikes or system glitches rather than real ridership. Downstream analytics often filter to `duration_minutes BETWEEN 1 AND 240` to focus on legitimate trips.",
"meta": {},
- "data_type": null,
+ "data_type": "INT64",
"constraints": [],
"quote": null,
"tags": []
diff --git a/src/signalforge/cli/__init__.py b/src/signalforge/cli/__init__.py
index 10db8070..748fc8a5 100644
--- a/src/signalforge/cli/__init__.py
+++ b/src/signalforge/cli/__init__.py
@@ -20,6 +20,7 @@
import signalforge
from signalforge.cli import generate as generate_cmd
from signalforge.cli import init_demo as init_demo_cmd
+from signalforge.cli import install_skill as install_skill_cmd
from signalforge.cli import lint as lint_cmd
from signalforge.cli import prune_existing as prune_existing_cmd
from signalforge.cli import version as version_cmd
@@ -82,6 +83,7 @@ def _build_parser() -> argparse.ArgumentParser:
lint_cmd.add_parser(subparsers)
generate_cmd.add_parser(subparsers)
init_demo_cmd.add_parser(subparsers)
+ install_skill_cmd.add_parser(subparsers)
prune_existing_cmd.add_parser(subparsers)
return parser
diff --git a/src/signalforge/cli/_estimate.py b/src/signalforge/cli/_estimate.py
index 7d68a92f..8dd6f693 100644
--- a/src/signalforge/cli/_estimate.py
+++ b/src/signalforge/cli/_estimate.py
@@ -72,7 +72,7 @@
import time
import uuid
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict
@@ -85,6 +85,7 @@
)
from signalforge.grade.rubric import DEFAULT_RUBRIC
from signalforge.llm import pricing as _pricing
+from signalforge.llm.providers import provider_for
from signalforge.safety.models import LLMRequest, SamplingMode
from signalforge.warehouse._sql_safety import validate_identifier
from signalforge.warehouse.errors import WarehouseError
@@ -94,7 +95,6 @@
from signalforge.draft.config import DraftConfig
from signalforge.grade.config import GradeConfig
from signalforge.grade.rubric import Criterion
- from signalforge.llm import AnthropicClientProtocol
from signalforge.manifest.models import Manifest, Model
from signalforge.prune.config import PruneConfig
from signalforge.warehouse.base import WarehouseAdapter
@@ -284,51 +284,55 @@ def _truncate_criterion_text(text: str) -> str:
def _count_draft_tokens(
*,
- client: AnthropicClientProtocol,
+ client: object | None,
draft_config: DraftConfig,
system: str,
cached_block: str,
dynamic_block: str,
) -> int:
- """Issue exactly one ``count_tokens`` for the drafter prompt and
- return the integer ``input_tokens`` field.
-
- Mirrors :func:`signalforge.llm.client.call_anthropic`'s pre-send
- ``count_tokens`` envelope: system + the single cached user-content
- block. The dynamic block is included in the messages list so the
- count reflects the full prompt the drafter would send (the cache
- boundary affects pricing, not token count — pricing math runs
- further down using ``draft_config.cache_ttl`` indirectly).
+ """Return the input-token count for the drafter prompt.
+
+ Refactored in #136 US-005 (DEC-003) to dispatch through
+ :meth:`signalforge.llm.providers.LLMProvider.estimate_input_tokens`
+ so the engine works against any registered provider — Anthropic
+ delegates to the SDK's server-side ``messages.count_tokens``;
+ OpenAI counts locally via ``tiktoken``. Byte-identity with the
+ pre-refactor Anthropic path is pinned by
+ ``tests/cli/test_estimate.py::test_estimate_anthropic_byte_identity_golden``
+ (DEC-013).
+
+ The ``system`` envelope is threaded as its own kwarg so providers
+ whose API counts the system block separately (Anthropic's
+ server-side ``messages.count_tokens(system=..., ...)``) produce
+ real-API-faithful counts (DEC-013 of #136). The ``cached_block`` +
+ ``dynamic_block`` are concatenated into the user-content text
+ payload because Anthropic's tokenizer collapses adjacent text blocks
+ identically to a single concatenated string for counting purposes.
+ The cache boundary affects pricing, not token count — pricing math
+ runs further down using ``draft_config.cache_ttl`` indirectly.
"""
- block_cached: dict[str, Any] = {
- "type": "text",
- "text": cached_block,
- "cache_control": {"type": "ephemeral", "ttl": draft_config.cache_ttl},
- }
- block_dynamic: dict[str, Any] = {"type": "text", "text": dynamic_block}
- response = client.messages.count_tokens(
- model=draft_config.model,
- system=system,
- messages=[{"role": "user", "content": [block_cached, block_dynamic]}],
+ text = cached_block + dynamic_block
+ return provider_for(draft_config.provider).estimate_input_tokens(
+ draft_config.model, text, system=system, client=client
)
- input_tokens = getattr(response, "input_tokens", None)
- if not isinstance(input_tokens, int):
- msg = "count_tokens response is missing the `input_tokens` field."
- raise RuntimeError(msg)
- return input_tokens
def _count_grade_criterion_tokens(
*,
- client: AnthropicClientProtocol,
+ client: object | None,
grade_config: GradeConfig,
system_and_rubric: str,
artifact_id: str,
artifact_text: str,
criterion: Criterion,
) -> int:
- """Issue one ``count_tokens`` for the representative ``(artifact,
- criterion)`` prompt and return ``input_tokens``.
+ """Return the input-token count for the representative
+ ``(artifact, criterion)`` grade prompt.
+
+ Refactored in #136 US-005 (DEC-003) to dispatch through
+ :meth:`signalforge.llm.providers.LLMProvider.estimate_input_tokens`
+ so the engine works against any registered provider — see
+ :func:`_count_draft_tokens` for the byte-identity contract.
Per ``grade-layer.md`` DEC-004 the real grader issues one
``messages.create`` per ``(artifact × criterion)`` pair. The
@@ -337,24 +341,28 @@ def _count_grade_criterion_tokens(
across artifacts so per-criterion token counts scale linearly with
artifact count, and counting one representative is a faithful
proxy that keeps the LLM-call count bounded.
+
+ **Pre-existing double-count corrected in #136 US-008 QG.** The
+ pre-US-005 inline Anthropic call passed ``system=system_and_rubric``
+ AND embedded ``system_and_rubric`` inside the cached user-content
+ block, counting the rubric twice on every per-criterion estimate.
+ The first QG fix mistakenly preserved that double-count in the
+ name of byte-identity, which then *triple*-counted for OpenAI
+ (system kwarg → OpenAI's ``system + text`` concat → rubric prefix
+ in text). The correct behaviour matches the runtime grader call:
+ rubric in ``system=`` once, the artifact envelope in user content,
+ no overlap. Anthropic call shape is now system-envelope(rubric) +
+ dynamic_block (drops one rubric copy from the pre-refactor bytes);
+ OpenAI counts system_and_rubric + dynamic_block once. Fake-driven
+ byte-identity golden still passes (canned token counts unchanged
+ by call-shape), so the rendered USD floor for the golden fixture
+ holds; real-API ``--estimate`` figures shift down by ~one rubric
+ per criterion. See CHANGELOG.
"""
dynamic_block = render_grade_dynamic_block(artifact_id, artifact_text, criterion)
- block_cached: dict[str, Any] = {
- "type": "text",
- "text": system_and_rubric,
- "cache_control": {"type": "ephemeral", "ttl": grade_config.cache_ttl},
- }
- block_dynamic: dict[str, Any] = {"type": "text", "text": dynamic_block}
- response = client.messages.count_tokens(
- model=grade_config.model,
- system=system_and_rubric,
- messages=[{"role": "user", "content": [block_cached, block_dynamic]}],
+ return provider_for(grade_config.provider).estimate_input_tokens(
+ grade_config.model, dynamic_block, system=system_and_rubric, client=client
)
- input_tokens = getattr(response, "input_tokens", None)
- if not isinstance(input_tokens, int):
- msg = "count_tokens response is missing the `input_tokens` field."
- raise RuntimeError(msg)
- return input_tokens
def _build_representative_sql(model: Model, adapter: WarehouseAdapter, sample_size: int) -> str:
@@ -416,7 +424,7 @@ def estimate(
grade_config: GradeConfig,
prune_config: PruneConfig,
adapter: WarehouseAdapter,
- anthropic_client: AnthropicClientProtocol,
+ client: object | None,
*,
project_dir: Path | None = None, # noqa: ARG001 (reserved for v0.2)
) -> EstimateReport:
@@ -443,8 +451,18 @@ def estimate(
prune_config: Loaded :class:`PruneConfig` (carries
``sample_size`` for the representative dry-run SQL).
adapter: Constructed :class:`WarehouseAdapter`.
- anthropic_client: Constructed Anthropic client (or test fake
- satisfying the protocol).
+ client: Optional pre-constructed provider-shaped client (or
+ test fake satisfying the active provider's protocol).
+ Renamed from ``anthropic_client`` in #136 US-008 QG —
+ after US-005 the slot was already typed ``object | None``
+ and forwarded verbatim to whichever provider strategy is
+ active; the old name implied Anthropic-only and would
+ mislead a future #137 Gemini wiring. The CLI in
+ ``generate.py`` builds an Anthropic SDK client only when
+ ``provider == "anthropic"`` and passes ``None`` otherwise;
+ providers whose count is local (OpenAI's ``tiktoken``,
+ #137 Gemini's planned native ``count_tokens``) ignore the
+ kwarg either way.
project_dir: Reserved for v0.2 (caching, sidecar paths).
Returns:
@@ -458,7 +476,7 @@ def estimate(
request = _build_schema_only_request(model)
system, cached_block, dynamic_block, _prompt_version = render_prompt(model, request, manifest)
draft_input_tokens = _count_draft_tokens(
- client=anthropic_client,
+ client=client,
draft_config=draft_config,
system=system,
cached_block=cached_block,
@@ -499,7 +517,7 @@ def estimate(
grade_usd = 0.0
for criterion in rubric:
input_tokens_per_call = _count_grade_criterion_tokens(
- client=anthropic_client,
+ client=client,
grade_config=grade_config,
system_and_rubric=system_and_rubric,
artifact_id=rep_artifact_id,
diff --git a/src/signalforge/cli/_helpers.py b/src/signalforge/cli/_helpers.py
index 446d563b..4da25fc2 100644
--- a/src/signalforge/cli/_helpers.py
+++ b/src/signalforge/cli/_helpers.py
@@ -47,6 +47,9 @@
CliInitDemoDestUnsafeError,
CliInitDemoFixtureMissingError,
CliInputError,
+ CliInstallSkillDestUnsafeError,
+ CliInstallSkillPackageDataMissingError,
+ CliInstallSkillPathError,
CliPathError,
CliSelectorNoMatchError,
CliSelectorParseError,
@@ -115,6 +118,13 @@
LLMRateLimitError,
LLMResponseFormatError,
LLMServerError,
+ UnknownProviderError,
+)
+from signalforge.llm.cost import (
+ CostError,
+ CostRollupAuditMissingError,
+ CostRollupMalformedRecordError,
+ CostRollupUnknownModelError,
)
from signalforge.manifest import (
AmbiguousRefError,
@@ -153,6 +163,11 @@
SafetyError,
UnknownConfigKeyError,
)
+from signalforge.skill import (
+ SkillDestPathError,
+ SkillDestUnsafeError,
+ SkillPackageDataMissingError,
+)
from signalforge.warehouse import (
BytesBilledExceededError,
ColumnNotFoundError,
@@ -276,6 +291,24 @@
# walk in :func:`map_exception_to_exit_code`.
DemoPathError: 1,
DemoFixtureMissingError: 1,
+ # signalforge.skill typed errors (issue #141 / DEC-008). The CLI
+ # wrappers will land in US-003 and re-raise these into
+ # ``CliInstallSkill*Error`` at the handler boundary; the lib
+ # concretes still appear here as defence-in-depth so the 7th AST
+ # scan finds them and so an escaping raise gets a sensible exit code
+ # via the MRO walk in :func:`map_exception_to_exit_code`. Like
+ # ``DemoError`` and ``IngestError``, the concretes span tiers 1 and
+ # 2, so ``SkillError`` itself has no single-tier fallback entry —
+ # it lives only in ``_EXCEPTION_MAPPING_EXCLUDED_BASES``.
+ SkillDestPathError: 1,
+ SkillPackageDataMissingError: 1,
+ # CLI wrappers for the skill-install handler boundary (issue #141 /
+ # US-003 / DEC-008). Tier 1 for the two load-time failures: a
+ # symlink-cycle resolve failure on ```` and a broken-install
+ # case where the bundled skill tree is missing. Mirrors the tiering
+ # of the underlying ``Skill*Error`` lib concretes above.
+ CliInstallSkillPathError: 1,
+ CliInstallSkillPackageDataMissingError: 1,
# Ingest layer (issue #104 / DEC-001 / US-001). The reader parses an
# external dbt schema.yml into a CandidateSchema. These three are
# load-tier: the schema file is missing, unparseable, or exceeds the
@@ -345,6 +378,10 @@
# See US-001 of issue #36 and the AC tying tier 2 to "looked-up
# identifier not in a static table" failures.
EstimateUnknownModelError: 2,
+ # Provider-registry: the operator selected a provider name not in the
+ # registry — same "looked-up identifier not in a static table" input-shape
+ # category as ``EstimateUnknownModelError`` (US-001 of issue #135).
+ UnknownProviderError: 2,
# CLI-layer input-shape errors.
CliInputError: 2,
# Selector-failure wrappers (issue #37 / DEC-007 — US-002): both
@@ -365,6 +402,17 @@
# above for the defence-in-depth rationale.
DemoDestExistsError: 2,
DemoDestUnsafeError: 2,
+ # signalforge.skill input-validation concrete (issue #141 / DEC-008).
+ # Fires when ``dest`` is a regular file or when the existing
+ # ``SKILL.md`` is a symlink — both operator-supplied input states
+ # that conflict with the install contract; mirrors
+ # ``DemoDestUnsafeError``'s tier.
+ SkillDestUnsafeError: 2,
+ # CLI wrapper for the skill-install dest-unsafe boundary (issue #141
+ # / US-003 / DEC-008). Tier 2 (input-validation — the operator
+ # supplied a destination state we refuse to write under); mirrors
+ # ``CliInitDemoDestUnsafeError``'s tier.
+ CliInstallSkillDestUnsafeError: 2,
# Ingest layer (issue #104 / DEC-002 of US-001). Both fire on
# operator-supplied input that conflicts with the manifest/schema:
# the named model is absent from the schema.yml (mirrors
@@ -373,6 +421,20 @@
# failure — the YAML is stale or wrong vs. the manifest).
IngestModelNotFoundError: 2,
IngestAnchorContractError: 2,
+ # LLM cost-rollup layer (issue #157 / DEC-002 of US-001). The rollup
+ # walks per-run audit JSONLs and turns token counts into USD via the
+ # pricing table; all three concretes are input-shape failures (the
+ # operator pointed the rollup at a directory missing the JSONLs, or
+ # at a project whose JSONLs contain a malformed record / unknown
+ # model id). ``CostError`` base is dual-registered at tier 2 below
+ # as a single-tier safety net per cli-layer.md § "7th AST scan" —
+ # mirrors the nine other single-tier base entries.
+ CostRollupAuditMissingError: 2,
+ CostRollupMalformedRecordError: 2,
+ CostRollupUnknownModelError: 2,
+ # ``CostError`` base dual-registration (safety net for forward-compat
+ # subclasses) — every concrete is individually mapped above.
+ CostError: 2,
# ---- Tier 3: API / external dep ---------------------------------------
# LLM connectivity / quota / SDK issues.
LLMError: 3,
diff --git a/src/signalforge/cli/errors.py b/src/signalforge/cli/errors.py
index 8395ad1c..c40b7352 100644
--- a/src/signalforge/cli/errors.py
+++ b/src/signalforge/cli/errors.py
@@ -348,3 +348,142 @@ def __init__(
)
self.dest = dest
self.cause = cause
+
+
+# ---------------------------------------------------------------------------
+# install-skill wrappers (issue #141 — US-003, DEC-008 / DEC-009)
+# ---------------------------------------------------------------------------
+#
+# The CLI subcommand ``signalforge install-skill`` calls into the public
+# :func:`signalforge.skill.install_skill` helper. The helper raises three typed
+# :class:`signalforge.skill.SkillError` subclasses; the CLI handler wraps each
+# at the boundary into one of the three ``CliInstallSkill*Error`` classes below
+# so the four-tier exit-code taxonomy stays homogeneous (DEC-008). DEC-008 also
+# locks the tier assignment: path-resolution (symlink cycle) and broken-install
+# (bundled tree missing) land at tier 1 (load); dest-unsafe (regular file or
+# symlinked SKILL.md) lands at tier 2 (input-validation — the operator chose a
+# destination state we refuse to write under).
+#
+# Each class carries a ``default_remediation`` so the layer-base ``__str__``
+# renders the canonical ``ERROR: \n ↳ Remediation: `` shape
+# without subclasses having to redefine rendering.
+
+
+_CLI_INSTALL_SKILL_PATH_DEFAULT_REMEDIATION: str = (
+ "Remove the symlink cycle at the destination or pick a different path."
+)
+
+_CLI_INSTALL_SKILL_DEST_UNSAFE_DEFAULT_REMEDIATION: str = (
+ "Pick an existing directory as the destination, or remove the symlinked SKILL.md first."
+)
+
+_CLI_INSTALL_SKILL_PACKAGE_DATA_MISSING_DEFAULT_REMEDIATION: str = (
+ "Reinstall signalforge-dbt — the bundled Claude Code skill tree is missing from your install."
+)
+
+
+class CliInstallSkillPathError(CliError):
+ """Raised by ``cmd_install_skill`` when the destination path cannot
+ be canonicalised (symlink cycle).
+
+ Wraps :class:`signalforge.skill.SkillDestPathError`. Tier 1 (load —
+ the filesystem state cannot be resolved into a coherent shape
+ before work begins). Mirrors the precedent set by
+ :class:`CliPathError` (every CLI-originated path-resolution failure
+ is tier 1).
+ """
+
+ def __init__(
+ self,
+ *,
+ dest: str,
+ cause: Exception | None = None,
+ remediation: str | None = None,
+ ) -> None:
+ if cause is None:
+ message = f"failed to resolve install destination {dest!r}"
+ else:
+ message = f"failed to resolve install destination {dest!r}: {cause}"
+ super().__init__(
+ message,
+ remediation=(
+ remediation
+ if remediation is not None
+ else _CLI_INSTALL_SKILL_PATH_DEFAULT_REMEDIATION
+ ),
+ )
+ self.dest = dest
+ self.cause = cause
+
+
+class CliInstallSkillDestUnsafeError(CliInputError):
+ """Raised by ``cmd_install_skill`` when ```` is in a shape the
+ install seam refuses to write under.
+
+ Wraps :class:`signalforge.skill.SkillDestUnsafeError`. Two surfaces
+ fire this: ```` exists as a regular file (not a directory),
+ OR the existing ``SKILL.md`` is a symlink (writing would follow the
+ link and clobber an arbitrary destination). Tier 2 (input
+ validation — the operator chose a destination state we cannot
+ safely write into).
+ """
+
+ def __init__(
+ self,
+ *,
+ dest: str,
+ cause: Exception | None = None,
+ remediation: str | None = None,
+ ) -> None:
+ if cause is None:
+ message = f"refusing to install skill to unsafe destination {dest!r}"
+ else:
+ message = f"refusing to install skill to unsafe destination {dest!r}: {cause}"
+ super().__init__(
+ message,
+ remediation=(
+ remediation
+ if remediation is not None
+ else _CLI_INSTALL_SKILL_DEST_UNSAFE_DEFAULT_REMEDIATION
+ ),
+ )
+ self.dest = dest
+ self.cause = cause
+
+
+class CliInstallSkillPackageDataMissingError(CliError):
+ """Raised by ``cmd_install_skill`` when the bundled
+ ``signalforge/skills/signalforge/`` tree cannot be located via
+ :mod:`importlib.resources`.
+
+ Wraps :class:`signalforge.skill.SkillPackageDataMissingError`. Tier
+ 1 (load — the wheel install is broken and there is no work that
+ can proceed). The wheel-packaging convention in
+ ``.claude/rules/python-build.md`` makes this practically
+ unreachable on a clean ``pip install signalforge-dbt`` run, but a
+ corrupted install (partial wheel extract, hand-edited
+ site-packages) would surface here.
+ """
+
+ def __init__(
+ self,
+ *,
+ cause: Exception | None = None,
+ remediation: str | None = None,
+ ) -> None:
+ if cause is None:
+ message = "bundled SignalForge skill tree is missing from the signalforge-dbt install"
+ else:
+ message = (
+ "bundled SignalForge skill tree is missing from the "
+ f"signalforge-dbt install: {cause}"
+ )
+ super().__init__(
+ message,
+ remediation=(
+ remediation
+ if remediation is not None
+ else _CLI_INSTALL_SKILL_PACKAGE_DATA_MISSING_DEFAULT_REMEDIATION
+ ),
+ )
+ self.cause = cause
diff --git a/src/signalforge/cli/generate.py b/src/signalforge/cli/generate.py
index a65428e0..c666c987 100644
--- a/src/signalforge/cli/generate.py
+++ b/src/signalforge/cli/generate.py
@@ -47,13 +47,23 @@
up from the override (DEC-027) — passing the flag means "use this
project, not whatever's above me".
-Test-injection seam (DEC-013): two private factory functions
-:func:`_make_anthropic_client` and :func:`_make_warehouse_adapter` are
-patched by tests in ``tests/cli/test_generate.py`` to return
-:class:`tests.llm._fake.FakeAnthropicClient` /
-:class:`tests.warehouse._fake.FakeBigQueryClient`-backed adapters. Both
-are ``_``-prefixed (DEC of safety-layer.md / llm-drafter.md / etc.) —
-not part of the public CLI contract.
+Test-injection seam (DEC-013): the private factory
+:func:`_make_warehouse_adapter` is patched by tests in
+``tests/cli/test_generate.py`` to return a
+:class:`tests.warehouse._fake.FakeBigQueryClient`-backed adapter. It is
+``_``-prefixed (DEC of safety-layer.md / llm-drafter.md / etc.) — not part
+of the public CLI contract.
+
+LLM-client construction (DEC-006 of #135): the real-run pipeline no longer
+builds an Anthropic client in the CLI. ``draft_schema`` / ``grade_artifacts``
+thread ``client=None`` into :func:`signalforge.llm.call_llm`, which
+lazy-builds the real client via the provider strategy resolved from the
+stage's registry-validated ``provider`` config field. Tests inject a fake by
+patching the provider's ``make_client`` (e.g.
+``AnthropicProvider.make_client``) rather than a CLI helper. The
+``--estimate`` short-circuit, which needs a concrete client up front for its
+Anthropic-specific ``count_tokens`` probe, builds one via
+``provider_for(draft_config.provider).make_client()``.
Stage-order test (DEC-025): ``test_generate_calls_stages_in_documented_order``
patches every stage entry point and asserts the documented
@@ -83,6 +93,7 @@
import time
from dataclasses import dataclass
from pathlib import Path
+from typing import cast
from signalforge import diff as diff_module
from signalforge import draft as draft_module
@@ -117,6 +128,7 @@
from signalforge.diff.models import DiffReport, ProposedTestFile
from signalforge.grade.rubric import DEFAULT_RUBRIC
from signalforge.llm import AnthropicClientProtocol
+from signalforge.llm.providers import provider_for
from signalforge.manifest import select_models
from signalforge.manifest.errors import SelectorParseError
from signalforge.manifest.models import Manifest, Model
@@ -405,17 +417,6 @@ def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[
# ---------------------------------------------------------------------------
-def _make_anthropic_client() -> AnthropicClientProtocol | None:
- """Return the Anthropic client to inject into the draft / grade stages.
-
- Default implementation returns ``None`` so the underlying stage's
- own ``client = anthropic.Anthropic(...)`` lazy construction (gated
- by the single SDK seam at :mod:`signalforge.llm._client`) runs.
- Tests patch this to return a :class:`tests.llm._fake.FakeAnthropicClient`.
- """
- return None
-
-
def _make_warehouse_adapter(profile: warehouse_module.DbtProfileTarget) -> WarehouseAdapter:
"""Construct the :class:`WarehouseAdapter` for the resolved profile.
@@ -803,17 +804,46 @@ def _run_single_model(
{**prune_config.model_dump(), **prune_overrides}
)
diff_module.load_diff_config(project_dir)
- client = _make_anthropic_client()
- if client is None:
- # The default factory returns None to let the underlying
- # stages lazy-construct via signalforge.llm._client.
- # The estimate engine needs a concrete client; build one
- # here through the same single SDK seam so any
- # LLMAuthError surfaces at the existing panic boundary
- # → tier 3 via _EXCEPTION_TO_EXIT_CODE.
- from signalforge.llm._client import _make_anthropic_client as _llm_make_client
-
- client = _llm_make_client()
+ # DEC-006 of #135 — the four pipeline orchestrators now let
+ # ``call_llm`` lazy-build the client via the configured provider,
+ # so the CLI no longer constructs one for the real-run path. The
+ # ``--estimate`` engine takes the same shape: each stage's
+ # ``count`` call dispatches through
+ # ``provider_for(config.provider).estimate_input_tokens(...)``
+ # (#136 US-005 DEC-003), so a non-Anthropic provider's
+ # ``--estimate`` path now works too — OpenAI counts locally via
+ # ``tiktoken`` and ignores the threaded client.
+ #
+ # The engine still accepts a single optional client object so
+ # Anthropic's per-call SDK construction is avoidable when the
+ # CLI already has one in scope. For non-Anthropic providers we
+ # pass ``None`` and the provider handles it (no-op for OpenAI;
+ # error for any provider that genuinely needs an SDK client and
+ # was given none — only Anthropic does today). The single-client
+ # design still requires the two stages' providers to match so
+ # we don't silently project grade-stage cost through the
+ # drafter's vendor when they diverge.
+ if draft_config.provider != grade_config.provider:
+ raise CliInputError(
+ "--estimate requires draft.provider and grade.provider to match "
+ f"(got {draft_config.provider!r} and {grade_config.provider!r}).",
+ remediation=(
+ "Set the same provider for both stages, or run without "
+ "--estimate until per-provider estimation support lands."
+ ),
+ )
+ # Build a concrete client only for providers that need one
+ # (Anthropic). Providers that count locally (OpenAI) ignore
+ # the kwarg; passing ``None`` keeps us from constructing an
+ # SDK client + requiring its API key purely for a local count.
+ client: object | None
+ if draft_config.provider == "anthropic":
+ client = cast(
+ "AnthropicClientProtocol",
+ provider_for(draft_config.provider).make_client(),
+ )
+ else:
+ client = None
report = estimate_module.estimate(
model,
manifest,
@@ -865,8 +895,13 @@ def _run_single_model(
emit_progress_done(1, "safety", time.monotonic() - _t0)
# ---- 2/5: draft -------------------------------------------------
+ # DEC-006 of #135 — the CLI no longer constructs an Anthropic client
+ # for the real-run path. ``draft_schema`` threads ``_client=None``
+ # into ``call_llm``, which lazy-builds the real client via the
+ # provider strategy resolved from ``draft_config.provider`` (the
+ # registry-validated config field, DEC-007). Tests inject a fake by
+ # patching the provider's ``make_client`` rather than a CLI helper.
draft_config = draft_module.load_draft_config(project_dir)
- client = _make_anthropic_client()
if progress_on:
emit_progress_entry(2, "draft", f"calling LLM (model {draft_config.model})...")
_t0 = time.monotonic()
@@ -876,7 +911,7 @@ def _run_single_model(
policy,
manifest,
config=draft_config,
- _client=client,
+ _client=None,
)
if progress_on:
emit_progress_done(2, "draft", time.monotonic() - _t0)
@@ -994,12 +1029,15 @@ def _run_single_model(
),
)
_t0 = time.monotonic()
+ # DEC-006 of #135 — ``client=None`` lets ``grade_artifacts`` thread it
+ # into ``call_llm``, which lazy-builds via the provider resolved from
+ # ``grade_config.provider`` (independent of the drafter's provider).
grade_report = grade_module.grade_artifacts(
model,
draft_outcome.candidate,
prune_result,
config=grade_config,
- client=client,
+ client=None,
project_dir=project_dir,
)
if progress_on:
diff --git a/src/signalforge/cli/install_skill.py b/src/signalforge/cli/install_skill.py
new file mode 100644
index 00000000..29348f4e
--- /dev/null
+++ b/src/signalforge/cli/install_skill.py
@@ -0,0 +1,220 @@
+"""``signalforge install-skill`` subcommand (US-003 — issue #141).
+
+Drops the bundled SignalForge Claude Code skill (the
+``src/signalforge/skills/signalforge/`` tree) into
+``/.claude/skills/signalforge/`` so a user can pair their Claude
+Code session with SignalForge in one command. Wraps the
+:func:`signalforge.skill.install_skill` library entry point (US-002) and
+re-raises the three :class:`signalforge.skill.SkillError` subclasses at
+the handler boundary as ``CliInstallSkill*Error`` wrappers so the CLI's
+four-tier exit-code taxonomy stays homogeneous (DEC-008).
+
+Path-handling note
+==================
+
+``install-skill`` is the second CLI subcommand that *creates* the
+project context rather than operating *inside* one (the first is
+``init-demo``), so it deliberately does **not** route ``dest`` through
+:func:`signalforge.cli._helpers.canonicalise_user_path` — that helper
+enforces a ``project_dir`` containment boundary appropriate for paths
+the CLI consumes inside an existing project (DEC-006 of
+``plans/super/141-claude-skill-install.md``). Symlink-cycle defence
+still applies: :func:`signalforge.skill.install_skill` resolves ``dest``
+via ``.resolve(strict=True)`` first (falling back to ``strict=False``
+on ``FileNotFoundError`` / ``NotADirectoryError``) and raises
+:class:`signalforge.skill.SkillDestPathError` on a cycle on every
+supported Python version (gh-108958).
+
+Default-dest is CWD
+===================
+
+The positional ```` defaults to ``"."`` (current working
+directory) per DEC-004. An operator running from the dbt project root
+gets ``/.claude/skills/signalforge/SKILL.md`` with no flag tuning
+needed. Mirrors :mod:`signalforge.cli.init_demo`'s
+default-to-CWD-friendly ergonomics.
+
+Overwrite UX (DEC-017)
+======================
+
+On success the handler prints a single INFO line to stdout:
+
+ ``Installed SignalForge skill to ``
+
+If a SKILL.md already existed at the install path (detected BEFORE the
+copy via :func:`Path.exists`), the line appends
+``(replaced existing SKILL.md)``. The lib seam's overwrite policy is
+upgrade-in-place friendly (DEC-003 — overwrites every file SignalForge
+ships; preserves every other file in the destination tree); the CLI
+surfaces just this one delta so operators know their hand-edited
+SKILL.md was replaced. No ``--force`` flag, no ``.bak`` file, no diff
+output — the operator can ``git diff`` if they had the file under
+version control.
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from signalforge.cli._helpers import (
+ format_error_to_stderr,
+ map_exception_to_exit_code,
+ print_stderr,
+)
+from signalforge.cli.errors import (
+ CliInstallSkillDestUnsafeError,
+ CliInstallSkillPackageDataMissingError,
+ CliInstallSkillPathError,
+)
+from signalforge.skill import (
+ SkillDestPathError,
+ SkillDestUnsafeError,
+ SkillPackageDataMissingError,
+ install_skill,
+)
+
+__all__ = ["add_parser", "cmd_install_skill"]
+
+
+# Path components for the SKILL.md install location relative to
+# ````. Mirrors ``signalforge.skill``'s private constants — kept
+# here for the pre-write existence probe that drives the DEC-017
+# ``(replaced existing SKILL.md)`` suffix decision.
+_INSTALLED_SKILL_REL: Path = Path(".claude") / "skills" / "signalforge" / "SKILL.md"
+
+
+def add_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
+ """Register the ``install-skill`` subcommand on the top-level parser.
+
+ Mirrors the registration shape of :mod:`signalforge.cli.init_demo`
+ (DEC-009 of ``.claude/rules/cli-layer.md`` — one flat module per
+ subcommand). One surface:
+
+ * Positional ``dest`` — optional (``nargs="?"``) with a string
+ default of ``"."`` (current working directory) per DEC-004. String
+ (not :class:`pathlib.Path`) so argparse's default stringification
+ is predictable across Python versions and platforms;
+ :func:`signalforge.skill.install_skill` itself runs ``Path(dest)``
+ so callers can pass either form.
+
+ Per DEC-003 there is no ``--force`` flag in v0.1 — the lib seam
+ always overwrites the bundled-skill files in place and never
+ touches any other file in the destination tree, so the
+ ``--force``-against-symlink-dest hazard ``copy_demo`` defends
+ against does not apply here.
+ """
+ parser = subparsers.add_parser(
+ "install-skill",
+ help=(
+ "Install the bundled SignalForge Claude Code skill into "
+ "/.claude/skills/signalforge/."
+ ),
+ description=(
+ "Drop the bundled SignalForge Claude Code skill (SKILL.md "
+ "+ assets) into /.claude/skills/signalforge/ so a "
+ "Claude Code session in picks up the skill. Default "
+ " is the current working directory. Overwrites the "
+ "files SignalForge ships; preserves every other file in "
+ "the destination tree (no --force flag, no backup file)."
+ ),
+ )
+ parser.add_argument(
+ "dest",
+ nargs="?",
+ default=".",
+ metavar="DEST",
+ help=(
+ "Destination directory. Default: current working "
+ "directory. The skill lands at "
+ "/.claude/skills/signalforge/SKILL.md."
+ ),
+ )
+ parser.set_defaults(func=cmd_install_skill)
+
+
+def cmd_install_skill(args: argparse.Namespace) -> int:
+ """Install the bundled SignalForge skill under ``args.dest`` and
+ print the DEC-017 INFO line.
+
+ Returns the integer exit code per the four-tier CLI taxonomy
+ (DEC-008 of ``.claude/rules/cli-layer.md``):
+
+ * ``0`` — install succeeded; INFO line printed to stdout.
+ * ``1`` — broken install
+ (:class:`CliInstallSkillPackageDataMissingError`), symlink cycle
+ (:class:`CliInstallSkillPathError`), or an unexpected
+ forward-compat exception caught at the
+ ``except Exception`` belt-and-braces boundary.
+ * ``2`` — operator-side dest mistakes
+ (:class:`CliInstallSkillDestUnsafeError`): ```` is a
+ regular file, or the existing ``SKILL.md`` is a symlink (writing
+ would follow the link).
+
+ The single ``try / except Exception`` boundary matches DEC-016 (no
+ traceback ever leaks); failures route through
+ :func:`format_error_to_stderr` so the canonical
+ ``ERROR: `` + ``↳ Remediation: `` shape applies
+ uniformly with the rest of the CLI.
+
+ DEC-017 — the success path prints a single INFO line to stdout
+ naming the absolute install path. When a SKILL.md already existed
+ at the install target (detected BEFORE the copy), the line appends
+ ``(replaced existing SKILL.md)`` so the operator knows the lib's
+ upgrade-in-place overwrite policy fired.
+ """
+ raw_dest = args.dest
+ # Pre-probe for an existing SKILL.md so the DEC-017 suffix is
+ # accurate. ``Path(...).expanduser()`` is enough — we do not need
+ # full canonicalisation here; the lib seam does that. ``exists()``
+ # ``.exists()`` returns True for regular files AND working symlinks
+ # (it follows the link); ``.is_symlink()`` returns True for symlinks
+ # regardless of whether the target is broken. We OR both so the
+ # probe reports "replaced" for every shape an operator would call
+ # an existing SKILL.md — including a broken symlink, which the lib
+ # seam refuses with ``SkillDestUnsafeError`` (the suffix is moot for
+ # that path but the semantics stay honest). If the parent dir is
+ # unreadable the probe silently returns False and the suffix is
+ # omitted — the lib seam's own failure surfaces in the except
+ # ladder below.
+ try:
+ target_skill_md = Path(raw_dest).expanduser() / _INSTALLED_SKILL_REL
+ existed_before = target_skill_md.exists() or target_skill_md.is_symlink()
+ except OSError:
+ existed_before = False
+
+ try:
+ installed_path = install_skill(raw_dest)
+ except SkillDestPathError as exc:
+ wrapped: Exception = CliInstallSkillPathError(dest=str(raw_dest), cause=exc)
+ print_stderr(format_error_to_stderr(wrapped))
+ return map_exception_to_exit_code(wrapped)
+ except SkillDestUnsafeError as exc:
+ wrapped = CliInstallSkillDestUnsafeError(dest=str(raw_dest), cause=exc)
+ print_stderr(format_error_to_stderr(wrapped))
+ return map_exception_to_exit_code(wrapped)
+ except SkillPackageDataMissingError as exc:
+ wrapped = CliInstallSkillPackageDataMissingError(cause=exc)
+ print_stderr(format_error_to_stderr(wrapped))
+ return map_exception_to_exit_code(wrapped)
+ except (KeyboardInterrupt, SystemExit):
+ # Preserve Python's default semantics for operator Ctrl-C and
+ # any clean SystemExit raised from within ``install_skill``
+ # (none today, but defensive parity with the rest of the CLI).
+ raise
+ except Exception as exc: # noqa: BLE001 — uniform CLI boundary catch (DEC-016)
+ # Belt-and-braces — any forward-compat exception added to the
+ # install helper's raise surface routes through the canonical
+ # formatter + mapper rather than leaking a traceback.
+ print_stderr(format_error_to_stderr(exc))
+ return map_exception_to_exit_code(exc)
+
+ # DEC-017 — single INFO line, names the absolute install path.
+ # ``installed_path`` is already an absolute :class:`Path` from the
+ # lib seam (``target_skill_md.resolve()``); ``str(...)`` is what
+ # operators copy-paste.
+ line = f"Installed SignalForge skill to {installed_path}"
+ if existed_before:
+ line += " (replaced existing SKILL.md)"
+ print(line)
+ return 0
diff --git a/src/signalforge/draft/config.py b/src/signalforge/draft/config.py
index 3815dc1e..d1bbbc63 100644
--- a/src/signalforge/draft/config.py
+++ b/src/signalforge/draft/config.py
@@ -110,6 +110,20 @@ class DraftConfig(BaseModel):
max_retries_conn: int = 1
"""Connection / transport-error retry budget."""
+ provider: str = "anthropic"
+ """LLM provider strategy name, resolved against the
+ :mod:`signalforge.llm.providers` registry (issue #135 DEC-007).
+
+ Threaded into :func:`signalforge.llm.call_llm` from
+ :func:`signalforge.draft.draft_schema` so a non-Anthropic provider
+ (#136 OpenAI / #137 Gemini) is selected per stage. Deliberately a
+ registry-validated ``str``, NOT a ``Literal`` (DEC-007): the provider
+ registry is a plugin point designed to grow, so a new provider
+ registers itself rather than editing a ``Literal`` in two config
+ modules. The field validator fails loud on an unknown value — listing
+ the registered provider names — mirroring ``prune``'s
+ ``trusted_models`` validate-at-entry fail-loud."""
+
exclude_tests: tuple[str, ...] = ()
"""Test types to omit from drafting entirely (issue #54).
@@ -129,6 +143,32 @@ def _max_output_tokens_positive(cls, v: int) -> int:
raise ValueError("max_output_tokens must be positive")
return v
+ @field_validator("provider")
+ @classmethod
+ def _provider_registered(cls, v: str) -> str:
+ """Reject an unknown provider name at config-load (issue #135 DEC-007).
+
+ Membership is checked against the live
+ :mod:`signalforge.llm.providers` registry via
+ :func:`signalforge.llm.providers.provider_for`, which raises
+ :class:`signalforge.llm.errors.UnknownProviderError` listing the
+ available provider names. Import is local to the validator to keep
+ the draft-config module free of any import-time coupling to the LLM
+ provider registry (no cycle exists today, but the local import is
+ the conservative choice per DEC-007).
+
+ Pydantic v2 wraps only ``ValueError`` / ``TypeError`` /
+ ``AssertionError`` into a ``ValidationError``; ``UnknownProviderError``
+ is an ``LLMError`` (an ``Exception`` subclass), so it propagates raw —
+ the config loader's ``load_draft_config`` surfaces it directly with
+ its available-keys remediation rather than burying it in a Pydantic
+ ``ValidationError``.
+ """
+ from signalforge.llm.providers import provider_for
+
+ provider_for(v)
+ return v
+
@field_validator("exclude_tests", mode="before")
@classmethod
def _coerce_exclude_tests(cls, value: object) -> tuple[str, ...]:
diff --git a/src/signalforge/draft/errors.py b/src/signalforge/draft/errors.py
index 6cfd77dc..1a715d4e 100644
--- a/src/signalforge/draft/errors.py
+++ b/src/signalforge/draft/errors.py
@@ -384,32 +384,67 @@ def __init__(
class PromptEnvelopeBreachError(DraftError):
- """A model's ``raw_code`` contains the closing ```` literal,
- breaking the prompt-injection envelope (DEC-007) before it can be sent.
+ """A prompt fragment contains the closing tag of a prompt-injection
+ envelope (DEC-007 of #5; extended for ```` by #163),
+ breaking the fence before it can be sent.
The envelope is the documented defence against adversarial dbt content:
- every byte between ```` and ```` is data, not
- instructions. A ``raw_code`` containing the closing tag — whether placed
- maliciously or by accident in a SQL comment — would terminate the fence
- early and let everything after be read by the LLM as instructions.
+ every byte between ```` and ```` is data, not instructions.
+ A payload containing the closing tag — whether placed maliciously or by
+ accident in a SQL comment / business-rule string — would terminate the
+ fence early and let everything after be read by the LLM as instructions.
- Raised BEFORE any LLM call so a poisoned model never reaches Anthropic.
+ Raised BEFORE any LLM call so a poisoned input never reaches the
+ provider.
+
+ Envelope-parameterised (#163 US-001, DEC-005):
+
+ * ``envelope="MODEL_SQL"`` (default) — the original ````
+ envelope. Message is byte-equal to the pre-#163 rendering so the
+ existing call site keeps working unchanged.
+ * ``envelope="BUSINESS_RULE"`` + ``rule_index`` — the per-rule
+ ```` envelope around operator-supplied rules.
+ Message names the 1-indexed offending rule.
+
+ Future envelopes follow the same shape — extend with a new ``envelope=``
+ value, never a new error class.
"""
default_remediation: ClassVar[str] = (
- "The model's raw SQL contains the literal '' which would "
- "break the prompt-injection envelope. Inspect the model file (likely "
- "a SQL comment); remove the literal or escape it. If this is "
- "legitimate content (rare), open an issue — the envelope tag will "
- "need to rotate to an unguessable nonce."
+ "The input contains the literal closing tag of a prompt-injection "
+ "envelope (e.g. '