Refuse to silently extract mock content into a real Space

A fresh Smriti install with no provider configured could still run
`smriti checkpoint create --extract`, which silently returned
MockAdapter content like "Mock decision from provider". If committed,
that placeholder text became part of the user's real reasoning state.

Root cause: backend/app/api/routes/checkpoint.py:417 called
`get_adapter(cfg.background.provider, allow_mock=True)`. The
`allow_mock=True` flag means the registry quietly returns MockAdapter
when no API key is configured, rather than raising. The CLI received
the canned mock JSON and committed it without inspecting whether it
came from a real LLM.

The extract endpoint was the only route in the codebase with this
pattern — draft, review, chat title, and chat send already correctly
pass `allow_mock=False`.

The new contract:

- Core Smriti (setup, doctor, quickstart, state/current/metrics,
  claims, attach, manual JSON checkpoints) requires no API key.
- Real LLM-backed paths (`--extract`, draft, review, chat send)
  require a configured provider — OpenAI / Anthropic / OpenRouter /
  generic OpenAI-compatible (local models like Ollama).
- Mock extraction still works for tests and demos, but only when the
  caller explicitly opts in (use_mock=true on the HTTP payload).
  It is never silently the default.

Backend:
- POST /api/v5/checkpoint/extract now passes allow_mock=False and
  catches ProviderNotConfiguredError, returning HTTP 412 with a
  structured detail: error code, human message, the provider it
  tried, and a list of fix paths the CLI surfaces.
- CheckpointExtractResponse gains `provider` and `model` echo fields
  (additive, default empty) so callers can confirm what answered.

CLI:
- checkpoint create --extract catches 412 and prints the actionable
  fix list; exits 78 (EX_CONFIG). Defense in depth: even on a 200,
  refuses to commit if response.provider == "mock" on the default
  path (so any future regression in the backend is still caught).
- smriti doctor surfaces background provider state prominently:
  `ready (real LLM extraction enabled)` or `⚠ MOCK or DISABLED — …
  will fail until a provider is configured`.
- smriti doctor --strict exits 78 when the background provider is
  mock/disabled or the backend is unreachable. Safe to wire into
  CI before any --extract step.
- On a successful --extract commit, the CLI shows `extracted via
  <provider>/<model>` under the commit confirmation.

Docs:
- README: new "Provider configuration (LLM-backed features)" section
  drawing the boundary explicitly; mentions the generic provider for
  local OpenAI-compatible models; flags mock as test-only.
- .env.example: rewrote the provider section so an empty key or a
  model-without-a-key is not interpreted as "ready".
- Skill pack template: new §4.1 "Before your first --extract: verify
  the provider" telling agents to run `smriti doctor`, refuse
  --extract when background_provider is mock/disabled, and fall back
  to manual JSON checkpoints or ask the human to configure a
  provider. Re-rendered to AGENTS.md (Codex target). The Claude Code
  target (.claude/skills/smriti/SKILL.md) is gitignored per-user
  install; rerun `smriti skills install claude-code` to refresh.
- website/index.html: Try-it lede now spells out which features need
  a provider rather than gesturing at "optional LLM features".

Tests:
- test_extract_without_provider_fails_loud: regression for the bug —
  monkeypatches get_adapter to raise ProviderNotConfiguredError,
  asserts HTTP 412 with the structured detail shape, and asserts
  the response body contains neither "Mock decision from provider"
  nor "Mock Checkpoint". This test would fail on pre-fix code.
- test_extract_with_provider_echoes_provider_and_model: pins the
  green path — provider and model must be echoed and must not be
  "mock" when the real adapter answers.
- test_extract_happy_path_with_mock: unchanged, still pins the
  explicit use_mock=true contract.
- Full backend integration suite: 165 passed locally (with the
  pre-existing real-provider draft test passing under
  backend/config/providers.yaml).
This commit is contained in:
Himanshu Dongre 2026-05-23 17:48:14 +05:30
parent 15e099b67e
commit 713ed9a007
10 changed files with 414 additions and 72 deletions

View file

@ -25,28 +25,55 @@ SMRITI_DB_MODE=local
# SMRITI_DB_MODE=postgres
# DATABASE_URL=postgresql://smriti:smriti@localhost:5432/smriti
# Provider API keys — uncomment and set the ones you want to use.
# If unset, the backend falls back to config/providers.yaml, then to
# the mock provider (deterministic responses, no real LLM calls).
# ────────────────────────────────────────────────────────────────────────
# Provider configuration (LLM-backed features only)
# ────────────────────────────────────────────────────────────────────────
#
# Core Smriti — setup, doctor, quickstart, state/current/metrics, claims,
# attach, and hand-written checkpoints — works WITHOUT any API key.
#
# These features require a real configured provider:
# - `smriti checkpoint create --extract`
# - `smriti checkpoint review`
# - checkpoint draft
# - chat UI send loop
#
# Without a provider, those paths refuse to run (HTTP 412) rather than
# silently returning placeholder MockAdapter content. Mock content is for
# tests and demos only; it must never be committed into a real project.
#
# Uncomment ONE of the keys below for a hosted provider, OR configure the
# generic provider (further down) for a local OpenAI-compatible model.
#
# IMPORTANT: do not leave a key set to empty (e.g. OPENAI_API_KEY=).
# An empty value is actively placed into os.environ by dotenv and can
# mask the yaml fallback in some configurations. Either set a real
# key or leave the line commented out.
# mask the yaml fallback. Either set a real key or leave the line commented.
# Setting OPENAI_MODEL alone (below) without OPENAI_API_KEY is NOT enough —
# the model needs an actual provider with credentials behind it.
#
# OPENAI_API_KEY=your-openai-key-here
# ANTHROPIC_API_KEY=your-anthropic-key-here
# OPENROUTER_API_KEY=your-openrouter-key-here
# Default model for background intelligence (extraction, draft, review)
# Default model for background intelligence (extraction, draft, review).
# Only takes effect when a provider key (above) is set or the generic
# provider (below) is configured.
OPENAI_MODEL=gpt-4o-mini
# Generic OpenAI-compatible provider (Ollama, LM Studio, vLLM, Together, etc.)
# Set the API URL to use a local or cheap model instead of OpenAI/Anthropic.
# API key is optional — local servers like Ollama don't require one.
# Then set background_intelligence.provider to "generic" in providers.yaml.
# Generic OpenAI-compatible provider — Ollama, LM Studio, vLLM, Together, etc.
# Use this slot for a LOCAL model (or any OpenAI-API-compatible endpoint).
# API key may be optional for local servers; OpenAI SDK accepts any non-empty
# string. Then set background_intelligence.provider to "generic" in
# backend/config/providers.yaml.
#
# Extraction is usually a lightweight structured-output task and is often
# handled well by cost-efficient or local models, but quality varies — try
# `smriti checkpoint create --extract --dry-run` on a representative
# document before relying on a small model for real work.
#
# SMRITI_GENERIC_API_URL=http://localhost:11434/v1
# SMRITI_GENERIC_MODEL=llama3.1:8b
# SMRITI_GENERIC_API_KEY=
# SMRITI_GENERIC_API_KEY=not-required
# App
DEBUG=false

View file

@ -582,6 +582,40 @@ know about. Concretely:
- **You are about to explore an alternative direction and want to
preserve the current line.** Fork first, then checkpoint on the fork.
### 4.1 Before your first `--extract`: verify the provider
The extract path uses Smriti's background LLM to turn freeform markdown
into structured checkpoint fields. It **requires a real configured
provider** (OpenAI / Anthropic / OpenRouter / generic OpenAI-compatible
endpoint). If no provider is configured, the extract endpoint returns
**HTTP 412 Precondition Failed** rather than silently falling back to
mock content — mock content committed into a real Space pollutes the
reasoning state Smriti exists to keep trustworthy.
Run `smriti doctor` once at the start of a session that intends to use
`--extract`, and read the `background provider:` line.
```
smriti doctor
```
Look for:
- **`background provider: ready (real LLM extraction enabled)`** —
good, use `--extract` freely.
- **`background provider: ⚠ MOCK or DISABLED — ... will fail until a
provider is configured`** — do **not** use `--extract`. Either ask
the human to configure a provider (OpenAI/Anthropic/OpenRouter key,
or a local OpenAI-compatible endpoint via the generic provider), or
write the checkpoint **manually as JSON** (see 4.1 below). Do not
attempt to commit mock content as if it were real reasoning state.
For scripted/CI use, `smriti doctor --strict` exits non-zero
(EX_CONFIG / 78) when the background provider is mock/disabled —
useful as a pre-flight gate before any `--extract` step.
### 4.2 The extract path (provider required)
Use the **extract path**, not hand-written JSON. Pass freeform markdown
describing the inflection point and Smriti's background LLM will pull
out the structured fields (message, objective, summary, decisions,
@ -611,7 +645,7 @@ Always tag `author_agent` with a stable identifier for your agent
agents know who wrote what on the shared timeline. Inconsistent or
missing `author_agent` makes divergence unattributable.
### 4.1 Checkpoint notes: annotating without checkpointing
### 4.3 Checkpoint notes: annotating without checkpointing
Sometimes you need to add context to an existing checkpoint without
creating a new one. Checkpoint notes are additive annotations —

View file

@ -91,7 +91,7 @@ Backend reachable, CLI/backend versions aligned, provider status. If anything's
smriti quickstart
```
Seeds a `smriti-demo` Space — one finished mini-project (a rate-limiting feature built by two agents, with a branch explored and dropped) — and prints a ~3-minute guided walkthrough. Works without API keys (mock-mode extraction). Clean up with `smriti quickstart --remove`.
Seeds a `smriti-demo` Space — one finished mini-project (a rate-limiting feature built by two agents, with a branch explored and dropped) — and prints a ~3-minute guided walkthrough. Works without API keys — quickstart seeds pre-built checkpoints, no live extraction. (For live `--extract` you need a real provider; see [Provider configuration](#provider-configuration-llm-backed-features).) Clean up with `smriti quickstart --remove`.
### 4. Attach your own project — `smriti init`
@ -211,27 +211,56 @@ The primitives that turn "shared state" from a phrase into something that actual
---
## API keys (optional)
## Provider configuration (LLM-backed features)
**The core coordination loop runs without API keys.** Setup, `smriti doctor`, `smriti quickstart`, `smriti state` / `current` / `metrics`, claims, attachments, repo-state drift detection, hand-written checkpoints, and the chat UI's read-only dashboards (timeline, checkpoints, claims, drift signals) all work with no key.
Smriti draws a hard line between **core coordination** (works with no API key) and **LLM-backed features** (require a configured provider).
API keys are only needed for the LLM-assisted features:
### Works with no key
- `smriti checkpoint create --extract` — extract structured fields from freeform markdown
- Checkpoint draft and consistency review (`smriti checkpoint review`)
- The chat UI's send loop — where the agent actually responds to your messages
- `setup` / `doctor` / `quickstart`
- `smriti state` / `current` / `metrics`
- claims (`smriti claim`, claim listing)
- attach / no-arg project workflow
- manual structured checkpoints (`smriti checkpoint create <space>` with a JSON payload on stdin)
- repo-state drift detection
- the chat UI's read-only dashboards (timeline, checkpoints, claims, drift signals)
Smriti supports OpenAI, Anthropic, OpenRouter, and any OpenAI-compatible provider (Ollama, LM Studio, vLLM) via the generic provider slot. Set keys in `.env` when you want those features for real:
### Require a real provider
- `smriti checkpoint create --extract` — extracts structured fields from freeform markdown
- `smriti checkpoint review` — consistency review of a checkpoint
- checkpoint draft
- the chat UI's send loop — where the agent actually responds
Without a configured provider, these refuse to run rather than silently return placeholder content. `--extract` returns **HTTP 412 Precondition Failed** with an actionable error listing the configuration paths. **Mock content is never silently committed into a real project** — that would pollute reasoning state, which Smriti exists to keep trustworthy.
### Configure a provider
Set one of these in `.env` (or your shell):
```
OPENAI_API_KEY=...
ANTHROPIC_API_KEY=...
OPENROUTER_API_KEY=...
SMRITI_GENERIC_API_URL=http://localhost:11434/v1 # for Ollama / LM Studio / vLLM
SMRITI_GENERIC_MODEL=llama3.1:8b
```
Without a key set, the LLM-assisted paths return deterministic placeholder content so the mechanics still work — handy for trying the coordination loop end-to-end. Add a key when you want real extraction.
Or, for a **local OpenAI-compatible model** (Ollama, LM Studio, vLLM, Together, etc.):
```
SMRITI_GENERIC_API_URL=http://localhost:11434/v1 # your local server
SMRITI_GENERIC_MODEL=llama3.1:8b
# SMRITI_GENERIC_API_KEY=not-required # most local servers don't need one
```
Then set `background_intelligence.provider: generic` in `backend/config/providers.yaml`.
Extraction is a relatively lightweight structured-output task and is usually handled well by cost-efficient or local OpenAI-compatible models. That said, *quality varies by model* — validate with `smriti checkpoint create --extract --dry-run` on a representative document before relying on a small model for real work.
After configuring, run `smriti doctor` (optionally with `--strict`) to confirm the background provider line reads `ready`. `--strict` exits non-zero (EX_CONFIG / 78) if the provider is `mock_or_disabled`, which makes it safe to use in CI before any `--extract` step.
### Mock mode
A deterministic `MockAdapter` exists for tests, demos, and the `quickstart` mechanics. It returns a fixed JSON blob (containing literal strings like `"Mock decision from provider"`). **It is never the default for `--extract`** — it only runs when the caller explicitly opts in (e.g. tests passing `use_mock=true` to the HTTP endpoint). Do not commit mock-extracted content into a real project Space.
---

View file

@ -20,7 +20,7 @@ from app.schemas import (
ReviewIssue,
)
from app.providers.registry import get_adapter, get_mock_adapter
from app.config_loader import get_config
from app.config_loader import get_config, ProviderNotConfiguredError
router = APIRouter()
logger = logging.getLogger(__name__)
@ -403,23 +403,57 @@ Rules:
- Output ONLY valid JSON. No markdown wrappers, no explanation.
"""
try:
if request.use_mock:
adapter = get_mock_adapter()
bg_model = "mock"
else:
cfg = get_config()
bg_model = cfg.background.model
# allow_mock=True so an unconfigured test env falls back to
# MockAdapter (which supports JSON-mode responses); production
# envs always have a real provider configured and go through
# the real adapter path.
adapter = get_adapter(cfg.background.provider, allow_mock=True)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Background provider not configured. Error: {e}",
)
# Resolve the adapter.
#
# Contract (post launch-blocker fix):
# - use_mock=True (explicit opt-in): always return MockAdapter. Used by
# tests and demos that need deterministic JSON without a real provider.
# - use_mock=False (default): require a real configured provider. If none
# is configured, return 412 Precondition Failed with a structured
# detail explaining how to fix it. Never silently fall back to mock —
# mock content committed into a real project pollutes reasoning state.
if request.use_mock:
adapter = get_mock_adapter()
bg_provider = "mock"
bg_model = "mock"
else:
cfg = get_config()
bg_provider = cfg.background.provider
bg_model = cfg.background.model
try:
adapter = get_adapter(bg_provider, allow_mock=False)
except ProviderNotConfiguredError as e:
logger.warning(
"extract refused: background provider '%s' not configured (%s)",
bg_provider, e,
)
raise HTTPException(
status_code=412,
detail={
"error": "provider_not_configured",
"message": (
f"Background LLM provider '{bg_provider}' is not configured. "
"Real checkpoint extraction requires a real provider — refusing "
"to silently fall back to mock content."
),
"provider": bg_provider,
"model": bg_model,
"fix": [
"Set OPENAI_API_KEY in .env (or your shell) for OpenAI.",
"Set ANTHROPIC_API_KEY for Anthropic.",
"Set OPENROUTER_API_KEY for OpenRouter.",
"For a local OpenAI-compatible model (Ollama, LM Studio, vLLM): "
"set SMRITI_GENERIC_API_URL + SMRITI_GENERIC_MODEL, then set "
"background_intelligence.provider to 'generic' in "
"backend/config/providers.yaml.",
"Or skip extraction and create a checkpoint manually with "
"`smriti checkpoint create <space>` and a JSON payload on stdin.",
"For tests/demos only: pass use_mock=true to explicitly request "
"the MockAdapter (do NOT commit that output into a real project).",
],
"details": str(e),
},
)
try:
raw_response = adapter.send(
@ -441,6 +475,8 @@ Rules:
open_questions=_dedup(data.get("open_questions", [])),
entities=_dedup(data.get("entities", [])),
artifacts=[a for a in data.get("artifacts", []) if isinstance(a, dict)],
provider=bg_provider,
model=bg_model,
)
except json.JSONDecodeError:
logger.error(f"Extract LLM returned invalid JSON: {raw_response}")

View file

@ -158,3 +158,9 @@ class CheckpointExtractResponse(BaseModel):
open_questions: list[str] = Field(default_factory=list)
entities: list[str] = Field(default_factory=list)
artifacts: list[dict] = Field(default_factory=list)
# Echo the provider/model that actually answered, so callers can confirm
# whether the extraction came from a real LLM or the mock adapter. The
# CLI surfaces this on every --extract commit and uses it to refuse to
# persist mock content into a real project.
provider: str = Field("", description="Provider that produced this extraction (e.g. 'openai', 'anthropic', 'mock').")
model: str = Field("", description="Model identifier that produced this extraction (or 'mock').")

View file

@ -1,11 +1,27 @@
"""Integration tests for POST /api/v5/checkpoint/extract.
Tests use MockAdapter's JSON-mode path (registry._MOCK_JSON_RESPONSE),
which returns a deterministic canned blob when the caller passes
response_format={"type": "json_object"}. The extract endpoint always
requests JSON mode, so every test hits the canned response.
Post launch-blocker fix (provider-extract-safety):
- The DEFAULT path (no `use_mock` flag) requires a real configured
background provider. If none is configured, the endpoint returns
HTTP 412 with a structured `provider_not_configured` detail. It
never silently falls back to MockAdapter that would pollute a
real user's reasoning state with placeholder content.
- The EXPLICIT mock path (`use_mock=True`) still works for tests
and demos. It always returns the canned MockAdapter response.
These tests pin both contracts.
"""
import json
import pytest
from app.config_loader import ProviderNotConfiguredError
from app.providers.base import ProviderAdapter
from app.providers import registry as provider_registry
from app.api.routes import checkpoint as checkpoint_route
def _sample_markdown() -> str:
return """# Design: envdiff CLI
@ -27,8 +43,11 @@ def main():
"""
# ── Explicit mock path: use_mock=True ─────────────────────────────────────────
def test_extract_happy_path_with_mock(client):
"""Extract endpoint returns canned mock fields when use_mock=True."""
"""use_mock=True: MockAdapter returns canned content, 200 OK."""
r = client.post(
"/api/v5/checkpoint/extract",
json={"content": _sample_markdown(), "use_mock": True},
@ -51,36 +70,99 @@ def test_extract_happy_path_with_mock(client):
assert "MockEntity" in data["entities"]
assert len(data["artifacts"]) == 1
assert data["artifacts"][0]["label"] == "Mock artifact"
# Echo fields must mark this as mock so callers can refuse to commit.
assert data["provider"] == "mock"
assert data["model"] == "mock"
def test_extract_default_path_returns_shape(client):
"""Default path (no use_mock flag) returns a valid CheckpointExtractResponse
shape regardless of which provider answers. In a test env with no API
keys this hits MockAdapter via the allow_mock=True fallback; in a dev
env with real keys it hits the real provider and returns real extracted
fields. Either way the response must be well-shaped.
# ── Default path without provider: must fail loud (the launch-blocker fix) ───
def test_extract_without_provider_fails_loud(client, monkeypatch):
"""Default path (no use_mock) + no real provider configured: HTTP 412 with
a structured 'provider_not_configured' detail. Never silent mock.
This is the regression test for the launch-blocker bug where the extract
endpoint silently returned MockAdapter content like
"Mock decision from provider" when no provider was configured.
"""
# Force the provider-not-configured state regardless of test-env API keys.
def _raise_not_configured(provider: str, allow_mock: bool = False):
# The endpoint must call with allow_mock=False (no silent fallback).
assert allow_mock is False, (
"extract endpoint must call get_adapter with allow_mock=False"
)
raise ProviderNotConfiguredError(f"no API key for {provider}")
monkeypatch.setattr(checkpoint_route, "get_adapter", _raise_not_configured)
r = client.post(
"/api/v5/checkpoint/extract",
json={"content": _sample_markdown()},
)
assert r.status_code == 412, r.text
detail = r.json().get("detail")
assert isinstance(detail, dict), f"detail must be a structured dict, got {type(detail)}"
assert detail.get("error") == "provider_not_configured"
assert "not configured" in detail.get("message", "").lower()
assert detail.get("provider") # backend echoes which provider it tried
assert isinstance(detail.get("fix"), list)
assert len(detail["fix"]) >= 3, "fix list should enumerate at least 3 paths"
# CRITICAL: response body must NOT contain mock content. The bug we are
# fixing literally returned "Mock decision from provider" in the response.
body_text = r.text
assert "Mock decision from provider" not in body_text
assert "Mock Checkpoint" not in body_text
# ── Default path with a real provider: provider/model echoed ─────────────────
class _FakeAdapter(ProviderAdapter):
"""Test double for a real provider — returns valid JSON shaped like a
real extraction so we can verify the endpoint's success-path metadata."""
def send(self, messages, model, **kwargs):
return json.dumps({
"title": "Real Extraction Result",
"objective": "Build envdiff CLI.",
"summary": "A short summary of the design doc.",
"decisions": ["Use argparse, not click"],
"assumptions": ["Python 3.11+ is available"],
"tasks": [{"id": "impl-1", "text": "Scaffold the CLI"}],
"open_questions": [],
"entities": ["envdiff"],
"artifacts": [],
})
def healthcheck(self) -> bool: # pragma: no cover
return True
def test_extract_with_provider_echoes_provider_and_model(client, monkeypatch):
"""Default path + a real (faked) provider: 200 OK, response echoes
`provider` and `model` so the CLI can show 'extracted via openai/gpt-4o-mini'
on commit and refuse to persist mock content."""
def _fake_adapter(provider: str, allow_mock: bool = False):
return _FakeAdapter()
monkeypatch.setattr(checkpoint_route, "get_adapter", _fake_adapter)
r = client.post(
"/api/v5/checkpoint/extract",
json={"content": _sample_markdown()},
)
assert r.status_code == 200, r.text
data = r.json()
# Response shape: every field present, strings are strings, lists are lists.
assert isinstance(data["title"], str)
assert isinstance(data["objective"], str)
assert isinstance(data["summary"], str)
assert isinstance(data["decisions"], list)
assert isinstance(data["assumptions"], list)
assert isinstance(data["tasks"], list)
assert isinstance(data["open_questions"], list)
assert isinstance(data["entities"], list)
assert isinstance(data["artifacts"], list)
# Something must have been extracted from the sample — the title must be
# non-empty and at least one decision should appear since the sample has
# a "## Decisions" section with two bullet points.
assert data["title"].strip() != ""
assert len(data["decisions"]) > 0
assert data["title"] == "Real Extraction Result"
# Echo: must be the configured provider/model, NOT "mock".
assert data["provider"] != "mock", "real-provider path must not echo provider=mock"
assert data["provider"] != ""
assert data["model"] != "mock"
assert data["model"] != ""
# ── Validation tests (unchanged) ─────────────────────────────────────────────
def test_extract_rejects_empty_content(client):

View file

@ -953,7 +953,16 @@ def format_doctor(report: dict) -> str:
else:
parts.append("- missing capabilities: none")
parts.append(f"- CLI path: {checks.get('cli_path') or 'unknown'}")
parts.append(f"- background provider: {checks.get('background_provider') or 'unknown'}")
bg = checks.get("background_provider") or "unknown"
if bg == "ready":
parts.append("- background provider: ready (real LLM extraction enabled)")
elif bg == "mock_or_disabled":
parts.append(
"- background provider: ⚠ MOCK or DISABLED — `smriti checkpoint create "
"--extract`, draft, and review will fail until a provider is configured"
)
else:
parts.append(f"- background provider: {bg}")
parts.append("")
parts.append("## Capabilities")

View file

@ -87,6 +87,29 @@ def _fail(message: str, code: int = 1) -> None:
sys.exit(code)
def _fail_provider_not_configured(detail: dict, code: int = 78) -> None:
"""Render the backend's structured 'provider_not_configured' error as a
clear, actionable message and exit non-zero.
Exit code 78 = EX_CONFIG (configuration error) so scripts can distinguish
a missing-provider failure from a generic CLI error.
"""
provider = detail.get("provider") or "background"
msg = detail.get("message") or (
f"Background LLM provider '{provider}' is not configured."
)
fix = detail.get("fix") or []
lines: list[str] = []
lines.append("error: " + msg)
lines.append("")
lines.append("To fix this, do one of:")
for item in fix:
lines.append(f" - {item}")
lines.append("")
lines.append("Run `smriti doctor` to confirm provider status after configuring.")
_fail("\n".join(lines), code=code)
def _confirm(preview: str, yes_flag: bool) -> bool:
"""Interactive 'Type yes' if stdin is a TTY, otherwise require --yes.
@ -799,13 +822,42 @@ def _read_checkpoint_json(args: argparse.Namespace) -> dict:
def cmd_doctor(client: SmritiClient, args: argparse.Namespace) -> None:
"""Print narrow backend/runtime diagnostics."""
"""Print narrow backend/runtime diagnostics.
With --strict, exits non-zero if any critical check fails. The current
strict checks are:
- backend reachable
- background provider configured for real LLM extraction (--extract,
draft, review). When this fails strict, agents and CI should refuse
to use --extract; manual JSON checkpointing still works.
"""
report = _build_doctor_report(client)
if args.json:
_print_json(report)
else:
print(format_doctor(report), end="")
if getattr(args, "strict", False):
failures: list[str] = []
backend = report.get("backend") or {}
if not backend.get("reachable"):
failures.append("backend is not reachable")
checks = report.get("checks") or {}
bg = checks.get("background_provider")
if bg == "mock_or_disabled":
failures.append(
"background provider is mock_or_disabled — `smriti checkpoint create "
"--extract`, draft, and review will fail. Configure a provider."
)
elif bg == "unknown":
failures.append("background provider status is unknown (backend reachable?).")
if failures:
print("", file=sys.stderr)
print("strict mode: " + str(len(failures)) + " check(s) failed:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
sys.exit(78) # EX_CONFIG
def cmd_space_list(client: SmritiClient, args: argparse.Namespace) -> None:
spaces = client.list_spaces()
@ -1169,7 +1221,24 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
# and use the returned fields as the commit payload. No hand-written
# JSON required.
content = _read_raw_content()
extracted = client.extract_checkpoint_content(content)
try:
extracted = client.extract_checkpoint_content(content)
except SmritiError as e:
# Provider not configured: backend returns 412 with a structured
# detail. Surface the actionable fix list and exit non-zero — do
# NOT fall back to mock or hand-written content silently.
if e.status == 412 and isinstance(e.detail, dict) and e.detail.get("error") == "provider_not_configured":
_fail_provider_not_configured(e.detail)
raise
# Defense in depth: if the backend somehow returned mock content on
# the default path (use_mock=False), refuse to commit it. Mock
# output committed into a real project pollutes reasoning state.
if extracted.get("provider") == "mock":
_fail(
"error: extract returned mock content (provider=mock) on the default path.\n"
"Refusing to commit. This is a backend bug — please report it.\n"
"If you intentionally want mock content (tests/demos), build the payload manually.",
)
# Extractor returns `title`; checkpoints store `message`. Map it.
# If the LLM returned an empty title, fall back to a generic label
# so the required `message` field is always populated.
@ -1184,12 +1253,17 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
"entities": extracted.get("entities", []),
"artifacts": extracted.get("artifacts", []),
}
# Stash provider/model so we can show them on commit confirmation.
_extract_provider = extracted.get("provider") or ""
_extract_model = extracted.get("model") or ""
else:
payload = _read_checkpoint_json(args)
if not isinstance(payload, dict):
_fail("Checkpoint JSON must be an object, got: " + type(payload).__name__)
if not payload.get("message"):
_fail("Checkpoint JSON must include a 'message' field.")
_extract_provider = ""
_extract_model = ""
if args.dry_run:
# Print the full payload (extracted or hand-written) as JSON and
@ -1260,6 +1334,8 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
else:
h = commit.get("commit_hash", "")
print(f"Created checkpoint: `{h[:7]}` {commit.get('message', '')}")
if _extract_provider:
print(f" extracted via {_extract_provider}/{_extract_model}")
def cmd_checkpoint_show(client: SmritiClient, args: argparse.Namespace) -> None:
@ -2124,6 +2200,15 @@ def _build_parser() -> argparse.ArgumentParser:
help="Diagnose backend reachability and runtime/code freshness",
)
doctor_parser.add_argument("--json", action="store_true", help="Output structured JSON")
doctor_parser.add_argument(
"--strict",
action="store_true",
help=(
"Exit non-zero (EX_CONFIG / 78) when any critical check fails — "
"currently: backend unreachable, or background provider mock_or_disabled "
"(blocks `checkpoint create --extract`, draft, review)."
),
)
doctor_parser.set_defaults(func=cmd_doctor)
# quickstart — seed a curated demo space so the product clicks fast

View file

@ -585,6 +585,40 @@ know about. Concretely:
- **You are about to explore an alternative direction and want to
preserve the current line.** Fork first, then checkpoint on the fork.
### 4.1 Before your first `--extract`: verify the provider
The extract path uses Smriti's background LLM to turn freeform markdown
into structured checkpoint fields. It **requires a real configured
provider** (OpenAI / Anthropic / OpenRouter / generic OpenAI-compatible
endpoint). If no provider is configured, the extract endpoint returns
**HTTP 412 Precondition Failed** rather than silently falling back to
mock content — mock content committed into a real Space pollutes the
reasoning state Smriti exists to keep trustworthy.
Run `smriti doctor` once at the start of a session that intends to use
`--extract`, and read the `background provider:` line.
```
{{mcp:Bash: `smriti doctor` (or ask the human to run it — `doctor` is CLI-only, no MCP tool).}}{{cli:smriti doctor}}
```
Look for:
- **`background provider: ready (real LLM extraction enabled)`** —
good, use `--extract` freely.
- **`background provider: ⚠ MOCK or DISABLED — ... will fail until a
provider is configured`** — do **not** use `--extract`. Either ask
the human to configure a provider (OpenAI/Anthropic/OpenRouter key,
or a local OpenAI-compatible endpoint via the generic provider), or
write the checkpoint **manually as JSON** (see 4.1 below). Do not
attempt to commit mock content as if it were real reasoning state.
For scripted/CI use, `smriti doctor --strict` exits non-zero
(EX_CONFIG / 78) when the background provider is mock/disabled —
useful as a pre-flight gate before any `--extract` step.
### 4.2 The extract path (provider required)
Use the **extract path**, not hand-written JSON. Pass freeform markdown
describing the inflection point and Smriti's background LLM will pull
out the structured fields (message, objective, summary, decisions,
@ -630,7 +664,7 @@ Always tag `author_agent` with a stable identifier for your agent
agents know who wrote what on the shared timeline. Inconsistent or
missing `author_agent` makes divergence unattributable.
### 4.1 Checkpoint notes: annotating without checkpointing
### 4.3 Checkpoint notes: annotating without checkpointing
Sometimes you need to add context to an existing checkpoint without
creating a new one. Checkpoint notes are additive annotations —

View file

@ -579,7 +579,7 @@
<div class="container">
<p class="section-eyebrow">Try it</p>
<h2>Up and running in <span class="accent">minutes</span>.</h2>
<p class="lede"><strong>No Docker, no API keys, no cloud required.</strong> The core coordination loop runs on a local SQLite file. API keys come in only for optional LLM-assisted features.</p>
<p class="lede"><strong>No Docker, no API keys, no cloud required</strong> for the core coordination loop — setup, doctor, quickstart, state/current/metrics, claims, attach, and hand-written checkpoints all run on a local SQLite file. <strong>LLM-backed features</strong><code>checkpoint create --extract</code>, draft, review, the chat send loop — need a real provider (OpenAI / Anthropic / OpenRouter / local OpenAI-compatible). They refuse to run on the default mock adapter rather than silently committing placeholder content.</p>
<div class="try-stack">