From 27bfa21adb0e55672fe49a124ba8d7015f120f12 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 3 Aug 2026 15:59:40 +0000 Subject: [PATCH 1/2] docs: normalize context launcher and provenance guidance --- QUICKSTART.md | 30 ++++- README.md | 35 +++-- SETUP-GUIDE.md | 53 +++++--- WIRING.md | 41 +++--- docs/AGENT_SURFACES.md | 28 ++++ docs/nexo-integration-guide.md | 2 +- docs/quickstart.md | 33 ++++- perseus.py | 226 ++++++++++++++++++++++++++++++++- spec/components.md | 18 +++ spec/integration.md | 32 ++--- src/perseus/config.py | 5 + src/perseus/doctor.py | 219 ++++++++++++++++++++++++++++++++ tests/test_docs_conventions.py | 52 ++++++++ tests/test_doctor.py | 181 ++++++++++++++++++++++++++ 14 files changed, 881 insertions(+), 74 deletions(-) create mode 100644 tests/test_docs_conventions.py diff --git a/QUICKSTART.md b/QUICKSTART.md index 9c75110d..aa3c1b2f 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 4d08ff51..bcdd2584 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 9fa3dccf..ae89690d 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 aec8a43d..5d597865 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 5f2c006c..f88f85b4 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 af53c0b4..58f3ba53 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 e8351a2c..e5b85973 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 9d06b82a..8ef0b50b 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 = "9378987-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 = _abbrev_home(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 c816e838..4b9aee42 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 0621ae32..c2c0ada9 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 c923a71c..b58f637e 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 9a13993a..723e9b05 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 = _abbrev_home(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 00000000..f75a57e5 --- /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 5dfc9e67..838125f8 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 result.details["source"]["root"] == str(source) + 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") From 0983c8efc1770ea580bdd613eac2c8db5ddd7364 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 3 Aug 2026 16:38:45 +0000 Subject: [PATCH 2/2] fix: preserve full provenance paths on Windows --- perseus.py | 4 ++-- src/perseus/doctor.py | 2 +- tests/test_doctor.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/perseus.py b/perseus.py index 8ef0b50b..76510135 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 = "9378987-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: @@ -27048,7 +27048,7 @@ 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 = _abbrev_home(str(path)) + text = str(path) return "".join(char if char.isprintable() and char not in "\r\n\t" else "?" for char in text) diff --git a/src/perseus/doctor.py b/src/perseus/doctor.py index 723e9b05..fa4afa50 100644 --- a/src/perseus/doctor.py +++ b/src/perseus/doctor.py @@ -868,7 +868,7 @@ 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 = _abbrev_home(str(path)) + text = str(path) return "".join(char if char.isprintable() and char not in "\r\n\t" else "?" for char in text) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 838125f8..827de26e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -530,7 +530,7 @@ def test_doctor_provenance_discovers_only_canonical_workspace_root(tmp_path, mon result = perseus._doctor_check_provenance_drift(cfg(), source) assert result.status == "ok" - assert result.details["source"]["root"] == str(source) + assert Path(result.details["source"]["root"]).resolve() == source.resolve() assert result.details["comparison"] == "match"