diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a60c2..d0e93b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ All notable changes to vouch are documented here. Format follows relation proposals automatically, tagged `proposed_by: vouch-extractor`. They land in `proposed/` like any hand-filed relation and need the usual review; `vouch reject-extracted [--page ]` mass-rejects them (#224). +- `vouch install-mcp openclaw` — ninth host in the adapter catalogue. + Declares plugin enablement (`.openclaw/plugins.json`), an `AGENTS.md` + fenced snippet, the four slash commands reused in place from the + `claude-code` adapter, and a project-local trust-boundary policy + (`.openclaw/policy.json`). Complements the repo-root + `openclaw.plugin.json` bundle manifest, which covers loading vouch into + an OpenClaw deployment rather than into one managed project (#230). ### Added - `vouch sync --vault ` — bidirectional sync between the KB and an Obsidian/Logseq-style markdown vault. Forward (vault → KB): edits to diff --git a/adapters/README.md b/adapters/README.md index c9e15b4..f4edce8 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -12,6 +12,7 @@ file you need into your project and edit it. | [cursor/](cursor/) | Cursor IDE | `mcp.json` snippet | | [codex/](codex/) | OpenAI's Codex CLI | `config.toml` snippet | | [continue/](continue/) | Continue.dev | `config.json` snippet | +| [openclaw/](openclaw/) | OpenClaw plugin host | `.openclaw/plugins.json`, `AGENTS.md` excerpt | | [generic-mcp/](generic-mcp/) | Any MCP-speaking host | annotated reference | | [jsonl-shell/](jsonl-shell/) | bash scripts via the JSONL transport | example pipeline | diff --git a/adapters/openclaw/AGENTS.md.snippet b/adapters/openclaw/AGENTS.md.snippet new file mode 100644 index 0000000..68852c4 --- /dev/null +++ b/adapters/openclaw/AGENTS.md.snippet @@ -0,0 +1,21 @@ +# Vouch — knowledge base (OpenClaw plugin) + +This project loads vouch via the OpenClaw plugin manifest at +[`openclaw.plugin.json`](https://github.com/vouchdev/vouch/blob/main/openclaw.plugin.json) +in the vouch repo. The plugin exports the `vouch serve` MCP surface, +the four bundled slash commands (`/vouch-recall`, `/vouch-status`, +`/vouch-resolve-issue`, `/vouch-propose-from-pr`), and a trust +boundary: every write tool is review-gated, every lifecycle op is +audit-logged, and remote callers' filesystem access is confined. + +You **cannot** write durable knowledge directly. Proposals land in +`.vouch/proposed/` and require human approval via `vouch approve`. +This is intentional. + +- `kb_search` / `kb_context` to read. +- `kb_propose_claim` / `kb_propose_page` to suggest additions — every + claim MUST cite at least one source or evidence id. +- `kb_supersede` to replace a stale claim, `kb_contradict` to flag a + conflict for a human to resolve. + +You are recorded as `proposed_by: openclaw` in the audit log. diff --git a/adapters/openclaw/README.md b/adapters/openclaw/README.md new file mode 100644 index 0000000..d4f5b4c --- /dev/null +++ b/adapters/openclaw/README.md @@ -0,0 +1,33 @@ +# openclaw adapter + +Two different things both go by "the OpenClaw integration": + +1. **Loading vouch into an OpenClaw deployment.** That's the repo-root + [`openclaw.plugin.json`](../../openclaw.plugin.json) manifest — drop + the vouch repo into a deployment that vendors plugin repos and the + loader picks up the MCP server, the four slash commands, and the + trust-boundary declaration automatically. See the README section + "Running vouch as an OpenClaw plugin". +2. **Enabling vouch in one OpenClaw-managed project.** That's this + adapter, run with `vouch install-mcp openclaw --path `. + +The writer drops: + +- `.openclaw/plugins.json` — declares the vouch plugin enabled for this + project (T1). +- `AGENTS.md` — a fenced snippet pointing at the plugin manifest and + summarizing the review-gate contract (T2). +- `.claude/commands/vouch-*.md` — the same four slash commands the + claude-code adapter ships, referenced in place rather than duplicated + (T3). +- `.openclaw/policy.json` — the trust boundary as project-local policy: + review-gated writes, audit-logged lifecycle ops, confined filesystem + access for remote callers (T4). + +```sh +vouch install-mcp openclaw --path . +``` + +Re-running is idempotent: existing files are left alone, and `AGENTS.md` +gets the vouch block appended once inside a fence so reruns don't +duplicate it. diff --git a/adapters/openclaw/install.yaml b/adapters/openclaw/install.yaml new file mode 100644 index 0000000..efd2ff8 --- /dev/null +++ b/adapters/openclaw/install.yaml @@ -0,0 +1,37 @@ +# OpenClaw adapter manifest. +# +# OpenClaw loads vouch as a bundle plugin via the repo-root +# `openclaw.plugin.json` manifest (see README.md "Running vouch as an +# OpenClaw plugin"). That covers the *deployment* side -- dropping the +# vouch repo into an OpenClaw install. This adapter covers the +# per-*project* side: a project that wants vouch enabled gets a small +# set of OpenClaw-native files, mirroring the claude-code adapter's tiers. +# +# T1 = `.openclaw/plugins.json` -- declares the vouch plugin enabled for +# this project (the project-local analogue of running +# `openclaw plugin enable vouch`). +# T2 = AGENTS.md fenced snippet pointing at the plugin manifest and +# summarizing the review-gate contract. +# T3 = the same four slash commands the claude-code adapter ships, +# referenced in place rather than duplicated (see +# install_adapter.py's docstring; OpenClaw's loader bundles these +# from adapters/claude-code/ directly). +# T4 = `.openclaw/policy.json` -- the trust boundary as project-local +# policy (review-gated writes, audit-logged lifecycle, confined fs). +host: openclaw +pretty: OpenClaw +fence: + begin: "" + end: "" +tiers: + T1: + - { src: plugins.json, dst: .openclaw/plugins.json } + T2: + - { src: AGENTS.md.snippet, dst: AGENTS.md, fenced_append: true } + T3: + - { src: ../claude-code/.claude/commands/vouch-recall.md, dst: .claude/commands/vouch-recall.md } + - { src: ../claude-code/.claude/commands/vouch-status.md, dst: .claude/commands/vouch-status.md } + - { src: ../claude-code/.claude/commands/vouch-resolve-issue.md, dst: .claude/commands/vouch-resolve-issue.md } + - { src: ../claude-code/.claude/commands/vouch-propose-from-pr.md, dst: .claude/commands/vouch-propose-from-pr.md } + T4: + - { src: policy.json, dst: .openclaw/policy.json } diff --git a/adapters/openclaw/plugins.json b/adapters/openclaw/plugins.json new file mode 100644 index 0000000..6525f04 --- /dev/null +++ b/adapters/openclaw/plugins.json @@ -0,0 +1,11 @@ +{ + "plugins": { + "vouch": { + "source": "github:vouchdev/vouch", + "enabled": true, + "config": { + "agent": "openclaw" + } + } + } +} diff --git a/adapters/openclaw/policy.json b/adapters/openclaw/policy.json new file mode 100644 index 0000000..b646e77 --- /dev/null +++ b/adapters/openclaw/policy.json @@ -0,0 +1,8 @@ +{ + "vouch": { + "review_gated_writes": true, + "audit_logged_lifecycle": true, + "remote_callers_filesystem": "confined", + "agent": "openclaw" + } +} diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 109f6f7..2a90b18 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -26,6 +26,8 @@ from pathlib import Path from typing import Any +import yaml + from . import audit, bundle, health, volunteer_context from . import lifecycle as life from . import salience as salience_mod diff --git a/src/vouch/sessions.py b/src/vouch/sessions.py index dcebb33..0762e6a 100644 --- a/src/vouch/sessions.py +++ b/src/vouch/sessions.py @@ -12,7 +12,7 @@ import uuid from datetime import UTC, datetime -from . import audit, index_db, volunteer_context +from . import audit, index_db, salience, volunteer_context from .models import Page, PageType, ProposalStatus, Session from .proposals import approve from .storage import KBStore diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 9d049a1..882428d 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -139,6 +139,52 @@ def test_install_claude_md_skips_when_already_fenced(tmp_path: Path) -> None: assert "CLAUDE.md" not in again.appended +# --- openclaw: second adapter with all four tiers, T3 reused from +# claude-code rather than duplicated (vouchdev/vouch#230) -------------------- + + +def test_install_openclaw_t4_writes_all_tiers(tmp_path: Path) -> None: + result = install("openclaw", target=tmp_path, tier="T4") + assert (tmp_path / ".openclaw" / "plugins.json").is_file() + assert (tmp_path / "AGENTS.md").is_file() + cmd_dir = tmp_path / ".claude" / "commands" + assert (cmd_dir / "vouch-recall.md").is_file() + assert (cmd_dir / "vouch-status.md").is_file() + assert (cmd_dir / "vouch-resolve-issue.md").is_file() + assert (cmd_dir / "vouch-propose-from-pr.md").is_file() + assert (tmp_path / ".openclaw" / "policy.json").is_file() + # T1 plugins.json + T2 AGENTS.md + 4 T3 commands + T4 policy.json = 7. + assert len(result.written) == 7, result.written + + +def test_install_openclaw_t3_commands_match_claude_code(tmp_path: Path) -> None: + """T3 is declared as a reuse of claude-code's commands, not a fork -- + the manifest's `src` points at adapters/claude-code/ directly.""" + install("openclaw", target=tmp_path, tier="T3") + for name in ( + "vouch-recall.md", "vouch-status.md", + "vouch-resolve-issue.md", "vouch-propose-from-pr.md", + ): + installed = (tmp_path / ".claude" / "commands" / name).read_text(encoding="utf-8") + ref_path = REPO_ROOT / "adapters" / "claude-code" / ".claude" / "commands" / name + assert installed == ref_path.read_text(encoding="utf-8") + + +def test_install_openclaw_is_idempotent(tmp_path: Path) -> None: + install("openclaw", target=tmp_path, tier="T4") + second = install("openclaw", target=tmp_path, tier="T4") + assert second.written == [] + assert set(second.skipped) == { + ".openclaw/plugins.json", + "AGENTS.md", + ".claude/commands/vouch-recall.md", + ".claude/commands/vouch-status.md", + ".claude/commands/vouch-resolve-issue.md", + ".claude/commands/vouch-propose-from-pr.md", + ".openclaw/policy.json", + } + + # --- error paths ---------------------------------------------------------- @@ -157,7 +203,7 @@ def test_install_unknown_tier_raises(tmp_path: Path) -> None: @pytest.mark.parametrize("host", [ "cursor", "continue", "codex", "claude-desktop", - "windsurf", "cline", "zed", + "windsurf", "cline", "zed", "openclaw", ]) def test_each_host_writes_its_t1_file(host: str, tmp_path: Path) -> None: """Smoke test: every shipped host must produce at least one file at T1."""