Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@
"group": "Tutorials",
"pages": [
"tutorials/hello-episode",
"tutorials/first-episode",
"tutorials/first-policy",
"tutorials/langgraph-episode",
"tutorials/governed-side-effects",
"tutorials/incident-triage",
"tutorials/trace-based-evals"
]
},
Expand Down
98 changes: 58 additions & 40 deletions docs/guides/configure-planner-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ Noēsis supports two planner modes that control the level of governance and obse

| Mode | Description | Use when |
| --- | --- | --- |
| `meta` (default) | Full governance with MetaPlanner and PreActGovernor | You need auditability, vetoes, and insight KPIs |
| `minimal` | Skips governance, uses legacy stepwise planner | Quick sanity checks or smoke tests |
| `meta` (default) | MetaPlanner + Direction search / directives | Production planning, intuition steering, richer insight |
| `minimal` | Simple planner; skips Direction search | Smoke tests, or when you only need a short plan |

<Info>
`planner_mode` chooses the planner. **Governance is separate:** `governance_mode` (`off` / `audit` / `enforce`) and `governance_pause_on_veto`. Pause-on-veto is covered with `planner_mode="minimal"` and `governance_mode="enforce"` in `tests/runtime/test_run_lifecycle.py`.
</Info>

## Setting the planner mode

Expand All @@ -20,12 +24,13 @@ Noēsis supports two planner modes that control the level of governance and obse
import noesis as ns

# Set globally
ns.set(planner_mode="meta") # Full governance (default)
ns.set(planner_mode="minimal") # Skip governance
ns.set(planner_mode="meta") # MetaPlanner (default)
ns.set(planner_mode="minimal") # Simple planner

# Check current mode
config = ns.config()
print(config.planner_mode)
# Read the current snapshot (ns.config() is not a public API)
config = ns.get()
print(config["planner_mode"])
print(config["governance_mode"])
```

### Environment variable
Expand Down Expand Up @@ -135,34 +140,48 @@ print(f"Governance events: {len(governance)}")

## Minimal mode

Minimal mode skips governance for faster execution:
Minimal mode uses a simple planner and, **with the default `governance_mode="off"`**, emits no direction or governance events:

```python
import noesis as ns

ns.set(planner_mode="minimal")
ns.set(planner_mode="minimal") # governance_mode still defaults to "off"

episode_id = ns.run("Quick sanity check")
episode_id = ns.run("Quick sanity check", intuition=False)

# No governance events
events = list(ns.events.read(episode_id))
governance = [e for e in events if e["phase"] == "governance"]
assert len(governance) == 0 # No governance in minimal mode
assert len(governance) == 0
```

### What minimal mode skips
That assertion matches `tests/governance/test_pre_act.py` (`planner_mode="minimal"` without an enforce override).

### What minimal mode changes by default

- MetaPlanner and PreActGovernor
- Direction and governance events
- Policy-based vetoes
- Some insight metrics (veto_count, plan_adherence)
- Uses `MinimalPlanner` instead of `MetaPlanner`
- Skips Direction search when `governance_mode` is `off`
- Keeps the cognitive loop and artifact pack (`events.jsonl`, `summary.json`, `state.json`)

### What minimal mode keeps
### What it does **not** disable

- The cognitive loop (observe → interpret → plan → act → reflect → learn)
- Event timeline (`events.jsonl`)
- Summary artifacts (`summary.json`)
- Basic metrics (success, act_count)
Setting `governance_mode="enforce"` still runs `PreActGovernor` before act, including pause-on-veto:

```python
class NoopGraph:
def invoke(self, payload):
return payload

ns.set(
planner_mode="minimal",
governance_mode="enforce",
governance_pause_on_veto=True,
)
episode_id = ns.solve(
"Danger operation: delete production database",
using=NoopGraph(),
intuition=False,
)
```

## Inspecting governance metrics

Expand Down Expand Up @@ -229,13 +248,13 @@ You can switch modes between episodes:
```python
import noesis as ns

# Run with full governance
# Run with MetaPlanner
ns.set(planner_mode="meta")
governed_ep = ns.run("Critical operation", intuition=True)

# Run quick test without governance
# Simple planner; governance still follows governance_mode
ns.set(planner_mode="minimal")
test_ep = ns.run("Quick test")
test_ep = ns.run("Quick test", intuition=False)

# Back to governed mode
ns.set(planner_mode="meta")
Expand All @@ -247,29 +266,28 @@ another_governed_ep = ns.run("Another critical operation", intuition=True)
Toggle modes from the command line:

```bash
# Meta mode (default)
# Meta planner (default)
noesis run "Sensitive task"

# Minimal mode via environment
NOESIS_PLANNER=minimal noesis run "Quick test"
# Minimal planner (does not by itself set governance_mode=off)
noesis run "Quick test" --planner minimal
# equivalent: NOESIS_PLANNER=minimal noesis run "Quick test"
```

## When to use each mode

### Use meta mode for:

- Production workloads
- Compliance-sensitive operations
- Operations requiring audit trails
- Tasks with intuition policies
- Any operation that might need to be vetoed
- Production planning that needs Direction + intuition steering
- Tasks that should record directive diffs and richer insight KPIs

### Use minimal mode for:

- Development and testing
- Quick sanity checks
- Benchmarking adapter performance
- Operations that don't need governance overhead

To actually block side effects, set `governance_mode="enforce"` regardless of planner mode. To pause instead of seal on veto, also set `governance_pause_on_veto=True`.

## Troubleshooting

Expand All @@ -278,20 +296,20 @@ NOESIS_PLANNER=minimal noesis run "Quick test"
You're likely in `minimal` mode. Check with:

```python
config = ns.config()
print(config.planner_mode) # Should be "meta"
config = ns.get()
print(config["planner_mode"]) # "meta" or "minimal"
print(config["governance_mode"])
```

Set to meta mode: `ns.set(planner_mode="meta")`
</Accordion>

<Accordion title="Veto not blocking actions">
Ensure Governance is enforcing:
Ensure Governance is enforcing. Planner mode is not enough:

```python
ns.set(planner_mode="meta")
ns.set(governance_mode="enforce")
ns.run("task", intuition=True)
ns.run("Danger operation: delete production database", intuition=False)
```

In `governance_mode="audit"` or `off`, veto-like risk signals can still appear in direction/intuition artifacts, but they do not block actuation.
Expand Down
100 changes: 70 additions & 30 deletions docs/guides/configure-shared-storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,77 @@ title: "Configure shared episode storage"
description: "Point Noēsis at a shared location via network volumes, S3 sync, Docker mounts, or Kubernetes PVCs."
---

By default, Noēsis writes episodes under `./.noesis/episodes/<episode-id>/`. In a team or service setting, you usually want a **shared** location so everyone can see/replay the same traces.
By default, Noēsis writes **flat** episode bundles under `.noesis/episodes/ep_<ULID>/`. In a team or service setting, you usually want a **shared** location so everyone can see/replay the same traces.

This guide shows four common setups:

1. Network volume (NFS/SMB)
2. Local + S3 sync
3. Docker bind mount
4. Kubernetes PersistentVolumeClaim
This guide shows four common setups, the `runs_dir` resolver pitfall, and how to migrate a legacy `runs/` tree.

<Tip>
Pick one of these patterns for your environment—you don’t need all four.
</Tip>

## Canonical layout

`resolve_noesis_paths()` (`noesis/runtime/paths.py`) treats `runs_dir` as the episodes root only when:

- the path is named `episodes` **and** its parent is named `.noesis`, or
- the path itself is named `.noesis`

Any other `runs_dir` resolves to `{runs_dir}/.noesis/episodes` (and `{runs_dir}/.noesis/processes`, `{runs_dir}/.noesis/index`).

```
.noesis/
episodes/ep_<ULID>/ # episode bundles (flat, no label/ nesting)
processes/ # process registry JSON
index/ # EpisodeIndex (ttl_days=30); vacuum drops index rows only
```

Verified by `tests/runtime/test_episode_dir_layout.py` (no `_episodes/` directory under `episodes/`).

## A) Shared dev: network volume

Mount a volume whose **parent is `.noesis`** if you want the mount path to *be* the episodes root:

```bash
# Example: shared volume mounted at /srv/noesis/episodes
ls /srv/noesis/episodes
# (empty or existing runs)
# Host path whose parent directory is named .noesis
export NOESIS_RUNS_DIR=/srv/noesis/.noesis/episodes
ls /srv/noesis/.noesis/episodes
```

Configure your app/runtime to use it as the base runs directory, e.g.:
```python
import noesis as ns

```bash
# pseudo-config
NOESIS_RUNS_DIR=/srv/noesis/episodes
ns.set(runs_dir="/srv/noesis/.noesis/episodes")
```

Result: `/srv/noesis/episodes/<service>/<episode-id>/` is shared across the team.
<Warning>
`NOESIS_RUNS_DIR=/srv/noesis/episodes` does **not** keep that path as the episodes root. The parent is `noesis`, not `.noesis`, so new bundles land at `/srv/noesis/episodes/.noesis/episodes/ep_<ULID>/`.
</Warning>

## B) Shared dev: local + S3 (or any object store)

Don’t write directly to S3; sync a local directory.
Don’t write directly to S3; sync the canonical `.noesis` tree.

```bash
# App writes episodes locally
NOESIS_RUNS_DIR=/var/noesis/episodes
export NOESIS_RUNS_DIR=/var/noesis/.noesis/episodes

# Periodic sync job (cron/CI)
aws s3 sync /var/noesis/episodes s3://my-bucket/noesis-runs --delete

# Optionally pull shared episodes down
aws s3 sync s3://my-bucket/noesis-runs /var/noesis/episodes
aws s3 sync /var/noesis/.noesis s3://my-bucket/noesis-layout
aws s3 sync s3://my-bucket/noesis-layout /var/noesis/.noesis
```

Pattern: local path for fast replay/debugging, S3 as central archive.

## C) Docker: bind-mount the runs directory
## C) Docker: bind-mount the `.noesis` root

```bash
mkdir -p /srv/noesis/episodes
mkdir -p /srv/noesis/.noesis/episodes

docker run \
-v /srv/noesis/episodes:/app/.noesis/episodes \
-v /srv/noesis/.noesis:/app/.noesis \
-e NOESIS_RUNS_DIR=/app/.noesis/episodes \
my-noesis-service:latest
```

Inside the container, Noēsis writes to `/app/.noesis/episodes/...`; on the host, you see `/srv/noesis/episodes/...`.
Inside the container, bundles are `/app/.noesis/episodes/ep_...`. On the host they appear under `/srv/noesis/.noesis/episodes/`.

## D) Kubernetes: PersistentVolumeClaim

Expand All @@ -88,17 +100,45 @@ spec:
image: my-noesis-service:latest
env:
- name: NOESIS_RUNS_DIR
value: /var/noesis/episodes
value: /var/noesis/.noesis/episodes
volumeMounts:
- name: noesis-runs
mountPath: /var/noesis/episodes
mountPath: /var/noesis/.noesis
volumes:
- name: noesis-runs
persistentVolumeClaim:
claimName: noesis-runs-pvc
```

Each pod writes episodes under `/var/noesis/episodes`, backed by the shared PVC.
Each pod writes under `/var/noesis/.noesis/episodes`, backed by the shared PVC.

## Migrate a legacy `runs/` tree

Older layouts stored episode directories directly under `runs/` (or `runs_dir` itself) instead of `.noesis/episodes/`. `noesis migrate-layout` **copies** those bundles; it does not delete the source.

```bash
noesis migrate-layout
noesis migrate-layout --json
```

Behavior (`noesis/infrastructure/layout_migration.py`):

- Legacy episode roots considered (when they exist and are not the canonical episodes dir): `runs_dir`, `runs_dir/runs`, `{workspace}/runs`, `{root}/runs`, `{root.parent}/.noesis/runs`
- Copies only directories whose names start with `ep_`
- Copies process `*.json` files from legacy process dirs
- Skips a target that already exists
- Writes `.noesis/MIGRATED_FROM.json` (or `{layout.root}/MIGRATED_FROM.json`)

### Pitfalls

| Symptom | Cause | Fix |
| --- | --- | --- |
| Nested `label/ep_...` dirs were not copied | Migrator only copies top-level `ep_*` directories | Flatten or copy those bundles into `.noesis/episodes/` yourself |
| `episodes_copied` is 0 | Legacy root missing, or names do not start with `ep_` | Confirm the source path with `noesis migrate-layout --json` |
| Duplicate IDs already in the new layout | Target `ep_*` directory exists | Migration skips; inspect both copies before deleting legacy data |
| CLI still cannot find an old episode | `NOESIS_RUNS_DIR` points at a different root | Align `runs_dir` with the layout you migrated into |

See the [CLI migrate-layout runbook](/reference/cli#migrate-a-legacy-layout).

---

Expand Down
23 changes: 16 additions & 7 deletions docs/guides/write-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,9 @@ pytest test_policy.py -v

## Wiring policies

### CLI
### Python (supported)

```bash
noesis run "my task" --intuition my_module:MyPolicy
```

### Python
The shipped `noesis run` command has no `--intuition` or `--policy` flag. Attach a policy instance on `ns.run` / `ns.solve`:

```python
import noesis as ns
Expand All @@ -257,6 +253,18 @@ from my_policy import MyPolicy
episode_id = ns.run("my task", intuition=MyPolicy())
```

Then inspect with the CLI:

```bash
noesis events "$EPISODE_ID" --phase intuition
noesis events "$EPISODE_ID" --phase direction
noesis explain "$EPISODE_ID"
```

### Config aliases

`policy_aliases` in `noesis.toml` maps short names to `module:Class` import specs. That mapping is consumed by the legacy argparse `solve` helper (`noesis/cli/commands/solve.py`), not by `noesis run`.

### Multiple policies

Chain multiple policies by creating a composite:
Expand Down Expand Up @@ -311,7 +319,8 @@ def advise(self, state: dict) -> IntuitionEvent | None:

### Policy not being called

- Verify `intuition=True` or `intuition=MyPolicy()` is passed to `ns.run()`
- Pass `intuition=MyPolicy()` (or `intuition=True` for the default policy) to `ns.run()` / `ns.solve()`
- Do not expect `noesis run --intuition ...` to work; that flag is not on the shipped CLI

### Veto not blocking

Expand Down
Loading
Loading