Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Claude Code edits code -> Plugin tracks changes -> Isolated agent updates memory
- **Isolated processing**: Agent runs in separate context window, doesn't consume main session tokens
- **Marker-based updates**: Only modifies AUTO-MANAGED sections, preserves manual content
- **Subtree support**: Hierarchical CLAUDE.md for monorepos
- **Opt-in receipts**: Write privacy-safe JSONL proof that memory refreshes were requested/completed without logging raw paths or memory content

## Installation

Expand Down Expand Up @@ -167,6 +168,19 @@ CLAUDE.md updated
- **Agent**: Runs in isolated context window - doesn't consume main session tokens
- **Skills**: Progressive disclosure - load only when invoked

### Privacy-safe receipts (optional)

Teams that need auditability can enable opt-in receipts in `.claude/auto-memory/config.json`:

```json
{
"receipts": true,
"receiptHmacKeyEnv": "AUTO_MEMORY_RECEIPT_HMAC_KEY"
}
```

When enabled, the Stop/SubagentStop hooks append JSONL records to `.claude/auto-memory/receipts.jsonl` for `auto_memory.update.requested` and `auto_memory.update.completed`. Receipts include counts, trigger mode, active memory file, auto-commit/push outcome, and privacy flags. For stable shareable identifiers, set `AUTO_MEMORY_RECEIPT_HMAC_KEY` (or the env var named by `receiptHmacKeyEnv`) so session/file identifiers are keyed with short HMAC-SHA256 values. If no key is set, receipts still write counts and outcomes but omit session/file hashes instead of falling back to guessable raw SHA-256 hashes. They intentionally set `raw_paths_included=false` and `raw_memory_included=false`; raw file paths, commit messages, prompts, and CLAUDE.md/AGENTS.md content are not logged.

## CLAUDE.md Format

Auto-managed sections use HTML comment markers:
Expand Down
98 changes: 94 additions & 4 deletions scripts/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@

from __future__ import annotations

import hashlib
import hmac
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -69,6 +72,72 @@ def dirty_file_path(project_dir: str, session_id: str = "") -> Path:
return base / "dirty-files"


def receipt_file_path(project_dir: str) -> Path:
"""Return the privacy-safe receipt log path."""
return Path(project_dir) / ".claude" / "auto-memory" / "receipts.jsonl"


def receipt_hmac_key(config: dict[str, Any]) -> str | None:
"""Return the configured HMAC key for shareable receipt hashes, if present."""
env_name = config.get("receiptHmacKeyEnv", "AUTO_MEMORY_RECEIPT_HMAC_KEY")
if not isinstance(env_name, str) or not env_name:
env_name = "AUTO_MEMORY_RECEIPT_HMAC_KEY"
key = os.environ.get(env_name)
return key if key else None


def receipt_hash(value: str, key: str | None) -> str | None:
"""Return a short keyed hash for receipt fields, or None when no key is set."""
if not key:
return None
return hmac.new(key.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()[:16]


def write_receipt(
project_dir: str,
event: str,
session_id: str,
files: list[str],
config: dict[str, Any],
extra: dict[str, Any] | None = None,
) -> None:
"""Append a privacy-safe auto-memory receipt when enabled.

Receipts are intentionally opt-in and never include raw file paths,
commit messages, prompts, or memory-file contents. They are useful for
teams that want proof that auto-memory requested or completed a memory
refresh without leaking the changed-file list into logs.
"""
if not config.get("receipts", False):
return

path = receipt_file_path(project_dir)
path.parent.mkdir(parents=True, exist_ok=True)
active = get_active_memory_file(get_memory_files(config))
key = receipt_hmac_key(config)
hashed_files = [receipt_hash(f, key) for f in sorted(files)] if key else []
receipt: dict[str, Any] = {
"schema": "auto-memory.receipt.v1",
"event": event,
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id_hash": receipt_hash(session_id, key) if session_id else None,
"trigger_mode": config.get("triggerMode", "default"),
"active_memory_file": active,
"changed_file_count": len(files),
"changed_file_hashes": hashed_files,
"hash_algorithm": "hmac-sha256-16" if key else "none",
"hmac_key_env": config.get("receiptHmacKeyEnv", "AUTO_MEMORY_RECEIPT_HMAC_KEY"),
"hashes_omitted_reason": None if key else "set AUTO_MEMORY_RECEIPT_HMAC_KEY or receiptHmacKeyEnv for shareable stable hashes",
"raw_paths_included": False,
"raw_memory_included": False,
}
if extra:
receipt.update(extra)

with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(receipt, sort_keys=True) + "\n")


def read_dirty_files(project_dir: str, session_id: str = "") -> list[str]:
"""Read and deduplicate dirty files, stripping commit context.

Expand Down Expand Up @@ -133,6 +202,8 @@ def handle_stop(input_data: dict[str, Any], project_dir: str) -> None:
# (which means a commit happened but the agent hasn't run yet)
_ = trigger_mode # Used for future mode-specific logic

write_receipt(project_dir, "auto_memory.update.requested", session_id, files, config)

output = {
"decision": "block",
"reason": build_spawn_reason(files, config),
Expand Down Expand Up @@ -243,10 +314,29 @@ def handle_subagent_stop(input_data: dict[str, Any], project_dir: str) -> None:

# Auto-commit/push before clearing dirty files
config = load_config(project_dir)
if config.get("autoCommit", False):
if auto_commit_memory_files(project_dir, config):
if config.get("autoPush", False):
auto_push(project_dir)
auto_commit_attempted = bool(config.get("autoCommit", False))
auto_commit_succeeded = False
auto_push_attempted = False
auto_push_succeeded = False
if auto_commit_attempted:
auto_commit_succeeded = auto_commit_memory_files(project_dir, config)
if auto_commit_succeeded and config.get("autoPush", False):
auto_push_attempted = True
auto_push_succeeded = auto_push(project_dir)

write_receipt(
project_dir,
"auto_memory.update.completed",
session_id,
files,
config,
{
"auto_commit_attempted": auto_commit_attempted,
"auto_commit_succeeded": auto_commit_succeeded,
"auto_push_attempted": auto_push_attempted,
"auto_push_succeeded": auto_push_succeeded,
},
)

clear_dirty_files(project_dir, session_id)
cleanup_stale_session_files(project_dir)
Expand Down
59 changes: 59 additions & 0 deletions tests/test_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -1084,3 +1084,62 @@ def test_returns_false_when_not_git_repo(self, tmp_path):
"""Returns False when not in a git repo."""
result = trigger.auto_push(str(tmp_path))
assert result is False


class TestReceipts:
"""Tests for opt-in privacy-safe receipt logging."""

def test_write_receipt_disabled_by_default(self, tmp_path):
"""Receipt log is not created unless receipts=true is configured."""
trigger.write_receipt(
str(tmp_path),
"auto_memory.update.requested",
"sess-001",
["/repo/src/private.py"],
{"triggerMode": "default"},
)

assert not trigger.receipt_file_path(str(tmp_path)).exists()

def test_write_receipt_hashes_paths_without_raw_values(self, tmp_path):
"""Receipt log stores hashes/counts, not raw file paths or memory bodies."""
raw_path = "/repo/src/customer-secret-flow.py"
trigger.write_receipt(
str(tmp_path),
"auto_memory.update.requested",
"sess-001",
[raw_path],
{"triggerMode": "gitmode", "memoryFiles": ["AGENTS.md"], "receipts": True},
)

receipt_path = trigger.receipt_file_path(str(tmp_path))
line = receipt_path.read_text()
receipt = json.loads(line)

assert receipt["schema"] == "auto-memory.receipt.v1"
assert receipt["event"] == "auto_memory.update.requested"
assert receipt["trigger_mode"] == "gitmode"
assert receipt["active_memory_file"] == "AGENTS.md"
assert receipt["changed_file_count"] == 1
assert receipt["raw_paths_included"] is False
assert receipt["raw_memory_included"] is False
assert receipt["changed_file_hashes"] == [trigger.sha256_short(raw_path)]
assert "customer-secret-flow.py" not in line
assert "sess-001" not in line

def test_handle_subagent_stop_writes_completed_receipt(self, tmp_path):
"""SubagentStop appends completion receipt before clearing dirty files."""
config_dir = tmp_path / ".claude" / "auto-memory"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"receipts": True}))
(config_dir / "dirty-files-sess-001").write_text("/repo/src/a.py\n")

trigger.handle_subagent_stop({"session_id": "sess-001"}, str(tmp_path))

receipt_path = trigger.receipt_file_path(str(tmp_path))
receipt = json.loads(receipt_path.read_text().splitlines()[-1])
assert receipt["event"] == "auto_memory.update.completed"
assert receipt["changed_file_count"] == 1
assert receipt["auto_commit_attempted"] is False
assert receipt["raw_paths_included"] is False
assert (config_dir / "dirty-files-sess-001").read_text() == ""