diff --git a/docs/docs.json b/docs/docs.json
index 955c7ba..abd3d71 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -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"
]
},
diff --git a/docs/guides/configure-planner-modes.mdx b/docs/guides/configure-planner-modes.mdx
index fd2fa3e..315644c 100644
--- a/docs/guides/configure-planner-modes.mdx
+++ b/docs/guides/configure-planner-modes.mdx
@@ -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 |
+
+
+`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`.
+
## Setting the planner mode
@@ -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
@@ -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
@@ -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")
@@ -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
@@ -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")`
- 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.
diff --git a/docs/guides/configure-shared-storage.mdx b/docs/guides/configure-shared-storage.mdx
index 3e4a88c..aa7fc5b 100644
--- a/docs/guides/configure-shared-storage.mdx
+++ b/docs/guides/configure-shared-storage.mdx
@@ -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//`. 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_/`. 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.
Pick one of these patterns for your environment—you don’t need all four.
+## 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_/ # 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///` is shared across the team.
+
+`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_/`.
+
## 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
@@ -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).
---
diff --git a/docs/guides/write-policies.mdx b/docs/guides/write-policies.mdx
index b4e52e5..a8f9f41 100644
--- a/docs/guides/write-policies.mdx
+++ b/docs/guides/write-policies.mdx
@@ -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
@@ -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:
@@ -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
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index b48ed36..75e0a1c 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -108,16 +108,14 @@ Every episode creates a structured artifact directory (written under `.noesis/ep
events.jsonl # append-only timeline
manifest.json # SHA-256 + size ledger
learn.jsonl # optional learning payloads
- _episodes/ # optional episode index (best-effort)
- episodes.jsonl
```
-The `_episodes/` index is written on a best-effort basis at the end of runs and may be missing or empty (for example, if indexing fails due to permissions).
+The runtime index lives at `.noesis/index/` (`ttl_days=30`), not under `episodes/_episodes/`. `tests/runtime/test_episode_dir_layout.py` asserts `_episodes` is not created inside the episodes directory.
Use `jq` for pretty-printing JSON artifacts: `cat .noesis/episodes/ep_01JH6Z2V9Q2K6Y6N0QZ7K2QW8C/summary.json | jq .`
-To change the artifact location, set `NOESIS_RUNS_DIR` or in Python call `ns.set(runs_dir="./my-runs")`.
+To change the artifact location, set `NOESIS_RUNS_DIR` or in Python call `ns.set(runs_dir="./my-runs")`. Unless that path is `.noesis` or `.noesis/episodes`, bundles are written to `{runs_dir}/.noesis/episodes`.
## What's next?
diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx
index 0305b08..b972b74 100644
--- a/docs/reference/cli.mdx
+++ b/docs/reference/cli.mdx
@@ -156,14 +156,40 @@ noesis explain ep_01JH6Z2V9Q2K6Y6N0QZ7K2QW8C
# Interactive terminal UI
noesis browse
-# Migrate old storage layout
+# Migrate old storage layout (copy, do not delete)
noesis migrate-layout
+noesis migrate-layout --json
# Help
noesis help
noesis help events
```
+## Migrate a legacy layout
+
+`noesis migrate-layout` copies episode directories and process JSON from legacy roots into the canonical `.noesis/` tree. Source files are left in place.
+
+```bash
+noesis migrate-layout --json
+```
+
+JSON fields (`MigrationResult.to_dict()`):
+
+| Field | Meaning |
+| --- | --- |
+| `episodes_copied` | Number of `ep_*` directories copied |
+| `processes_copied` | Number of process `*.json` files copied |
+| `legacy_roots` | Absolute paths that were scanned |
+| `warnings` | Per-entry copy failures |
+
+Constraints:
+
+- Only **directories named `ep_*`** are treated as episodes. Nested `label/ep_...` trees are not flattened.
+- Existing destination paths are skipped (no overwrite).
+- A marker file `MIGRATED_FROM.json` is written at the layout root.
+
+After migration, confirm with `noesis ps` and `noesis processes`. Full layout notes: [Configure shared episode storage](/guides/configure-shared-storage).
+
## Command constraints
### Verification flags on `run`
@@ -223,6 +249,16 @@ Cause: `textual` is not installed in the current environment.
Fix: install UI dependencies, then re-run `noesis browse`.
+### `migrate-layout` copied 0 episodes
+
+Cause: no legacy root contained a top-level `ep_*` directory, or destinations already exist.
+
+Fix:
+
+- run `noesis migrate-layout --json` and inspect `legacy_roots` / `warnings`
+- remember the copier does not recurse into `label/` folders
+- check `NOESIS_RUNS_DIR` so you are migrating the tree you think you are
+
### `invalid cli version (expected cli/MAJOR.MINOR)` on `run --json`
Cause: unsupported `--cli-version` value.
diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx
index fbe8631..f8ce50f 100644
--- a/docs/reference/configuration.mdx
+++ b/docs/reference/configuration.mdx
@@ -410,23 +410,40 @@ episode_id = ns.run(
seed=42, # reproducibility seed
tags={"env": "staging"}, # metadata tags
intuition=True, # enable/disable intuition
+ process="docs-smoke", # process registry grouping
)
```
-Global configuration (via `ns.set()`) applies to all episodes. Per-call parameters like `seed`, `tags`, and `intuition` are set at run time.
+Global configuration (via `ns.set()`) applies to all episodes. Per-call parameters like `seed`, `tags`, `process`, and `intuition` are set at run time, not via `ns.set()`.
+## Keys that are **not** configuration
+
+`ns.set()` only accepts `ALLOWED_CONFIG_KEYS` (`noesis/domain/config/settings.py`). Unknown names raise `ValueError`.
+
+These are **not** config keys:
+
+| Attempt | What to use instead |
+| --- | --- |
+| `ns.set(label="production")` | Group runs with `ns.run(..., process="production")` / `noesis run ... --process` |
+| `ns.set(seed=42)` | Pass `seed=` to `ns.run` / `ns.solve` |
+| `ns.set(log_prompts=True)` | `prompt_provenance_enabled` + `prompt_provenance_mode` |
+
+Episode directories are flat `.noesis/episodes/ep_/`. There is no `label/` nesting.
+
## CLI configuration
Most CLI configuration is done via environment variables:
```bash
-NOESIS_RUNS_DIR=./artifacts noesis run "task"
+NOESIS_RUNS_DIR=./.noesis/episodes noesis run "task"
NOESIS_PLANNER=minimal noesis run "task"
NOESIS_DIRECTION_MIN_CONFIDENCE=0.8 noesis run "task"
```
+`NOESIS_RUNS_DIR=./artifacts` stores bundles at `./artifacts/.noesis/episodes` because the parent directory is not named `.noesis`. See [shared storage](/guides/configure-shared-storage).
+
## Configuration validation
Noēsis validates configuration on startup:
diff --git a/docs/reference/llms-txt.mdx b/docs/reference/llms-txt.mdx
new file mode 100644
index 0000000..605eacc
--- /dev/null
+++ b/docs/reference/llms-txt.mdx
@@ -0,0 +1,36 @@
+---
+title: "llms.txt"
+description: "LLM-friendly indexes for Noēsis docs."
+---
+
+Noēsis publishes a small index of public docs pages so coding agents can discover the current site without scraping every MDX file.
+
+## Files
+
+| File | Role |
+| --- | --- |
+| [`llms.txt`](https://github.com/saraeloop/noesis/blob/main/llms.txt) (repo root) | Curated list of **published** docs URLs |
+| Mintlify `/llms.txt` / `/llms-full.txt` | Generated from `docs/docs.json` navigation when the docs site is built |
+
+Keep the root `llms.txt` aligned with pages that actually exist under `docs/` **and** are listed in `docs/docs.json`. Unpublished, deprecated, or removed pages (for example `tutorials/guarded-langgraph-agent`) should not appear there.
+
+## What to include
+
+- Get started, tutorials, how-to guides, explanation, and reference pages that are in the sidebar
+- One-line descriptions that match the page frontmatter
+
+## What to omit
+
+- Deprecated stubs
+- Internal notes under `docs/internal/`
+- Schema JSON under `docs/schema/` (those are generated artifacts)
+
+## Preview locally
+
+From `docs/`:
+
+```bash
+npx --yes mintlify@latest broken-links
+```
+
+Run that from `docs/`, not the repo root. Root execution reports false broken absolute links.
diff --git a/docs/tutorials/first-policy.mdx b/docs/tutorials/first-policy.mdx
index fde7819..2d95301 100644
--- a/docs/tutorials/first-policy.mdx
+++ b/docs/tutorials/first-policy.mdx
@@ -121,29 +121,32 @@ python test_policy.py
## Step 3: Wire the policy into an episode
-Use your policy with the CLI:
-
-```bash
-noesis run "SELECT * FROM users" --intuition my_policy:SafetyGuard
-```
-
-Or with Python:
+Attach the policy in Python. The shipped `noesis run` entrypoint (`noesis/cli/__main__.py`) has no `--intuition` or `--policy` flag.
```python
import noesis as ns
from my_policy import SafetyGuard
-# Run with the policy attached
episode_id = ns.run(
"SELECT * FROM users",
intuition=SafetyGuard(),
)
-# Check what happened
summary = ns.summary.read(episode_id)
print(f"Direction applied: {summary['flags'].get('direction', {}).get('applied', 0)}")
```
+Inspect the same episode from the CLI after it finishes:
+
+```bash
+noesis view "$EPISODE_ID"
+noesis events "$EPISODE_ID" --phase direction
+```
+
+
+`policy_aliases` in `noesis.toml` is a config mapping used by the legacy `solve` argparse helper. It does not add a policy flag to `noesis run`.
+
+
## Step 4: Inspect policy decisions
When a policy intervenes or vetoes, the events timeline shows the decision:
diff --git a/docs/tutorials/incident-triage.mdx b/docs/tutorials/incident-triage.mdx
index f9ad50d..9729a5c 100644
--- a/docs/tutorials/incident-triage.mdx
+++ b/docs/tutorials/incident-triage.mdx
@@ -1,109 +1,100 @@
---
-title: "Build an incident triage dashboard"
-description: "Create a production-ready incident response system with Noēsis, complete with guardrails and human approval loops."
+title: "Incident triage with pause-on-veto"
+description: "Build an SRE-style incident loop with intuition policies, enforce-mode governance, and same-run resume after review."
---
-This tutorial walks you through building an incident triage dashboard that showcases Noēsis in a production-minded SRE scenario. You'll implement the full cognitive loop with guardrails, human approvals, and learning proposals.
+This tutorial wires a small incident-response loop onto the canonical Noēsis runtime: detect, plan, govern, then either seal or pause. Approval continues the **same** episode with `ns.resume_run(...)`. It does not start a follow-up `ns.run(...)`.
## What you'll build
-A Gradio-based control room that:
-
-- Detects and classifies incidents
-- Proposes response actions with governance
-- Requires human approval for high-risk operations
-- Captures learning signals for future improvement
+- A mock detector and responder (swap for Prometheus, PagerDuty, LangGraph later)
+- An intuition policy for org-specific incident rules
+- Enforce-mode governance with pause-on-veto
+- A review step that resumes from `run.checkpoint` on the same episode ID
## Prerequisites
-- Completed the [first policy tutorial](/tutorials/first-policy)
-- Understanding of the [cognitive loop](/explanation/cognitive-loop)
-- Familiarity with Python async patterns (helpful but not required)
-
-## Step 1: Understand the architecture
+- Completed [Your first policy](/tutorials/first-policy)
+- Familiarity with the [cognitive loop](/explanation/cognitive-loop) and [human-in-the-loop](/guides/human-in-the-loop) continuation contract
-The incident triage system follows the Noēsis cognitive loop:
+## Architecture
```mermaid
flowchart TD
- A[Detect incident] --> B[Interpret signals]
- B --> C[Plan response]
- C --> D{Governance check}
- D -->|approved| E[Execute action]
- D -->|needs review| F[Human approval]
- F --> E
- E --> G[Reflect on outcome]
- G --> H[Learn for next time]
+ A[Detect incident] --> B[Plan response]
+ B --> C[ns.run / ns.solve]
+ C --> D{Governance}
+ D -->|allow or audit| E[Act and seal]
+ D -->|enforce veto + pause-on-veto| F[run.interrupt then run.checkpoint]
+ F --> G[Human review]
+ G -->|approve| H["ns.resume_run same episode"]
+ G -->|reject| I[Leave the run paused and unsealed]
```
| Component | Noēsis concept | Production swap |
| --- | --- | --- |
-| Detector | Observe phase | Prometheus/Datadog queries |
-| Classifier | Interpret phase | LLM + retrieval |
-| Responder | Plan + Act phases | LangGraph plan generator |
-| Reviewer | Governance | Slack/ServiceNow approval |
-| Policy | Intuition | Your org's guardrails |
+| Detector | Observe input | Prometheus / Alertmanager / PagerDuty |
+| Planner | Your code before `ns.run` | LangGraph / runbook lookup |
+| Intuition policy | `DirectedIntuition.advise` | Org change-control rules |
+| Pre-act gate | `governance_mode="enforce"` | Built-in `PreActGovernor` |
+| Review | `run.checkpoint` + `ns.resume_run` | Slack / ServiceNow on the same run ID |
+
+
+`ns.run(...)` and `ns.solve(...)` seal the episode when they terminate. After `final.json` exists, `ns.interrupt` / `ns.checkpoint` / `ns.resume_run` raise `RunSealedError`. Do not start a second episode to represent approval.
+
+
+## Step 1: Write the incident policy
-## Step 2: Create the policy
+Intuition can hint, intervene, or veto. **Pause-on-veto is a Governance runtime mode**, not an intuition patch. Returning `{"requires_approval": True}` does not pause the run.
-Create a policy that enforces your organization's incident response rules:
+`advise` receives a snapshot with `task` and `tags` (see `_build_snapshot` in `noesis/usecases/episode_runner.py`). It does not receive a free-form `proposed_action` key unless you put that value in `tags`.
+
+Create `prod_guard.py` (same shape as `examples/incident_triage/prod_guard.py`):
```python prod_guard.py
+import re
import noesis as ns
from noesis.intuition import IntuitionEvent
+_DESTRUCT = re.compile(r"\b(drop\s+db|delete\s+all|shutdown\s+cluster|wipe)\b", re.I)
+
class IncidentPolicy(ns.DirectedIntuition):
- """Guards incident response actions."""
-
+ """Org rules that steer planning. Fail-closed gates still live in Governance."""
+
__version__ = "1.0"
-
- # Risk thresholds
- HIGH_RISK_ACTIONS = {"rollback", "restart", "scale_down", "delete"}
- REQUIRE_APPROVAL = {"rollback", "delete"}
-
+
def advise(self, state: dict) -> IntuitionEvent | None:
- action = state.get("proposed_action", "").lower()
- severity = state.get("severity", "low")
- has_canary = state.get("canary_scope", False)
-
- # Hard veto: never allow deletes in production
- if action == "delete" and state.get("environment") == "production":
+ task = str(state.get("task") or "").strip()
+ tags = state.get("tags") or {}
+ action = str(tags.get("proposed_action") or "").lower()
+
+ if _DESTRUCT.search(task) or tags.get("risk") == "high":
return self.veto(
- advice="Blocked: delete operations forbidden in production.",
+ advice="Blocked: destructive or high-risk incident action.",
target="plan",
- rationale="Production deletes require manual execution with audit trail.",
+ rationale="Dangerous ops require a privileged change window.",
)
-
- # Require human approval for high-risk + high-severity
- if action in self.HIGH_RISK_ACTIONS and severity in ("high", "critical"):
- if not has_canary:
- return self.intervene(
- advice="Requires approval: high-risk action without canary scope.",
- patch={"requires_approval": True, "scope": "canary_first"},
- target="plan",
- rationale="High-severity incidents need canary validation before full rollout.",
- )
-
- # Warn about off-hours changes
- if self._is_off_hours() and action in self.HIGH_RISK_ACTIONS:
+
+ if action in {"rollback", "restart", "scale_down", "delete"} and self._is_off_hours():
return self.hint(
- advice="Caution: executing high-risk action outside change window.",
+ advice="Caution: high-risk action outside the change window.",
target="plan",
rationale="Consider waiting for on-call handoff.",
)
-
+
return None
-
+
def _is_off_hours(self) -> bool:
from datetime import datetime
+
hour = datetime.now().hour
return hour < 9 or hour > 18
```
-## Step 3: Build the detector
+Direction records steering (`applied` / `blocked`). Governance is the layer that can stop actuation. See [Faculties](/explanation/faculties).
-Create a mock detector that simulates incident detection:
+## Step 2: Detect and plan (mocks)
```python detector.py
from dataclasses import dataclass
@@ -120,31 +111,17 @@ class Incident:
def detect_incidents() -> list[Incident]:
- """
- In production, replace with:
- - Prometheus/Alertmanager queries
- - Datadog/New Relic API calls
- - PagerDuty webhook events
- """
return [
Incident(
id="INC-001",
title="API latency spike in checkout service",
severity="high",
service="checkout-api",
- signals={
- "p99_latency_ms": 2500,
- "error_rate": 0.05,
- "affected_users": 1200,
- },
+ signals={"p99_latency_ms": 2500, "error_rate": 0.05, "affected_users": 1200},
)
]
```
-## Step 4: Build the responder
-
-Create a responder that proposes actions:
-
```python responder.py
from dataclasses import dataclass
@@ -158,289 +135,199 @@ class ResponsePlan:
rationale: str
-def plan_response(incident, state: dict) -> ResponsePlan:
- """
- In production, replace with:
- - LangGraph plan generator
- - Runbook lookup
- - LLM-based reasoning
- """
- # Simple heuristic-based planning
+def plan_response(incident) -> ResponsePlan:
if incident.signals.get("error_rate", 0) > 0.1:
return ResponsePlan(
action="rollback",
target=incident.service,
parameters={"to_version": "v1.2.3"},
confidence=0.85,
- rationale="Error rate exceeds threshold; rollback to last stable version.",
+ rationale="Error rate exceeds threshold; roll back to last stable version.",
)
-
+
if incident.signals.get("p99_latency_ms", 0) > 2000:
return ResponsePlan(
action="scale_up",
target=incident.service,
parameters={"replicas": 10},
confidence=0.75,
- rationale="Latency spike suggests capacity issue; scaling up.",
+ rationale="Latency spike suggests capacity pressure.",
)
-
+
return ResponsePlan(
action="monitor",
target=incident.service,
parameters={"duration_minutes": 15},
confidence=0.9,
- rationale="Signals within acceptable range; continuing to monitor.",
+ rationale="Signals within range; keep watching.",
)
```
-## Step 5: Wire it together with Noēsis
+## Step 3: Run with enforce + pause-on-veto
+
+`planner_mode` chooses the planner. `governance_mode` is independent. Pause-on-veto is tested with `planner_mode="minimal"` and `governance_mode="enforce"` in `tests/runtime/test_run_lifecycle.py`.
-Create the main orchestration:
+The built-in `PreActGovernor` (`noesis/domain/faculties/governance.py`) vetoes when the goal or a plan step contains `danger`, or matches `veto|destroy|shutdown|wipe`. `write|delete|drop` is an **audit** (action still proceeds). `rollback` / `scale_up` / `monitor` are not veto keywords by themselves.
```python incident_triage.py
+from pathlib import Path
+
import noesis as ns
from detector import detect_incidents
from responder import plan_response
from prod_guard import IncidentPolicy
-def run_triage():
- """Run incident triage with full cognitive loop."""
-
- # Detect incidents
- incidents = detect_incidents()
-
- for incident in incidents:
- # Build initial state
- state = {
- "task": f"Respond to incident: {incident.title}",
- "incident_id": incident.id,
- "severity": incident.severity,
- "service": incident.service,
- "signals": incident.signals,
- "environment": "production",
- }
-
- # Plan the response
- plan = plan_response(incident, state)
- state["proposed_action"] = plan.action
- state["confidence"] = plan.confidence
-
- # Run through Noēsis with policy
+def checkpoint_id_from_events(events: list[dict]) -> str | None:
+ for event in reversed(events):
+ if event.get("phase") == "runtime" and event.get("event_type") == "run.checkpoint":
+ checkpoint_id = (event.get("payload") or {}).get("checkpoint_id")
+ if isinstance(checkpoint_id, str) and checkpoint_id:
+ return checkpoint_id
+ return None
+
+
+def run_triage() -> None:
+ ns.set(
+ planner_mode="minimal",
+ governance_mode="enforce",
+ governance_failure_policy="fail_closed",
+ governance_pause_on_veto=True,
+ )
+
+ for incident in detect_incidents():
+ plan = plan_response(incident)
+ task = (
+ f"Respond to {incident.id}: {plan.action} on {incident.service}. "
+ f"{incident.title}"
+ )
+
episode_id = ns.run(
- state["task"],
+ task,
intuition=IncidentPolicy(),
+ process="incident-triage",
tags={
"incident_id": incident.id,
"severity": incident.severity,
+ "proposed_action": plan.action,
+ "environment": "production",
},
)
-
- # Check outcomes
- summary = ns.summary.read(episode_id)
- events = list(ns.events.read(episode_id))
-
- # Find governance decisions
- governance = [e for e in events if e["phase"] == "governance"]
- direction = [e for e in events if e["phase"] == "direction"]
-
- print(f"\nIncident: {incident.id}")
- print(f" Action: {plan.action}")
- print(f" Status: {summary['metrics'].get('success', 'unknown')}")
-
- if direction:
- status = direction[-1]["payload"].get("status")
- if status == "blocked":
- print(f" Blocked: {direction[-1]['payload'].get('advice')}")
- elif "requires_approval" in str(direction[-1].get("payload", {})):
- print(" Awaiting human approval")
+ events = list(ns.events.read(episode_id))
+ from noesis.io import paths
-if __name__ == "__main__":
- run_triage()
-```
+ run_dir = Path(paths(episode_id)["dir"])
+ sealed = (run_dir / "final.json").exists()
+ checkpoint_id = checkpoint_id_from_events(events)
-## Step 6: Add a Gradio UI (optional)
+ print(f"Incident {incident.id} episode={episode_id}")
+ print(f" proposed_action={plan.action} sealed={sealed}")
-Create a visual control room:
+ if checkpoint_id and not sealed:
+ print(f" paused at checkpoint {checkpoint_id} (no act, no terminate)")
+ print(" review events with: noesis events", episode_id, "--phase governance")
+ continue
-```python gradio_app.py
-import gradio as gr
-import noesis as ns
-from incident_triage import run_triage
-
-
-def triage_ui(incident_prompt: str, severity: str, intuition_mode: str):
- """Run triage and return results for display."""
-
- # Configure Noēsis
- ns.set(planner_mode="meta" if intuition_mode == "Full governance" else "minimal")
-
- # Run the episode
- episode_id = ns.run(
- incident_prompt,
- intuition=True,
- tags={"severity": severity, "source": "gradio"},
- )
-
- # Gather results
- summary = ns.summary.read(episode_id)
- events = list(ns.events.read(episode_id))
-
- # Format timeline
- timeline = "\n".join([
- f"[{e['phase']}] {e.get('payload', {}).get('status', 'ok')}"
- for e in events
- ])
-
- # Format metrics
- metrics = summary.get("metrics", {})
- metrics_text = f"""
- Success: {metrics.get('success', 'N/A')}
- Plans: {metrics.get('plan_count', 0)}
- Actions: {metrics.get('act_count', 0)}
- Vetoes: {metrics.get('veto_count', 0)}
- """
-
- return episode_id, timeline, metrics_text
-
-
-# Build the interface
-with gr.Blocks(title="Incident Triage Dashboard") as app:
- gr.Markdown("# 🚨 Incident Triage Dashboard")
- gr.Markdown("Powered by Noēsis cognitive loop")
-
- with gr.Row():
- with gr.Column():
- prompt = gr.Textbox(
- label="Incident description",
- placeholder="API latency spike in checkout service...",
- lines=3,
- )
- severity = gr.Dropdown(
- choices=["low", "medium", "high", "critical"],
- value="medium",
- label="Severity",
- )
- mode = gr.Radio(
- choices=["Full governance", "Minimal (no guardrails)"],
- value="Full governance",
- label="Mode",
- )
- run_btn = gr.Button("Run Triage", variant="primary")
-
- with gr.Column():
- episode_out = gr.Textbox(label="Episode ID")
- timeline_out = gr.Textbox(label="Event Timeline", lines=10)
- metrics_out = gr.Textbox(label="Metrics", lines=6)
-
- run_btn.click(
- triage_ui,
- inputs=[prompt, severity, mode],
- outputs=[episode_out, timeline_out, metrics_out],
- )
+ if sealed:
+ summary = ns.summary.read(episode_id)
+ print(f" sealed success={summary.get('metrics', {}).get('success')}")
if __name__ == "__main__":
- app.launch()
+ run_triage()
```
-Run it:
+Group related pages with `process=` / `noesis --process`. Episode bundles stay flat: `.noesis/episodes/ep_/`.
+
+## Step 4: Review a paused run
+
+Paused enforce vetoes emit `action_candidate → governance → run.interrupt → run.checkpoint`. There is no `act`, no `terminate`, and no `final.json` / `manifest.json` yet. `state.json` outcomes use `status="partial"`.
+
+Inspect:
```bash
-pip install gradio
-python gradio_app.py
+noesis view ep_01JH6Z2V9Q2K6Y6N0QZ7K2QW8C
+noesis events ep_01JH6Z2V9Q2K6Y6N0QZ7K2QW8C --phase governance
+noesis explain ep_01JH6Z2V9Q2K6Y6N0QZ7K2QW8C
```
-## Step 7: Human-in-the-loop approval
+Continue the **same** run after approval:
-When the policy requires approval, you need a way to capture human decisions:
+```python review.py
+from pathlib import Path
-```python approval.py
import noesis as ns
+from noesis.io import paths
-def await_approval(episode_id: str) -> bool:
- """
- In production, replace with:
- - Slack interactive message
- - ServiceNow approval workflow
- - PagerDuty acknowledgment
- """
- # For demo, simulate approval
- print(f"Episode {episode_id} requires approval.")
- response = input("Approve? (y/n): ")
- return response.lower() == "y"
-
-
-def run_with_approval():
- """Example of human-in-the-loop pattern."""
-
- episode_id = ns.run(
- "Rollback checkout-api to v1.2.3",
- intuition=True,
- tags={"requires_review": True},
- )
-
- # Check if approval is needed
+def resume_if_paused(episode_id: str, *, approved: bool) -> str | None:
+ run_dir = Path(paths(episode_id)["dir"])
+ if (run_dir / "final.json").exists():
+ print(f"{episode_id} is sealed; interrupt/resume will raise RunSealedError")
+ return None
+
events = list(ns.events.read(episode_id))
- needs_approval = any(
- "requires_approval" in str(e.get("payload", {}))
- for e in events
- )
-
- if needs_approval:
- approved = await_approval(episode_id)
- if approved:
- # Re-run with approval flag
- episode_id = ns.run(
- "Rollback checkout-api to v1.2.3 [APPROVED]",
- intuition=True,
- tags={"approved": True},
- )
-
- return episode_id
-```
+ checkpoint_id = None
+ for event in reversed(events):
+ if event.get("phase") == "runtime" and event.get("event_type") == "run.checkpoint":
+ value = (event.get("payload") or {}).get("checkpoint_id")
+ if isinstance(value, str) and value:
+ checkpoint_id = value
+ break
+ if checkpoint_id is None:
+ print(f"{episode_id} has no run.checkpoint event")
+ return None
-## What you've built
+ if not approved:
+ print(f"rejected; leave {episode_id} paused and unsealed")
+ return None
+
+ # Same episode ID. For ns.run / minimal checkpoints, omitting using= is allowed.
+ return ns.resume_run(episode_id, checkpoint_id=checkpoint_id)
+```
-
-You now have a complete incident triage system with:
-- Incident detection and classification
-- Policy-based guardrails
-- Human approval workflows
-- Full observability through Noēsis artifacts
-
+
+`resume_run` continues the paused episode; it does not rewrite the original goal. If Governance still vetoes that goal (for example the task still contains `danger`), the run can pause or terminate again. Approval is not a bypass of `PreActGovernor`.
+
-## Artifacts produced
+For a side-effect that must itself be gated, use [`ns.governed_act(...)`](/tutorials/governed-side-effects) (`kind="shell"` or `kind="adapter"`) with the same `governance_pause_on_veto` setting. Pause still raises `NoesisVeto` and leaves that governed episode unsealed.
-Every triage run produces:
+## Constraints and pitfalls
-| File | Contents |
+| Pitfall | What actually happens |
| --- | --- |
-| `events.jsonl` | Full timeline with phases, agent IDs, advice, status |
-| `summary.json` | Success metrics, latencies, learn proposal counts |
-| `state.json` | Current plan state and cognitive context |
-| `learn.jsonl` | Learning signals for policy improvement |
+| Start `ns.run("... [APPROVED]")` after the first episode | New episode ID; original sealed/paused run is not continued |
+| Call `ns.interrupt` after `ns.run` returns a sealed episode | `RunSealedError` |
+| Treat intuition `intervene(..., patch={"requires_approval": True})` as a pause | Run continues unless Governance vetoes |
+| Expect `planner_mode="minimal"` to disable Governance | Governance follows `governance_mode`; pause-on-veto works in minimal |
+| Set `NOESIS_RUNS_DIR=/srv/noesis/episodes` and look for bundles there | Unless the parent directory is `.noesis`, artifacts land under `{runs_dir}/.noesis/episodes` |
+| `noesis run TASK --intuition my_policy:Class` | Shipped `noesis run` has no `--intuition` / `--policy` flag; attach policies in Python |
-## Production considerations
+## Artifacts
-
-The mocks in this tutorial are deterministic for demo purposes. For production:
+| File | Allow / audit / sealed veto | Paused enforce veto |
+| --- | --- | --- |
+| `events.jsonl` | Includes `terminate` | `run.interrupt` then `run.checkpoint`; no `act` |
+| `state.json` | Terminal outcomes | `outcomes.status="partial"` |
+| `final.json` + `manifest.json` | Present (sealed) | Absent until continuation or a later terminal path |
+| `checkpoints//checkpoint.json` | Not the veto-pause path | Present when pause-on-veto fires |
-- Replace `detect_incidents()` with real monitoring integrations
-- Replace `plan_response()` with LLM-based planning (LangGraph, etc.)
-- Replace approval simulation with Slack/ServiceNow workflows
-- Add authentication and audit logging
-
+## Production notes
+
+- Replace the detector/planner mocks with your monitoring and runbook stack.
+- Keep approval on the paused episode ID (`resume_run`), not a linked follow-up episode.
+- Do not hand-edit `events.jsonl` or checkpoint files before resume; checkpoint consistency checks are fail-closed.
+- `examples/incident_triage/` ships Gradio/Streamlit viewers around `incident_graph`. Those UIs inspect artifacts after a run; they simulate in-graph approval and still call the legacy `ns.run_using` helper. Use this tutorial's pause-on-veto path when you need same-run continuation.
+- Export veto and pause counts with the [metrics guide](/guides/export-metrics).
## Next steps
-
- Deep dive into approval patterns.
+
+ Pause, checkpoint, and resume contracts.
-
- Send Noēsis metrics to your observability stack.
+
+ Gate shell/adapter actions at the OS boundary.
diff --git a/llms.txt b/llms.txt
index df1f502..72c60fb 100644
--- a/llms.txt
+++ b/llms.txt
@@ -5,24 +5,23 @@
- [Quickstart](https://docs.noesis.systems/quickstart): Get your first Noēsis episode running in under 5 minutes.
## Tutorials
+- [Hello Episode: See Your Agent Think in 5 Minutes](https://docs.noesis.systems/tutorials/hello-episode): Run a tiny agent through Noēsis, open the artifacts, and read the timeline that shows every phase.
- [Your first episode](https://docs.noesis.systems/tutorials/first-episode): Learn how to run, inspect, and understand Noēsis episodes step by step.
- [Your first policy](https://docs.noesis.systems/tutorials/first-policy): Learn how to create intuition policies that guide and guard your agent episodes.
-- [Guarded LangGraph Agent: Vetoing Dangerous File Deletes](https://docs.noesis.systems/tutorials/guarded-langgraph-agent): Wrap a LangGraph agent with Noēsis so every tool call is observable and dangerous deletes get vetoed.
-- [Hello Episode: See Your Agent Think in 5 Minutes](https://docs.noesis.systems/tutorials/hello-episode): Run a tiny agent through Noēsis, open the artifacts, and read the timeline that shows every phase.
-- [Build an incident triage dashboard](https://docs.noesis.systems/tutorials/incident-triage): Create a production-ready incident response system with Noēsis, complete with guardrails and human approval loops.
+- [LangGraph Episode: Trace the Cognitive Loop](https://docs.noesis.systems/tutorials/langgraph-episode): Run a LangGraph agent with Noesis and capture cognitive artifacts.
+- [Governed Side Effects: Action Candidates + Veto](https://docs.noesis.systems/tutorials/governed-side-effects): Use ns.governed_act to enforce governance at the side-effect boundary.
+- [Incident triage with pause-on-veto](https://docs.noesis.systems/tutorials/incident-triage): Build an SRE-style incident loop with enforce-mode governance and same-run resume after review.
- [Trace-Based Evals: Safety & Success in 100 Lines](https://docs.noesis.systems/tutorials/trace-based-evals): Score your guarded agent over a dataset using Noēsis traces, not just final answers.
## How-to guides
- [Add a memory port](https://docs.noesis.systems/guides/add-memory-port): Plug long-term memory into Noēsis using SQLite, your DB, or a custom store.
- [Adopting Noēsis in real systems](https://docs.noesis.systems/guides/adopting-noesis): Move from local traces to team-wide observability and evals without surprises.
-- [Configure planner modes](https://docs.noesis.systems/guides/configure-planner-modes): How to toggle between meta and minimal planner modes for different governance levels.
+- [Configure planner modes](https://docs.noesis.systems/guides/configure-planner-modes): How to toggle between meta and minimal planner modes without mixing them up with governance_mode.
- [Configure shared episode storage](https://docs.noesis.systems/guides/configure-shared-storage): Point Noēsis at a shared location via network volumes, S3 sync, Docker mounts, or Kubernetes PVCs.
- [Export metrics](https://docs.noesis.systems/guides/export-metrics): How to send Noēsis insight metrics to your observability stack.
- [Human-in-the-loop](https://docs.noesis.systems/guides/human-in-the-loop): How to add human approval workflows to your Noēsis episodes.
- [Integrate adapters](https://docs.noesis.systems/guides/integrate-adapters): How to connect Noēsis to LangGraph, CrewAI, or your own agent runtime.
- [Write intuition policies](https://docs.noesis.systems/guides/write-policies): How to create, test, and deploy policies that guide and guard your agent episodes.
-- [Use Noēsis in Cursor](https://docs.noesis.systems/guides/use-noesis-in-cursor): Run episodes, add guardrails, and inspect traces without leaving your editor.
-- [Use GitHub Copilot with Noēsis](https://docs.noesis.systems/guides/use-copilot): Pasteable Noēsis cheat sheet for Copilot to guide users through the API and CLI without hallucinations.
## Explanation
- [Core concepts](https://docs.noesis.systems/explanation/core-concepts): Understand the fundamental building blocks of Noēsis: episodes, faculties, and artifacts.