diff --git a/QUICKSTART.md b/QUICKSTART.md
index 9c75110..aa3c1b2 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -27,11 +27,21 @@ and Synthesis.
5. **(Optional) LLM configured** — if you chose a free backend during the prompt,
Perseus is ready for `perseus suggest` and `perseus synthesize`
-## Add Persistent Memory (optional)
+## Context, memory, and session terms
-Cross-session memory is a separate, optional component — the **Perseus Vault**
-MCP server. `perseus quickstart` already wires the connector in your config; to
-install the engine (prebuilt binary, Linux/macOS):
+Perseus resolves and shapes the active working context; Perseus Vault owns durable-memory persistence and recall.
+
+- **Active working context** is the current, task-relevant workspace state — files, services, tasks, and other facts that can change. Perseus resolves and shapes it at render time before the assistant sees it.
+- **Durable memory** is information intended to survive session boundaries. Perseus Vault owns its persistence and recall.
+- **Recalled memory** is the subset of durable memory returned for a query and shaped into the rendered context. The public `@memory` directive remains the compatibility API name for Vault-backed recall; existing MCP compatibility names remain unchanged.
+- **Session history** is Perseus's recent checkpoint and session-digest record. `@waypoint` and `@session` expose it; it is distinct from durable memory. An explicit capture may persist a checkpoint in Perseus Vault as durable memory.
+
+## Add Durable Memory (optional)
+
+Cross-session durable memory is a separate, optional component — the **Perseus Vault**
+MCP server. Perseus resolves and shapes the active working context; Perseus Vault
+owns durable-memory persistence and recall. `perseus quickstart` already wires the
+connector in your config; to install the engine (prebuilt binary, Linux/macOS):
```bash
curl -sSf https://raw.githubusercontent.com/Perseus-Computing-LLC/perseus-vault/main/scripts/install.sh | sh
@@ -150,9 +160,15 @@ start.
### MCP Server
+For MCP configurations and scheduled jobs, use the stable launcher
+`~/.local/bin/perseus`. It remains the same entry point across package upgrades,
+so background jobs do not pin a version-specific Python or Library path. Bare
+`perseus` remains fine for interactive shells; use `command -v perseus` to
+inspect the resolved installation when diagnosing a path problem.
+
```bash
-perseus mcp config # Print MCP client config for Claude Desktop, Cursor, etc.
-perseus mcp serve # Run as an MCP server over stdio
+~/.local/bin/perseus mcp config # Print MCP client config for Claude Desktop, Cursor, etc.
+~/.local/bin/perseus mcp serve # Run as an MCP server over stdio
```
## CI/CD Integration
@@ -161,7 +177,7 @@ Add to your CI pipeline (GitHub Actions, etc.):
```yaml
- name: Refresh Perseus context
- run: perseus render .perseus/context.md --output .hermes.md --strict
+ run: ~/.local/bin/perseus render .perseus/context.md --output .hermes.md --strict
```
The `--strict` flag fails the build if any directive emits a warning.
diff --git a/README.md b/README.md
index 4d08ff5..bcdd258 100755
--- a/README.md
+++ b/README.md
@@ -28,6 +28,15 @@ start (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, ...). Keep it live with
- **Local-first by default** — the core renderer reads your workspace locally; no account or hosted service is required.
- **MCP-native when you need it** — expose the same live context as a stdio or SSE MCP server, with shell-executing tools opt-in.
+### Context, memory, and session terms
+
+Perseus resolves and shapes the active working context; Perseus Vault owns durable-memory persistence and recall.
+
+- **Active working context** is the current, task-relevant workspace state — files, services, tasks, and other facts that can change. Perseus resolves and shapes it at render time before the assistant sees it.
+- **Durable memory** is information intended to survive session boundaries. Perseus Vault owns its persistence and recall.
+- **Recalled memory** is the subset of durable memory returned for a query and shaped into the rendered context. The public `@memory` directive remains the compatibility API name for Vault-backed recall; existing MCP compatibility names remain unchanged.
+- **Session history** is Perseus's recent checkpoint and session-digest record. `@waypoint` and `@session` expose it; it is distinct from durable memory. An explicit capture may persist a checkpoint in Perseus Vault as durable memory.
+
### Fastest path
```bash
@@ -145,14 +154,16 @@ PR Pilot — 5-agent autonomous PR review pipeline. Gemini API, Google Cloud Run
Perseus implements the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), exposing tools over stdio or SSE transport. Every tool resolves live workspace state at invocation time — no stale cache, no pre-computed snapshots.
+> **Stable launcher for MCP and schedulers:** Use `~/.local/bin/perseus` in MCP configurations and scheduled jobs. This install-managed launcher stays stable across package upgrades instead of baking a version-specific Python or Library path into background configuration. Interactive shell commands may still use `perseus`; verify the resolved entry point with `command -v perseus` when diagnosing an installation.
+
> **⚠️ Security Gate:** Shell-executing directives (`@query`, `@agent`, `@services command:`) require `export PERSEUS_ALLOW_DANGEROUS=1`. Without it, shell directives are silently skipped.
### Quick Start (MCP Server)
```bash
pip install perseus-ctx
-perseus mcp serve # stdio (Claude Desktop, Claude Code, Cursor, Codex)
-perseus mcp serve --transport sse --port 8420 # SSE (remote agents, multi-machine)
+~/.local/bin/perseus mcp serve # stdio (Claude Desktop, Claude Code, Cursor, Codex)
+~/.local/bin/perseus mcp serve --transport sse --port 8420 # SSE (remote agents, multi-machine)
```
### Assistant-Specific Wiring
@@ -164,13 +175,13 @@ Pick your assistant and add the config block shown:
```yaml
mcp_servers:
perseus:
- command: perseus
+ command: ~/.local/bin/perseus
args: ["mcp", "serve", "--workspace", "/path/to/workspace"]
```
Then verify with `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in your session.
-> Use an absolute path for `--workspace`. Perseus's non-interactive shell context has a limited PATH — a bare `perseus` command works in the Hermes MCP config because Hermes resolves it from the user's environment, but the workspace path must be absolute.
+> Use an absolute path for `--workspace`. Perseus's non-interactive shell context has a limited PATH, so the stable launcher above avoids relying on interactive-shell lookup.
**Claude Desktop** (`claude_desktop_config.json`):
@@ -178,7 +189,7 @@ Then verify with `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in y
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve", "--workspace", "/path/to/workspace"]
}
}
@@ -191,7 +202,7 @@ Then verify with `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in y
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -204,7 +215,7 @@ Then verify with `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in y
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -217,7 +228,7 @@ Then verify with `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in y
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -230,7 +241,7 @@ Then verify with `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in y
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -360,13 +371,13 @@ Keep it fresh with cron, launchd, systemd, or `perseus watch`:
```bash
# Linux systemd (auto-refresh every 5 minutes)
-perseus systemd .perseus/context.md --output AGENTS.md --interval 5m --install --enable
+~/.local/bin/perseus systemd .perseus/context.md --output AGENTS.md --interval 5m --install --enable
# macOS launchd
-perseus launchd .perseus/context.md --output AGENTS.md
+~/.local/bin/perseus launchd .perseus/context.md --output AGENTS.md
# Cron (any POSIX host)
-perseus cron .perseus/context.md --output AGENTS.md --every 5 --install
+~/.local/bin/perseus cron .perseus/context.md --output AGENTS.md --every 5 --install
```
See the [Integration Guide](https://github.com/Perseus-Computing-LLC/perseus/blob/main/docs/HERMES_INTEGRATION.md) for Hermes-specific auto-refresh setups and [adapter patterns](https://github.com/Perseus-Computing-LLC/perseus/blob/main/spec/integration.md) for full integration details.
diff --git a/SETUP-GUIDE.md b/SETUP-GUIDE.md
index 9fa3dcc..ae89690 100755
--- a/SETUP-GUIDE.md
+++ b/SETUP-GUIDE.md
@@ -20,6 +20,15 @@ Perseus is a **compile-before-context engine** — it runs a set of directives i
The key insight: **the AI reads the rendered output, not the directives**. Perseus solves the problem of giving an AI accurate "what is happening right now" context without relying on the AI to go fetch it.
+## Context, memory, and session terms
+
+Perseus resolves and shapes the active working context; Perseus Vault owns durable-memory persistence and recall.
+
+- **Active working context** is the current, task-relevant workspace state — files, services, tasks, and other facts that can change. Perseus resolves and shapes it at render time before the assistant sees it.
+- **Durable memory** is information intended to survive session boundaries. Perseus Vault owns its persistence and recall.
+- **Recalled memory** is the subset of durable memory returned for a query and shaped into the rendered context. The public `@memory` directive remains the compatibility API name for Vault-backed recall; existing MCP compatibility names remain unchanged.
+- **Session history** is Perseus's recent checkpoint and session-digest record. `@waypoint` and `@session` expose it; it is distinct from durable memory. An explicit capture may persist a checkpoint in Perseus Vault as durable memory.
+
### Token Efficiency
Perseus is a **long-session efficiency play**. Context is injected once at session start and reused across all turns — the LLM never wastes turns asking "what machine is this?" or "what tools do I have?"
@@ -84,6 +93,14 @@ cd perseus
pip install -e .
```
+### Stable launcher for automation
+
+Use `~/.local/bin/perseus` as the launcher in MCP configurations and scheduled
+jobs. This install-managed entry point stays stable across package upgrades and
+avoids pinning a version-specific Python or Library path into background
+configuration. Interactive shell commands may use `perseus`; run `command -v
+perseus` when you need to discover or diagnose the resolved executable.
+
---
## Quick Start
@@ -157,8 +174,10 @@ in `.perseus/config.yaml`.
> **Rovo Dev users:** The "two-file problem" — Rovo Dev CLI reads `~/.rovodev/AGENTS.md` while
> the Rovo web agent reads `~/AGENTS.md`. Keep them in sync via the automation section below.
>
-> **Cross-platform paths:** All examples below use macOS-style `/Users/yourname/...` paths.
-> Substitute as needed:
+> **Cross-platform paths:** Workspace examples below use macOS-style `/Users/yourname/...` paths.
+> Substitute as needed. Automation examples intentionally use the stable
+> `~/.local/bin/perseus` launcher; expand it only when an MCP client requires a
+> fully resolved path:
> - **Windows (git-bash):** `C:/Users/yourname/...` or `/c/Users/yourname/...`
> - **Linux:** `/home/yourname/...`
> - **Docker:** `/opt/data/...` or wherever `$HERMES_HOME` points
@@ -204,7 +223,7 @@ trust:
> ```bash
> export PERSEUS_ALLOW_DANGEROUS=1
> # or per-command:
-> PERSEUS_ALLOW_DANGEROUS=1 perseus render ~/.perseus/context.md --output ~/AGENTS.md
+> PERSEUS_ALLOW_DANGEROUS=1 ~/.local/bin/perseus render ~/.perseus/context.md --output ~/AGENTS.md
> ```
> If missing, these directives will render as disabled even when `render.allow_query_shell: true`.
@@ -755,7 +774,7 @@ In addition to AGENTS.md auto-injection, Hermes can wire Perseus as an MCP serve
```yaml
mcp_servers:
perseus:
- command: /home/yourname/.local/bin/perseus # Linux/Docker; use /Users/… on macOS
+ command: ~/.local/bin/perseus # stable launcher; expand ~ if this client requires an absolute path
args:
- mcp
- serve
@@ -773,7 +792,7 @@ mcp_servers:
> reload. Smoke-test with:
> ```bash
> echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | \
-> timeout 3 /home/yourname/.local/bin/perseus mcp serve --workspace /home/yourname
+> timeout 3 ~/.local/bin/perseus mcp serve --workspace /home/yourname
> ```
### Claude Code (hooks-based injection)
@@ -793,7 +812,7 @@ Add Perseus MCP to your MCP config at `~/.rovodev/mcp.json`:
{
"mcpServers": {
"perseus": {
- "command": "/Users/yourname/Library/Python/3.13/bin/perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve", "--workspace", "/Users/yourname"]
}
}
@@ -806,10 +825,10 @@ Perseus also auto-renders AGENTS.md at session start if the launchd job is confi
```bash
# Print MCP client config for your editor
-perseus mcp config
+~/.local/bin/perseus mcp config
# Or use the MCP server directly in any MCP-compatible client:
-# command: perseus mcp serve --workspace /path/to/workspace
+# command: ~/.local/bin/perseus mcp serve --workspace /path/to/workspace
```
---
@@ -828,7 +847,7 @@ If you're already using Hermes Agent, its built-in cron scheduler is the simples
#!/bin/bash
# Silent on success, alerts on failure (designed for no_agent=true cron)
export PATH="$HOME/.local/bin:$PATH"
-PERSEUS_ALLOW_DANGEROUS=1 perseus render "$HOME/.perseus/context.md" --output "$HOME/AGENTS.md" >/dev/null 2>&1
+PERSEUS_ALLOW_DANGEROUS=1 ~/.local/bin/perseus render "$HOME/.perseus/context.md" --output "$HOME/AGENTS.md" >/dev/null 2>&1
exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "Perseus render FAILED (exit $exit_code)"
@@ -888,7 +907,7 @@ Create `~/Library/LaunchAgents/com.yourname.perseus.render.plist`:
/bin/sh
-c
- /Users/yourname/.local/bin/perseus render /Users/yourname/.perseus/context.md --output /Users/yourname/AGENTS.md
+ ~/.local/bin/perseus render /Users/yourname/.perseus/context.md --output /Users/yourname/AGENTS.md
StartInterval
1800
@@ -920,7 +939,7 @@ Description=Perseus context render
[Service]
Type=oneshot
-ExecStart=/bin/sh -c '/home/yourname/.local/bin/perseus render /home/yourname/.perseus/context.md --output /home/yourname/AGENTS.md'
+ExecStart=/bin/sh -c '~/.local/bin/perseus render /home/yourname/.perseus/context.md --output /home/yourname/AGENTS.md'
# ~/.config/systemd/user/perseus-render.timer
[Unit]
@@ -943,7 +962,7 @@ systemctl --user enable --now perseus-render.timer
```bash
crontab -e
# Add:
-*/30 * * * * /full/path/to/perseus render /home/yourname/.perseus/context.md --output /home/yourname/AGENTS.md
+*/30 * * * * ~/.local/bin/perseus render /home/yourname/.perseus/context.md --output /home/yourname/AGENTS.md
```
---
@@ -994,13 +1013,13 @@ Perseus can run as an MCP server over stdio, or as an HTTP server with a dashboa
```bash
# MCP server (stdio, JSON-RPC 2.0) — exposes directives as tools
-perseus mcp serve --workspace /path/to/workspace
+~/.local/bin/perseus mcp serve --workspace /path/to/workspace
# Print MCP client config for Claude Desktop / Cursor
-perseus mcp config
+~/.local/bin/perseus mcp config
# HTTP server with dashboard at http://127.0.0.1:7991
-perseus serve --port 7991 --workspace /path/to/workspace
+~/.local/bin/perseus serve --port 7991 --workspace /path/to/workspace
```
HTTP endpoints:
@@ -1230,7 +1249,7 @@ cd ~/my-project && perseus init
# Render context to AGENTS.md (Hermes, Claude Code, Rovo web agent)
# Requires PERSEUS_ALLOW_DANGEROUS=1 for @query, @agent, @services command: directives
-PERSEUS_ALLOW_DANGEROUS=1 perseus render ~/.perseus/context.md --output ~/AGENTS.md
+PERSEUS_ALLOW_DANGEROUS=1 ~/.local/bin/perseus render ~/.perseus/context.md --output ~/AGENTS.md
perseus render ~/.perseus/context.md --output ~/AGENTS.md
# Render to .hermes.md (Hermes high-priority context)
@@ -1264,7 +1283,7 @@ perseus memory show
perseus memory status
# MCP server
-perseus mcp serve --workspace ~
+~/.local/bin/perseus mcp serve --workspace ~
# Hermes cronjob (automated render every 30 min)
hermes cron create "every 30m" --name "Perseus render" --script perseus-render.sh --no-agent
diff --git a/WIRING.md b/WIRING.md
index aec8a43..5d59786 100644
--- a/WIRING.md
+++ b/WIRING.md
@@ -4,6 +4,17 @@ Perseus resolves your project state *before* the AI assistant sees it. This
guide covers every way to wire Perseus into your workflow so context stays
live-loaded — no stale files, no "discover what's running" preambles.
+## Context, memory, and session terms
+
+Perseus resolves and shapes the active working context; Perseus Vault owns durable-memory persistence and recall.
+
+- **Active working context** is the current, task-relevant workspace state — files, services, tasks, and other facts that can change. Perseus resolves and shapes it at render time before the assistant sees it.
+- **Durable memory** is information intended to survive session boundaries. Perseus Vault owns its persistence and recall.
+- **Recalled memory** is the subset of durable memory returned for a query and shaped into the rendered context. The public `@memory` directive remains the compatibility API name for Vault-backed recall; existing MCP compatibility names remain unchanged.
+- **Session history** is Perseus's recent checkpoint and session-digest record. `@waypoint` and `@session` expose it; it is distinct from durable memory. An explicit capture may persist a checkpoint in Perseus Vault as durable memory.
+
+> **Stable launcher for MCP and schedulers:** Use `~/.local/bin/perseus` in client configurations and scheduled jobs. It remains the same install-managed entry point across upgrades instead of pinning a version-specific Python or Library path. Interactive shell commands may use `perseus`; use `command -v perseus` to discover the resolved executable when diagnosing a path problem.
+
---
## Quick Reference
@@ -12,9 +23,9 @@ live-loaded — no stale files, no "discover what's running" preambles.
|---------|---------|---------|
| **One-shot render** | `perseus render .perseus/context.md --output .hermes.md` | Manual |
| **Watch** | `perseus watch` | Auto on file change |
-| **Systemd timer** | `perseus systemd create … --install --enable` | Every N minutes |
-| **Cron** | `perseus cron create … --install` | Every N minutes |
-| **MCP server** | `perseus mcp serve` | Live on tool call |
+| **Systemd timer** | `~/.local/bin/perseus systemd create … --install --enable` | Every N minutes |
+| **Cron** | `~/.local/bin/perseus cron create … --install` | Every N minutes |
+| **MCP server** | `~/.local/bin/perseus mcp serve` | Live on tool call |
| **Editor hook** | `perseus install --target claude-code` | Before session start |
---
@@ -27,7 +38,7 @@ pre-computed snapshots.
### stdio (Claude Desktop, Claude Code, Cursor, Codex)
```bash
-perseus mcp serve
+~/.local/bin/perseus mcp serve
```
Add to your assistant's MCP config:
@@ -37,7 +48,7 @@ Add to your assistant's MCP config:
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -49,7 +60,7 @@ Add to your assistant's MCP config:
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -58,13 +69,13 @@ Add to your assistant's MCP config:
Print the exact config:
```bash
-perseus mcp config
+~/.local/bin/perseus mcp config
```
### SSE (remote agents, multi-machine)
```bash
-perseus mcp serve --transport sse --port 8420
+~/.local/bin/perseus mcp serve --transport sse --port 8420
```
Then point remote assistants at `http://:8420/sse`.
@@ -144,7 +155,7 @@ Runs in the foreground. For background operation, use systemd or cron.
```bash
# Create, install, and enable a systemd timer for every-5-minute refresh
-perseus systemd create .perseus/context.md --output .hermes.md --interval 5m --install --enable
+~/.local/bin/perseus systemd create .perseus/context.md --output .hermes.md --interval 5m --install --enable
```
This creates:
@@ -159,17 +170,17 @@ systemctl --user status perseus-render-context.timer
systemctl --user start perseus-render-context.service
# Remove
-perseus systemd uninstall .perseus/context.md
+~/.local/bin/perseus systemd uninstall .perseus/context.md
```
### Cron (macOS / Linux)
```bash
# Install a crontab entry
-perseus cron create .perseus/context.md --output .hermes.md --every 5 --install
+~/.local/bin/perseus cron create .perseus/context.md --output .hermes.md --every 5 --install
# Remove
-perseus cron uninstall .perseus/context.md
+~/.local/bin/perseus cron uninstall .perseus/context.md
```
---
@@ -255,7 +266,7 @@ perseus quickstart
perseus install --target claude-code
# 3. Set up auto-refresh (Linux)
-perseus systemd create .perseus/context.md \
+~/.local/bin/perseus systemd create .perseus/context.md \
--output CLAUDE.md \
--interval 5m \
--install --enable
@@ -271,8 +282,8 @@ perseus pack validate
For macOS:
```bash
# Replace step 3 with:
-perseus watch & # background, or
-perseus launchd create .perseus/context.md \
+~/.local/bin/perseus watch & # background, or
+~/.local/bin/perseus launchd create .perseus/context.md \
--output CLAUDE.md \
--interval 300
```
diff --git a/docs/AGENT_SURFACES.md b/docs/AGENT_SURFACES.md
index 5f2c006..f88f85b 100644
--- a/docs/AGENT_SURFACES.md
+++ b/docs/AGENT_SURFACES.md
@@ -223,6 +223,34 @@ Failure:
}
```
+## `perseus doctor --json` provenance details
+
+The additive `provenance_drift` check reports the installed artifact separately
+from the current source checkout when the source root is unambiguous. The
+canonical source layout is recognized only at the requested workspace (or an
+explicit `doctor.source_root` may be configured):
+
+```yaml
+doctor:
+ source_root: /path/to/perseus
+```
+
+Its optional `details` mapping uses JSON-safe values:
+
+```json
+{
+ "artifact": {"path": "~/.local/.../perseus.py", "sha": "abc1234", "dirty": true, "state": "dirty"},
+ "source": {"root": "/path/to/perseus", "sha": "abc1234", "dirty": false, "state": "clean"},
+ "comparison": "artifact_dirty_source_clean",
+ "reasons": ["artifact_dirty_source_clean"],
+ "source_root_configured": true
+}
+```
+
+Unknown/legacy metadata or an unavailable source produces `unknown` or
+`artifact_only` comparison details without turning the check into an error.
+Short SHAs are compared literally; no ancestry is inferred.
+
## MCP health/context surfaces (CLI ↔ MCP mapping)
The MCP server exposes health/context tools that mirror the CLI health
diff --git a/docs/nexo-integration-guide.md b/docs/nexo-integration-guide.md
index af53c0b..58f3ba5 100644
--- a/docs/nexo-integration-guide.md
+++ b/docs/nexo-integration-guide.md
@@ -114,7 +114,7 @@ Nexo's `nexo_startup` tool can call Perseus's MCP server via stdio:
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
diff --git a/docs/quickstart.md b/docs/quickstart.md
index e8351a2..e5b8597 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -2,6 +2,15 @@
Get from zero to a live rendered context in under 5 minutes.
+## Context, memory, and session terms
+
+Perseus resolves and shapes the active working context; Perseus Vault owns durable-memory persistence and recall.
+
+- **Active working context** is the current, task-relevant workspace state — files, services, tasks, and other facts that can change. Perseus resolves and shapes it at render time before the assistant sees it.
+- **Durable memory** is information intended to survive session boundaries. Perseus Vault owns its persistence and recall.
+- **Recalled memory** is the subset of durable memory returned for a query and shaped into the rendered context. The public `@memory` directive remains the compatibility API name for Vault-backed recall; existing MCP compatibility names remain unchanged.
+- **Session history** is Perseus's recent checkpoint and session-digest record. `@waypoint` and `@session` expose it; it is distinct from durable memory. An explicit capture may persist a checkpoint in Perseus Vault as durable memory.
+
---
## 1. Prerequisites
@@ -56,6 +65,12 @@ perseus --version
> `./scripts/install.sh` still exists for compatibility, but package install is the preferred path for most users.
+> **Stable launcher for automation:** Use `~/.local/bin/perseus` in MCP
+> configurations and scheduled jobs. It remains stable across package upgrades,
+> avoiding a version-specific Python or Library path in background configuration.
+> Interactive shell examples may use `perseus`; use `command -v perseus` when
+> discovering or diagnosing the installed executable.
+
---
## 3. Configure
@@ -142,6 +157,12 @@ This document was rendered live by Perseus. All values below are current.
## Last Session
@waypoint ttl=86400
+## Recalled Memory
+@memory mode=search query="project architecture decisions" k=5
+
+## Session History
+@session count=5 format=digest
+
## What's Running
@query "docker ps --format 'table {{.Names}}\t{{.Status}}'" @cache ttl=60
@@ -187,24 +208,26 @@ Re-renders whenever the source file changes.
```bash
# Print a crontab entry
-perseus cron .perseus/context.md --output .hermes.md --every 5
+~/.local/bin/perseus cron .perseus/context.md --output .hermes.md --every 5
# Install it (macOS/Linux)
-perseus cron .perseus/context.md --output .hermes.md --every 5 --install
+~/.local/bin/perseus cron .perseus/context.md --output .hermes.md --every 5 --install
```
### Option C — systemd / launchd
```bash
-perseus systemd .perseus/context.md --output .hermes.md # Linux
-perseus launchd .perseus/context.md --output .hermes.md # macOS
+~/.local/bin/perseus systemd .perseus/context.md --output .hermes.md # Linux
+~/.local/bin/perseus launchd .perseus/context.md --output .hermes.md # macOS
```
---
## 8. Write checkpoints
-At natural pause points, write a checkpoint so the next session recovers instantly:
+At natural pause points, write a checkpoint so the next session recovers instantly.
+A checkpoint is part of Perseus session history; if you explicitly capture it,
+Perseus Vault can persist it as durable memory for later recalled memory.
```bash
perseus checkpoint \
diff --git a/perseus.py b/perseus.py
index 9d06b82..7651013 100644
--- a/perseus.py
+++ b/perseus.py
@@ -79,7 +79,7 @@ def _perseus_lazy_urllib_request(name, _urllib=urllib):
# ── Build provenance (injected by scripts/build.py at build time) ───────────
# Short git SHA of the source revision the artifact was built from (#853).
# Empty when unknown (unbuilt source tree without git metadata).
-_PERSEUS_BUILD_SHA = "dcba5c0-dirty" # replaced at build time by scripts/build.py — see #853
+_PERSEUS_BUILD_SHA = "27bfa21-dirty" # replaced at build time by scripts/build.py — see #853
def _perseus_build_sha() -> str:
@@ -282,6 +282,11 @@ def _warn_once(key: str, msg: str) -> None:
"context_line_warning": 400,
"include_completed_tasks_older_than_days": 14,
},
+ "doctor": {
+ # Optional checkout root for provenance comparison. When unset, doctor
+ # only uses the exact canonical source layout when it is unambiguous.
+ "source_root": None,
+ },
"memory": {
"store": str(PERSEUS_HOME / "memory"),
"recent_keep": 5, # raw checkpoints to include in Recent Activity
@@ -26316,6 +26321,7 @@ class DoctorResult(NamedTuple):
label: str
value: str
remediation: str # "" if none
+ details: dict | None = None
def _doctor_check_config(cfg: dict, workspace: Path) -> DoctorResult:
@@ -26990,6 +26996,222 @@ def _abbrev_home(path_str: str) -> str:
return path_str
+_BUILD_SHA_VALUE_RE = re.compile(r"^([0-9a-f]{4,40})(-dirty)?$", re.IGNORECASE)
+
+
+def _parse_build_provenance(value: object) -> dict:
+ """Parse a build SHA literal without treating arbitrary text as metadata.
+
+ Build metadata predates this check in some installed artifacts, so missing,
+ empty, and legacy values are represented as an explicit unknown state. Only
+ a hexadecimal short/full SHA with an optional ``-dirty`` suffix is exposed
+ to doctor output.
+ """
+ if not isinstance(value, str):
+ return {"sha": None, "dirty": None, "state": "unknown"}
+ text = value.strip().lower()
+ if text in {"", "?", "unknown", "legacy", "none"}:
+ return {"sha": None, "dirty": None, "state": "unknown"}
+ match = _BUILD_SHA_VALUE_RE.fullmatch(text)
+ if not match:
+ return {"sha": None, "dirty": None, "state": "unknown"}
+ dirty = bool(match.group(2))
+ return {"sha": match.group(1), "dirty": dirty, "state": "dirty" if dirty else "clean"}
+
+
+def _read_perseus_module_build_sha(path: str) -> str:
+ """Read a build SHA literal from an artifact without importing it.
+
+ Importing a discovered ``perseus.py`` copy could execute stale code and
+ shadow the active module. The build assignment is near the artifact head,
+ so bounded text parsing is sufficient and keeps this check side-effect free.
+ """
+ try:
+ with open(path, encoding="utf-8", errors="replace") as f:
+ head = f.read(16384)
+ except Exception:
+ return ""
+ match = re.search(
+ r"^\s*_PERSEUS_BUILD_SHA\s*=\s*(['\"])(.*?)\1\s*(?:#.*)?$",
+ head,
+ re.MULTILINE,
+ )
+ return match.group(2).strip() if match else ""
+
+
+def _read_perseus_module_provenance(path: str) -> dict:
+ """Return sanitized build provenance parsed from an artifact file."""
+ return _parse_build_provenance(_read_perseus_module_build_sha(path))
+
+
+def _doctor_safe_path(path: Path | None) -> str | None:
+ """Return a printable, home-abbreviated path for structured output."""
+ if path is None:
+ return None
+ text = str(path)
+ return "".join(char if char.isprintable() and char not in "\r\n\t" else "?" for char in text)
+
+
+def _doctor_source_root(cfg: dict, workspace: Path, artifact_path: Path) -> tuple[Path | None, bool]:
+ """Resolve an explicit or narrowly discoverable source checkout.
+
+ Explicit ``doctor.source_root`` is authoritative. Without it, only the
+ canonical checkout layout at the requested workspace or beside the active
+ root-level artifact is considered; no ancestor or filesystem scan is done.
+ The boolean says whether the root was explicitly configured.
+ """
+ doctor_cfg = cfg.get("doctor", {}) if isinstance(cfg, dict) else {}
+ configured = doctor_cfg.get("source_root") if isinstance(doctor_cfg, dict) else None
+ if configured is not None and str(configured).strip():
+ try:
+ root = Path(configured).expanduser()
+ if not root.is_absolute():
+ root = workspace / root
+ return root.resolve(), True
+ except Exception:
+ return None, True
+
+ candidates: list[Path] = [workspace]
+ if artifact_path.name == "perseus.py":
+ candidates.append(artifact_path.parent)
+ elif artifact_path.name in {"doctor.py", "__init__.py"} and artifact_path.parent.name == "perseus":
+ candidates.append(artifact_path.parent.parent.parent)
+
+ seen: set[str] = set()
+ for candidate in candidates:
+ try:
+ root = candidate.resolve()
+ key = str(root)
+ except Exception:
+ continue
+ if key in seen:
+ continue
+ seen.add(key)
+ if (
+ (root / ".git").exists()
+ and (root / "src" / "perseus").is_dir()
+ and (
+ (root / "VERSION").is_file()
+ or (root / "scripts" / "build.py").is_file()
+ )
+ ):
+ return root, False
+ return None, False
+
+
+def _doctor_read_source_provenance(
+ source_root: Path | None,
+ *,
+ require_markers: bool = False,
+) -> dict:
+ """Read current checkout SHA and cleanliness with fail-closed states."""
+ source = {"root": _doctor_safe_path(source_root), "sha": None, "dirty": None, "state": "unavailable"}
+ if source_root is None:
+ return source
+ if require_markers and not (
+ (source_root / ".git").exists()
+ and (source_root / "src" / "perseus").is_dir()
+ and (
+ (source_root / "VERSION").is_file()
+ or (source_root / "scripts" / "build.py").is_file()
+ )
+ ):
+ return source
+ try:
+ sha_result = subprocess.run(
+ ["git", "-C", str(source_root), "rev-parse", "--short", "HEAD"],
+ capture_output=True, text=True, timeout=5,
+ )
+ sha = sha_result.stdout.strip().lower() if sha_result.returncode == 0 else ""
+ if not re.fullmatch(r"[0-9a-f]{4,40}", sha):
+ sha = ""
+
+ status_result = subprocess.run(
+ ["git", "-C", str(source_root), "status", "--porcelain", "--untracked-files=all"],
+ capture_output=True, text=True, timeout=5,
+ )
+ if status_result.returncode == 0:
+ dirty = bool(status_result.stdout.strip())
+ source.update({"sha": sha or None, "dirty": dirty, "state": "dirty" if dirty else "clean"})
+ else:
+ source.update({"sha": sha or None, "state": "unknown"})
+ except Exception:
+ # Doctor must remain useful when git is absent or a checkout is gone.
+ pass
+ return source
+
+
+def _doctor_check_provenance_drift(cfg: dict, workspace: Path) -> DoctorResult:
+ """Compare installed artifact provenance with a known current checkout."""
+ try:
+ artifact_path = Path(__file__).resolve()
+ except Exception:
+ artifact_path = Path(__file__)
+ artifact = _read_perseus_module_provenance(str(artifact_path))
+ artifact["path"] = _doctor_safe_path(artifact_path)
+
+ # A source-package import keeps the build assignment in __init__.py; the
+ # generated single-file artifact keeps it in perseus.py itself.
+ if artifact["state"] == "unknown" and artifact_path.name == "doctor.py" and artifact_path.parent.name == "perseus":
+ package_init = artifact_path.parent / "__init__.py"
+ artifact = _read_perseus_module_provenance(str(package_init))
+ artifact["path"] = _doctor_safe_path(package_init)
+
+ source_root, explicitly_configured = _doctor_source_root(cfg, workspace, artifact_path)
+ source = _doctor_read_source_provenance(
+ source_root,
+ require_markers=not explicitly_configured,
+ )
+
+ reasons: list[str] = []
+ if artifact["dirty"] is True and source["dirty"] is False:
+ reasons.append("artifact_dirty_source_clean")
+ if artifact["sha"] and source["sha"] and artifact["sha"] != source["sha"]:
+ reasons.append("sha_mismatch")
+
+ if reasons:
+ comparison = reasons[0]
+ status = "warn"
+ remediation = (
+ "Reinstall Perseus from the current source checkout to refresh the "
+ "installed artifact provenance."
+ )
+ elif source["state"] == "unavailable":
+ comparison = "artifact_only" if artifact["state"] != "unknown" else "unknown"
+ status = "ok"
+ remediation = ""
+ elif artifact["state"] == "unknown":
+ comparison = "unknown"
+ status = "ok"
+ remediation = ""
+ elif artifact["sha"] and source["sha"] and artifact["sha"] == source["sha"]:
+ comparison = "match" if artifact["dirty"] == source["dirty"] else "state_mismatch"
+ status = "ok"
+ remediation = ""
+ else:
+ comparison = "unknown"
+ status = "ok"
+ remediation = ""
+
+ artifact_state = artifact["state"]
+ artifact_sha = artifact["sha"] or "unknown"
+ artifact_suffix = "-dirty" if artifact["dirty"] is True else ""
+ source_sha = source["sha"] or "unknown"
+ source_state = source["state"]
+ value = (
+ f"artifact g{artifact_sha}{artifact_suffix} ({artifact_state}); "
+ f"source g{source_sha} ({source_state}); comparison={comparison}"
+ )
+ details = {
+ "artifact": artifact,
+ "source": source,
+ "comparison": comparison,
+ "reasons": reasons,
+ "source_root_configured": explicitly_configured,
+ }
+ return DoctorResult("provenance_drift", status, "Build provenance", value, remediation, details)
+
+
def _doctor_check_render_freshness(cfg: dict, workspace: Path) -> DoctorResult:
"""Warn when a rendered output is older than render.staleness_warn_hours (#431).
@@ -27199,6 +27421,7 @@ def _doctor_check_vault_config(cfg: dict, workspace: Path) -> DoctorResult:
_doctor_check_version_header,
_doctor_check_stale_shim,
_doctor_check_duplicate_installs,
+ _doctor_check_provenance_drift,
_doctor_check_render_freshness,
_doctor_check_agents_startup_route,
]
@@ -27401,6 +27624,7 @@ def _run_doctor_check(check_fn) -> DoctorResult:
"label": r.label,
"value": r.value,
**({"remediation": r.remediation} if r.remediation else {}),
+ **({"details": r.details} if r.details is not None else {}),
}
for r in results
],
diff --git a/spec/components.md b/spec/components.md
index c816e83..4b9aee4 100644
--- a/spec/components.md
+++ b/spec/components.md
@@ -539,6 +539,24 @@ perseus doctor [--workspace ] [--json]
- Pythia log readability
- serve loopback default
- directive registry invariants
+- installed build provenance versus the current source checkout (when the
+ checkout root is unambiguous or configured)
+
+The provenance row is `provenance_drift`. Its `--json` check object keeps the
+existing `id`, `status`, `label`, and `value` fields and adds a `details`
+mapping:
+
+```yaml
+doctor:
+ source_root: /path/to/perseus # optional; otherwise use the exact canonical checkout layout
+```
+
+`details.artifact` and `details.source` report sanitized `sha`, `dirty`, and
+`state` values. `details.comparison` is `match`, `state_mismatch`,
+`artifact_dirty_source_clean`, `sha_mismatch`, `artifact_only`, or `unknown`.
+Missing/legacy artifact metadata and unavailable source checkouts are explicit
+unknown/artifact-only results, not doctor errors. SHA comparison is literal;
+the check does not infer ancestry from short SHAs.
---
diff --git a/spec/integration.md b/spec/integration.md
index 0621ae3..c2c0ada 100644
--- a/spec/integration.md
+++ b/spec/integration.md
@@ -71,9 +71,9 @@ no pre-rendered files.
### Starting the MCP Server
```bash
-perseus mcp serve # stdio (default)
-perseus mcp serve --transport sse --port 8420 # SSE for remote agents
-perseus mcp serve --workspace /path/to/project # scope to a specific workspace
+~/.local/bin/perseus mcp serve # stdio (default)
+~/.local/bin/perseus mcp serve --transport sse --port 8420 # SSE for remote agents
+~/.local/bin/perseus mcp serve --workspace /path/to/project # scope to a specific workspace
```
### Assistant-Specific MCP Config
@@ -84,7 +84,7 @@ perseus mcp serve --workspace /path/to/project # scope to a specific workspace
mcp_servers:
perseus:
transport: stdio
- command: perseus
+ command: ~/.local/bin/perseus
args: ["mcp", "serve"]
```
@@ -96,7 +96,7 @@ Verify: `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in session.
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve", "--workspace", "/path/to/workspace"]
}
}
@@ -109,7 +109,7 @@ Verify: `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in session.
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -122,7 +122,7 @@ Verify: `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in session.
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -135,7 +135,7 @@ Verify: `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in session.
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -148,7 +148,7 @@ Verify: `hermes mcp test perseus`. Tools appear as `mcp_perseus_*` in session.
{
"mcpServers": {
"perseus": {
- "command": "perseus",
+ "command": "~/.local/bin/perseus",
"args": ["mcp", "serve"]
}
}
@@ -176,9 +176,9 @@ Prints a POSIX crontab entry on any host and can install it where `crontab` is
available (macOS, Linux, BSD, WSL).
```bash
-perseus render .perseus/context.md --output AGENTS.md
-perseus cron .perseus/context.md --output AGENTS.md --every 5
-perseus cron .perseus/context.md --output AGENTS.md --every 5 --install
+~/.local/bin/perseus render .perseus/context.md --output AGENTS.md
+~/.local/bin/perseus cron .perseus/context.md --output AGENTS.md --every 5
+~/.local/bin/perseus cron .perseus/context.md --output AGENTS.md --every 5 --install
```
Use this when you want periodic refresh regardless of assistant. Native
@@ -190,7 +190,7 @@ their own scheduler.
Perseus provides a helper for Mac users:
```bash
-perseus launchd .perseus/context.md --output AGENTS.md
+~/.local/bin/perseus launchd .perseus/context.md --output AGENTS.md
```
This scaffolds a LaunchAgent plist that periodically refreshes the rendered output.
@@ -201,13 +201,13 @@ Perseus scaffolds user-space systemd units for Linux users:
```bash
# Print the .service and .timer files to stdout
-perseus systemd .perseus/context.md --output AGENTS.md --interval 5m
+~/.local/bin/perseus systemd .perseus/context.md --output AGENTS.md --interval 5m
# Write them to ~/.config/systemd/user/ and print activation commands
-perseus systemd .perseus/context.md --output AGENTS.md --interval 5m --install
+~/.local/bin/perseus systemd .perseus/context.md --output AGENTS.md --interval 5m --install
# Combined: write + run systemctl --user daemon-reload/enable/start
-perseus systemd .perseus/context.md --output AGENTS.md --install --enable
+~/.local/bin/perseus systemd .perseus/context.md --output AGENTS.md --install --enable
```
Interval accepts `Nm` / `Nh` / `Ns` shorthand or any systemd time spec.
diff --git a/src/perseus/config.py b/src/perseus/config.py
index c923a71..b58f637 100644
--- a/src/perseus/config.py
+++ b/src/perseus/config.py
@@ -135,6 +135,11 @@ def _warn_once(key: str, msg: str) -> None:
"context_line_warning": 400,
"include_completed_tasks_older_than_days": 14,
},
+ "doctor": {
+ # Optional checkout root for provenance comparison. When unset, doctor
+ # only uses the exact canonical source layout when it is unambiguous.
+ "source_root": None,
+ },
"memory": {
"store": str(PERSEUS_HOME / "memory"),
"recent_keep": 5, # raw checkpoints to include in Recent Activity
diff --git a/src/perseus/doctor.py b/src/perseus/doctor.py
index 9a13993..fa4afa5 100644
--- a/src/perseus/doctor.py
+++ b/src/perseus/doctor.py
@@ -141,6 +141,7 @@ class DoctorResult(NamedTuple):
label: str
value: str
remediation: str # "" if none
+ details: dict | None = None
def _doctor_check_config(cfg: dict, workspace: Path) -> DoctorResult:
@@ -815,6 +816,222 @@ def _abbrev_home(path_str: str) -> str:
return path_str
+_BUILD_SHA_VALUE_RE = re.compile(r"^([0-9a-f]{4,40})(-dirty)?$", re.IGNORECASE)
+
+
+def _parse_build_provenance(value: object) -> dict:
+ """Parse a build SHA literal without treating arbitrary text as metadata.
+
+ Build metadata predates this check in some installed artifacts, so missing,
+ empty, and legacy values are represented as an explicit unknown state. Only
+ a hexadecimal short/full SHA with an optional ``-dirty`` suffix is exposed
+ to doctor output.
+ """
+ if not isinstance(value, str):
+ return {"sha": None, "dirty": None, "state": "unknown"}
+ text = value.strip().lower()
+ if text in {"", "?", "unknown", "legacy", "none"}:
+ return {"sha": None, "dirty": None, "state": "unknown"}
+ match = _BUILD_SHA_VALUE_RE.fullmatch(text)
+ if not match:
+ return {"sha": None, "dirty": None, "state": "unknown"}
+ dirty = bool(match.group(2))
+ return {"sha": match.group(1), "dirty": dirty, "state": "dirty" if dirty else "clean"}
+
+
+def _read_perseus_module_build_sha(path: str) -> str:
+ """Read a build SHA literal from an artifact without importing it.
+
+ Importing a discovered ``perseus.py`` copy could execute stale code and
+ shadow the active module. The build assignment is near the artifact head,
+ so bounded text parsing is sufficient and keeps this check side-effect free.
+ """
+ try:
+ with open(path, encoding="utf-8", errors="replace") as f:
+ head = f.read(16384)
+ except Exception:
+ return ""
+ match = re.search(
+ r"^\s*_PERSEUS_BUILD_SHA\s*=\s*(['\"])(.*?)\1\s*(?:#.*)?$",
+ head,
+ re.MULTILINE,
+ )
+ return match.group(2).strip() if match else ""
+
+
+def _read_perseus_module_provenance(path: str) -> dict:
+ """Return sanitized build provenance parsed from an artifact file."""
+ return _parse_build_provenance(_read_perseus_module_build_sha(path))
+
+
+def _doctor_safe_path(path: Path | None) -> str | None:
+ """Return a printable, home-abbreviated path for structured output."""
+ if path is None:
+ return None
+ text = str(path)
+ return "".join(char if char.isprintable() and char not in "\r\n\t" else "?" for char in text)
+
+
+def _doctor_source_root(cfg: dict, workspace: Path, artifact_path: Path) -> tuple[Path | None, bool]:
+ """Resolve an explicit or narrowly discoverable source checkout.
+
+ Explicit ``doctor.source_root`` is authoritative. Without it, only the
+ canonical checkout layout at the requested workspace or beside the active
+ root-level artifact is considered; no ancestor or filesystem scan is done.
+ The boolean says whether the root was explicitly configured.
+ """
+ doctor_cfg = cfg.get("doctor", {}) if isinstance(cfg, dict) else {}
+ configured = doctor_cfg.get("source_root") if isinstance(doctor_cfg, dict) else None
+ if configured is not None and str(configured).strip():
+ try:
+ root = Path(configured).expanduser()
+ if not root.is_absolute():
+ root = workspace / root
+ return root.resolve(), True
+ except Exception:
+ return None, True
+
+ candidates: list[Path] = [workspace]
+ if artifact_path.name == "perseus.py":
+ candidates.append(artifact_path.parent)
+ elif artifact_path.name in {"doctor.py", "__init__.py"} and artifact_path.parent.name == "perseus":
+ candidates.append(artifact_path.parent.parent.parent)
+
+ seen: set[str] = set()
+ for candidate in candidates:
+ try:
+ root = candidate.resolve()
+ key = str(root)
+ except Exception:
+ continue
+ if key in seen:
+ continue
+ seen.add(key)
+ if (
+ (root / ".git").exists()
+ and (root / "src" / "perseus").is_dir()
+ and (
+ (root / "VERSION").is_file()
+ or (root / "scripts" / "build.py").is_file()
+ )
+ ):
+ return root, False
+ return None, False
+
+
+def _doctor_read_source_provenance(
+ source_root: Path | None,
+ *,
+ require_markers: bool = False,
+) -> dict:
+ """Read current checkout SHA and cleanliness with fail-closed states."""
+ source = {"root": _doctor_safe_path(source_root), "sha": None, "dirty": None, "state": "unavailable"}
+ if source_root is None:
+ return source
+ if require_markers and not (
+ (source_root / ".git").exists()
+ and (source_root / "src" / "perseus").is_dir()
+ and (
+ (source_root / "VERSION").is_file()
+ or (source_root / "scripts" / "build.py").is_file()
+ )
+ ):
+ return source
+ try:
+ sha_result = subprocess.run(
+ ["git", "-C", str(source_root), "rev-parse", "--short", "HEAD"],
+ capture_output=True, text=True, timeout=5,
+ )
+ sha = sha_result.stdout.strip().lower() if sha_result.returncode == 0 else ""
+ if not re.fullmatch(r"[0-9a-f]{4,40}", sha):
+ sha = ""
+
+ status_result = subprocess.run(
+ ["git", "-C", str(source_root), "status", "--porcelain", "--untracked-files=all"],
+ capture_output=True, text=True, timeout=5,
+ )
+ if status_result.returncode == 0:
+ dirty = bool(status_result.stdout.strip())
+ source.update({"sha": sha or None, "dirty": dirty, "state": "dirty" if dirty else "clean"})
+ else:
+ source.update({"sha": sha or None, "state": "unknown"})
+ except Exception:
+ # Doctor must remain useful when git is absent or a checkout is gone.
+ pass
+ return source
+
+
+def _doctor_check_provenance_drift(cfg: dict, workspace: Path) -> DoctorResult:
+ """Compare installed artifact provenance with a known current checkout."""
+ try:
+ artifact_path = Path(__file__).resolve()
+ except Exception:
+ artifact_path = Path(__file__)
+ artifact = _read_perseus_module_provenance(str(artifact_path))
+ artifact["path"] = _doctor_safe_path(artifact_path)
+
+ # A source-package import keeps the build assignment in __init__.py; the
+ # generated single-file artifact keeps it in perseus.py itself.
+ if artifact["state"] == "unknown" and artifact_path.name == "doctor.py" and artifact_path.parent.name == "perseus":
+ package_init = artifact_path.parent / "__init__.py"
+ artifact = _read_perseus_module_provenance(str(package_init))
+ artifact["path"] = _doctor_safe_path(package_init)
+
+ source_root, explicitly_configured = _doctor_source_root(cfg, workspace, artifact_path)
+ source = _doctor_read_source_provenance(
+ source_root,
+ require_markers=not explicitly_configured,
+ )
+
+ reasons: list[str] = []
+ if artifact["dirty"] is True and source["dirty"] is False:
+ reasons.append("artifact_dirty_source_clean")
+ if artifact["sha"] and source["sha"] and artifact["sha"] != source["sha"]:
+ reasons.append("sha_mismatch")
+
+ if reasons:
+ comparison = reasons[0]
+ status = "warn"
+ remediation = (
+ "Reinstall Perseus from the current source checkout to refresh the "
+ "installed artifact provenance."
+ )
+ elif source["state"] == "unavailable":
+ comparison = "artifact_only" if artifact["state"] != "unknown" else "unknown"
+ status = "ok"
+ remediation = ""
+ elif artifact["state"] == "unknown":
+ comparison = "unknown"
+ status = "ok"
+ remediation = ""
+ elif artifact["sha"] and source["sha"] and artifact["sha"] == source["sha"]:
+ comparison = "match" if artifact["dirty"] == source["dirty"] else "state_mismatch"
+ status = "ok"
+ remediation = ""
+ else:
+ comparison = "unknown"
+ status = "ok"
+ remediation = ""
+
+ artifact_state = artifact["state"]
+ artifact_sha = artifact["sha"] or "unknown"
+ artifact_suffix = "-dirty" if artifact["dirty"] is True else ""
+ source_sha = source["sha"] or "unknown"
+ source_state = source["state"]
+ value = (
+ f"artifact g{artifact_sha}{artifact_suffix} ({artifact_state}); "
+ f"source g{source_sha} ({source_state}); comparison={comparison}"
+ )
+ details = {
+ "artifact": artifact,
+ "source": source,
+ "comparison": comparison,
+ "reasons": reasons,
+ "source_root_configured": explicitly_configured,
+ }
+ return DoctorResult("provenance_drift", status, "Build provenance", value, remediation, details)
+
+
def _doctor_check_render_freshness(cfg: dict, workspace: Path) -> DoctorResult:
"""Warn when a rendered output is older than render.staleness_warn_hours (#431).
@@ -1024,6 +1241,7 @@ def _doctor_check_vault_config(cfg: dict, workspace: Path) -> DoctorResult:
_doctor_check_version_header,
_doctor_check_stale_shim,
_doctor_check_duplicate_installs,
+ _doctor_check_provenance_drift,
_doctor_check_render_freshness,
_doctor_check_agents_startup_route,
]
@@ -1226,6 +1444,7 @@ def _run_doctor_check(check_fn) -> DoctorResult:
"label": r.label,
"value": r.value,
**({"remediation": r.remediation} if r.remediation else {}),
+ **({"details": r.details} if r.details is not None else {}),
}
for r in results
],
diff --git a/tests/test_docs_conventions.py b/tests/test_docs_conventions.py
new file mode 100644
index 0000000..f75a57e
--- /dev/null
+++ b/tests/test_docs_conventions.py
@@ -0,0 +1,52 @@
+"""User-facing documentation contracts for context, memory, and launchers."""
+
+import re
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+PUBLIC_DOCS = (
+ ROOT / "README.md",
+ ROOT / "QUICKSTART.md",
+ ROOT / "SETUP-GUIDE.md",
+ ROOT / "WIRING.md",
+ ROOT / "docs" / "quickstart.md",
+)
+
+LAUNCHER_DOCS = PUBLIC_DOCS + (
+ ROOT / "spec" / "integration.md",
+ ROOT / "docs" / "nexo-integration-guide.md",
+)
+
+
+def test_public_docs_define_the_context_and_memory_boundary():
+ """All primary onboarding docs use the same ownership vocabulary."""
+ required = (
+ "active working context",
+ "durable memory",
+ "recalled memory",
+ "session history",
+ "perseus resolves and shapes the active working context",
+ "perseus vault owns durable-memory persistence and recall",
+ "@memory",
+ )
+
+ for path in PUBLIC_DOCS:
+ text = path.read_text(encoding="utf-8").lower()
+ missing = [phrase for phrase in required if phrase not in text]
+ assert not missing, f"{path.relative_to(ROOT)} missing: {missing}"
+
+
+def test_public_docs_use_the_stable_launcher_for_automation():
+ """MCP and scheduler guidance must point at the upgrade-safe launcher."""
+ for path in LAUNCHER_DOCS:
+ text = path.read_text(encoding="utf-8")
+ rel = path.relative_to(ROOT)
+ assert "~/.local/bin/perseus" in text, f"{rel} lacks the stable launcher"
+ assert not re.search(r"(?i)library/python|python/3\.", text), (
+ f"{rel} contains a version-specific launcher path"
+ )
+ assert not re.search(
+ r"(?m)^\s*['\"]?command['\"]?\s*:\s*['\"]?perseus['\"]?\s*$",
+ text,
+ ), f"{rel} contains a bare MCP launcher"
diff --git a/tests/test_doctor.py b/tests/test_doctor.py
index 5dfc9e6..827de26 100644
--- a/tests/test_doctor.py
+++ b/tests/test_doctor.py
@@ -409,6 +409,187 @@ def test_read_perseus_module_version_missing_returns_question(tmp_path):
assert perseus._read_perseus_module_version(str(tmp_path / "nope.py")) == "?"
+def test_doctor_provenance_warns_dirty_artifact_against_clean_source(tmp_path, monkeypatch):
+ """A dirty installed artifact is visible when the configured source is clean."""
+ import subprocess
+
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text('_PERSEUS_BUILD_SHA = "abc1234-dirty"\n', encoding="utf-8")
+ source = tmp_path / "source"
+ source.mkdir()
+ subprocess.run(["git", "-C", str(source), "init", "-q"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.email", "test@example.invalid"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.name", "Doctor Test"], check=True)
+ (source / "tracked.txt").write_text("clean\n", encoding="utf-8")
+ subprocess.run(["git", "-C", str(source), "add", "tracked.txt"], check=True)
+ subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "initial"], check=True)
+
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+ c = cfg()
+ c["doctor"] = {"source_root": str(source)}
+ result = perseus._doctor_check_provenance_drift(c, tmp_path)
+
+ assert result.id == "provenance_drift"
+ assert result.status == "warn"
+ assert result.details["artifact"]["sha"] == "abc1234"
+ assert result.details["artifact"]["dirty"] is True
+ assert result.details["source"]["dirty"] is False
+ assert result.details["comparison"] == "artifact_dirty_source_clean"
+
+
+def test_parse_build_provenance_rejects_unknown_metadata():
+ """Only a SHA with an optional dirty marker is exposed as provenance."""
+ assert perseus._parse_build_provenance("abc1234-dirty") == {
+ "sha": "abc1234",
+ "dirty": True,
+ "state": "dirty",
+ }
+ assert perseus._parse_build_provenance("legacy-format") == {
+ "sha": None,
+ "dirty": None,
+ "state": "unknown",
+ }
+
+
+def test_doctor_provenance_unknown_artifact_is_machine_readable(tmp_path, monkeypatch):
+ """A pre-provenance artifact remains an explicit non-error result."""
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text("# artifact without build metadata\n", encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+
+ result = perseus._doctor_check_provenance_drift(cfg(), tmp_path)
+
+ assert result.status == "ok"
+ assert result.details["artifact"]["state"] == "unknown"
+ assert result.details["source"]["state"] == "unavailable"
+ assert result.details["comparison"] == "unknown"
+ assert "legacy-format" not in result.value
+
+
+def test_doctor_provenance_warns_sha_mismatch_without_ancestry_inference(tmp_path, monkeypatch):
+ """Different short SHAs warn, without claiming an ancestry relationship."""
+ source = tmp_path / "source"
+ source.mkdir()
+ subprocess.run(["git", "-C", str(source), "init", "-q"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.email", "test@example.invalid"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.name", "Doctor Test"], check=True)
+ (source / "tracked.txt").write_text("clean\n", encoding="utf-8")
+ subprocess.run(["git", "-C", str(source), "add", "tracked.txt"], check=True)
+ subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "initial"], check=True)
+
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text('_PERSEUS_BUILD_SHA = "deadbeef"\n', encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+ c = cfg()
+ c["doctor"] = {"source_root": str(source)}
+
+ result = perseus._doctor_check_provenance_drift(c, tmp_path)
+
+ assert result.status == "warn"
+ assert result.details["comparison"] == "sha_mismatch"
+ assert "ancestry" not in result.value.lower()
+
+
+def test_doctor_json_includes_provenance_details_additively(tmp_path, monkeypatch):
+ """The structured provenance payload is additive to the doctor check schema."""
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text('_PERSEUS_BUILD_SHA = "abc1234"\n', encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+ monkeypatch.setattr(perseus, "_DOCTOR_CHECKS", [perseus._doctor_check_provenance_drift])
+
+ output = perseus.run_doctor_checks(cfg(), tmp_path)
+ check = output["checks"][0]
+
+ assert check["id"] == "provenance_drift"
+ assert check["status"] == "ok"
+ assert check["value"]
+ assert check["details"]["artifact"]["sha"] == "abc1234"
+ assert check["details"]["comparison"] == "artifact_only"
+
+
+def test_doctor_provenance_discovers_only_canonical_workspace_root(tmp_path, monkeypatch):
+ """A requested workspace with the source layout is safe to compare."""
+ source = tmp_path / "repo"
+ (source / "src" / "perseus").mkdir(parents=True)
+ (source / "src" / "perseus" / "__init__.py").write_text("# source\n", encoding="utf-8")
+ (source / "VERSION").write_text("1.0.0\n", encoding="utf-8")
+ subprocess.run(["git", "-C", str(source), "init", "-q"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.email", "test@example.invalid"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.name", "Doctor Test"], check=True)
+ (source / "tracked.txt").write_text("clean\n", encoding="utf-8")
+ subprocess.run(["git", "-C", str(source), "add", "tracked.txt", "VERSION", "src/perseus"], check=True)
+ subprocess.run(["git", "-C", str(source), "commit", "-q", "-m", "initial"], check=True)
+ source_sha = subprocess.check_output(
+ ["git", "-C", str(source), "rev-parse", "--short", "HEAD"], text=True,
+ ).strip()
+
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text(f'_PERSEUS_BUILD_SHA = "{source_sha}"\n', encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+
+ result = perseus._doctor_check_provenance_drift(cfg(), source)
+
+ assert result.status == "ok"
+ assert Path(result.details["source"]["root"]).resolve() == source.resolve()
+ assert result.details["comparison"] == "match"
+
+
+def test_doctor_provenance_does_not_walk_parent_for_source(tmp_path, monkeypatch):
+ """An unrelated parent checkout is not guessed from a nested workspace."""
+ source = tmp_path / "repo"
+ (source / ".git").mkdir(parents=True)
+ (source / "src" / "perseus").mkdir(parents=True)
+ workspace = source / "nested"
+ workspace.mkdir()
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text('_PERSEUS_BUILD_SHA = "abc1234"\n', encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+
+ result = perseus._doctor_check_provenance_drift(cfg(), workspace)
+
+ assert result.status == "ok"
+ assert result.details["source"]["state"] == "unavailable"
+ assert result.details["comparison"] == "artifact_only"
+
+
+def test_doctor_provenance_does_not_accept_source_shaped_unrelated_checkout(tmp_path, monkeypatch):
+ """A same-shaped unrelated checkout without build markers is unavailable."""
+ source = tmp_path / "unrelated"
+ (source / ".git").mkdir(parents=True)
+ (source / "src" / "perseus").mkdir(parents=True)
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text('_PERSEUS_BUILD_SHA = "deadbeef"\n', encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+
+ result = perseus._doctor_check_provenance_drift(cfg(), source)
+
+ assert result.status == "ok"
+ assert result.details["source"]["state"] == "unavailable"
+ assert result.details["comparison"] == "artifact_only"
+
+
+def test_doctor_provenance_unresolvable_paths_degrade_to_unknown(tmp_path, monkeypatch):
+ """Broken path resolution does not turn provenance into a doctor error."""
+ artifact = tmp_path / "perseus.py"
+ artifact.write_text("# no metadata\n", encoding="utf-8")
+ monkeypatch.setattr(perseus, "__file__", str(artifact))
+
+ def _broken_resolve(_self):
+ raise RuntimeError("symlink loop")
+
+ monkeypatch.setattr(perseus.Path, "resolve", _broken_resolve)
+ result = perseus._doctor_check_provenance_drift(cfg(), tmp_path)
+
+ assert result.status == "ok"
+ assert result.details["artifact"]["state"] == "unknown"
+ assert result.details["comparison"] == "unknown"
+
+
+def test_doctor_default_source_root_is_unset():
+ """Source comparison is opt-in unless a canonical checkout is discoverable."""
+ assert perseus.DEFAULT_CONFIG["doctor"]["source_root"] is None
+
+
def test_doctor_duplicate_installs_single_is_ok(tmp_path, monkeypatch):
"""Exactly one copy on disk -> ok, no warning."""
active = _seed_user_install(tmp_path, "3.14", "1.0.22")