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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ All notable changes to vouch are documented here. Format follows
## [Unreleased]

### Added
- `vouch sync-check` and `vouch sync-apply` reconcile another `.vouch`
directory or bundle by importing only non-conflicting durable artifacts and
reporting conflicts without overwriting reviewed knowledge.
- `vouch pending --json` emits pending proposals as structured JSON for shell
scripts, CI checks, and multi-agent review dashboards.
- `vouch diff <id-old> <id-new>` shows what changed between two claim revisions or two page revisions — field-level changes plus a line-diff of the long text/body. Auto-detects the artifact kind and hides always-churning metadata. Read-only; supports `--json`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ vouch export --out path.tar.gz
vouch export-check path.tar.gz
vouch import-check path.tar.gz
vouch import-apply path.tar.gz [--on-conflict skip|overwrite|fail]
vouch sync-check PATH_OR_BUNDLE
vouch sync-apply PATH_OR_BUNDLE [--on-conflict fail|skip|propose]

vouch serve [--transport stdio|jsonl]
```
Expand Down
23 changes: 18 additions & 5 deletions docs/multi-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,26 @@ vouch session start --task "implement password reset" \
--note "tag:agent:claude-code-anna"
```

## Distributed sync

When two teammates each have their own `.vouch/` directory, use the
sync workflow to reconcile them deterministically:

```bash
vouch sync-check ../other-repo
vouch sync-apply ../other-repo --on-conflict fail
```

`sync-check` accepts either another repo / `.vouch` directory or a
bundle. It reports new files, identical files, and conflicts without
writing anything. `sync-apply` imports non-conflicting files only; it
never overwrites reviewed knowledge. Use `--on-conflict skip` to leave
conflicts untouched, or `--on-conflict propose` to write a local conflict
report under `proposed/sync-reports/` for human review. `config.yaml`
stays local to each KB and is not synced.

## What doesn't work yet

- **Distributed `.vouch/` directories that sync.** Today it's one
filesystem. If two teammates each have their own `.vouch/` and want
them to merge, the path is bundle export + import-check, manually,
for now. See [bundles.md](bundles.md) and the multi-agent-sync
roadmap item.
- **Live merge conflicts.** Two agents editing the same proposal at
once isn't a scenario vouch addresses — agents create proposals,
they don't edit existing ones.
Expand Down
39 changes: 37 additions & 2 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict
from pathlib import Path

import click
Expand All @@ -22,6 +23,7 @@
from . import audit as audit_mod
from . import lifecycle as life
from . import sessions as sess_mod
from . import sync as sync_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
from .context import build_context_pack
Expand Down Expand Up @@ -870,6 +872,41 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None:
_emit_json(r)


# --- sync ------------------------------------------------------------------


@cli.command("sync-check")
@click.argument("source_path", type=click.Path(exists=True))
def sync_check_cmd(source_path: str) -> None:
"""Compare another .vouch directory or bundle without writing."""
store = _load_store()
try:
r = sync_mod.sync_check(store.kb_dir, Path(source_path))
except RuntimeError as e:
raise click.ClickException(str(e)) from e
_emit_json(asdict(r))


@cli.command("sync-apply")
@click.argument("source_path", type=click.Path(exists=True))
@click.option("--on-conflict", default="fail", show_default=True,
type=click.Choice(["fail", "skip", "propose"]))
def sync_apply_cmd(source_path: str, on_conflict: str) -> None:
"""Apply non-conflicting files from another .vouch directory or bundle."""
store = _load_store()
try:
r = sync_mod.sync_apply(
store.kb_dir,
Path(source_path),
on_conflict=on_conflict,
actor=_whoami(),
)
except (RuntimeError, ValueError) as e:
raise click.ClickException(str(e)) from e
health.rebuild_index(store)
_emit_json(r)


# --- diff -----------------------------------------------------------------


Expand All @@ -880,8 +917,6 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None:
help="Emit the diff as JSON.")
def diff(old_id: str, new_id: str, as_json: bool) -> None:
"""Show what changed between two claim or two page revisions."""
from dataclasses import asdict

from .diff import diff_artifacts
store = _load_store()
with _cli_errors():
Expand Down
9 changes: 8 additions & 1 deletion src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def _serialize_page(page: Page) -> str:


def _deserialize_page(text: str) -> Page:
text = text.replace("\r\n", "\n")
m = _FRONTMATTER_RE.match(text)
if not m:
raise ValueError("page file missing YAML frontmatter")
Expand Down Expand Up @@ -151,8 +152,14 @@ def read_under_root(self, path: str | Path) -> tuple[Path, bytes]:
raise ValueError(
f"path must be inside project root ({self.root}): {resolved}"
)
if resolved.is_dir():
raise ValueError(f"not a regular file: {resolved}")
flags = os.O_RDONLY
# POSIX can reject a symlink swapped in after resolve(); Windows has
# no O_NOFOLLOW, so it falls back to the regular-file check below.
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW)
fd = os.open(resolved, flags)
except OSError as e:
raise ValueError(f"cannot read {resolved}: {e}") from e
try:
Expand Down
Loading
Loading