Metadata-Version: 2.4
Name: capstack
Version: 0.1.0
Summary: Cascading, explainable memory for LLM agents — layered profiles (default → org → user → session) merged with CSS-cascade precedence and full provenance.
License: MIT
Project-URL: Homepage, https://github.com/sudarc/capstack-lib
Project-URL: Repository, https://github.com/sudarc/capstack-lib
Project-URL: Changelog, https://github.com/sudarc/capstack-lib/blob/main/CHANGELOG.md
Project-URL: MCP server, https://github.com/sudarc/capstack-lib/blob/main/capstack/mcp/README.md
Keywords: llm,agents,memory,mcp,cascade,provenance,claude-code
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: pyyaml>=6.0
Requires-Dist: jsonschema>=4.21.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.10; extra == "mcp"
Provides-Extra: sealed
Requires-Dist: msgpack>=1.0; extra == "sealed"
Requires-Dist: zstandard>=0.22; extra == "sealed"
Requires-Dist: cryptography>=42.0; extra == "sealed"

# capstack

A cascading stack of agent profiles. Think CSS cascade for LLM agent
memory: the application stacks multiple profiles
(`default → org → user → session`), the library merges them into one
effective view, the agent reads from that view. Every value is tracked
back to the layer that produced it.

The library is **domain-agnostic**. It does not know what a "fixture" or
a "customer" is. The application registers JSON schemas at startup
describing its own rule kinds; the library validates, merges, and serves
— never interprets the payloads.

Built for LLM agents that need durable memory (preferences, terminology
aliases, multi-step procedures, fixture quirks, safety constraints)
without hard-coding any of it into the agent itself.

---

## Why a stack of profiles?

Agent memory has natural layers:

| Layer | Lifetime | Example |
|-------|----------|---------|
| `default` (sealed) | App version | The system prompt + built-in colors |
| `community/X` | Network effect | "Hex Par UV best at ≤200" learned from many users |
| `org/X` | Organisation-wide | Your company's brand colors |
| `venue/X` | Per location | "House lights cap 40% in this room" |
| `user/<name>` | Per operator | "I prefer warm white" |
| `session:current` | Per document | This show's specific fixture quirks |

Highest priority wins per id. **Safety clamps go the other way** — most
restrictive across the stack always wins, so a parent layer can lock
down what a child can loosen.

---

## Get started in 60 seconds

```python
from capstack import ApplicationContract, ProfileManager

mgr = ProfileManager(ApplicationContract())   # zero-config — works out of the box

mgr.record_memory(
    {"title": "ICI compat layer", "body": "Use the wrapper for outbound calls."},
    tags=["icertis", "ICI"],
)
for hit in mgr.recall("ICI"):
    print(hit["_layer_id"], "→", hit["title"])
# user:<you> → ICI compat layer
```

That's the whole loop: open a manager, write, read. `ApplicationContract()`
with no arguments ships three permissive default kinds (`fact`,
`preference`, `procedure`) so the first call works without a schema dance.
`record_memory(...)` writes to your user layer by default.

The cascade shows up when you stack **more than one** profile:

```python
from capstack import Profile, ProfileHeader

# Stack a venue-level profile under the user layer.
venue = Profile(
    header=ProfileHeader(id="venue/starlight", name="Starlight", version="1.0.0"),
    entries=[
        {"id": "house-cap", "kind": "fact",
         "title": "House lights cap at 40%", "body": "Low dimmer headroom here."},
    ],
)
mgr.storage.save_profile(venue)
mgr.activate("venue/starlight")          # sits below user, above default

# Same id on the user layer overrides the venue layer.
mgr.record_memory(
    {"id": "house-cap", "title": "House lights cap at 50%",
     "body": "I prefer a little more headroom."},
)

for hit in mgr.recall("house"):
    print(hit["_layer_id"], "→", hit["title"])
# user:<you> → House lights cap at 50%      # user value wins; venue is shadowed
```

Same `id`, two layers, one winner. That's the whole library in one
example — `mgr.explain("house-cap")` will show you the full shadow
chain. Everything below is detail you reach for when you need it.

---

## How profiles are organised

### On disk (default)

```
~/.capstack/                       # or storage_dir you passed to the contract
├── available/                     # one YAML per loaded profile
│   ├── default.yaml
│   ├── user:alice.yaml
│   └── venue:starlight-1.0.0.yaml
├── catalog.json                   # index of available profiles
├── stack.yaml                     # which profiles are active, in priority order
└── audit/
    └── <layer_id>.jsonl           # append-only log of every mutation
```

Two slots the manager owns automatically:

- **`default`** — at the bottom of the stack, sealed. Apps that ship
  baseline rules put them here.
- **`user:<name>`** — at the top, auto-created on first read of the
  manager. `mgr.user_layer_id` returns its id.

You can't `activate()` or `deactivate()` these slots manually; everything
else (`venue/...`, `org/...`, `community/...`) is opt-in.

### As a portable bundle

For sharing, backup, or moving a whole memory between machines, serialise
the entire active stack into one file. The `samples/stackviz` reference
app demonstrates this with `export_all()` / `import_all()`:

```yaml
schema: capstack-bundle/v1
stack: [default, venue:starlight, user:alice]   # low → high priority
profiles:
  - header: { id: default, name: Default, version: "1.0.0" }
    sealed: true
    entries: [...]
  - header: { id: venue:starlight, ... }
    entries: [...]
  - header: { id: user:alice, ... }
    entries: [...]
```

The bundle format is a consumer convention, not a storage backend — the
library itself still reads and writes the multi-file layout above. Use
the bundle for share/transport; let the manager own on-disk state.

---

## Reading and writing memories

Two methods on `ProfileManager` cover the common case.

### `record_memory(memory, *, layer_id=None, kind_hint=None, tags=None)`

One method to store any kind of memory. `layer_id` defaults to the user's
top layer (`mgr.user_layer_id`); pass an explicit one when you want to
write somewhere else (session, venue, etc.). The library classifies the
memory by shape and routes it to an entry of the right kind:

```python
# Untyped — lands in 'fact' (the catch-all default kind).
mgr.record_memory({"title": "the AC is broken in room 3"})

# A procedure (recognised by the 'steps' payload).
mgr.record_memory(
    {"title": "Pre-show check",
     "payload": {"steps": ["Snapshot universe", "Test each fixture"]}},
    layer_id="session:current",       # write to the session layer instead of user
)

# An app-defined rule — only available when the contract registers its kind.
mgr.record_memory(
    {"title": "UV cap", "payload": {"channel_type": "uv", "max": 200}},
    kind_hint="channel_clamp",
)
```

Classification order: `kind_hint` (if registered) → shape detection
(`rule_type` → rule, `steps` → procedure, `alias`+`canonical` → alias) →
fall back to `fact` (or the first registered kind, for apps that opted
out of defaults). Apps that want full control over the entry shape call
`add_entry()` directly.

**Tags** are an optional free-form list the agent attaches at write
time. They join the recall haystack so future queries find the memory
even when the literal query doesn't appear in title/body — useful for
synonyms and acronyms the operator may not remember verbatim:

```python
mgr.record_memory(
    {"title": "Icertis compat layer", "body": "Use the wrapper for outbound calls."},
    tags=["icertis", "ICI", "integration"],
)
mgr.recall("ICI")        # matches via the tag, even though "ICI" isn't in body
```

### `recall(query="", *, kinds=None, tags=None, context=None)`

Search across the merged view. Returns entries annotated with
`_layer_id` for provenance. Filters out entries whose `when:` clause
doesn't match the current `context`, and excludes disabled entries.

```python
# Everything active.
mgr.recall()

# Substring match across title, body, tags, and prose payload fields.
mgr.recall("patch")

# Narrow by kind (AND) and by tag overlap (AND, case-insensitive).
mgr.recall(kinds=["procedure"], tags=["pre-show"])

# Context-sensitive — entries whose `when:` clause matches the dict are included.
mgr.recall(context={"venue_kind": "outdoor", "audience_age": 12})
```

`kinds` and `tags` narrow the result set. The `query` *broadens* by
matching against the entry's own tags as well as its prose — so the two
parameters serve different purposes.

---

## The data model

A `Profile` has these sections (all optional):

| Section | Purpose |
|---------|---------|
| `header` | Identity: id, name, version, author, etc. |
| `prompt` | System addendum + few-shot examples |
| `tokens` | Named values (`{warm: {kind: rgb, r:255, g:180, b:90}}`) |
| `entries` | Typed records of `kind` defined by your contract — **the main store** |
| `terminology` | alias → canonical mappings |
| `templates` | Named multi-step workflows |
| `defaults` | Tool-argument defaults |
| `knowledge` | Nested namespace tree of facts |
| `capabilities` | Enabled/disabled tools + safety/taste clamps |
| `telemetry` | Auto-maintained stats (apply counts; never merged) |

Most agents only need `entries` — `record_memory` writes there. The
other sections exist for apps that want richer merge semantics. See
[PROFILE_SPEC.md](PROFILE_SPEC.md) for the full data model.

### `ApplicationContract`

The contract is how your app teaches capstack its vocabulary. The minimum
is **no arguments** — `ApplicationContract()` ships three permissive
default kinds (`fact`, `preference`, `procedure`) so you can write your
first memory immediately:

```python
ApplicationContract()                       # → rule_kinds = {fact, preference, procedure}
```

Apps that want strict per-kind shapes pass their own:

```python
from capstack import DEFAULT_RULE_KINDS

# Just your kinds (no defaults merged in).
ApplicationContract(rule_kinds={"channel_clamp": {...}})

# Your kinds plus the library defaults.
ApplicationContract(rule_kinds={**DEFAULT_RULE_KINDS, "channel_clamp": {...}})

# Explicit opt-out of all rule kinds.
ApplicationContract(rule_kinds={})
```

Richer contract with context, safety, and per-app storage:

```python
ApplicationContract(
    rule_kinds={
        "channel_clamp": {
            "type": "object",
            "properties": {
                "channel_type": {"type": "string"},
                "max": {"type": "integer", "minimum": 0, "maximum": 255},
            },
            "required": ["channel_type"],
        },
        "alias": {
            "type": "object",
            "properties": {
                "alias": {"type": "string"},
                "canonical": {"type": "string"},
            },
            "required": ["alias", "canonical"],
        },
    },
    context_schema={
        "show_tag": {"type": "string"},
        "venue_kind": {"type": "string", "enum": ["indoor", "outdoor"]},
    },
    safety_fields=["fixture_set_intensity.intensity_pct.max"],
    storage_dir=Path("~/.myapp/profiles").expanduser(),
)
```

---

## Going further

### Conditions — context-sensitive entries

Any entry can carry a `when:` clause; entries whose clause doesn't match
the request `context` are filtered out of the merged view.

```yaml
when:
  show_tag: kids_show              # equality (the default operator)
  audience_age: { lt: 13 }         # operator dict
  all:
    - venue_kind: outdoor
    - daylight: { gt: 1000 }
```

Operators: `eq`, `ne`, `in`, `not_in`, `gt`, `lt`, `gte`, `lte`.
Combinators: `all`, `any`, `not`. A safe Python-subset expression DSL
(`when.expr: "show_tag == 'kids_show' and house_count < 500"`) is
available for compact composites; full grammar in
[PROFILE_SPEC.md §4.10](PROFILE_SPEC.md).

### Tokens — substitution variables

Like CSS custom properties. Define once, reference anywhere:

```yaml
tokens:
  warm: { kind: rgb, r: 255, g: 180, b: 90 }

entries:
  - id: warm-default-color
    kind: lighting_rule
    payload: { color: "{{ warm }}" }
```

A higher-priority layer can redefine `warm` and every reference picks up
the new value automatically.

### Conflicts — advisory, never blocking

Cascade overrides (same `id` across layers) are intentional and resolved
by priority. **Logical contradictions** between *different* ids in the
merged view are what `find_conflicts()` looks for:

```python
for c in mgr.find_conflicts():
    print(c.severity, c.code, c.message)

# Pre-flight: would this candidate write contradict anything?
hits = mgr.check_conflicts_for(candidate, layer_id=mgr.user_layer_id)
```

Apps register their own detectors via
`ApplicationContract.conflict_detectors`. Conflicts are **never
blocking** — they're surfaced, the app decides what to do.

### Provenance — `explain()` and `_layer_id`

Every value can be traced back:

```python
from capstack import ExplainQuery

trace = mgr.explain(ExplainQuery.entry("uv-cap-200"))
trace.effective_value   # entry as it appears in the merged view
trace.from_layer        # which layer produced this value
trace.shadows           # lower-priority entries it shadowed
trace.reverted_in       # higher layers that suppressed it via revert
```

Lighter-weight lookup: `mgr.find_entry_layer(entry_id)` returns just
`(layer_id, entry)` without the full trace. Every `recall()` hit
already carries `_layer_id` so you can render provenance inline.

### Linting

Before activating a new profile:

```python
from capstack import lint_profile, lint_stack

report = lint_profile(profile, contract)
report = lint_stack(active_profiles, contract, context={...})
```

Lint warnings are advisory. The app decides whether to proceed.

### Building agent-facing UIs

The 8 patterns CuePilot settled on for surfacing layers, the
"learn from me" chat loop, scope pickers, and the "why is this active?"
inspector are documented in **[UX_GUIDE.md](UX_GUIDE.md)**.

---

## Sealed profiles (`.cstk`)

A capstack profile is normally a YAML file you commit to version
control. For **portable, opaque, IP-protected distribution** — e.g.
shipping a domain-expert layer to a customer, or publishing on a
marketplace — capstack also produces a binary `.cstk` container.

What `.cstk` buys you:

- **Encrypted at rest.** Body is AES-256-GCM. Without the content key,
  the file is opaque to `cat`, `strings`, `file`, `yaml.safe_load`, and
  any other off-the-shelf inspection tool — proven by
  `test_sealed_opacity.py`.
- **Size-bucketed.** Power-of-2 buckets from 4 KiB → 16 MiB. A
  100-entry profile and a 500-entry profile both fall in the 4 KiB
  bucket; an observer counting bytes can't tell them apart to within
  ~2×.
- **Tamper-evident.** Every file ends with an HMAC-SHA256 trailer
  keyed from the content key. Any modification anywhere fails MAC
  verify before decryption is attempted. Optional Ed25519 creator
  signature (SIG chunk) covers the same range under a separate
  authentication.
- **Opaque consumption contract.** When loaded via
  `ProfileManager.add_sealed_layer`, the profile sits in memory
  privately; **the only way to read it is `manager.recall(query)`**.
  `list_entries`, `peek_entry`, and `summarize_stack` refuse with a
  clear error pointing at `recall`. An honest licensee using the
  shipped library cannot dump the memory — see
  [capstack-secure-profiles-spec.md](capstack-secure-profiles-spec.md)
  §15.9–§15.10 for the threat model and §16.9 for the marketplace
  license language.

### Install the sealed extras

```bash
pip install 'capstack[sealed]'
```

Pulls in `cryptography`, `msgpack`, and `zstandard`. The core
capstack library does not depend on these — sealed support is opt-in.

### Seal a profile

```bash
capstack-seal profile.yaml --out profile.cstk --key-out profile.key
```

Produces an encrypted `.cstk` and writes the 32-byte content key (CK)
to `profile.key`. To sign the artifact with a creator key:

```bash
capstack-seal profile.yaml --out profile.cstk --key-out profile.key \
              --sign creator.priv
```

Generate keypairs from Python:

```python
from capstack.sealed import generate_signing_keypair, public_key_bytes
priv, pub = generate_signing_keypair()
open("creator.priv", "wb").write(priv.private_bytes_raw())
open("creator.pub",  "wb").write(public_key_bytes(pub))
```

### Inspect a sealed file (metadata only)

```bash
capstack-inspect profile.cstk
```

Shows the clear-header metadata — file size and bucket, format
version, suite, flags, profile UUID, creator id and cert fingerprint,
issued_at, chunk-stream summary. **The encrypted body is never
decrypted by this command.** There is intentionally no CLI command
that writes decrypted YAML to disk — that would defeat sealing.

### Consume a sealed profile

Two paths, both library-only:

**1. In-memory `Profile` (for testing / debugging / one-off use):**

```python
from capstack.sealed import unseal_from_bytes

blob = open("profile.cstk", "rb").read()
ck   = open("profile.key",  "rb").read()
_, profile = unseal_from_bytes(blob, key=ck)
# profile is a normal Profile object, in memory only.
```

**2. Opaque sealed layer in `ProfileManager` (production):**

```python
from capstack import ProfileManager
from capstack.sealed import unseal_from_bytes  # if you want signature verification too

mgr = ProfileManager(contract)
handle = mgr.add_sealed_layer("profile.cstk", content_key=ck,
                              creator_public_key=pub)  # optional SIG check

# handle is a SealedLayerHandle — UUID, version, creator id only.
# The decrypted Profile is held privately by the manager.

# Recall works as normal across the whole stack including the sealed layer.
results = mgr.recall(query="kubernetes ingress")

# But these refuse:
mgr.list_entries(handle.layer_id)   # → SealedLayerAccessError
mgr.peek_entry(handle.layer_id, "x") # → SealedLayerAccessError
mgr.summarize_stack()[handle.layer_id]  # → [{"_sealed": True, "_use_recall": True}]
```

Sealed layers are session-scope: not persisted to `stack.yaml`. Call
`add_sealed_layer` again per process. Remove with
`mgr.remove_sealed_layer(handle.layer_id)`.

### Multi-licensee artifacts (WCK chunks)

For marketplace distribution, you can wrap the CK to one or more
licensee X25519 public keys; each licensee then derives CK from the
matching WCK chunk with their private key — no shared secrets, no
hosted key server.

```python
from capstack.sealed import (
    generate_licensee_keypair, seal_to_bytes, unseal_from_bytes,
    FileHeader, ClearHeader, generate_key,
)

# At publish time:
lic1_priv, lic1_pub = generate_licensee_keypair()
lic2_priv, lic2_pub = generate_licensee_keypair()
ck = generate_key()
blob = seal_to_bytes(profile, key=ck, header=header,
                     licensee_public_keys=[lic1_pub, lic2_pub])

# At a licensee:
_, profile = unseal_from_bytes(blob, licensee_private_key=lic1_priv)
```

Full design and threat model:
**[capstack-secure-profiles-spec.md](capstack-secure-profiles-spec.md)**.

---

## Reference

### Stack management

```python
mgr.activate(profile_id, position="end")  # "start" | "after:X" | "before:X"
mgr.deactivate(profile_id)
mgr.suspend_layer(profile_id)             # runtime hide; not persisted
mgr.resume_layer(profile_id)
mgr.reorder_stack([...])
mgr.describe_stack()                      # → list[{layer_id, name, sealed, suspended}]

mgr.fork(source_id, new_id, new_name)     # clone a sealed profile to customise

# Session layer — owned by the host app per request/document.
mgr.set_session_overrides({"entries": [...]})
mgr.set_session_overrides(None)           # clear
```

### Lower-level entry API

```python
mgr.add_entry(layer_id, entry)            # validates against contract
mgr.update_entry(layer_id, entry_id, patch)
mgr.delete_entry(layer_id, entry_id)
mgr.disable_entry(layer_id, entry_id)     # struck-through in UI, not gone
mgr.revert_entry(layer_id, entry_id)      # suppress inherited entry
mgr.move_entry(entry_id, target_layer_id) # promote/demote between layers
```

### Other readers

```python
mgr.find_entry_layer(entry_id)            # → (layer_id, entry) or None
mgr.get_entry(entry_id)                   # merged view value
mgr.get_entries(kind=..., scope_level=..., scope_target=..., context=...)
mgr.get_token(name, context=...)
mgr.get_defaults(context=...)
mgr.get_knowledge(namespace, *path, context=...)
mgr.is_tool_enabled(tool_id, context=...)
mgr.get_tool_safety_clamps(tool_id, context=...)
```

### Concurrency

`ProfileManager` is **not thread-safe**. Single-flight your access
(Flask's per-request thread is fine; serialise behind a lock if you have
multiple writers in one process).

The merged view is rebuilt per request — cheap (microsecond-scale on
small stacks) and the `context` can change anything, so caching it would
be a bug factory. To amortise, hold the view across multiple reads
within one request and pass it explicitly.

### Error model

All library exceptions inherit from `CapstackError`:

```
CapstackError
├─ ProfileNotFoundError
├─ ProfileSealedError              # tried to mutate a sealed layer
├─ ProfileValidationError          # entry.payload didn't match the kind schema
├─ TokenTypeMismatchError          # same token redefined with different `kind`
├─ TokenUnresolvedError            # dangling {{ ref }} at query time
└─ DSLForbiddenNodeError           # `when.expr:` used a disallowed construct
```

### Public exports

```python
from capstack import (
    # Data model
    Profile, ProfileHeader, ApplicationContract, SECTION_NAMES, SCOPE_KEY,

    # Manager + query
    ProfileManager,
    ExplainQuery, ExplainTrace,
    MergedView, ProvenanceTrail, merge_stack,

    # Linter
    LintReport, LintWarning, lint_profile, lint_stack,

    # Conflicts
    Conflict, detect_alias_collisions,

    # Exceptions
    CapstackError,
    ProfileNotFoundError, ProfileSealedError, ProfileValidationError,
    TokenTypeMismatchError, TokenUnresolvedError, DSLForbiddenNodeError,
)
```

---

## Use it as an MCP server

For agents (Claude Code, Claude Desktop, any MCP client) that just need
layered memory without writing a host integration, capstack ships an MCP
server in the same package. In Claude Code it's a plugin — two commands,
nothing to paste:

```
/plugin marketplace add sudarc/capstack-lib
/plugin install capstack@capstack
```

For any other MCP client, one config entry and no install step:

```json
{ "mcpServers": { "capstack": { "command": "uvx", "args": ["--from", "capstack[mcp]", "capstack-mcp"] } } }
```

The server runs locally over stdio — no service to host, data lives at
`~/.capstack/`. By default it exposes six tools (record / recall /
search / update / forget / explain); set `CAPSTACK_MCP_TOOLS=full` for
the layer-management surface. See
**[capstack/mcp/README.md](capstack/mcp/README.md)** for client
configuration, the activation note, and the full tool table.

---

## Run the evals

Two eval apps live in the repo, each with its own README. Together they
let you reproduce the numbers in [IMPROVEMENTS.md](IMPROVEMENTS.md) and
see where things stand on your hardware.

### `samples/dual_agent/` — the headline beats-DIY comparison

Same model, same questions, two memory substrates: capstack vs. a
persistent flatfile notes file with `append_note` / `read_notes` /
`search_notes` tools. Mirrored system prompts; the only variable is
the memory shape. This is the eval the recent verdicts came from.

**Prerequisites:**

```bash
ollama serve                           # leave running
ollama pull qwen3-coder:30b            # or any other tool-capable model
pip install -e .[mcp]                  # MCP server the capstack agent talks to
```

**Smoke test** (no distractor corpus, 1 trial, single model, ~5 min):

```bash
python -m samples.dual_agent.eval --baseline flatfile --trials 1
```

**Replicate the 1K-corpus scale verdict** (`memory_used 61±14 vs 34±6`
in [IMPROVEMENTS.md §Scale verdict](IMPROVEMENTS.md); 5 trials, ~30 min
on a 30b local model). `OLLAMA_NUM_CTX` is required — without it
Ollama's small default window silently truncates the flatfile agent's
large `read_notes` dumps and rigs the comparison:

```bash
OLLAMA_NUM_CTX=24576 python -m samples.dual_agent.eval \
    --baseline flatfile --corpus 1000 --trials 5 \
    --model qwen3-coder:30b \
    --json samples/dual_agent/runs/local-replication.json
```

**Re-score a saved run** against current scenario definitions (no
agents run; useful after needle fixes):

```bash
python -m samples.dual_agent.eval --regrade samples/dual_agent/runs/<file>.json
```

**Interactive web demo** (the side-by-side panes, not graded):

```bash
python -m samples.dual_agent        # serves on http://127.0.0.1:8770
```

Full flag reference, baseline modes, and model-pulling notes:
**[samples/dual_agent/README.md](samples/dual_agent/README.md)**.

### `capstack/evals/` — the formal scenario harness

Multi-config comparison (no-memory / session-only / capstack / etc.)
across a scenario set, with HTML report output. Implementation of
[EVAL_SPEC.md](EVAL_SPEC.md).

```bash
python -m capstack.evals.cli                                # every scenario × every config
python -m capstack.evals.cli --configs B0_no_memory,B3_capstack
python -m capstack.evals.cli \
    --json-out capstack/evals/runs/now.json \
    --html-out capstack/evals/runs/now.html
python -m capstack.evals.report                             # bundle past runs into one viewer
```

Full options and report features:
**[capstack/evals/README.md](capstack/evals/README.md)**.

---

## Install / tests

Runtime deps: `pyyaml`, `jsonschema`. No other external dependencies.
The MCP server is an optional extra (`capstack[mcp]`) — it pulls in the
`mcp` Python SDK only when you install with that extra.

```bash
pytest capstack/tests/
```

Coverage focuses on the API consumers actually use: merge cascade, token
resolution, conflict detection, mutation lifecycle, conditional
filtering, provenance traces.

---

## See also

- **[PROFILE_SPEC.md](PROFILE_SPEC.md)** — the full design document:
  section semantics, locked decisions, the rationale behind the cascade
  choices, and the future roadmap.
- **[UX_GUIDE.md](UX_GUIDE.md)** — patterns for building agent-facing UIs
  on top of capstack (scope pickers, the "learn from me" chat loop, the
  "why is this active?" inspector).
- **[IMPROVEMENTS.md](IMPROVEMENTS.md)** — design work that's been
  thought through but not yet built (LLM-driven recall, ranked retrieval,
  the `Memory` facade).
- **[capstack/mcp/README.md](capstack/mcp/README.md)** — drop-in MCP
  server for agents that want layered memory without writing a host
  integration.
- **`samples/stackviz/`** — a runnable interactive cascade visualiser
  that exercises the full read/write/import/export loop.
- **`capstack/tests/`** — runnable examples of every API surface.
